| @@ -167,8 +167,20 @@ | ||
| 167 | 167 | 'rich_snippets' => ['video', 'video_carousel'], |
| 168 | 168 | 'context_types' => ['post', 'page'], |
| 169 | 169 | 'priority' => 'medium' |
| 170 | 170 | ], |
| 171 | + // Offered by the metabox dropdown and registered in Schema_Factory, but | |
| 172 | + // absent here — generate_schema_markup() keys off this array, so a | |
| 173 | + // Review request was silently skipped (#462). | |
| 174 | + 'Review' => [ | |
| 175 | + 'name' => 'Review', | |
| 176 | + 'description' => 'Reviews and ratings of a product, service or place', | |
| 177 | + 'required_properties' => ['itemReviewed', 'reviewRating', 'author'], | |
| 178 | + 'recommended_properties' => ['reviewBody', 'datePublished', 'publisher'], | |
| 179 | + 'rich_snippets' => ['review', 'review_snippet'], | |
| 180 | + 'context_types' => ['post', 'page'], | |
| 181 | + 'priority' => 'medium' | |
| 182 | + ], | |
| 171 | 183 | 'Recipe' => [ |
| 172 | 184 | 'name' => 'Recipe', |
| 173 | 185 | 'description' => 'Cooking recipes and food preparation', |
| 174 | 186 | 'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'], |
| @@ -309,8 +321,19 @@ | ||
| 309 | 321 | */ |
| 310 | 322 | private ?Schema_Cache_Manager $cache_manager = null; |
| 311 | 323 | |
| 312 | 324 | /** |
| 325 | + * Whether the foreign-settings listener has been registered this request. | |
| 326 | + * | |
| 327 | + * Static because `thinkrank_seo_settings_saved` is a global hook — one | |
| 328 | + * listener serves every instance. See the constructor for why (#463). | |
| 329 | + * | |
| 330 | + * @since 1.16.0 | |
| 331 | + * @var bool | |
| 332 | + */ | |
| 333 | + private static bool $foreign_settings_listener_registered = false; | |
| 334 | + | |
| 335 | + /** | |
| 313 | 336 | * Constructor |
| 314 | 337 | * |
| 315 | 338 | * @since 1.0.0 |
| 316 | 339 | */ |
| @@ -326,11 +349,117 @@ | ||
| 326 | 349 | $this->initialize_schema_builder(); |
| 327 | 350 | |
| 328 | 351 | // Initialize Schema Cache Manager for performance optimization |
| 329 | 352 | $this->initialize_cache_manager(); |
| 353 | + | |
| 354 | + // LocalBusiness and Organization both read Business Info, which Site | |
| 355 | + // Identity owns. Without this, editing an address or phone number never | |
| 356 | + // refreshed the deployed schema (#455). | |
| 357 | + // | |
| 358 | + // Registered at most once per request. WordPress keys callbacks by | |
| 359 | + // object hash, so binding $this here added a fresh listener for every | |
| 360 | + // instance — and this class is constructed from inside the very callback | |
| 361 | + // it registers, which doubled the listener count on every settings save | |
| 362 | + // (#463). The guard is static because the hook itself is global. | |
| 363 | + if (!self::$foreign_settings_listener_registered) { | |
| 364 | + self::$foreign_settings_listener_registered = true; | |
| 365 | + add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4); | |
| 366 | + } | |
| 330 | 367 | } |
| 331 | 368 | |
| 332 | 369 | /** |
| 370 | + * Regenerate schema when another manager saves settings this schema reads. | |
| 371 | + * | |
| 372 | + * Site Identity owns the Business Info fields that feed LocalBusiness and | |
| 373 | + * the Organization address/contactPoint, so a save there has to refresh the | |
| 374 | + * deployed schema even though no schema setting changed. | |
| 375 | + * | |
| 376 | + * @since 2.0.2 | |
| 377 | + * | |
| 378 | + * @param string $manager_type Settings category that was saved. | |
| 379 | + * @param array $settings Settings that were written. | |
| 380 | + * @param string $context_type Context type. | |
| 381 | + * @param int|null $context_id Context ID. | |
| 382 | + * @return void | |
| 383 | + */ | |
| 384 | + public function refresh_schema_for_foreign_settings( | |
| 385 | + string $manager_type, | |
| 386 | + array $settings, | |
| 387 | + string $context_type, | |
| 388 | + ?int $context_id | |
| 389 | + ): void { | |
| 390 | + if ('site_identity' !== $manager_type) { | |
| 391 | + return; | |
| 392 | + } | |
| 393 | + | |
| 394 | + $business_keys = [ | |
| 395 | + 'business_name', 'business_type', 'business_address', 'business_city', | |
| 396 | + 'business_state', 'business_postal_code', 'business_country', | |
| 397 | + 'business_phone', 'business_email', 'business_hours', | |
| 398 | + 'business_latitude', 'business_longitude', 'business_price_range', | |
| 399 | + ]; | |
| 400 | + | |
| 401 | + // Which deployed types a Site Identity key can invalidate. The business | |
| 402 | + // block feeds LocalBusiness and Organization; alternate_name feeds the | |
| 403 | + // WebSite node, which had no entry here at all — so editing it left the | |
| 404 | + // deployed schema showing the previous value until something else | |
| 405 | + // happened to redeploy (#692). | |
| 406 | + $refresh_types = []; | |
| 407 | + | |
| 408 | + if (!empty(array_intersect_key($settings, array_flip($business_keys)))) { | |
| 409 | + $refresh_types[] = 'LocalBusiness'; | |
| 410 | + $refresh_types[] = 'Organization'; | |
| 411 | + } | |
| 412 | + | |
| 413 | + if (array_key_exists('alternate_name', $settings)) { | |
| 414 | + $refresh_types[] = 'WebSite'; | |
| 415 | + } | |
| 416 | + | |
| 417 | + if (empty($refresh_types)) { | |
| 418 | + return; | |
| 419 | + } | |
| 420 | + | |
| 421 | + $schema_settings = $this->get_settings($context_type, $context_id); | |
| 422 | + if (empty($schema_settings['auto_deploy'])) { | |
| 423 | + return; | |
| 424 | + } | |
| 425 | + | |
| 426 | + // Only refresh types that are actually deployed, so this never adds a | |
| 427 | + // type the admin did not enable. | |
| 428 | + $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id)); | |
| 429 | + $affected = array_values(array_intersect($deployed, $refresh_types)); | |
| 430 | + | |
| 431 | + if (empty($affected)) { | |
| 432 | + return; | |
| 433 | + } | |
| 434 | + | |
| 435 | + try { | |
| 436 | + $generation = $this->generate_schema_markup($context_type, $context_id, $affected); | |
| 437 | + | |
| 438 | + // Deploy the types that validated, not all-or-nothing. Gating on | |
| 439 | + // deployment_ready meant one invalid type blocked every valid one | |
| 440 | + // in the same batch (#470). | |
| 441 | + $deployable = []; | |
| 442 | + foreach ($affected as $type) { | |
| 443 | + if (!empty($generation['generated_schemas'][$type]) | |
| 444 | + && !empty($generation['validation_results'][$type]['is_valid']) | |
| 445 | + ) { | |
| 446 | + $deployable[$type] = $generation['generated_schemas'][$type]; | |
| 447 | + } | |
| 448 | + } | |
| 449 | + | |
| 450 | + if (!empty($deployable)) { | |
| 451 | + $this->deploy_schema_markup($context_type, $context_id, $deployable); | |
| 452 | + } | |
| 453 | + } catch (\Exception $e) { | |
| 454 | + if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 455 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 456 | + error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage()); | |
| 457 | + } | |
| 458 | + } | |
| 459 | + } | |
| 460 | + | |
| 461 | + /** | |
| 333 | 462 | * Initialize Schema Builder |
| 334 | 463 | * |
| 335 | 464 | * @return void |
| 336 | 465 | */ |
| @@ -356,10 +485,20 @@ | ||
| 356 | 485 | require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php'; |
| 357 | 486 | } |
| 358 | 487 | |
| 359 | 488 | if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) { |
| 360 | - // Get cache duration from deployment config | |
| 489 | + // Honour the stored cache_duration setting. It is exposed in | |
| 490 | + // get_settings_schema() (min 300 / max 86400), validated, persisted | |
| 491 | + // and surfaced through both abilities — but the cache manager was | |
| 492 | + // always built from the hardcoded config value, so the setting had | |
| 493 | + // no effect (#473). Falls back to the config default. | |
| 361 | 494 | $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600; |
| 495 | + | |
| 496 | + $stored = $this->get_settings('site', null)['cache_duration'] ?? null; | |
| 497 | + if (is_numeric($stored) && (int) $stored > 0) { | |
| 498 | + $cache_duration = (int) $stored; | |
| 499 | + } | |
| 500 | + | |
| 362 | 501 | $this->cache_manager = new Schema_Cache_Manager($cache_duration); |
| 363 | 502 | } |
| 364 | 503 | } |
| 365 | 504 | |
| @@ -367,12 +506,19 @@ | ||
| 367 | 506 | * Generate schema markup with comprehensive content analysis integration |
| 368 | 507 | * |
| 369 | 508 | * @since 1.0.0 |
| 370 | 509 | * |
| 510 | + * Generation is read-only by default. Persisting the result is opt-in via | |
| 511 | + * `$options['persist']`, because this method is also reached from the | |
| 512 | + * front-end read path (get_output_data()) and from GET routes — where a | |
| 513 | + * DELETE + INSERT would destroy the admin's deployed rows and publish | |
| 514 | + * types nobody deployed (#460). | |
| 515 | + * | |
| 371 | 516 | * @param string $context_type Context type |
| 372 | 517 | * @param int|null $context_id Context ID |
| 373 | 518 | * @param array $schema_types Schema types to generate |
| 374 | - * @param array $options Generation options | |
| 519 | + * @param array $options Generation options. Pass `persist => true` | |
| 520 | + * from explicit write paths only. | |
| 375 | 521 | * @return array Comprehensive schema generation results |
| 376 | 522 | */ |
| 377 | 523 | public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array { |
| 378 | 524 | $generation = [ |
| @@ -443,10 +589,16 @@ | ||
| 443 | 589 | |
| 444 | 590 | // Check deployment readiness |
| 445 | 591 | $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']); |
| 446 | 592 | |
| 447 | - // Store schema data | |
| 448 | - $this->store_schema_data($context_type, $context_id, $generation); | |
| 593 | + // Persistence belongs to deployment, not generation. Every write path | |
| 594 | + // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(), | |
| 595 | + // the deploy route) calls deploy_schema_markup() straight after generating, | |
| 596 | + // so nothing needs to opt in today — the flag exists to keep this an | |
| 597 | + // explicit decision rather than an accident. | |
| 598 | + if (!empty($options['persist'])) { | |
| 599 | + $this->store_schema_data($context_type, $context_id, $generation); | |
| 600 | + } | |
| 449 | 601 | |
| 450 | 602 | return $generation; |
| 451 | 603 | } |
| 452 | 604 | |
| @@ -574,8 +726,24 @@ | ||
| 574 | 726 | |
| 575 | 727 | // Determine deployment method |
| 576 | 728 | $deployment['deployment_method'] = $this->determine_deployment_method($options); |
| 577 | 729 | |
| 730 | + // When the caller owns the whole context — the user pressing Deploy, where | |
| 731 | + // the payload is exactly what the preview showed — anything not in that | |
| 732 | + // payload should come off the page (#464). Incremental callers such as | |
| 733 | + // auto_deploy_schema_on_settings_change() pass only the types they | |
| 734 | + // regenerated, so they must NOT retire the rest. | |
| 735 | + if (!empty($options['authoritative'])) { | |
| 736 | + $deployment['retired_schemas'] = $this->retire_schema_types( | |
| 737 | + $context_type, | |
| 738 | + $context_id, | |
| 739 | + array_diff( | |
| 740 | + array_keys($this->get_deployed_schemas($context_type, $context_id)), | |
| 741 | + array_keys($schema_data) | |
| 742 | + ) | |
| 743 | + ); | |
| 744 | + } | |
| 745 | + | |
| 578 | 746 | // Deploy each schema |
| 579 | 747 | foreach ($schema_data as $schema_type => $schema) { |
| 580 | 748 | $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id); |
| 581 | 749 | $deployment['deployed_schemas'][$schema_type] = $deploy_result; |
| @@ -661,9 +829,9 @@ | ||
| 661 | 829 | } |
| 662 | 830 | |
| 663 | 831 | return [ |
| 664 | 832 | 'validation_passed' => true, |
| 665 | - 'message' => __('Schema deployed and verified on the front end', 'thinkrank'), | |
| 833 | + 'message' => __('Schema deployed and read back from storage', 'thinkrank'), | |
| 666 | 834 | 'missing_types' => [] |
| 667 | 835 | ]; |
| 668 | 836 | } |
| 669 | 837 | |
| @@ -783,9 +951,11 @@ | ||
| 783 | 951 | 'validation_results' => [], |
| 784 | 952 | 'rich_snippets_preview' => [], |
| 785 | 953 | 'performance_data' => [], |
| 786 | 954 | 'recommendations' => [], |
| 787 | - 'enabled' => true | |
| 955 | + // Report the real setting. Hardcoding true here told every consumer | |
| 956 | + // the feature was on even when the master switch was off (#461). | |
| 957 | + 'enabled' => (bool) ($settings['enabled'] ?? true) | |
| 788 | 958 | ]; |
| 789 | 959 | |
| 790 | 960 | // Get enabled schema types |
| 791 | 961 | $enabled_types = $settings['enabled_schema_types'] ?? []; |
| @@ -1039,8 +1209,47 @@ | ||
| 1039 | 1209 | return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg'); |
| 1040 | 1210 | } |
| 1041 | 1211 | |
| 1042 | 1212 | /** |
| 1213 | + * Schema keys outside the shared config defaults. | |
| 1214 | + * | |
| 1215 | + * @since 2.0.1 | |
| 1216 | + * | |
| 1217 | + * @return string[] | |
| 1218 | + */ | |
| 1219 | + protected function additional_setting_keys(): array { | |
| 1220 | + return [ | |
| 1221 | + 'enable_article_schema', 'enable_product_schema', | |
| 1222 | + 'enable_faq_schema', 'enable_howto_schema', | |
| 1223 | + ]; | |
| 1224 | + } | |
| 1225 | + | |
| 1226 | + /** | |
| 1227 | + * Per-entity schema fields are an open set. | |
| 1228 | + * | |
| 1229 | + * Each schema type the UI can edit contributes its own field family — | |
| 1230 | + * organization_*, person_*, website_*, business_*, software_*, howto_* — | |
| 1231 | + * and a new type adds another. The families this manager owns are matched | |
| 1232 | + * rather than enumerated, so adding a form does not silently start | |
| 1233 | + * dropping its fields (#452). | |
| 1234 | + * | |
| 1235 | + * @since 2.0.1 | |
| 1236 | + * | |
| 1237 | + * @return string[] | |
| 1238 | + */ | |
| 1239 | + protected function dynamic_setting_key_patterns(): array { | |
| 1240 | + return [ | |
| 1241 | + '/^organization_[a-z0-9_]+$/', | |
| 1242 | + '/^person_[a-z0-9_]+$/', | |
| 1243 | + '/^website_[a-z0-9_]+$/', | |
| 1244 | + '/^business_[a-z0-9_]+$/', | |
| 1245 | + '/^software_[a-z0-9_]+$/', | |
| 1246 | + '/^howto_[a-z0-9_]+$/', | |
| 1247 | + '/^product_[a-z0-9_]+$/', | |
| 1248 | + ]; | |
| 1249 | + } | |
| 1250 | + | |
| 1251 | + /** | |
| 1043 | 1252 | * Get default settings for a context type (implements interface) |
| 1044 | 1253 | * |
| 1045 | 1254 | * @since 1.0.0 |
| 1046 | 1255 | * |
| @@ -1132,38 +1341,59 @@ | ||
| 1132 | 1341 | if ($this->has_person_settings_changed($settings)) { |
| 1133 | 1342 | $schema_types_to_regenerate[] = 'Person'; |
| 1134 | 1343 | } |
| 1135 | 1344 | |
| 1345 | + // Honour the user's Schema Types selection. Without this the payload | |
| 1346 | + // shape alone decided what shipped, so every save deployed all four | |
| 1347 | + // types — including ones the user had explicitly deselected (#461). | |
| 1348 | + // An empty selection means "auto", so only filter when one is set. | |
| 1349 | + $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? []; | |
| 1350 | + | |
| 1351 | + if (!empty($enabled_types) && is_array($enabled_types)) { | |
| 1352 | + $schema_types_to_regenerate = array_values( | |
| 1353 | + array_intersect($schema_types_to_regenerate, $enabled_types) | |
| 1354 | + ); | |
| 1355 | + } | |
| 1356 | + | |
| 1357 | + // Types that were deployed but are no longer wanted must come back off | |
| 1358 | + // the page — deployment used to be additive-only (#464). | |
| 1359 | + $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types); | |
| 1360 | + | |
| 1136 | 1361 | // If no schema types need regeneration, return early |
| 1137 | 1362 | if (empty($schema_types_to_regenerate)) { |
| 1138 | 1363 | return; |
| 1139 | 1364 | } |
| 1140 | 1365 | |
| 1141 | - // Generate and deploy each schema type | |
| 1142 | - foreach ($schema_types_to_regenerate as $schema_type) { | |
| 1143 | - try { | |
| 1144 | - // Generate schema using generate_schema_markup | |
| 1145 | - $generation_result = $this->generate_schema_markup( | |
| 1146 | - $context_type, | |
| 1147 | - $context_id, | |
| 1148 | - [$schema_type] | |
| 1149 | - ); | |
| 1366 | + // Generate every affected type in ONE call. Generating them one at a | |
| 1367 | + // time re-entered store_schema_data() per type, and each pass replaced | |
| 1368 | + // the rows written by the previous one, so only the last type survived | |
| 1369 | + // (#454). One batch also means one delete and one cache flush. | |
| 1370 | + try { | |
| 1371 | + $generation_result = $this->generate_schema_markup( | |
| 1372 | + $context_type, | |
| 1373 | + $context_id, | |
| 1374 | + $schema_types_to_regenerate | |
| 1375 | + ); | |
| 1150 | 1376 | |
| 1151 | - // Deploy if generation was successful | |
| 1152 | - if (!empty($generation_result['generated_schemas'][$schema_type]) && $generation_result['deployment_ready']) { | |
| 1153 | - $this->deploy_schema_markup( | |
| 1154 | - $context_type, | |
| 1155 | - $context_id, | |
| 1156 | - [$schema_type => $generation_result['generated_schemas'][$schema_type]] | |
| 1157 | - ); | |
| 1377 | + $deployable = []; | |
| 1378 | + foreach ($schema_types_to_regenerate as $schema_type) { | |
| 1379 | + // Only deploy what validated — see #470. | |
| 1380 | + if (!empty($generation_result['generated_schemas'][$schema_type]) | |
| 1381 | + && !empty($generation_result['validation_results'][$schema_type]['is_valid']) | |
| 1382 | + ) { | |
| 1383 | + $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type]; | |
| 1158 | 1384 | } |
| 1159 | - } catch (\Exception $e) { | |
| 1160 | - // Log error but don't fail the settings save | |
| 1161 | - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 1162 | - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1163 | - error_log('ThinkRank: Auto-deploy failed for ' . $schema_type . ': ' . $e->getMessage()); | |
| 1164 | - } | |
| 1165 | 1385 | } |
| 1386 | + | |
| 1387 | + if (!empty($deployable)) { | |
| 1388 | + $this->deploy_schema_markup($context_type, $context_id, $deployable); | |
| 1389 | + } | |
| 1390 | + } catch (\Exception $e) { | |
| 1391 | + // Log error but don't fail the settings save | |
| 1392 | + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 1393 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 1394 | + error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage()); | |
| 1395 | + } | |
| 1166 | 1396 | } |
| 1167 | 1397 | } |
| 1168 | 1398 | |
| 1169 | 1399 | /** |
| @@ -1201,9 +1431,15 @@ | ||
| 1201 | 1431 | * @param array $settings Updated settings |
| 1202 | 1432 | * @return bool True if website settings changed |
| 1203 | 1433 | */ |
| 1204 | 1434 | private function has_website_settings_changed(array $settings): bool { |
| 1205 | - $website_keys = ['site_name', 'site_description', 'site_url']; | |
| 1435 | + // These are the keys the Website tab actually stores. It previously | |
| 1436 | + // looked for site_name/site_description/site_url, which belong to Site | |
| 1437 | + // Identity and never appear in a schema settings payload — so WebSite | |
| 1438 | + // schema never auto-deployed no matter what was edited (#455). | |
| 1439 | + $website_keys = [ | |
| 1440 | + 'website_name', 'website_url', 'website_description', 'website_author', | |
| 1441 | + ]; | |
| 1206 | 1442 | |
| 1207 | 1443 | foreach ($website_keys as $key) { |
| 1208 | 1444 | if (isset($settings[$key])) { |
| 1209 | 1445 | return true; |
| @@ -1221,11 +1457,20 @@ | ||
| 1221 | 1457 | * @param array $settings Updated settings |
| 1222 | 1458 | * @return bool True if business settings changed |
| 1223 | 1459 | */ |
| 1224 | 1460 | private function has_business_settings_changed(array $settings): bool { |
| 1461 | + // Only the keys this manager actually stores. business_name/address/ | |
| 1462 | + // phone/hours live in the site_identity category and never reach a | |
| 1463 | + // schema settings save, so keying off them meant LocalBusiness never | |
| 1464 | + // auto-deployed (#455). Edits to those fields refresh LocalBusiness | |
| 1465 | + // through the Site Identity save path instead — see | |
| 1466 | + // refresh_schema_for_foreign_settings(). | |
| 1225 | 1467 | $business_keys = [ |
| 1226 | - 'business_name', 'business_type', 'business_address', 'business_phone', | |
| 1227 | - 'business_hours', 'business_price_range' | |
| 1468 | + 'enable_local_business', | |
| 1469 | + 'business_price_range', | |
| 1470 | + 'business_geo_latitude', | |
| 1471 | + 'business_geo_longitude', | |
| 1472 | + 'business_opening_hours', | |
| 1228 | 1473 | ]; |
| 1229 | 1474 | |
| 1230 | 1475 | foreach ($business_keys as $key) { |
| 1231 | 1476 | if (isset($settings[$key])) { |
| @@ -1269,8 +1514,29 @@ | ||
| 1269 | 1514 | $detected_types = []; |
| 1270 | 1515 | |
| 1271 | 1516 | switch ($context_type) { |
| 1272 | 1517 | case 'site': |
| 1518 | + // The admin's Schema Types selection is the answer to "what | |
| 1519 | + // does this site need"; detection is only the fallback for an | |
| 1520 | + // install that has not chosen yet (#456). | |
| 1521 | + $settings = $this->get_settings($context_type, $context_id); | |
| 1522 | + $enabled = array_values(array_filter( | |
| 1523 | + array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])), | |
| 1524 | + 'strlen' | |
| 1525 | + )); | |
| 1526 | + | |
| 1527 | + // Drop stale names the factory no longer registers rather than | |
| 1528 | + // handing them to the builder to silently skip. | |
| 1529 | + $enabled = array_values(array_filter( | |
| 1530 | + $enabled, | |
| 1531 | + fn($type) => isset($this->schema_types[$type]) | |
| 1532 | + )); | |
| 1533 | + | |
| 1534 | + if (!empty($enabled)) { | |
| 1535 | + $detected_types = $enabled; | |
| 1536 | + break; | |
| 1537 | + } | |
| 1538 | + | |
| 1273 | 1539 | $detected_types = ['Organization']; |
| 1274 | 1540 | // Check if it's a local business |
| 1275 | 1541 | if ($this->is_local_business()) { |
| 1276 | 1542 | $detected_types[] = 'LocalBusiness'; |
| @@ -1336,16 +1602,20 @@ | ||
| 1336 | 1602 | if ($post) { |
| 1337 | 1603 | $content_data = [ |
| 1338 | 1604 | 'title' => $post->post_title, |
| 1339 | 1605 | 'url' => get_permalink($post->ID), |
| 1340 | - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30), | |
| 1606 | + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30), | |
| 1341 | 1607 | 'content' => $post->post_content, |
| 1342 | 1608 | 'author' => [ |
| 1343 | 1609 | 'name' => get_the_author_meta('display_name', $post->post_author), |
| 1344 | 1610 | 'url' => get_author_posts_url($post->post_author) |
| 1345 | 1611 | ], |
| 1346 | - 'date' => $post->post_date, | |
| 1347 | - 'modified' => $post->post_modified, | |
| 1612 | + // ISO 8601 with offset. post_date/post_modified are raw | |
| 1613 | + // MySQL columns in site-local time with no timezone, which | |
| 1614 | + // Google rejects as "Invalid value in field datePublished" | |
| 1615 | + // and drops the Article rich result (#465). | |
| 1616 | + 'date' => get_the_date('c', $post), | |
| 1617 | + 'modified' => get_the_modified_date('c', $post), | |
| 1348 | 1618 | 'image' => get_the_post_thumbnail_url($post->ID, 'full'), |
| 1349 | 1619 | 'focus_keywords' => Focus_Keywords::get($post->ID), |
| 1350 | 1620 | 'business_data' => $this->get_business_data_from_local_seo(), |
| 1351 | 1621 | 'site_data' => $this->get_site_data_for_schema(), |
| @@ -1440,10 +1710,16 @@ | ||
| 1440 | 1710 | global $wpdb; |
| 1441 | 1711 | |
| 1442 | 1712 | $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1443 | 1713 | |
| 1444 | - // First, delete all existing schemas for this context to ensure clean storage | |
| 1445 | - $this->delete_existing_schemas($context_type, $context_id); | |
| 1714 | + // Replace only the types in this batch. Clearing the whole context | |
| 1715 | + // destroyed types the caller never asked about — and callers do | |
| 1716 | + // regenerate a subset, one type at a time (#454). | |
| 1717 | + $generated_types = array_keys($generation['generated_schemas'] ?? []); | |
| 1718 | + if (empty($generated_types)) { | |
| 1719 | + return false; | |
| 1720 | + } | |
| 1721 | + $this->delete_existing_schemas($context_type, $context_id, $generated_types); | |
| 1446 | 1722 | |
| 1447 | 1723 | foreach ($generation['generated_schemas'] as $schema_type => $schema_data) { |
| 1448 | 1724 | // Prepare schema data with validation status embedded |
| 1449 | 1725 | $schema_data_with_validation = $schema_data; |
| @@ -1459,9 +1735,13 @@ | ||
| 1459 | 1735 | 'context_id' => $context_id, |
| 1460 | 1736 | 'schema_type' => $schema_type, |
| 1461 | 1737 | 'schema_data' => wp_json_encode($schema_data_with_validation), |
| 1462 | 1738 | 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid', |
| 1463 | - 'is_active' => $generation['deployment_ready'] ? 1 : 0 | |
| 1739 | + // Per-type, not batch-wide. deployment_ready is only true when | |
| 1740 | + // EVERY type in the batch validated, so one invalid type (a site | |
| 1741 | + // with no Business Info makes LocalBusiness invalid) deactivated | |
| 1742 | + // all the valid ones alongside it (#470). | |
| 1743 | + 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0 | |
| 1464 | 1744 | ]; |
| 1465 | 1745 | |
| 1466 | 1746 | // Insert new schema (existing ones were already deleted) |
| 1467 | 1747 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access |
| @@ -1792,11 +2072,14 @@ | ||
| 1792 | 2072 | ARRAY_A |
| 1793 | 2073 | ); |
| 1794 | 2074 | } |
| 1795 | 2075 | |
| 1796 | - if (empty($deployed_schemas)) { | |
| 1797 | - return []; | |
| 1798 | - } | |
| 2076 | + // Deliberately no early return on an empty result: it has to reach the | |
| 2077 | + // cache write below. Most URLs have no deployed schema, so gating the | |
| 2078 | + // write on a non-empty result made the majority of front-end requests | |
| 2079 | + // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY | |
| 2080 | + // ...) query with two filesorts on every pageview (#392). | |
| 2081 | + $deployed_schemas = $deployed_schemas ?: []; | |
| 1799 | 2082 | |
| 1800 | 2083 | // Process schemas for return |
| 1801 | 2084 | $processed_schemas = []; |
| 1802 | 2085 | foreach ($deployed_schemas as $deployed_schema) { |
| @@ -1808,8 +2091,26 @@ | ||
| 1808 | 2091 | if (isset($schema_data['_validation'])) { |
| 1809 | 2092 | unset($schema_data['_validation']); |
| 1810 | 2093 | } |
| 1811 | 2094 | |
| 2095 | + // Deployed schema is a snapshot, so rows written before #465 | |
| 2096 | + // still carry raw MySQL datetimes. Normalise on read so the | |
| 2097 | + // fix reaches existing sites without a migration. | |
| 2098 | + $schema_data = $this->normalize_stored_schema($schema_data); | |
| 2099 | + | |
| 2100 | + // The permalink was frozen at deploy time, so schema deployed | |
| 2101 | + // while a post was a draft advertised "?p=123" as both url and | |
| 2102 | + // mainEntityOfPage forever — contradicting the node's own @id | |
| 2103 | + // and the canonical (#470). Resolve it live instead. | |
| 2104 | + $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id); | |
| 2105 | + | |
| 2106 | + // schema.org types `sameAs`, `url`, `logo` and `image` as URLs, | |
| 2107 | + // but the form stored whatever was typed, so free text entered | |
| 2108 | + // in a social-profile field shipped as a sameAs member and made | |
| 2109 | + // the whole entity invalid (#480). Drop bad values on read, so | |
| 2110 | + // existing sites stop emitting them without a migration. | |
| 2111 | + $schema_data = $this->filter_entity_urls($schema_data); | |
| 2112 | + | |
| 1812 | 2113 | $processed_schemas[$schema_type] = [ |
| 1813 | 2114 | 'data' => $schema_data, |
| 1814 | 2115 | 'method' => 'json_ld', // Default method |
| 1815 | 2116 | 'type' => $schema_type |
| @@ -1816,10 +2117,13 @@ | ||
| 1816 | 2117 | ]; |
| 1817 | 2118 | } |
| 1818 | 2119 | } |
| 1819 | 2120 | |
| 1820 | - // CACHE LAYER: Store result in cache for future requests | |
| 1821 | - if ($this->cache_manager && !empty($processed_schemas)) { | |
| 2121 | + // CACHE LAYER: Store result in cache for future requests — including | |
| 2122 | + // an empty one. Cache_Manager::set() wraps the payload in a metadata | |
| 2123 | + // envelope, so an empty result is still stored as a truthy value and | |
| 2124 | + // reads back as a hit rather than a miss (#392). | |
| 2125 | + if ($this->cache_manager) { | |
| 1822 | 2126 | $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id); |
| 1823 | 2127 | $this->cache_manager->set($cache_key, $processed_schemas); |
| 1824 | 2128 | } |
| 1825 | 2129 | |
| @@ -1826,8 +2130,208 @@ | ||
| 1826 | 2130 | return $processed_schemas; |
| 1827 | 2131 | } |
| 1828 | 2132 | |
| 1829 | 2133 | /** |
| 2134 | + * Properties schema.org defines as URLs. | |
| 2135 | + * | |
| 2136 | + * @since 2.0.2 | |
| 2137 | + * @var string[] | |
| 2138 | + */ | |
| 2139 | + private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image']; | |
| 2140 | + | |
| 2141 | + /** | |
| 2142 | + * Whether a value is a URL safe to publish in structured data. | |
| 2143 | + * | |
| 2144 | + * @since 2.0.2 | |
| 2145 | + * | |
| 2146 | + * @param mixed $url Candidate value. | |
| 2147 | + * @return bool | |
| 2148 | + */ | |
| 2149 | + private function is_publishable_url($url): bool { | |
| 2150 | + if (!is_string($url) || '' === trim($url)) { | |
| 2151 | + return false; | |
| 2152 | + } | |
| 2153 | + | |
| 2154 | + if (!filter_var($url, FILTER_VALIDATE_URL)) { | |
| 2155 | + return false; | |
| 2156 | + } | |
| 2157 | + | |
| 2158 | + $scheme = wp_parse_url($url, PHP_URL_SCHEME); | |
| 2159 | + | |
| 2160 | + return in_array(strtolower((string) $scheme), ['http', 'https'], true); | |
| 2161 | + } | |
| 2162 | + | |
| 2163 | + /** | |
| 2164 | + * Drop values that are not URLs from URL-typed properties. | |
| 2165 | + * | |
| 2166 | + * An absent property is valid; one holding free text is not, and it can | |
| 2167 | + * invalidate the entity around it. Nested objects (`logo` and `image` are | |
| 2168 | + * frequently ImageObjects) are walked so a bad `url` inside one is caught | |
| 2169 | + * too. A property left with nothing is removed rather than emitted empty. | |
| 2170 | + * | |
| 2171 | + * @since 2.0.2 | |
| 2172 | + * | |
| 2173 | + * @param array $schema Decoded schema data. | |
| 2174 | + * @return array Schema carrying only publishable URLs. | |
| 2175 | + */ | |
| 2176 | + private function filter_entity_urls(array $schema): array { | |
| 2177 | + foreach ($schema as $key => $value) { | |
| 2178 | + if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) { | |
| 2179 | + $schema[$key] = $this->filter_entity_urls($value); | |
| 2180 | + continue; | |
| 2181 | + } | |
| 2182 | + | |
| 2183 | + if (!in_array($key, self::URL_PROPERTIES, true)) { | |
| 2184 | + continue; | |
| 2185 | + } | |
| 2186 | + | |
| 2187 | + // A nested object (ImageObject and friends) carries its own url. | |
| 2188 | + if (is_array($value) && isset($value['@type'])) { | |
| 2189 | + $schema[$key] = $this->filter_entity_urls($value); | |
| 2190 | + continue; | |
| 2191 | + } | |
| 2192 | + | |
| 2193 | + if (is_array($value)) { | |
| 2194 | + $kept = []; | |
| 2195 | + | |
| 2196 | + foreach ($value as $item) { | |
| 2197 | + if (is_array($item)) { | |
| 2198 | + $kept[] = $this->filter_entity_urls($item); | |
| 2199 | + } elseif ($this->is_publishable_url($item)) { | |
| 2200 | + $kept[] = $item; | |
| 2201 | + } | |
| 2202 | + } | |
| 2203 | + | |
| 2204 | + if ([] === $kept) { | |
| 2205 | + unset($schema[$key]); | |
| 2206 | + } else { | |
| 2207 | + $schema[$key] = array_values($kept); | |
| 2208 | + } | |
| 2209 | + | |
| 2210 | + continue; | |
| 2211 | + } | |
| 2212 | + | |
| 2213 | + if (!$this->is_publishable_url($value)) { | |
| 2214 | + unset($schema[$key]); | |
| 2215 | + } | |
| 2216 | + } | |
| 2217 | + | |
| 2218 | + return $schema; | |
| 2219 | + } | |
| 2220 | + | |
| 2221 | + /** | |
| 2222 | + * Schema types whose `url` identifies the entity, not the page. | |
| 2223 | + * | |
| 2224 | + * On a Person or an Organization, `url` is that entity's own website, so | |
| 2225 | + * overwriting it with the permalink of whichever post the schema happens to | |
| 2226 | + * be deployed on is simply wrong. It also breaks graph assembly: the site | |
| 2227 | + * identity emits the same entity with its real `url`, and once the two | |
| 2228 | + * copies disagree they can no longer be recognised as one entity (#479). | |
| 2229 | + * | |
| 2230 | + * @since 2.0.2 | |
| 2231 | + * @var string[] | |
| 2232 | + */ | |
| 2233 | + private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness']; | |
| 2234 | + | |
| 2235 | + /** | |
| 2236 | + * Replace a stored permalink snapshot with the post's live permalink. | |
| 2237 | + * | |
| 2238 | + * Only touches `url` and `mainEntityOfPage`, and only for post-like | |
| 2239 | + * contexts where a permalink actually exists. Identity entities are | |
| 2240 | + * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES. | |
| 2241 | + * | |
| 2242 | + * @since 1.16.0 | |
| 2243 | + * | |
| 2244 | + * @param array $schema Decoded schema data. | |
| 2245 | + * @param string $context_type Context type. | |
| 2246 | + * @param int|null $context_id Context ID. | |
| 2247 | + * @return array Schema with a current permalink. | |
| 2248 | + */ | |
| 2249 | + private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array { | |
| 2250 | + if ('site' === $context_type || empty($context_id)) { | |
| 2251 | + return $schema; | |
| 2252 | + } | |
| 2253 | + | |
| 2254 | + $permalink = get_permalink($context_id); | |
| 2255 | + | |
| 2256 | + if (!$permalink) { | |
| 2257 | + return $schema; | |
| 2258 | + } | |
| 2259 | + | |
| 2260 | + $type = $schema['@type'] ?? ''; | |
| 2261 | + $type = is_array($type) ? reset($type) : $type; | |
| 2262 | + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true); | |
| 2263 | + | |
| 2264 | + if (isset($schema['url']) && !$is_entity) { | |
| 2265 | + $schema['url'] = $permalink; | |
| 2266 | + } | |
| 2267 | + | |
| 2268 | + if (isset($schema['mainEntityOfPage'])) { | |
| 2269 | + if (is_array($schema['mainEntityOfPage'])) { | |
| 2270 | + if (isset($schema['mainEntityOfPage']['@id'])) { | |
| 2271 | + $schema['mainEntityOfPage']['@id'] = $permalink; | |
| 2272 | + } | |
| 2273 | + } else { | |
| 2274 | + $schema['mainEntityOfPage'] = $permalink; | |
| 2275 | + } | |
| 2276 | + } | |
| 2277 | + | |
| 2278 | + return $schema; | |
| 2279 | + } | |
| 2280 | + | |
| 2281 | + /** | |
| 2282 | + * Normalise properties that stored snapshots may hold in a stale format. | |
| 2283 | + * | |
| 2284 | + * Deployed schema is written once and read forever, so a formatting fix in | |
| 2285 | + * the builder never reaches rows already on disk. Correcting on read means | |
| 2286 | + * existing sites benefit without a migration. | |
| 2287 | + * | |
| 2288 | + * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must | |
| 2289 | + * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values | |
| 2290 | + * inside author/publisher/@graph entries are covered too. | |
| 2291 | + * | |
| 2292 | + * @since 1.16.0 | |
| 2293 | + * | |
| 2294 | + * @param array $schema Decoded schema data. | |
| 2295 | + * @return array Normalised schema. | |
| 2296 | + */ | |
| 2297 | + private function normalize_stored_schema(array $schema): array { | |
| 2298 | + static $date_keys = [ | |
| 2299 | + 'datePublished', 'dateModified', 'dateCreated', 'uploadDate', | |
| 2300 | + 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires', | |
| 2301 | + ]; | |
| 2302 | + | |
| 2303 | + foreach ($schema as $key => $value) { | |
| 2304 | + if (is_array($value)) { | |
| 2305 | + $schema[$key] = $this->normalize_stored_schema($value); | |
| 2306 | + continue; | |
| 2307 | + } | |
| 2308 | + | |
| 2309 | + if ('inLanguage' === $key && is_string($value) && '' !== $value) { | |
| 2310 | + $schema[$key] = str_replace('_', '-', $value); | |
| 2311 | + continue; | |
| 2312 | + } | |
| 2313 | + | |
| 2314 | + if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) { | |
| 2315 | + continue; | |
| 2316 | + } | |
| 2317 | + | |
| 2318 | + // Already ISO 8601 — leave it alone. | |
| 2319 | + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) { | |
| 2320 | + continue; | |
| 2321 | + } | |
| 2322 | + | |
| 2323 | + $timestamp = strtotime($value); | |
| 2324 | + | |
| 2325 | + if (false !== $timestamp) { | |
| 2326 | + $schema[$key] = (string) wp_date('c', $timestamp); | |
| 2327 | + } | |
| 2328 | + } | |
| 2329 | + | |
| 2330 | + return $schema; | |
| 2331 | + } | |
| 2332 | + | |
| 2333 | + /** | |
| 1830 | 2334 | * Clean up duplicate schemas in database |
| 1831 | 2335 | * |
| 1832 | 2336 | * @since 1.0.0 |
| 1833 | 2337 | * |
| @@ -1879,28 +2383,131 @@ | ||
| 1879 | 2383 | } |
| 1880 | 2384 | |
| 1881 | 2385 | return $deleted ?: 0; |
| 1882 | 2386 | } |
| 2387 | + | |
| 1883 | 2388 | /** |
| 1884 | - * Delete all existing schemas for a context before storing new ones | |
| 2389 | + * Deactivate deployed schema rows for the given types. | |
| 1885 | 2390 | * |
| 2391 | + * Deployment was insert-only, so anything ever deployed to a context stayed | |
| 2392 | + * on the page forever — switching a post's schema type left the old one live | |
| 2393 | + * and deactivating a saved schema did nothing (#464). Rows are deactivated | |
| 2394 | + * rather than deleted so a later redeploy can revive them and so there is a | |
| 2395 | + * trail of what was published. | |
| 2396 | + * | |
| 2397 | + * @since 1.16.0 | |
| 2398 | + * | |
| 2399 | + * @param string $context_type Context type. | |
| 2400 | + * @param int|null $context_id Context ID. | |
| 2401 | + * @param string[] $schema_types Types to retire. | |
| 2402 | + * @return int Number of rows deactivated. | |
| 2403 | + */ | |
| 2404 | + private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int { | |
| 2405 | + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); | |
| 2406 | + | |
| 2407 | + if (empty($schema_types)) { | |
| 2408 | + return 0; | |
| 2409 | + } | |
| 2410 | + | |
| 2411 | + global $wpdb; | |
| 2412 | + | |
| 2413 | + $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; | |
| 2414 | + $placeholders = implode(', ', array_fill(0, count($schema_types), '%s')); | |
| 2415 | + | |
| 2416 | + if (null === $context_id) { | |
| 2417 | + $sql = sprintf( | |
| 2418 | + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)', | |
| 2419 | + $table_name, | |
| 2420 | + $placeholders | |
| 2421 | + ); | |
| 2422 | + $args = array_merge([$context_type], $schema_types); | |
| 2423 | + } else { | |
| 2424 | + $sql = sprintf( | |
| 2425 | + 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)', | |
| 2426 | + $table_name, | |
| 2427 | + $placeholders | |
| 2428 | + ); | |
| 2429 | + $args = array_merge([$context_type, $context_id], $schema_types); | |
| 2430 | + } | |
| 2431 | + | |
| 2432 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access. | |
| 2433 | + $updated = $wpdb->query( | |
| 2434 | + $wpdb->prepare( | |
| 2435 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders. | |
| 2436 | + $sql, | |
| 2437 | + $args | |
| 2438 | + ) | |
| 2439 | + ); | |
| 2440 | + | |
| 2441 | + if ($updated && $this->cache_manager) { | |
| 2442 | + $this->cache_manager->invalidate_context_cache($context_type, $context_id); | |
| 2443 | + } | |
| 2444 | + | |
| 2445 | + return (int) ($updated ?: 0); | |
| 2446 | + } | |
| 2447 | + | |
| 2448 | + /** | |
| 2449 | + * Retire deployed types that are no longer in the user's Schema Types selection. | |
| 2450 | + * | |
| 2451 | + * An empty selection means "auto-detect", so nothing is retired in that case. | |
| 2452 | + * | |
| 2453 | + * @since 1.16.0 | |
| 2454 | + * | |
| 2455 | + * @param string $context_type Context type. | |
| 2456 | + * @param int|null $context_id Context ID. | |
| 2457 | + * @param array $enabled_types The user's selected types. | |
| 2458 | + * @return int Number of rows deactivated. | |
| 2459 | + */ | |
| 2460 | + private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int { | |
| 2461 | + if (empty($enabled_types)) { | |
| 2462 | + return 0; | |
| 2463 | + } | |
| 2464 | + | |
| 2465 | + $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id)); | |
| 2466 | + $stale = array_diff($deployed, $enabled_types); | |
| 2467 | + | |
| 2468 | + return $this->retire_schema_types($context_type, $context_id, $stale); | |
| 2469 | + } | |
| 2470 | + | |
| 2471 | + /** | |
| 2472 | + * Delete stored schemas for a context before storing new ones. | |
| 2473 | + * | |
| 2474 | + * `$schema_types` scopes the delete to the types actually being rewritten. | |
| 2475 | + * Without it this wiped every type in the context, which silently destroyed | |
| 2476 | + * deployed schema whenever a caller regenerated a subset — and | |
| 2477 | + * auto_deploy_schema_on_settings_change() regenerates one type at a time | |
| 2478 | + * (#454). Passing an empty array keeps the original clear-the-context | |
| 2479 | + * behaviour for callers that genuinely rewrite everything. | |
| 2480 | + * | |
| 1886 | 2481 | * @since 1.0.0 |
| 1887 | 2482 | * |
| 1888 | 2483 | * @param string $context_type Context type |
| 1889 | 2484 | * @param int|null $context_id Context ID |
| 2485 | + * @param string[] $schema_types Optional. Limit the delete to these types. | |
| 1890 | 2486 | * @return int Number of schemas deleted |
| 1891 | 2487 | */ |
| 1892 | - private function delete_existing_schemas(string $context_type, ?int $context_id): int { | |
| 2488 | + private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int { | |
| 1893 | 2489 | global $wpdb; |
| 1894 | 2490 | |
| 1895 | 2491 | $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; |
| 1896 | 2492 | |
| 2493 | + // Build an optional `AND schema_type IN (…)` clause with one prepared | |
| 2494 | + // placeholder per type, so the scoping cannot be injected through. | |
| 2495 | + $type_clause = ''; | |
| 2496 | + $type_values = []; | |
| 2497 | + $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); | |
| 2498 | + if (!empty($schema_types)) { | |
| 2499 | + $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')'; | |
| 2500 | + $type_values = $schema_types; | |
| 2501 | + } | |
| 2502 | + | |
| 1897 | 2503 | if (null === $context_id) { |
| 1898 | 2504 | // Delete all schemas for NULL context_id |
| 1899 | 2505 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 1900 | 2506 | $sql = sprintf( |
| 1901 | - 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL', | |
| 1902 | - $table_name | |
| 2507 | + 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s', | |
| 2508 | + $table_name, | |
| 2509 | + $type_clause | |
| 1903 | 2510 | ); |
| 1904 | 2511 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 1905 | 2512 | $deleted = $wpdb->query( |
| 1906 | 2513 | $wpdb->prepare( |
| @@ -1905,9 +2512,9 @@ | ||
| 1905 | 2512 | $deleted = $wpdb->query( |
| 1906 | 2513 | $wpdb->prepare( |
| 1907 | 2514 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 1908 | 2515 | $sql, |
| 1909 | - $context_type | |
| 2516 | + array_merge([$context_type], $type_values) | |
| 1910 | 2517 | ) |
| 1911 | 2518 | ); |
| 1912 | 2519 | } else { |
| 1913 | 2520 | // Delete all schemas for specific context_id |
| @@ -1912,10 +2519,11 @@ | ||
| 1912 | 2519 | } else { |
| 1913 | 2520 | // Delete all schemas for specific context_id |
| 1914 | 2521 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 1915 | 2522 | $sql = sprintf( |
| 1916 | - 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d', | |
| 1917 | - $table_name | |
| 2523 | + 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s', | |
| 2524 | + $table_name, | |
| 2525 | + $type_clause | |
| 1918 | 2526 | ); |
| 1919 | 2527 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access |
| 1920 | 2528 | $deleted = $wpdb->query( |
| 1921 | 2529 | $wpdb->prepare( |
| @@ -1920,10 +2528,9 @@ | ||
| 1920 | 2528 | $deleted = $wpdb->query( |
| 1921 | 2529 | $wpdb->prepare( |
| 1922 | 2530 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders |
| 1923 | 2531 | $sql, |
| 1924 | - $context_type, | |
| 1925 | - $context_id | |
| 2532 | + array_merge([$context_type, $context_id], $type_values) | |
| 1926 | 2533 | ) |
| 1927 | 2534 | ); |
| 1928 | 2535 | } |
| 1929 | 2536 | |
| @@ -2007,8 +2614,11 @@ | ||
| 2007 | 2614 | 'site_url' => home_url(), |
| 2008 | 2615 | 'admin_email' => get_option('admin_email'), |
| 2009 | 2616 | 'language' => get_locale(), |
| 2010 | 2617 | 'timezone' => get_option('timezone_string'), |
| 2618 | + // Read by populate_website_schema(), so the deployed WebSite node | |
| 2619 | + // carries the same alternateName as the default one (#692). | |
| 2620 | + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '', | |
| 2011 | 2621 | 'founded_date' => $site_identity_settings['founded_date'] ?? '', |
| 2012 | 2622 | 'founder_name' => $site_identity_settings['founder_name'] ?? '', |
| 2013 | 2623 | 'company_type' => $site_identity_settings['company_type'] ?? 'Organization', |
| 2014 | 2624 | // Site Identity assets |