PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.0.0 1.0.1 All 51 releases
← All changes | includes/api/class-snippets-endpoint.php +79 -32 2.9.0 → 2.10.0 View file →
@@ -17,9 +17,11 @@
17 17
18 18 use ThinkRank\Admin\Metabox_Manager;
19 19 use ThinkRank\AI\Metadata_Generator;
20 20 use ThinkRank\Core\Capability_Manager;
21 +use ThinkRank\Core\Seo_Text;
21 22 use ThinkRank\Core\Settings;
23 +use ThinkRank\SEO\Duplicate_Snippets;
22 24 use ThinkRank\SEO\Focus_Keywords;
23 25 use ThinkRank\SEO\Global_SEO_Post_Types;
24 26 use ThinkRank\SEO\Snippet_Index;
25 27 use ThinkRank\SEO\Snippet_Issues;
@@ -168,8 +170,20 @@
168 170 ],
169 171 ],
170 172 ]);
171 173
174 + register_rest_route(self::NAMESPACE, '/' . self::REST_BASE . '/duplicates', [
175 + 'methods' => 'GET',
176 + 'callback' => [$this, 'list_duplicates'],
177 + 'permission_callback' => [$this, 'check_permissions'],
178 + 'args' => [
179 + 'refresh' => [
180 + 'type' => 'boolean',
181 + 'default' => false,
182 + ],
183 + ],
184 + ]);
185 +
172 186 register_rest_route(self::NAMESPACE, '/' . self::REST_BASE . '/(?P<id>\d+)/generate', [
173 187 'methods' => 'POST',
174 188 'callback' => [$this, 'generate_suggestion'],
175 189 'permission_callback' => [$this, 'check_permissions'],
@@ -212,31 +226,24 @@
212 226
213 227 /**
214 228 * Is the selected AI provider configured?
215 229 *
216 - * Defers to Settings::has_ai_provider_configured() when it exists (#721),
217 - * which knows that an OpenAI-compatible endpoint is configured by URL and
218 - * model rather than by key. Until then, the selected provider's key is the
219 - * answer — the same test the metabox uses.
230 + * Settings::has_ai_provider_configured() is the one answer to this across
231 + * the plugin, and it knows what a per-provider key check cannot: an
232 + * OpenAI-compatible endpoint is configured by URL and model rather than by
233 + * key, so a key test reports it unconfigured.
220 234 *
235 + * This used to probe with method_exists() and fall back to the selected
236 + * provider's key, because #741 and #745 were open at the same time and
237 + * either could merge first. Both have landed, so the probe and its fallback
238 + * are gone (#759).
239 + *
221 240 * @since 2.8.0
222 241 *
223 242 * @return bool
224 243 */
225 244 private static function ai_configured(): bool {
226 - $settings = Settings::instance();
227 -
228 - if (method_exists($settings, 'has_ai_provider_configured')) {
229 - return (bool) $settings->has_ai_provider_configured();
230 - }
231 -
232 - $provider = (string) $settings->get('ai_provider', '');
233 -
234 - // A provider stored by a newer build (openai_compatible before #721
235 - // lands) has no client here, so a key under its name is not enough.
236 - return in_array($provider, Settings::selectable_ai_providers(), true)
237 - && '' !== $provider
238 - && !empty($settings->get($provider . '_api_key'));
245 + return Settings::instance()->has_ai_provider_configured();
239 246 }
240 247
241 248 /**
242 249 * GET — one page of snippets, with issue counts for the whole filtered set.
@@ -292,8 +299,36 @@
292 299 ]);
293 300 }
294 301
295 302 /**
303 + * GET — the sitewide duplicate title / description report.
304 + *
305 + * Separate from the list above because it answers a different question.
306 + * The list is "what is wrong with this post type's snippets"; this is "which
307 + * pages on this site carry the same title as another one", and the answer
308 + * has to cross post types to be true (#564).
309 + *
310 + * Bounded like the list: each call builds one batch of missing index
311 + * entries and reports how many are left, so the screen keeps asking until
312 + * `pending` reaches zero rather than any one request scanning the site.
313 + *
314 + * @since 2.10.0
315 + *
316 + * @param WP_REST_Request $request Request.
317 + * @return WP_REST_Response
318 + */
319 + public function list_duplicates(WP_REST_Request $request): WP_REST_Response {
320 + $report = Duplicate_Snippets::report((bool) $request->get_param('refresh'));
321 +
322 + $report['limits'] = [
323 + 'max_groups' => Duplicate_Snippets::MAX_GROUPS,
324 + 'max_members' => Duplicate_Snippets::MAX_MEMBERS,
325 + ];
326 +
327 + return new WP_REST_Response($report);
328 + }
329 +
330 + /**
296 331 * One page of snippets, answered from the persisted index.
297 332 *
298 333 * The work a request does is bounded: {@see Snippet_Index::refresh()}
299 334 * builds at most one batch of stale entries, counts and the page come from
@@ -313,15 +348,19 @@
313 348 public static function page(array $args): array {
314 349 $pending = Snippet_Index::refresh($args['post_type'], $args['statuses']);
315 350
316 351 $result = Snippet_Index::query([
317 - 'post_type' => $args['post_type'],
318 - 'statuses' => $args['statuses'],
319 - 'issue' => $args['issue'],
320 - 'search' => $args['search'],
321 - 'page' => $args['page'],
322 - 'per_page' => $args['per_page'],
323 - 'visibility_sql' => Snippet_Index::visibility_sql($args['post_type']),
352 + 'post_type' => $args['post_type'],
353 + 'statuses' => $args['statuses'],
354 + // Duplicates are looked for across every post type ThinkRank
355 + // manages, not only the one being listed: a page sharing a title
356 + // with a post is still a duplicate (#564).
357 + 'group_post_types' => Global_SEO_Post_Types::allowed(),
358 + 'issue' => $args['issue'],
359 + 'search' => $args['search'],
360 + 'page' => $args['page'],
361 + 'per_page' => $args['per_page'],
362 + 'visibility_sql' => Snippet_Index::visibility_sql($args['post_type']),
324 363 ]);
325 364
326 365 _prime_post_caches($result['ids'], false, true);
327 366
@@ -337,9 +376,10 @@
337 376 }
338 377
339 378 $snippets[] = self::judge(
340 379 Snippet_Issues::snapshot($post),
341 - in_array($post_id, $result['duplicate_ids'], true)
380 + in_array($post_id, $result['duplicate_ids'], true),
381 + in_array($post_id, $result['duplicate_description_ids'], true)
342 382 );
343 383 }
344 384
345 385 return [
@@ -355,14 +395,16 @@
355 395
356 396 /**
357 397 * Attach the verdict to a snapshot.
358 398 *
359 - * @param array<string,mixed> $snapshot From {@see Snippet_Issues::snapshot()}.
360 - * @param bool $duplicate Whether another post shares its title.
399 + * @param array<string,mixed> $snapshot From {@see Snippet_Issues::snapshot()}.
400 + * @param bool $duplicate Whether another post shares its title.
401 + * @param bool $duplicate_description Whether another post shares its description.
361 402 * @return array<string,mixed>
362 403 */
363 - private static function judge(array $snapshot, bool $duplicate): array {
404 + private static function judge(array $snapshot, bool $duplicate, bool $duplicate_description = false): array {
364 405 $snapshot['duplicate_title'] = $duplicate;
406 + $snapshot['duplicate_description'] = $duplicate_description;
365 407 $snapshot['issues'] = Snippet_Issues::evaluate($snapshot);
366 408
367 409 return $snapshot;
368 410 }
@@ -389,10 +431,13 @@
389 431 'description' => $snippet['raw_description'],
390 432 'focus_keyword' => $snippet['focus_keyword'],
391 433 // What the page renders when the field above is empty, so the
392 434 // table can show the inherited template value as a placeholder.
393 - 'effective_title' => $snippet['effective_title'],
394 - 'effective_description' => $snippet['effective_description'],
435 + // Decoded: the resolved value is still HTML, so a texturized
436 + // `&#038;` was shown in the placeholder and counted as six
437 + // characters by the length counter beside it.
438 + 'effective_title' => Seo_Text::as_displayed((string) $snippet['effective_title']),
439 + 'effective_description' => Seo_Text::as_displayed((string) $snippet['effective_description']),
395 440 'noindex' => (bool) $snippet['noindex'],
396 441 'noindex_source' => $snippet['noindex_source'],
397 442 'issues' => $snippet['issues'],
398 443 'can_edit' => current_user_can('edit_post', $post->ID),
@@ -436,12 +481,13 @@
436 481 // Only the saved posts are rebuilt; duplicate status comes from the
437 482 // index, so peers need no work — the other half of a duplicate pair
438 483 // clears itself the next time the list is read.
439 484 $fresh = [];
485 + $group_types = Global_SEO_Post_Types::allowed();
440 486 foreach ($reload as $post_type => $statuses) {
441 487 $ids = $saved_ids[$post_type] ?? [];
442 488 Snippet_Index::rebuild($ids);
443 - $duplicates = Snippet_Index::duplicates_among($post_type, array_keys($statuses), $ids);
489 + $duplicates = Snippet_Index::duplicates_among($group_types, array_keys($statuses), $ids);
444 490
445 491 foreach ($ids as $post_id) {
446 492 $post = get_post($post_id);
447 493 if ($post instanceof \WP_Post) {
@@ -446,9 +492,10 @@
446 492 $post = get_post($post_id);
447 493 if ($post instanceof \WP_Post) {
448 494 $fresh[$post_id] = self::judge(
449 495 Snippet_Issues::snapshot($post),
450 - in_array($post_id, $duplicates, true)
496 + in_array($post_id, $duplicates['title'], true),
497 + in_array($post_id, $duplicates['description'], true)
451 498 );
452 499 }
453 500 }
454 501 }