PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-snippet-index.php +614 -88 2.9.0 → 2.11.0 View file →
@@ -24,20 +24,28 @@
24 24 * made a page of 20 cost as much as the whole post type, which a store with
25 25 * 20,000 products cannot afford (#727 review).
26 26 *
27 27 * So each post's verdict is computed once and stored in one post meta value,
28 - * `{generation}:{flags}:{duplicate key}`, and every list, count and page is
29 - * answered by SQL over it:
28 + * `{generation}:{flags}:{title key}:{description key}`, and every list, count
29 + * and page is answered by SQL over it:
30 30 *
31 31 * - **flags** is a bitmask of the issues that depend on the post alone.
32 - * - **duplicate key** is a hash of what produces the post's title
33 - * ({@see Snippet_Issues::duplicate_key()}). Duplicates are found at query
34 - * time by grouping on it, so fixing one of two duplicates clears the other
35 - * without touching the other's entry.
32 + * - **title key** is a hash of the title the page renders
33 + * ({@see Snippet_Issues::duplicate_key()}), and **description key** a hash
34 + * of the description it renders
35 + * ({@see Snippet_Issues::description_duplicate_key()}). Duplicates are found
36 + * at query time by grouping on them, so fixing one of two duplicates clears
37 + * the other without touching the other's entry.
36 38 * - **generation** ties the entry to the global inputs (templates, robots
37 39 * settings, site name, separator). Changing any of them bumps the
38 40 * generation, which makes every entry stale at once without a write per post.
39 41 *
42 + * Grouping is **sitewide**: two pages carrying the same title are competing
43 + * with each other whether or not they are the same post type, which is how a
44 + * post and a page built from one bad template collide (#564). Only entries at
45 + * the current generation take part, so a post type the index has not reached
46 + * yet cannot invent a duplicate — coverage growing can only reveal more.
47 + *
40 48 * A post's own inputs (its title, content, SEO meta, robots meta, terms) mark
41 49 * just that post stale. Stale entries are rebuilt a bounded batch at a time by
42 50 * {@see self::refresh()}, so no request does unbounded work.
43 51 *
@@ -55,8 +63,61 @@
55 63 */
56 64 public const GENERATION_OPTION = 'thinkrank_snippet_index_generation';
57 65
58 66 /**
67 + * Option counting how many times entries have been rebuilt.
68 + *
69 + * The generation answers "did a global input change". This answers "did
70 + * any entry change", which is what a report derived from the whole index
71 + * has to know: one post's title edited to match another's changes the
72 + * duplicate report without touching the generation. Bumped once per batch
73 + * rather than once per entry, so a 500-post rebuild is one extra write.
74 + *
75 + * @since 2.10.0
76 + */
77 + public const REVISION_OPTION = 'thinkrank_snippet_index_revision';
78 +
79 + /**
80 + * Version of the rules that turn a snapshot into an entry.
81 + *
82 + * Entries carry no record of how their keys were computed, so a change to
83 + * those rules would leave every stored entry "current" with a key the new
84 + * code would never produce: two posts indexed before a change and one
85 + * after could not group. Raising this bumps the generation once on the
86 + * next request, so everything is rebuilt under the new rules.
87 + *
88 + * - 2: keys compare entity-decoded text, and lengths are measured on it
89 + * ({@see Snippet_Issues::duplicate_key()}).
90 + *
91 + * @since 2.10.0
92 + */
93 + public const KEY_FORMAT = 2;
94 +
95 + /**
96 + * Option recording the {@see self::KEY_FORMAT} the stored entries follow.
97 + *
98 + * @since 2.10.0
99 + */
100 + public const KEY_FORMAT_OPTION = 'thinkrank_snippet_index_key_format';
101 +
102 + /**
103 + * Taxonomy whose term names render into titles, through `%category%`.
104 + *
105 + * @since 2.10.0
106 + */
107 + private const RENDERED_TAXONOMY = 'category';
108 +
109 + /**
110 + * Term names captured before an edit, keyed by term ID, so the edit can
111 + * tell whether the name, the only thing a title renders, changed.
112 + *
113 + * @since 2.10.0
114 + *
115 + * @var array<int,string>
116 + */
117 + private static $term_names_before = [];
118 +
119 + /**
59 120 * Most entries one refresh call will build, and the time it may spend.
60 121 */
61 122 private const REFRESH_MAX_POSTS = 500;
62 123 private const REFRESH_MAX_SECONDS = 2.0;
@@ -79,8 +140,12 @@
79 140 private const WATCHED_OPTIONS = [
80 141 'thinkrank_global_seo_settings',
81 142 'thinkrank_global_robot_meta_settings',
82 143 'blogname',
144 + // %date% and %modified% render through get_the_date() in the site's
145 + // date format, and in its language.
146 + 'date_format',
147 + 'WPLANG',
83 148 ];
84 149
85 150 /**
86 151 * Register invalidation hooks. Runs on every request, not only on the
@@ -104,11 +169,119 @@
104 169 add_action('thinkrank_seo_settings_saved', [self::class, 'bump_generation'], 10, 0);
105 170
106 171 // %author% renders the display name.
107 172 add_action('profile_update', [self::class, 'bump_generation'], 10, 0);
173 +
174 + // Deleting a user and attributing their posts to someone else
175 + // rewrites post_author in one query, with no save_post for any post,
176 + // so %author% changes on every one of them unseen.
177 + add_action('deleted_user', [self::class, 'on_user_deleted'], 10, 2);
178 +
179 + // %category% renders the first category's name, and renaming a
180 + // category changes it on every post filed there without touching any
181 + // of them. Assigning or removing a category already arrives through
182 + // set_object_terms above, including the reassignment wp_delete_term()
183 + // does.
184 + add_action('edit_terms', [self::class, 'before_term_edit'], 10, 2);
185 + add_action('edited_term', [self::class, 'after_term_edit'], 10, 3);
186 +
187 + self::maybe_upgrade_key_format();
108 188 }
109 189
110 190 /**
191 + * Rebuild everything once when the entry rules have changed since the
192 + * stored entries were written. See {@see self::KEY_FORMAT}.
193 + *
194 + * The option is autoloaded, so the check on every request after the first
195 + * is a read from memory.
196 + *
197 + * @since 2.10.0
198 + *
199 + * @return void
200 + */
201 + public static function maybe_upgrade_key_format(): void {
202 + if ((int) get_option(self::KEY_FORMAT_OPTION, 1) >= self::KEY_FORMAT) {
203 + return;
204 + }
205 +
206 + self::bump_generation();
207 + update_option(self::KEY_FORMAT_OPTION, self::KEY_FORMAT, true);
208 + }
209 +
210 + /**
211 + * A user was deleted.
212 + *
213 + * @since 2.10.0
214 + *
215 + * @param int|mixed $user_id Deleted user.
216 + * @param int|null|mixed $reassign User their posts went to, or null when
217 + * the posts were deleted with them.
218 + * @return void
219 + */
220 + public static function on_user_deleted($user_id, $reassign = null): void {
221 + // Without a reassignment the posts were deleted, which drops them
222 + // from every scope on its own.
223 + if (null !== $reassign && (int) $reassign > 0) {
224 + self::bump_generation();
225 + }
226 + }
227 +
228 + /**
229 + * Remember a category's name before it is edited.
230 + *
231 + * @since 2.10.0
232 + *
233 + * @param int|mixed $term_id Term ID.
234 + * @param string|mixed $taxonomy Taxonomy.
235 + * @return void
236 + */
237 + public static function before_term_edit($term_id, $taxonomy): void {
238 + if (self::RENDERED_TAXONOMY !== $taxonomy) {
239 + return;
240 + }
241 +
242 + $term = get_term((int) $term_id, self::RENDERED_TAXONOMY);
243 + if ($term instanceof \WP_Term) {
244 + self::$term_names_before[(int) $term_id] = (string) $term->name;
245 + }
246 + }
247 +
248 + /**
249 + * A term was edited: rebuild everything when a category's name changed.
250 + *
251 + * Only the name renders, so a description, slug or parent edit is left
252 + * alone; a rename is rare, and when it happens the posts filed under it
253 + * are exactly the ones whose titles moved. Marking just those would mean a
254 + * write per post in a category that may hold thousands, where the
255 + * generation is one write and the rebuild is already bounded per request.
256 + * Without a captured name, it is treated as renamed: a spurious rebuild
257 + * costs time, a missed one reports stale duplicates.
258 + *
259 + * @since 2.10.0
260 + *
261 + * @param int|mixed $term_id Term ID.
262 + * @param int|mixed $tt_id Term taxonomy ID.
263 + * @param string|mixed $taxonomy Taxonomy.
264 + * @return void
265 + */
266 + public static function after_term_edit($term_id, $tt_id, $taxonomy): void {
267 + if (self::RENDERED_TAXONOMY !== $taxonomy) {
268 + return;
269 + }
270 +
271 + $term_id = (int) $term_id;
272 + $before = self::$term_names_before[$term_id] ?? null;
273 + unset(self::$term_names_before[$term_id]);
274 +
275 + $term = get_term($term_id, self::RENDERED_TAXONOMY);
276 + if (null !== $before && $term instanceof \WP_Term && (string) $term->name === $before) {
277 + return;
278 + }
279 +
280 + self::bump_generation();
281 + }
282 +
283 + /**
111 284 * Current generation.
112 285 *
113 286 * @return int
114 287 */
@@ -125,8 +298,31 @@
125 298 update_option(self::GENERATION_OPTION, self::generation() + 1, true);
126 299 }
127 300
128 301 /**
302 + * How many times a batch of entries has been rebuilt.
303 + *
304 + * @since 2.10.0
305 + *
306 + * @return int
307 + */
308 + public static function revision(): int {
309 + return (int) get_option(self::REVISION_OPTION, 0);
310 + }
311 +
312 + /**
313 + * Record that entries changed. Not autoloaded: only a report that spans
314 + * the whole index reads it, and never on a front-end request.
315 + *
316 + * @since 2.10.0
317 + *
318 + * @return void
319 + */
320 + private static function bump_revision(): void {
321 + update_option(self::REVISION_OPTION, self::revision() + 1, false);
322 + }
323 +
324 + /**
129 325 * Make one post's entry stale.
130 326 *
131 327 * @param int|mixed $post_id Post ID.
132 328 * @return void
@@ -170,44 +366,104 @@
170 366
171 367 /**
172 368 * Encode an entry.
173 369 *
174 - * @param int $generation Generation it was built under.
175 - * @param int $flags Bitmask from {@see Snippet_Issues::to_flags()}.
176 - * @param string $dup_key Duplicate key ('' when there is nothing to compare).
370 + * @since 2.10.0 Carries a description key as well as a title key.
371 + *
372 + * @param int $generation Generation it was built under.
373 + * @param int $flags Bitmask from {@see Snippet_Issues::to_flags()}.
374 + * @param string $title_key Title duplicate key ('' when there is nothing to compare).
375 + * @param string $description_key Description duplicate key ('' when there is nothing to compare).
177 376 * @return string
178 377 */
179 - public static function encode(int $generation, int $flags, string $dup_key): string {
180 - return $generation . ':' . $flags . ':' . ('' === $dup_key ? '-' : md5($dup_key));
378 + public static function encode(int $generation, int $flags, string $title_key, string $description_key): string {
379 + return $generation . ':' . $flags
380 + . ':' . ('' === $title_key ? '-' : md5($title_key))
381 + . ':' . ('' === $description_key ? '-' : md5($description_key));
181 382 }
182 383
183 384 /**
184 385 * Decode an entry.
185 386 *
387 + * Entries written before 2.10.0 have three fields rather than four. They
388 + * are rejected here and, because {@see self::current_like()} matches on the
389 + * field count too, they read as stale and are rebuilt — so no upgrade
390 + * routine is needed to migrate the format.
391 + *
186 392 * @param string $value Stored value.
187 - * @return array{generation:int, flags:int, dup:string}|null Null when malformed.
393 + * @return array{generation:int, flags:int, title:string, description:string}|null Null when malformed.
188 394 */
189 395 public static function decode(string $value): ?array {
190 396 $parts = explode(':', $value);
191 - if (3 !== count($parts) || !ctype_digit($parts[0]) || !ctype_digit($parts[1])) {
397 + if (4 !== count($parts) || !ctype_digit($parts[0]) || !ctype_digit($parts[1])) {
192 398 return null;
193 399 }
194 400
195 401 return [
196 - 'generation' => (int) $parts[0],
197 - 'flags' => (int) $parts[1],
198 - 'dup' => '-' === $parts[2] ? '' : $parts[2],
402 + 'generation' => (int) $parts[0],
403 + 'flags' => (int) $parts[1],
404 + 'title' => '-' === $parts[2] ? '' : $parts[2],
405 + 'description' => '-' === $parts[3] ? '' : $parts[3],
199 406 ];
200 407 }
201 408
202 409 /**
203 - * Build a bounded batch of stale entries.
410 + * LIKE pattern matching an entry that is current — right generation *and*
411 + * right format.
204 412 *
413 + * The three trailing wildcards mean three colons after the generation, so
414 + * a pre-2.10.0 three-field entry does not match even at the current
415 + * generation. That is the whole migration: it reads as stale.
416 + *
417 + * @since 2.10.0
418 + *
419 + * @return string
420 + */
421 + private static function current_like(): string {
422 + global $wpdb;
423 +
424 + return $wpdb->esc_like(self::generation() . ':') . '%:%:%';
425 + }
426 +
427 + /**
428 + * Build a bounded batch of stale entries for one post type.
429 + *
205 430 * @param string $post_type Post type.
206 431 * @param string[] $statuses Post statuses.
207 432 * @return int How many stale entries remain after this batch.
208 433 */
209 434 public static function refresh(string $post_type, array $statuses): int {
435 + return self::refresh_scope(self::scope_sql([$post_type], $statuses, 'p'));
436 + }
437 +
438 + /**
439 + * Build a bounded batch of stale entries across every post type given.
440 + *
441 + * The duplicate report is sitewide, so it needs entries for post types the
442 + * Bulk Snippets screen may never have been opened on. Same batch bounds as
443 + * {@see self::refresh()} — the caller keeps asking until it returns zero.
444 + *
445 + * @since 2.10.0
446 + *
447 + * @param string[] $post_types Post types.
448 + * @param string[] $statuses Post statuses.
449 + * @return int How many stale entries remain after this batch.
450 + */
451 + public static function refresh_sitewide(array $post_types, array $statuses): int {
452 + if (empty($post_types)) {
453 + return 0;
454 + }
455 +
456 + return self::refresh_scope(self::scope_sql($post_types, $statuses, 'p'));
457 + }
458 +
459 + /**
460 + * Build a bounded batch of the stale entries a scope covers.
461 + *
462 + * @param string $scope_sql Trusted WHERE fragment from {@see self::scope_sql()}.
463 + * @return int How many stale entries remain after this batch.
464 + */
465 + private static function refresh_scope(string $scope_sql): int {
210 466 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
211 467 global $wpdb;
212 468
213 469 $generation = self::generation();
@@ -214,24 +470,22 @@
214 470 $started = microtime(true);
215 471 $ids = $wpdb->get_col($wpdb->prepare(
216 472 "SELECT p.ID FROM {$wpdb->posts} p
217 473 LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
218 - WHERE " . self::scope_sql($post_type, $statuses, 'p') . "
474 + WHERE " . $scope_sql . "
219 475 AND (m.meta_value IS NULL OR m.meta_value NOT LIKE %s)
220 476 ORDER BY p.ID DESC
221 477 LIMIT %d",
222 478 self::META_KEY,
223 - $wpdb->esc_like($generation . ':') . '%',
479 + self::current_like(),
224 480 self::REFRESH_MAX_POSTS
225 481 ));
226 482
227 - $built = 0;
228 483 foreach (array_chunk(array_map('intval', $ids), 100) as $chunk) {
229 484 _prime_post_caches($chunk, false, true);
230 485
231 486 foreach ($chunk as $post_id) {
232 487 self::build($post_id, $generation);
233 - $built++;
234 488 }
235 489
236 490 if (microtime(true) - $started > self::REFRESH_MAX_SECONDS) {
237 491 break;
@@ -237,9 +491,13 @@
237 491 break;
238 492 }
239 493 }
240 494
241 - return max(0, self::stale_count($post_type, $statuses) );
495 + if (!empty($ids)) {
496 + self::bump_revision();
497 + }
498 +
499 + return max(0, self::stale_count_in($scope_sql));
242 500 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
243 501 }
244 502
245 503 /**
@@ -259,8 +517,10 @@
259 517 _prime_post_caches($post_ids, false, true);
260 518 foreach ($post_ids as $post_id) {
261 519 self::build($post_id, $generation);
262 520 }
521 +
522 + self::bump_revision();
263 523 }
264 524
265 525 /**
266 526 * Compute and store one entry.
@@ -276,15 +536,19 @@
276 536 }
277 537
278 538 $snapshot = Snippet_Issues::snapshot($post);
279 539 $flags = Snippet_Issues::to_flags(Snippet_Issues::evaluate($snapshot));
280 - $dup_key = Snippet_Issues::duplicate_key($snapshot['raw_title'], $post->post_title);
281 540
282 - update_post_meta($post_id, self::META_KEY, self::encode($generation, $flags, $dup_key));
541 + update_post_meta($post_id, self::META_KEY, self::encode(
542 + $generation,
543 + $flags,
544 + Snippet_Issues::duplicate_key((string) $snapshot['effective_title']),
545 + Snippet_Issues::description_duplicate_key((string) $snapshot['effective_description'])
546 + ));
283 547 }
284 548
285 549 /**
286 - * How many entries in scope are missing or stale.
550 + * How many entries for one post type are missing or stale.
287 551 *
288 552 * @param string $post_type Post type.
289 553 * @param string[] $statuses Post statuses.
290 554 * @return int
@@ -289,17 +553,27 @@
289 553 * @param string[] $statuses Post statuses.
290 554 * @return int
291 555 */
292 556 public static function stale_count(string $post_type, array $statuses): int {
557 + return self::stale_count_in(self::scope_sql([$post_type], $statuses, 'p'));
558 + }
559 +
560 + /**
561 + * How many entries the given scope is missing or has stale.
562 + *
563 + * @param string $scope_sql Trusted WHERE fragment from {@see self::scope_sql()}.
564 + * @return int
565 + */
566 + private static function stale_count_in(string $scope_sql): int {
293 567 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
294 568 global $wpdb;
295 569 return (int) $wpdb->get_var($wpdb->prepare(
296 570 "SELECT COUNT(*) FROM {$wpdb->posts} p
297 571 LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
298 - WHERE " . self::scope_sql($post_type, $statuses, 'p') . "
572 + WHERE " . $scope_sql . "
299 573 AND (m.meta_value IS NULL OR m.meta_value NOT LIKE %s)",
300 574 self::META_KEY,
301 - $wpdb->esc_like(self::generation() . ':') . '%'
575 + self::current_like()
302 576 ));
303 577 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
304 578 }
305 579
@@ -311,8 +585,9 @@
311 585 *
312 586 * @param array{
313 587 * post_type: string,
314 588 * statuses: string[],
589 + * group_post_types: string[],
315 590 * issue: string,
316 591 * search: string,
317 592 * page: int,
318 593 * per_page: int,
@@ -317,37 +592,36 @@
317 592 * page: int,
318 593 * per_page: int,
319 594 * visibility_sql: string
320 595 * } $args Query arguments. visibility_sql is a trusted WHERE fragment from
321 - * {@see self::visibility_sql()} ('' for no restriction).
322 - * @return array{ids: int[], total: int, counts: array<string,int>, with_problem: int, duplicate_ids: int[]}
596 + * {@see self::visibility_sql()} ('' for no restriction), and
597 + * group_post_types are the post types duplicates are looked for
598 + * across ({@see Global_SEO_Post_Types::allowed()}).
599 + * @return array{ids: int[], total: int, counts: array<string,int>, with_problem: int, duplicate_ids: int[], duplicate_description_ids: int[]}
323 600 */
324 601 public static function query(array $args): array {
325 602 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
326 603 global $wpdb;
327 604
328 - $generation = self::generation();
329 - $current = $wpdb->esc_like($generation . ':') . '%';
605 + $current = self::current_like();
330 606
331 607 $flags_sql = "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(m.meta_value, ':', 2), ':', -1) AS UNSIGNED)";
332 - $dup_sql = "SUBSTRING_INDEX(m.meta_value, ':', -1)";
608 + $title_key_sql = self::key_sql('m', 'title');
609 + $description_key_sql = self::key_sql('m', 'description');
333 610
334 611 // Duplicates are about the site, not about what this user may see: a
335 612 // title another post already uses is a problem whether or not the
336 613 // viewer can open that post. Only the flag is revealed, never the peer.
337 - $dupes_sql = $wpdb->prepare(
338 - "SELECT SUBSTRING_INDEX(dm.meta_value, ':', -1) AS dk
339 - FROM {$wpdb->posts} dp
340 - INNER JOIN {$wpdb->postmeta} dm ON dm.post_id = dp.ID AND dm.meta_key = %s
341 - WHERE " . self::scope_sql($args['post_type'], $args['statuses'], 'dp') . "
342 - AND dm.meta_value LIKE %s
343 - GROUP BY dk
344 - HAVING COUNT(*) > 1 AND dk <> '-'",
345 - self::META_KEY,
346 - $current
347 - );
614 + //
615 + // The scope is every post type ThinkRank manages, not the one being
616 + // listed: a page and a post carrying the same title compete with each
617 + // other (#564). Post types whose entries are still stale simply do not
618 + // take part yet, which can hide a duplicate but never invent one.
619 + $group_types = self::group_post_types($args);
620 + $title_dupes_sql = self::duplicate_group_sql($group_types, $args['statuses'], 'title');
621 + $description_dupes_sql = self::duplicate_group_sql($group_types, $args['statuses'], 'description');
348 622
349 - $where = self::scope_sql($args['post_type'], $args['statuses'], 'p')
623 + $where = self::scope_sql([$args['post_type']], $args['statuses'], 'p')
350 624 . $wpdb->prepare(' AND m.meta_value LIKE %s', $current);
351 625
352 626 if ('' !== $args['visibility_sql']) {
353 627 $where .= ' AND (' . $args['visibility_sql'] . ')';
@@ -359,12 +633,18 @@
359 633 }
360 634
361 635 $from = "{$wpdb->posts} p
362 636 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '" . esc_sql(self::META_KEY) . "'
363 - LEFT JOIN ({$dupes_sql}) d ON d.dk = {$dup_sql}";
637 + LEFT JOIN ({$title_dupes_sql}) d ON d.dk = {$title_key_sql}
638 + LEFT JOIN ({$description_dupes_sql}) e ON e.dk = {$description_key_sql}";
364 639
365 640 // One pass for every chip count.
366 - $select = ['COUNT(*) AS total', "SUM(({$flags_sql}) > 0 OR d.dk IS NOT NULL) AS with_problem", 'SUM(d.dk IS NOT NULL) AS ' . Snippet_Issues::DUPLICATE_TITLE];
641 + $select = [
642 + 'COUNT(*) AS total',
643 + "SUM(({$flags_sql}) > 0 OR d.dk IS NOT NULL OR e.dk IS NOT NULL) AS with_problem",
644 + 'SUM(d.dk IS NOT NULL) AS ' . Snippet_Issues::DUPLICATE_TITLE,
645 + 'SUM(e.dk IS NOT NULL) AS ' . Snippet_Issues::DUPLICATE_DESCRIPTION,
646 + ];
367 647 foreach (Snippet_Issues::flag_bits() as $issue => $bit) {
368 648 $select[] = "SUM(({$flags_sql} & {$bit}) > 0) AS {$issue}";
369 649 }
370 650 $row = (array) $wpdb->get_row('SELECT ' . implode(', ', $select) . " FROM {$from} WHERE {$where}", ARRAY_A);
@@ -376,8 +656,10 @@
376 656
377 657 $issue_sql = '';
378 658 if (Snippet_Issues::DUPLICATE_TITLE === $args['issue']) {
379 659 $issue_sql = ' AND d.dk IS NOT NULL';
660 + } elseif (Snippet_Issues::DUPLICATE_DESCRIPTION === $args['issue']) {
661 + $issue_sql = ' AND e.dk IS NOT NULL';
380 662 } elseif ('' !== $args['issue']) {
381 663 $bits = Snippet_Issues::flag_bits();
382 664 $issue_sql = ' AND (' . $flags_sql . ' & ' . (int) $bits[$args['issue']] . ') > 0';
383 665 }
@@ -384,9 +666,10 @@
384 666
385 667 $total = '' === $args['issue'] ? (int) ($row['total'] ?? 0) : $counts[$args['issue']];
386 668 $offset = max(0, ($args['page'] - 1) * $args['per_page']);
387 669 $page = $wpdb->get_results($wpdb->prepare(
388 - "SELECT p.ID, (d.dk IS NOT NULL) AS dup FROM {$from} WHERE {$where}{$issue_sql}
670 + "SELECT p.ID, (d.dk IS NOT NULL) AS dup, (e.dk IS NOT NULL) AS dup_description
671 + FROM {$from} WHERE {$where}{$issue_sql}
389 672 ORDER BY p.post_date DESC, p.ID DESC LIMIT %d OFFSET %d",
390 673 $args['per_page'],
391 674 $offset
392 675 ), ARRAY_A);
@@ -392,69 +675,301 @@
392 675 ), ARRAY_A);
393 676
394 677 $ids = [];
395 678 $duplicate_ids = [];
679 + $duplicate_description_ids = [];
396 680 foreach ((array) $page as $item) {
397 681 $ids[] = (int) $item['ID'];
398 682 if (!empty($item['dup'])) {
399 683 $duplicate_ids[] = (int) $item['ID'];
400 684 }
685 + if (!empty($item['dup_description'])) {
686 + $duplicate_description_ids[] = (int) $item['ID'];
687 + }
401 688 }
402 689
403 690 return [
404 - 'ids' => $ids,
405 - 'total' => $total,
691 + 'ids' => $ids,
692 + 'total' => $total,
406 693 // Before the issue filter, so the "All" chip keeps its number.
407 - 'total_all' => (int) ($row['total'] ?? 0),
408 - 'counts' => $counts,
409 - 'with_problem' => (int) ($row['with_problem'] ?? 0),
410 - 'duplicate_ids' => $duplicate_ids,
694 + 'total_all' => (int) ($row['total'] ?? 0),
695 + 'counts' => $counts,
696 + 'with_problem' => (int) ($row['with_problem'] ?? 0),
697 + 'duplicate_ids' => $duplicate_ids,
698 + 'duplicate_description_ids' => $duplicate_description_ids,
411 699 ];
412 700 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
413 701 }
414 702
415 703 /**
416 - * Which of these posts share their title with another post in scope.
704 + * Which of these posts share their title, or their description, with
705 + * another post on the site.
417 706 *
418 - * @param string $post_type Post type.
419 - * @param string[] $statuses Post statuses.
420 - * @param int[] $post_ids Posts to check.
421 - * @return int[] The ones that are duplicates.
707 + * @since 2.10.0 Reports descriptions too, and groups across post types.
708 + *
709 + * @param string[] $post_types Post types duplicates are looked for across.
710 + * @param string[] $statuses Post statuses.
711 + * @param int[] $post_ids Posts to check.
712 + * @return array{title:int[], description:int[]} The ones that are duplicates.
422 713 */
423 - public static function duplicates_among(string $post_type, array $statuses, array $post_ids): array {
714 + public static function duplicates_among(array $post_types, array $statuses, array $post_ids): array {
715 + $post_ids = array_values(array_filter(array_map('intval', $post_ids)));
716 + if (empty($post_ids) || empty($post_types)) {
717 + return ['title' => [], 'description' => []];
718 + }
719 +
720 + return [
721 + 'title' => self::duplicates_of($post_types, $statuses, $post_ids, 'title'),
722 + 'description' => self::duplicates_of($post_types, $statuses, $post_ids, 'description'),
723 + ];
724 + }
725 +
726 + /**
727 + * Which of these posts share one kind of key with another post on the site.
728 + *
729 + * @param string[] $post_types Post types duplicates are looked for across.
730 + * @param string[] $statuses Post statuses.
731 + * @param int[] $post_ids Posts to check (already integers, non-empty).
732 + * @param string $which 'title' or 'description'.
733 + * @return int[]
734 + */
735 + private static function duplicates_of(array $post_types, array $statuses, array $post_ids, string $which): array {
424 736 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
425 737 global $wpdb;
426 738
427 - $post_ids = array_values(array_filter(array_map('intval', $post_ids)));
428 - if (empty($post_ids)) {
429 - return [];
430 - }
739 + $key_sql = self::key_sql('m', $which);
740 + $in = implode(',', $post_ids);
741 + $groups = self::duplicate_group_sql($post_types, $statuses, $which);
431 742
432 - $current = $wpdb->esc_like(self::generation() . ':') . '%';
433 - $in = implode(',', $post_ids);
434 743 $rows = $wpdb->get_col($wpdb->prepare(
435 744 "SELECT m.post_id FROM {$wpdb->postmeta} m
436 745 WHERE m.meta_key = %s AND m.post_id IN ({$in}) AND m.meta_value LIKE %s
437 - AND SUBSTRING_INDEX(m.meta_value, ':', -1) <> '-'
438 - AND SUBSTRING_INDEX(m.meta_value, ':', -1) IN (
439 - SELECT SUBSTRING_INDEX(dm.meta_value, ':', -1)
440 - FROM {$wpdb->posts} dp
441 - INNER JOIN {$wpdb->postmeta} dm ON dm.post_id = dp.ID AND dm.meta_key = %s
442 - WHERE " . self::scope_sql($post_type, $statuses, 'dp') . " AND dm.meta_value LIKE %s
443 - GROUP BY SUBSTRING_INDEX(dm.meta_value, ':', -1)
444 - HAVING COUNT(*) > 1
445 - )",
746 + AND {$key_sql} <> '-'
747 + AND {$key_sql} IN (SELECT dk FROM ({$groups}) g)",
446 748 self::META_KEY,
447 - $current,
749 + self::current_like()
750 + ));
751 +
752 + return array_map('intval', (array) $rows);
753 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
754 + }
755 +
756 + /**
757 + * How many posts in scope have a current entry — the size of what the
758 + * duplicate report actually looked at, as opposed to what it will cover
759 + * once {@see self::refresh_sitewide()} has finished.
760 + *
761 + * @since 2.10.0
762 + *
763 + * @param string[] $post_types Post types.
764 + * @param string[] $statuses Post statuses.
765 + * @return int
766 + */
767 + public static function current_count(array $post_types, array $statuses): int {
768 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
769 + global $wpdb;
770 +
771 + if (empty($post_types)) {
772 + return 0;
773 + }
774 +
775 + return (int) $wpdb->get_var($wpdb->prepare(
776 + "SELECT COUNT(*) FROM {$wpdb->posts} p
777 + INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
778 + WHERE " . self::scope_sql($post_types, $statuses, 'p') . "
779 + AND m.meta_value LIKE %s",
448 780 self::META_KEY,
449 - $current
781 + self::current_like()
450 782 ));
783 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
784 + }
451 785
452 - return array_map('intval', (array) $rows);
786 + /**
787 + * How many duplicate groups there are, and how many posts they hold.
788 + *
789 + * Separate from {@see self::duplicate_groups()} because the report lists a
790 + * bounded number of groups but must state the true totals above them — a
791 + * count taken from the listed groups would under-report the moment the
792 + * list is capped.
793 + *
794 + * @since 2.10.0
795 + *
796 + * @param string[] $post_types Post types to group across.
797 + * @param string[] $statuses Post statuses.
798 + * @param string $which 'title' or 'description'.
799 + * @return array{groups:int, posts:int}
800 + */
801 + public static function duplicate_totals(array $post_types, array $statuses, string $which): array {
802 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
803 + global $wpdb;
804 +
805 + if (empty($post_types)) {
806 + return ['groups' => 0, 'posts' => 0];
807 + }
808 +
809 + $groups_sql = self::duplicate_group_sql($post_types, $statuses, $which, true);
810 +
811 + $row = (array) $wpdb->get_row(
812 + "SELECT COUNT(*) AS groups_count, COALESCE(SUM(g.members), 0) AS posts_count
813 + FROM ({$groups_sql}) g",
814 + ARRAY_A
815 + );
816 +
817 + return [
818 + 'groups' => (int) ($row['groups_count'] ?? 0),
819 + 'posts' => (int) ($row['posts_count'] ?? 0),
820 + ];
453 821 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
454 822 }
455 823
456 824 /**
825 + * Every key more than one post in scope shares, with the posts carrying it.
826 + *
827 + * Largest group first, so a template stamping one title on fifty pages is
828 + * the first thing a reader sees rather than something they page to.
829 + *
830 + * The member IDs come back through GROUP_CONCAT so this stays one query
831 + * rather than one per group. `total` is a real COUNT and is exact even
832 + * when the concatenated list was cut short by `group_concat_max_len`,
833 + * which is why the caller reports the count and the names separately.
834 + *
835 + * @since 2.10.0
836 + *
837 + * @param string[] $post_types Post types to group across.
838 + * @param string[] $statuses Post statuses.
839 + * @param string $which 'title' or 'description'.
840 + * @param int $max_members Most member IDs to return per group.
841 + * @param int $max_groups Most groups to return; 0 for all.
842 + * @return array<int,array{key:string, post_ids:int[], total:int}>
843 + */
844 + public static function duplicate_groups(
845 + array $post_types,
846 + array $statuses,
847 + string $which,
848 + int $max_members,
849 + int $max_groups = 0
850 + ): array {
851 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
852 + global $wpdb;
853 +
854 + if (empty($post_types)) {
855 + return [];
856 + }
857 +
858 + $posts_alias = 'title' === $which ? 'dp' : 'ep';
859 + $meta_alias = 'title' === $which ? 'dm' : 'em';
860 + $key_sql = self::key_sql($meta_alias, $which);
861 +
862 + $limit_sql = $max_groups > 0 ? $wpdb->prepare(' LIMIT %d', $max_groups) : '';
863 +
864 + $rows = $wpdb->get_results($wpdb->prepare(
865 + "SELECT {$key_sql} AS dk,
866 + COUNT(*) AS total,
867 + GROUP_CONCAT({$posts_alias}.ID ORDER BY {$posts_alias}.post_date DESC) AS ids
868 + FROM {$wpdb->posts} {$posts_alias}
869 + INNER JOIN {$wpdb->postmeta} {$meta_alias}
870 + ON {$meta_alias}.post_id = {$posts_alias}.ID AND {$meta_alias}.meta_key = %s
871 + WHERE " . self::scope_sql($post_types, $statuses, $posts_alias) . "
872 + AND {$meta_alias}.meta_value LIKE %s
873 + GROUP BY dk
874 + HAVING COUNT(*) > 1 AND dk <> '-'
875 + ORDER BY total DESC, dk ASC" . $limit_sql,
876 + self::META_KEY,
877 + self::current_like()
878 + ), ARRAY_A);
879 +
880 + $groups = [];
881 + foreach ((array) $rows as $row) {
882 + $ids = array_values(array_filter(array_map('intval', explode(',', (string) $row['ids']))));
883 +
884 + $groups[] = [
885 + 'key' => (string) $row['dk'],
886 + 'total' => (int) $row['total'],
887 + 'post_ids' => $max_members > 0 ? array_slice($ids, 0, $max_members) : $ids,
888 + ];
889 + }
890 +
891 + return $groups;
892 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
893 + }
894 +
895 + /**
896 + * The keys shared by more than one post, as a subquery selecting `dk`.
897 + *
898 + * Each kind gets its own table aliases so two of these can appear in one
899 + * statement.
900 + *
901 + * @since 2.10.0
902 + *
903 + * @param string[] $post_types Post types to group across.
904 + * @param string[] $statuses Post statuses.
905 + * @param string $which 'title' or 'description'.
906 + * @param bool $with_counts Also select the group's size as `members`.
907 + * @return string Trusted SQL.
908 + */
909 + private static function duplicate_group_sql(array $post_types, array $statuses, string $which, bool $with_counts = false): string {
910 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
911 + global $wpdb;
912 +
913 + $posts_alias = 'title' === $which ? 'dp' : 'ep';
914 + $meta_alias = 'title' === $which ? 'dm' : 'em';
915 + $key_sql = self::key_sql($meta_alias, $which);
916 + $members_sql = $with_counts ? ', COUNT(*) AS members' : '';
917 +
918 + return $wpdb->prepare(
919 + "SELECT {$key_sql} AS dk{$members_sql}
920 + FROM {$wpdb->posts} {$posts_alias}
921 + INNER JOIN {$wpdb->postmeta} {$meta_alias}
922 + ON {$meta_alias}.post_id = {$posts_alias}.ID AND {$meta_alias}.meta_key = %s
923 + WHERE " . self::scope_sql($post_types, $statuses, $posts_alias) . "
924 + AND {$meta_alias}.meta_value LIKE %s
925 + GROUP BY dk
926 + HAVING COUNT(*) > 1 AND dk <> '-'",
927 + self::META_KEY,
928 + self::current_like()
929 + );
930 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
931 + }
932 +
933 + /**
934 + * SQL reading one key out of a stored entry.
935 + *
936 + * The title key is the third colon-separated field and the description key
937 + * the fourth, which is also the last.
938 + *
939 + * @since 2.10.0
940 + *
941 + * @param string $alias Postmeta table alias.
942 + * @param string $which 'title' or 'description'.
943 + * @return string Trusted SQL.
944 + */
945 + private static function key_sql(string $alias, string $which): string {
946 + $alias = self::alias($alias);
947 +
948 + return 'title' === $which
949 + ? "SUBSTRING_INDEX(SUBSTRING_INDEX({$alias}.meta_value, ':', 3), ':', -1)"
950 + : "SUBSTRING_INDEX({$alias}.meta_value, ':', -1)";
951 + }
952 +
953 + /**
954 + * The post types a query groups duplicates across.
955 + *
956 + * Falls back to the type being listed, so a caller that has not said
957 + * behaves as the pre-#564 per-type grouping rather than silently grouping
958 + * over nothing.
959 + *
960 + * @since 2.10.0
961 + *
962 + * @param array<string,mixed> $args Query arguments.
963 + * @return string[]
964 + */
965 + private static function group_post_types(array $args): array {
966 + $types = array_values(array_filter((array) ($args['group_post_types'] ?? []), 'is_string'));
967 +
968 + return empty($types) ? [(string) $args['post_type']] : $types;
969 + }
970 +
971 + /**
457 972 * WHERE fragment limiting posts to what the current user may read.
458 973 *
459 974 * The SQL form of map_meta_cap('read_post') for the statuses this screen
460 975 * lists, so pagination and counts are computed over readable posts only —
@@ -501,31 +1016,42 @@
501 1016 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
502 1017 }
503 1018
504 1019 /**
505 - * WHERE fragment for post type and statuses.
1020 + * WHERE fragment for post types and statuses.
506 1021 *
507 - * @param string $post_type Post type.
508 - * @param string[] $statuses Post statuses.
509 - * @param string $alias Posts table alias.
1022 + * @since 2.10.0 Takes a list of post types rather than one.
1023 + *
1024 + * @param string[] $post_types Post types.
1025 + * @param string[] $statuses Post statuses.
1026 + * @param string $alias Posts table alias.
510 1027 * @return string
511 1028 */
512 - private static function scope_sql(string $post_type, array $statuses, string $alias): string {
1029 + private static function scope_sql(array $post_types, array $statuses, string $alias): string {
513 1030 $alias = self::alias($alias);
514 - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- interpolated parts are this class's own table aliases (whitelisted in alias()) and fragments already passed through prepare(); every value is a placeholder. The index is itself the cache.
1031 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the alias is whitelisted in alias(); both IN lists are runs of %s built from the argument counts, so the sniff cannot see the placeholders it is looking for, and every value is still passed to prepare().
515 1032 global $wpdb;
516 1033
1034 + $post_types = array_values(array_unique(array_filter((array) $post_types, 'is_string')));
1035 + if (empty($post_types)) {
1036 + // No post type matches nothing. Falling back to every post type
1037 + // here would silently widen a scope the caller meant to narrow.
1038 + return '1 = 0';
1039 + }
1040 +
517 1041 $statuses = array_values(array_intersect($statuses, ['publish', 'future', 'draft', 'pending', 'private']));
518 1042 if (empty($statuses)) {
519 1043 $statuses = ['publish'];
520 1044 }
521 1045
522 - $placeholders = implode(',', array_fill(0, count($statuses), '%s'));
1046 + $types_in = implode(',', array_fill(0, count($post_types), '%s'));
1047 + $statuses_in = implode(',', array_fill(0, count($statuses), '%s'));
1048 +
523 1049 return $wpdb->prepare(
524 - "{$alias}.post_type = %s AND {$alias}.post_status IN ({$placeholders})",
525 - array_merge([$post_type], $statuses)
1050 + "{$alias}.post_type IN ({$types_in}) AND {$alias}.post_status IN ({$statuses_in})",
1051 + array_merge($post_types, $statuses)
526 1052 );
527 - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1053 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
528 1054 }
529 1055
530 1056 /**
531 1057 * A table alias this class uses, and nothing else.
@@ -536,7 +1062,7 @@
536 1062 * @param string $alias Requested alias.
537 1063 * @return string
538 1064 */
539 1065 private static function alias(string $alias): string {
540 - return in_array($alias, ['p', 'dp'], true) ? $alias : 'p';
1066 + return in_array($alias, ['p', 'm', 'dp', 'dm', 'ep', 'em'], true) ? $alias : 'p';
541 1067 }
542 1068 }