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 / indexable-posts-collector.php

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

358 lines 13.9 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 Yoast\WP\Lib\ORM;
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 use Yoast\WP\SEO\Models\Indexable;
13 use Yoast\WP\SEO\Repositories\Indexable_Repository;
14
15 /**
16 * Collects bulk editor posts by reading from the indexable table.
17 *
18 * This is the default used when indexables are active.
19 */
20 class Indexable_Posts_Collector implements Posts_Collector_Interface {
21
22 use Post_Title_Trait;
23 use Searchable_Fields_Trait;
24
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 /**
50 * The indexable repository.
51 *
52 * @var Indexable_Repository
53 */
54 private $indexable_repository;
55
56 /**
57 * The resolver for the per-post edit permission.
58 *
59 * @var Post_Editability_Resolver
60 */
61 private $post_editability_resolver;
62
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 /**
71 * The constructor.
72 *
73 * @param Indexable_Repository $indexable_repository The indexable repository.
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.
76 */
77 public function __construct(
78 Indexable_Repository $indexable_repository,
79 Post_Editability_Resolver $post_editability_resolver,
80 Default_Template_Resolver $default_template_resolver
81 ) {
82 $this->indexable_repository = $indexable_repository;
83 $this->post_editability_resolver = $post_editability_resolver;
84 $this->default_template_resolver = $default_template_resolver;
85 }
86
87 /**
88 * Collects a page of posts for the given query.
89 *
90 * A single page is fetched and counted through the database; the per-post edit permission is then
91 * resolved for that page so posts the user cannot edit are returned locked and without their SEO data.
92 *
93 * @param Posts_Query $query The query describing the page to collect.
94 *
95 * @return Posts_Page The collected posts together with the totals for pagination.
96 */
97 public function get_posts( Posts_Query $query ): Posts_Page {
98 $indexables = $this->build_query( $query )
99 ->order_by_desc( 'object_id' )
100 ->limit( $query->get_per_page() )
101 ->offset( $query->get_offset() )
102 ->find_many();
103
104 // Deduplicate on the post, keeping the query order.
105 $indexables_by_id = [];
106 foreach ( $indexables as $indexable ) {
107 $indexables_by_id[ (int) $indexable->object_id ] = $indexable;
108 }
109
110 $total = $this->resolve_total( $query, \count( $indexables ), \count( $indexables_by_id ) );
111
112 $editability = $this->post_editability_resolver->resolve( \array_keys( $indexables_by_id ) );
113
114 $posts_list = new Posts_List();
115 foreach ( $indexables_by_id as $object_id => $indexable ) {
116 $posts_list->add( $this->build_post( $indexable, ( $editability[ $object_id ] ?? false ), $query->are_scores_enabled() ) );
117 }
118
119 return new Posts_Page( $posts_list, $total, $query->get_page(), $query->get_per_page() );
120 }
121
122 /**
123 * Resolves the total number of matching posts.
124 *
125 * A partially-filled page means the result set ended within it, so the total is known without a
126 * second query: the offset plus the distinct posts on this page. Whether the page ended is judged
127 * on the number of rows fetched, before duplicates are removed, since that is what reveals the
128 * database had no more rows. A full or empty page does not reveal the total, so it falls back to a
129 * count query. Non-editable posts are shown locked rather than removed, so they still count.
130 *
131 * @param Posts_Query $query The query that produced the page.
132 * @param int $fetched The number of rows the page returned, before duplicates are removed.
133 * @param int $distinct The number of distinct posts on the page, after duplicates are removed.
134 *
135 * @return int The total number of matching posts.
136 */
137 private function resolve_total( Posts_Query $query, int $fetched, int $distinct ): int {
138 if ( $fetched > 0 && $fetched < $query->get_per_page() ) {
139 return ( $query->get_offset() + $distinct );
140 }
141
142 return (int) $this->build_query( $query )->count();
143 }
144
145 /**
146 * Builds the filtered indexable query for the given query, without ordering or paging.
147 *
148 * Built fresh for each use so the same filters back both the total count and the page of rows.
149 *
150 * @param Posts_Query $query The query describing the filters to apply.
151 *
152 * @return ORM The filtered query.
153 */
154 private function build_query( Posts_Query $query ): ORM {
155 $builder = $this->indexable_repository->query()
156 ->where( 'object_type', 'post' )
157 ->where( 'object_sub_type', $query->get_content_type() )
158 ->where_in( 'post_status', $query->get_statuses() )
159 // Password-protected posts (is_protected) are not shown in bulk editing.
160 ->where( 'is_protected', 0 );
161
162 if ( $query->has_author_filter() ) {
163 $builder->where( 'author_id', $query->get_author_id() );
164 }
165
166 if ( $query->has_include() ) {
167 $builder->where_in( 'object_id', $query->get_include_ids() );
168 }
169
170 if ( $query->has_search() ) {
171 $this->apply_search( $builder, $query->get_search() );
172 }
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
178 return $builder;
179 }
180
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 /**
244 * Adds the catch-all search clause to the query.
245 *
246 * The post title lives in the posts table, so it is matched through a subquery while the remaining
247 * fields are matched directly on the indexable. All clauses are OR-ed inside a single group so they
248 * do not interfere with the other filters.
249 *
250 * @param ORM $builder The query to add the search clause to.
251 * @param string $search The search term.
252 *
253 * @return void
254 */
255 private function apply_search( ORM $builder, string $search ): void {
256 global $wpdb;
257
258 $like = '%' . $wpdb->esc_like( $search ) . '%';
259
260 // The post title lives in the posts table, so match it through a subquery; the rest are indexable columns.
261 $clauses = [ 'object_id IN ( SELECT ID FROM ' . $wpdb->posts . ' WHERE post_title LIKE %s )' ];
262 foreach ( \array_keys( $this->searchable_fields() ) as $column ) {
263 $clauses[] = $column . ' LIKE %s';
264 }
265
266 // The ORM binds each %s through $wpdb->prepare; one bound value per clause.
267 $builder->where_raw(
268 '( ' . \implode( ' OR ', $clauses ) . ' )',
269 \array_fill( 0, \count( $clauses ), $like ),
270 );
271 }
272
273 /**
274 * Builds a post from an indexable.
275 *
276 * The SEO data and edit link of a post the current user cannot edit are withheld, so the post is
277 * shown in the list but stays locked and does not expose its metadata.
278 *
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.
282 *
283 * @return Post The post.
284 */
285 private function build_post( Indexable $indexable, bool $editable, bool $scores_enabled ): Post {
286 $object_id = (int) $indexable->object_id;
287 $title = $this->get_normalized_title( $object_id );
288
289 if ( ! $editable ) {
290 return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false );
291 }
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
308 return new Post(
309 $object_id,
310 $title,
311 (string) $indexable->post_status,
312 (string) \get_edit_post_link( $object_id, 'raw' ),
313 (string) $indexable->primary_focus_keyword,
314 $raw_seo_title,
315 $raw_meta_description,
316 $raw_social_title,
317 $raw_social_description,
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'] : '',
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;
356 }
357 }
358