PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
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
← All changes | src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +165 -9 28.3 → trunk View file →
@@ -22,8 +22,32 @@
22 22 use Post_Title_Trait;
23 23 use Searchable_Fields_Trait;
24 24
25 25 /**
26 + * Maps each "needs improvement" field key to its indexable column.
27 + *
28 + * @var array<string, string>
29 + */
30 + private const FIELD_COLUMNS = [
31 + 'seo_title' => 'title',
32 + 'meta_description' => 'description',
33 + 'social_title' => 'open_graph_title',
34 + 'social_description' => 'open_graph_description',
35 + ];
36 +
37 + /**
38 + * Maps the fields with a persisted per-field score to their indexable score column.
39 + *
40 + * The social fields have no assessors, so they match on emptiness only.
41 + *
42 + * @var array<string, string>
43 + */
44 + private const FIELD_SCORE_COLUMNS = [
45 + 'seo_title' => 'seo_title_score',
46 + 'meta_description' => 'meta_description_score',
47 + ];
48 +
49 + /**
26 50 * The indexable repository.
27 51 *
28 52 * @var Indexable_Repository
29 53 */
@@ -36,19 +60,29 @@
36 60 */
37 61 private $post_editability_resolver;
38 62
39 63 /**
64 + * The resolver for the post type's default SEO title / meta description template.
65 + *
66 + * @var Default_Template_Resolver
67 + */
68 + private $default_template_resolver;
69 +
70 + /**
40 71 * The constructor.
41 72 *
42 73 * @param Indexable_Repository $indexable_repository The indexable repository.
43 74 * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
75 + * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template.
44 76 */
45 77 public function __construct(
46 78 Indexable_Repository $indexable_repository,
47 - Post_Editability_Resolver $post_editability_resolver
79 + Post_Editability_Resolver $post_editability_resolver,
80 + Default_Template_Resolver $default_template_resolver
48 81 ) {
49 82 $this->indexable_repository = $indexable_repository;
50 83 $this->post_editability_resolver = $post_editability_resolver;
84 + $this->default_template_resolver = $default_template_resolver;
51 85 }
52 86
53 87 /**
54 88 * Collects a page of posts for the given query.
@@ -78,9 +112,9 @@
78 112 $editability = $this->post_editability_resolver->resolve( \array_keys( $indexables_by_id ) );
79 113
80 114 $posts_list = new Posts_List();
81 115 foreach ( $indexables_by_id as $object_id => $indexable ) {
82 - $posts_list->add( $this->build_post( $indexable, ( $editability[ $object_id ] ?? false ) ) );
116 + $posts_list->add( $this->build_post( $indexable, ( $editability[ $object_id ] ?? false ), $query->are_scores_enabled() ) );
83 117 }
84 118
85 119 return new Posts_Page( $posts_list, $total, $query->get_page(), $query->get_per_page() );
86 120 }
@@ -128,16 +162,86 @@
128 162 if ( $query->has_author_filter() ) {
129 163 $builder->where( 'author_id', $query->get_author_id() );
130 164 }
131 165
166 + if ( $query->has_include() ) {
167 + $builder->where_in( 'object_id', $query->get_include_ids() );
168 + }
169 +
132 170 if ( $query->has_search() ) {
133 171 $this->apply_search( $builder, $query->get_search() );
134 172 }
135 173
174 + if ( $query->get_needs_improvement() !== [] ) {
175 + $this->apply_needs_improvement( $builder, $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() );
176 + }
177 +
136 178 return $builder;
137 179 }
138 180
139 181 /**
182 + * Adds the "needs improvement" clause to the query.
183 + *
184 + * A field needs improvement when its indexable column is NULL or an empty string, or — for fields
185 + * with a persisted per-field score and while scoring is enabled — when that score falls in the bad/ok
186 + * range. The empty-value check is skipped for fields whose post type has a configured fallback template,
187 + * since template-defaulted posts are not genuinely empty. The selected fields are OR-ed inside a single
188 + * group so they broaden the result without interfering with the other filters, and unknown field keys
189 + * are ignored.
190 + *
191 + * @param ORM $builder The query to add the clause to.
192 + * @param array<string> $fields The fields that need improvement.
193 + * @param bool $scores_enabled Whether the per-field scores may back the filter.
194 + * @param string $post_type The post type slug.
195 + *
196 + * @return void
197 + */
198 + private function apply_needs_improvement( ORM $builder, array $fields, bool $scores_enabled, string $post_type ): void {
199 + $has_fallback = [
200 + 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '',
201 + 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '',
202 + 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '',
203 + 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '',
204 + ];
205 +
206 + $clauses = [];
207 + $values = [];
208 + foreach ( $fields as $field ) {
209 + if ( ! isset( self::FIELD_COLUMNS[ $field ] ) ) {
210 + continue;
211 + }
212 +
213 + $column = self::FIELD_COLUMNS[ $field ];
214 + $field_clauses = [];
215 +
216 + if ( ! ( $has_fallback[ $field ] ?? false ) ) {
217 + $field_clauses[] = $column . ' IS NULL OR ' . $column . ' = %s';
218 + $values[] = '';
219 + }
220 +
221 + if ( $scores_enabled && isset( self::FIELD_SCORE_COLUMNS[ $field ] ) ) {
222 + $field_clauses[] = self::FIELD_SCORE_COLUMNS[ $field ] . ' BETWEEN %d AND %d';
223 + $values[] = self::NEEDS_IMPROVEMENT_MIN_SCORE;
224 + $values[] = self::NEEDS_IMPROVEMENT_MAX_SCORE;
225 + }
226 +
227 + // Always add a clause per field — use a false condition when no real predicate applies so the
228 + // field still participates in the outer OR group without incorrectly matching every row.
229 + $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '1 = 0' ) . ' )';
230 + }
231 +
232 + if ( $clauses === [] ) {
233 + return;
234 + }
235 +
236 + // The column names come from the internal maps, never from input; only the empty string and score bounds are bound.
237 + $builder->where_raw(
238 + '( ' . \implode( ' OR ', $clauses ) . ' )',
239 + $values,
240 + );
241 + }
242 +
243 + /**
140 244 * Adds the catch-all search clause to the query.
141 245 *
142 246 * The post title lives in the posts table, so it is matched through a subquery while the remaining
143 247 * fields are matched directly on the indexable. All clauses are OR-ed inside a single group so they
@@ -171,14 +275,15 @@
171 275 *
172 276 * The SEO data and edit link of a post the current user cannot edit are withheld, so the post is
173 277 * shown in the list but stays locked and does not expose its metadata.
174 278 *
175 - * @param Indexable $indexable The indexable.
176 - * @param bool $editable Whether the current user may edit the post.
279 + * @param Indexable $indexable The indexable.
280 + * @param bool $editable Whether the current user may edit the post.
281 + * @param bool $scores_enabled Whether the per-field scores may back the needs-improvement verdict.
177 282 *
178 283 * @return Post The post.
179 284 */
180 - private function build_post( Indexable $indexable, bool $editable ): Post {
285 + private function build_post( Indexable $indexable, bool $editable, bool $scores_enabled ): Post {
181 286 $object_id = (int) $indexable->object_id;
182 287 $title = $this->get_normalized_title( $object_id );
183 288
184 289 if ( ! $editable ) {
@@ -184,8 +289,23 @@
184 289 if ( ! $editable ) {
185 290 return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false );
186 291 }
187 292
293 + $post_type = (string) $indexable->object_sub_type;
294 +
295 + $raw_seo_title = (string) $indexable->title;
296 + $raw_meta_description = (string) $indexable->description;
297 + $raw_social_title = (string) $indexable->open_graph_title;
298 + $raw_social_description = (string) $indexable->open_graph_description;
299 +
300 + // Resolver results are used for needs-improvement scoring and as display fallbacks when the stored value is empty.
301 + $resolved_values = [
302 + 'seo_title' => $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, $raw_seo_title ),
303 + 'meta_description' => $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, $raw_meta_description ),
304 + 'social_title' => $this->default_template_resolver->resolve_social_title( $object_id, $post_type, $raw_social_title ),
305 + 'social_description' => $this->default_template_resolver->resolve_social_description( $object_id, $post_type, $raw_social_description ),
306 + ];
307 +
188 308 return new Post(
189 309 $object_id,
190 310 $title,
191 311 (string) $indexable->post_status,
@@ -190,12 +310,48 @@
190 310 $title,
191 311 (string) $indexable->post_status,
192 312 (string) \get_edit_post_link( $object_id, 'raw' ),
193 313 (string) $indexable->primary_focus_keyword,
194 - (string) $indexable->title,
195 - (string) $indexable->description,
196 - (string) $indexable->open_graph_title,
197 - (string) $indexable->open_graph_description,
314 + $raw_seo_title,
315 + $raw_meta_description,
316 + $raw_social_title,
317 + $raw_social_description,
198 318 true,
319 + $this->build_needs_improvement( $indexable, $scores_enabled, $resolved_values ),
320 + ( $raw_seo_title === '' ) ? $resolved_values['seo_title'] : '',
321 + ( $raw_meta_description === '' ) ? $resolved_values['meta_description'] : '',
322 + ( $raw_social_title === '' ) ? $resolved_values['social_title'] : '',
323 + ( $raw_social_description === '' ) ? $resolved_values['social_description'] : '',
199 324 );
325 + }
326 +
327 + /**
328 + * Builds the per-field needs-improvement verdict for a post, keyed by field param.
329 + *
330 + * A field needs improvement when its value is empty, or when its score falls in the bad/ok range.
331 + * All four display values are passed in already-resolved so that a post whose stored value is empty
332 + * but whose post type has a configured default template is not incorrectly flagged.
333 + *
334 + * @param Indexable $indexable The indexable.
335 + * @param bool $scores_enabled Whether the per-field scores may back the verdict.
336 + * @param array<string, string> $resolved_values The resolved display values, keyed by field param.
337 + *
338 + * @return array<string, bool> Whether each field needs improvement, keyed by field param.
339 + */
340 + private function build_needs_improvement( Indexable $indexable, bool $scores_enabled, array $resolved_values ): array {
341 + $needs_improvement = [];
342 + foreach ( self::FIELD_COLUMNS as $field => $column ) {
343 + $value = $resolved_values[ $field ];
344 + $is_empty = ( $value === '' );
345 +
346 + $is_bad_score = false;
347 + if ( $scores_enabled && isset( self::FIELD_SCORE_COLUMNS[ $field ] ) ) {
348 + $score = (int) $indexable->{self::FIELD_SCORE_COLUMNS[ $field ]};
349 + $is_bad_score = ( $score >= self::NEEDS_IMPROVEMENT_MIN_SCORE && $score <= self::NEEDS_IMPROVEMENT_MAX_SCORE );
350 + }
351 +
352 + $needs_improvement[ $field ] = ( $is_empty || $is_bad_score );
353 + }
354 +
355 + return $needs_improvement;
200 356 }
201 357 }