PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.4
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.4
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / bulk-editor / infrastructure / posts / post-meta-posts-collector.php

post-meta-posts-collector.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.4, at src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php

411 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts;
5
6 use WP_Query;
7 use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Collector_Interface;
8 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Post;
9 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_List;
10 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Page;
11 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query;
12
13 /**
14 * Collects bulk editor posts by reading raw Yoast post meta.
15 *
16 * This is the fallback used when indexables are disabled.
17 */
18 class Post_Meta_Posts_Collector implements Posts_Collector_Interface {
19
20 use Post_Title_Trait;
21 use Searchable_Fields_Trait;
22
23 /**
24 * The Yoast post meta key prefix.
25 */
26 private const META_PREFIX = '_yoast_wpseo_';
27
28 /**
29 * Maps each "needs improvement" field key to its Yoast meta key suffix.
30 *
31 * @var array<string, string>
32 */
33 private const FIELD_META_SUFFIXES = [
34 'seo_title' => 'title',
35 'meta_description' => 'metadesc',
36 'social_title' => 'opengraph-title',
37 'social_description' => 'opengraph-description',
38 ];
39
40 /**
41 * Maps the fields with a persisted per-field score to their score meta key suffix.
42 *
43 * The social fields have no assessors, so they match on emptiness only.
44 *
45 * @var array<string, string>
46 */
47 private const FIELD_SCORE_META_SUFFIXES = [
48 'seo_title' => 'seo_title_score',
49 'meta_description' => 'meta_description_score',
50 ];
51
52 /**
53 * The query var that flags our own query so the posts_where filter only touches it.
54 */
55 private const QUERY_FLAG = 'yoast_bulk_editor_query';
56
57 /**
58 * The prepared search WHERE clause to append while our query runs.
59 *
60 * @var string
61 */
62 private $search_where = '';
63
64 /**
65 * The prepared "needs improvement" WHERE clause to append while our query runs.
66 *
67 * @var string
68 */
69 private $needs_improvement_where = '';
70
71 /**
72 * The resolver for the per-post edit permission.
73 *
74 * @var Post_Editability_Resolver
75 */
76 private $post_editability_resolver;
77
78 /**
79 * The resolver for the post type's default SEO title / meta description template.
80 *
81 * @var Default_Template_Resolver
82 */
83 private $default_template_resolver;
84
85 /**
86 * The constructor.
87 *
88 * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
89 * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template.
90 */
91 public function __construct(
92 Post_Editability_Resolver $post_editability_resolver,
93 Default_Template_Resolver $default_template_resolver
94 ) {
95 $this->post_editability_resolver = $post_editability_resolver;
96 $this->default_template_resolver = $default_template_resolver;
97 }
98
99 /**
100 * Collects a page of posts for the given query.
101 *
102 * A single page is fetched and counted through WP_Query; the per-post edit permission is then resolved
103 * for that page so posts the user cannot edit are returned locked and without their SEO data.
104 *
105 * @param Posts_Query $query The query describing the page to collect.
106 *
107 * @return Posts_Page The collected posts together with the totals for pagination.
108 */
109 public function get_posts( Posts_Query $query ): Posts_Page {
110 $wp_query = $this->run_query( $query );
111 $post_ids = \array_map( 'intval', $wp_query->posts );
112
113 $editability = $this->post_editability_resolver->resolve( $post_ids );
114
115 $posts_list = new Posts_List();
116 foreach ( $post_ids as $post_id ) {
117 $posts_list->add( $this->build_post( $post_id, ( $editability[ $post_id ] ?? false ), $query->are_scores_enabled() ) );
118 }
119
120 return new Posts_Page( $posts_list, (int) $wp_query->found_posts, $query->get_page(), $query->get_per_page() );
121 }
122
123 /**
124 * Runs the WP_Query for the given query.
125 *
126 * The catch-all search and "needs improvement" clauses are both injected through a scoped posts_where
127 * filter rather than WP_Query's own 's'/'meta_query'. WP_Query AND-joins those, which would miss posts
128 * matching only one side, and WP_Meta_Query's INNER JOINs drop the very missing-meta-row posts the
129 * "needs improvement" filter targets; a hand-built OR clause of correlated subqueries avoids both.
130 *
131 * @param Posts_Query $query The query describing the page to collect.
132 *
133 * @return WP_Query The executed query.
134 */
135 protected function run_query( Posts_Query $query ): WP_Query {
136 $args = $this->build_query_args( $query );
137
138 $this->search_where = $query->has_search() ? $this->build_search_where( $query->get_search() ) : '';
139 $this->needs_improvement_where = $this->build_needs_improvement_where( $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() );
140
141 if ( $this->search_where === '' && $this->needs_improvement_where === '' ) {
142 return new WP_Query( $args );
143 }
144
145 $args[ self::QUERY_FLAG ] = true;
146
147 \add_filter( 'posts_where', [ $this, 'filter_posts_where' ], 10, 2 );
148 $wp_query = new WP_Query( $args );
149 \remove_filter( 'posts_where', [ $this, 'filter_posts_where' ], 10 );
150
151 $this->search_where = '';
152 $this->needs_improvement_where = '';
153
154 return $wp_query;
155 }
156
157 /**
158 * Builds the WP_Query arguments for the given query.
159 *
160 * @param Posts_Query $query The query describing the page to collect.
161 *
162 * @return array<string, string|int|bool|array<string>|array<int>> The WP_Query arguments.
163 */
164 private function build_query_args( Posts_Query $query ): array {
165 $args = [
166 'post_type' => $query->get_content_type(),
167 'post_status' => $query->get_statuses(),
168 // Exclude password-protected posts from bulk editing.
169 'has_password' => false,
170 'fields' => 'ids',
171 'posts_per_page' => $query->get_per_page(),
172 'paged' => $query->get_page(),
173 // Order by post ID so the result matches the indexable collector's ordering.
174 'orderby' => 'ID',
175 'order' => 'DESC',
176 'ignore_sticky_posts' => true,
177 // We render the title, status and Yoast meta, but never the terms, so don't prime the term cache.
178 'update_post_term_cache' => false,
179 ];
180
181 if ( $query->has_author_filter() ) {
182 $args['author'] = $query->get_author_id();
183 }
184
185 if ( $query->has_include() ) {
186 $args['post__in'] = $query->get_include_ids();
187 }
188
189 return $args;
190 }
191
192 /**
193 * Appends the prepared search and "needs improvement" clauses to our own query's WHERE.
194 *
195 * @param string $where The WHERE clause so far.
196 * @param WP_Query $wp_query The query being filtered.
197 *
198 * @return string The WHERE clause, with our clauses appended for our own query.
199 *
200 * @internal Only public because it is registered as a posts_where filter callback.
201 */
202 public function filter_posts_where( $where, $wp_query ): string {
203 if ( $wp_query->get( self::QUERY_FLAG ) ) {
204 $where .= $this->search_where;
205 $where .= $this->needs_improvement_where;
206 }
207
208 return $where;
209 }
210
211 /**
212 * Builds a post from its ID.
213 *
214 * The SEO data and edit link of a post the current user cannot edit are withheld, so the post is
215 * shown in the list but stays locked and does not expose its metadata.
216 *
217 * @param int $post_id The post ID.
218 * @param bool $editable Whether the current user may edit the post.
219 * @param bool $scores_enabled Whether the per-field scores may back the needs-improvement verdict.
220 *
221 * @return Post The post.
222 */
223 private function build_post( int $post_id, bool $editable, bool $scores_enabled ): Post {
224 $post = \get_post( $post_id );
225 $status = ( $post !== null ) ? (string) $post->post_status : '';
226 $post_type = ( $post !== null ) ? (string) $post->post_type : '';
227 $title = $this->get_normalized_title( $post_id );
228
229 if ( ! $editable ) {
230 return new Post( $post_id, $title, $status, '', '', '', '', '', '', false );
231 }
232
233 // Read each field's value once from its meta suffix, keyed by field param, so the values can be reused for
234 // the needs-improvement filter. Built from the suffix map to keep a single source of truth.
235 $fields = [];
236 foreach ( self::FIELD_META_SUFFIXES as $field => $suffix ) {
237 $fields[ $field ] = $this->get_meta( $post_id, $suffix );
238 }
239
240 $raw_seo_title = $fields['seo_title'];
241 $raw_meta_description = $fields['meta_description'];
242 $raw_social_title = $fields['social_title'];
243 $raw_social_description = $fields['social_description'];
244
245 // Resolve templates for needs-improvement scoring and as display fallbacks when the stored value is empty.
246 $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $raw_seo_title );
247 $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $raw_meta_description );
248 $fields['social_title'] = $this->default_template_resolver->resolve_social_title( $post_id, $post_type, $raw_social_title );
249 $fields['social_description'] = $this->default_template_resolver->resolve_social_description( $post_id, $post_type, $raw_social_description );
250
251 return new Post(
252 $post_id,
253 $title,
254 $status,
255 (string) \get_edit_post_link( $post_id, 'raw' ),
256 $this->get_meta( $post_id, 'focuskw' ),
257 $raw_seo_title,
258 $raw_meta_description,
259 $raw_social_title,
260 $raw_social_description,
261 true,
262 $this->build_needs_improvement( $post_id, $fields, $scores_enabled ),
263 ( $raw_seo_title === '' ) ? $fields['seo_title'] : '',
264 ( $raw_meta_description === '' ) ? $fields['meta_description'] : '',
265 ( $raw_social_title === '' ) ? $fields['social_title'] : '',
266 ( $raw_social_description === '' ) ? $fields['social_description'] : '',
267 );
268 }
269
270 /**
271 * Builds the per-field needs-improvement verdict for a post, keyed by field param.
272 *
273 * A field needs improvement when its value is empty, or when its score falls in the bad/ok range.
274 *
275 * @param int $post_id The post ID.
276 * @param array<string, string> $fields The field values, keyed by field param.
277 * @param bool $scores_enabled Whether the per-field scores may back the verdict.
278 *
279 * @return array<string, bool> Whether each field needs improvement, keyed by field param.
280 */
281 private function build_needs_improvement( int $post_id, array $fields, bool $scores_enabled ): array {
282 $needs_improvement = [];
283 foreach ( \array_keys( self::FIELD_META_SUFFIXES ) as $field ) {
284 $is_empty = ( ( $fields[ $field ] ?? '' ) === '' );
285
286 $is_bad_score = false;
287 if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) {
288 $score = (int) $this->get_meta( $post_id, self::FIELD_SCORE_META_SUFFIXES[ $field ] );
289 $is_bad_score = ( $score >= self::NEEDS_IMPROVEMENT_MIN_SCORE && $score <= self::NEEDS_IMPROVEMENT_MAX_SCORE );
290 }
291
292 $needs_improvement[ $field ] = ( $is_empty || $is_bad_score );
293 }
294
295 return $needs_improvement;
296 }
297
298 /**
299 * Builds the prepared "needs improvement" WHERE clause.
300 *
301 * A field needs improvement when its meta row is missing or stores an empty string, or — for fields
302 * with a persisted per-field score and while scoring is enabled — when that score falls in the bad/ok
303 * range. The selected fields are OR-ed so they broaden the result, and unknown field keys are ignored.
304 *
305 * Each field is matched through correlated subqueries rather than WP_Query's meta_query. A `NOT IN`
306 * subquery over the non-empty rows matches both a missing meta row and a present-but-empty one in one
307 * shot; a meta_query cannot, because WP_Meta_Query gives its value comparisons their own INNER JOIN,
308 * which eliminates the missing-row posts before the OR-ed `NOT EXISTS` branch is ever evaluated.
309 *
310 * @param array<string> $fields The fields that need improvement.
311 * @param bool $scores_enabled Whether the per-field scores may back the filter.
312 * @param string $post_type The post type slug.
313 *
314 * @return string The prepared WHERE clause, or an empty string when no known field is selected.
315 */
316 protected function build_needs_improvement_where( array $fields, bool $scores_enabled, string $post_type = '' ): string {
317 global $wpdb;
318
319 $has_fallback = [
320 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '',
321 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '',
322 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '',
323 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '',
324 ];
325
326 $clauses = [];
327 foreach ( $fields as $field ) {
328 if ( ! isset( self::FIELD_META_SUFFIXES[ $field ] ) ) {
329 continue;
330 }
331
332 $meta_key = self::META_PREFIX . self::FIELD_META_SUFFIXES[ $field ];
333 $field_clauses = [];
334
335 if ( ! ( $has_fallback[ $field ] ?? false ) ) {
336 $field_clauses[] = $wpdb->prepare(
337 '%i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s )',
338 $wpdb->posts,
339 $wpdb->postmeta,
340 $meta_key,
341 '',
342 );
343 }
344
345 if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) {
346 $field_clauses[] = $wpdb->prepare(
347 '%i.ID IN ( SELECT post_id FROM %i WHERE meta_key = %s AND CAST( meta_value AS SIGNED ) BETWEEN %d AND %d )',
348 $wpdb->posts,
349 $wpdb->postmeta,
350 self::META_PREFIX . self::FIELD_SCORE_META_SUFFIXES[ $field ],
351 self::NEEDS_IMPROVEMENT_MIN_SCORE,
352 self::NEEDS_IMPROVEMENT_MAX_SCORE,
353 );
354 }
355
356 // Always add a clause per field — use a false condition when no real predicate applies so the
357 // field still participates in the outer OR group without incorrectly matching every row.
358 $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '0 = 1' ) . ' )';
359 }
360
361 if ( $clauses === [] ) {
362 return '';
363 }
364
365 return ' AND ( ' . \implode( ' OR ', $clauses ) . ' )';
366 }
367
368 /**
369 * Builds the prepared catch-all search WHERE clause.
370 *
371 * @param string $search The search term.
372 *
373 * @return string The prepared WHERE clause.
374 */
375 private function build_search_where( string $search ): string {
376 global $wpdb;
377
378 $like = '%' . $wpdb->esc_like( $search ) . '%';
379 $meta_keys = \array_map(
380 static function ( $suffix ) {
381 return self::META_PREFIX . $suffix;
382 },
383 \array_values( $this->searchable_fields() ),
384 );
385
386 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Reason: we're passing an array instead.
387 return $wpdb->prepare(
388 ' AND ( %i.post_title LIKE %s'
389 . ' OR %i.ID IN ( SELECT post_id FROM %i'
390 . ' WHERE meta_key IN ( ' . \implode( ', ', \array_fill( 0, \count( $meta_keys ), '%s' ) ) . ' ) AND meta_value LIKE %s ) )',
391 \array_merge( [ $wpdb->posts, $like, $wpdb->posts, $wpdb->postmeta ], $meta_keys, [ $like ] ),
392 );
393 // phpcs:enable
394 }
395
396 /**
397 * Reads a raw Yoast post meta value.
398 *
399 * Reads the raw meta directly so the stored value round-trips with the bulk update endpoint,
400 * regardless of which social options are enabled.
401 *
402 * @param int $post_id The post ID.
403 * @param string $key The meta key, without the Yoast prefix.
404 *
405 * @return string The meta value.
406 */
407 private function get_meta( int $post_id, string $key ): string {
408 return (string) \get_post_meta( $post_id, self::META_PREFIX . $key, true );
409 }
410 }
411