| @@ -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 | + // `&` 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 | } |