← All changes
|
src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
+165
-9
28.1
→
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 | } |