| @@ -52,8 +52,20 @@ | ||
| 52 | 52 | */ |
| 53 | 53 | private SEO_Settings_Manager $seo_settings; |
| 54 | 54 | |
| 55 | 55 | /** |
| 56 | + * Keys the most recent core-category save could not persist. | |
| 57 | + * | |
| 58 | + * A batch save is all-or-nothing in its reporting but not in its writes, so | |
| 59 | + * a caller that gets false needs to know *which* settings did not make it — | |
| 60 | + * a bare boolean leaves the UI unable to say anything useful (#300). | |
| 61 | + * | |
| 62 | + * @since 1.30.0 | |
| 63 | + * @var string[] | |
| 64 | + */ | |
| 65 | + private array $last_failed_keys = []; | |
| 66 | + | |
| 67 | + /** | |
| 56 | 68 | * Settings categories mapping |
| 57 | 69 | * |
| 58 | 70 | * @since 1.0.0 |
| 59 | 71 | * @var array |
| @@ -79,9 +91,14 @@ | ||
| 79 | 91 | 'enable_logging', |
| 80 | 92 | 'debug_mode', |
| 81 | 93 | 'api_timeout', |
| 82 | 94 | 'retry_attempts', |
| 83 | - 'rate_limit_enabled', | |
| 95 | + // 'rate_limit_enabled' used to be listed here, but it has no | |
| 96 | + // entry in Settings::defaults, so Settings::set() rejected it on | |
| 97 | + // every save and no code ever read it. Under the old 70% | |
| 98 | + // threshold that silent rejection was reported as success; | |
| 99 | + // all-or-nothing reporting would now fail every core save that | |
| 100 | + // carried it, so the dead key goes rather than the save (#300). | |
| 84 | 101 | 'data_retention_days', |
| 85 | 102 | 'anonymize_logs', |
| 86 | 103 | 'share_usage_data', |
| 87 | 104 | 'keep_data_on_uninstall' |
| @@ -229,17 +246,19 @@ | ||
| 229 | 246 | // Core settings |
| 230 | 247 | 'seo_analytics_enabled', |
| 231 | 248 | 'seo_analytics_setup_completed', |
| 232 | 249 | |
| 233 | - // Google Analytics configuration | |
| 250 | + // Google Analytics configuration. NOTE: the account/property/ | |
| 251 | + // data-stream picker that reads AND writes these three keys is | |
| 252 | + // thinkrank-pro's GoogleAnalyticsSettings.js (via this plugin's | |
| 253 | + // settings-management endpoint) — a free-repo grep will find no | |
| 254 | + // consumer. ga_analytics_data_stream_id was once removed as a | |
| 255 | + // "dead key" on that basis, which silently broke the Pro | |
| 256 | + // picker's stream selection persisting across reloads. | |
| 234 | 257 | 'seo_analytics_google_analytics_property_id', |
| 235 | 258 | 'ga_analytics_account_id', |
| 236 | 259 | 'ga_analytics_data_stream_id', |
| 237 | 260 | |
| 238 | - // GA4 Tracking Code Injection (Pro) | |
| 239 | - 'ga4_auto_inject', | |
| 240 | - 'ga4_measurement_id', | |
| 241 | - | |
| 242 | 261 | // Search Console configuration |
| 243 | 262 | 'search_console_property', |
| 244 | 263 | |
| 245 | 264 | // AI features |
| @@ -302,17 +321,25 @@ | ||
| 302 | 321 | * @param array $settings Settings to update |
| 303 | 322 | * @param string $category Settings category |
| 304 | 323 | * @param string $context_type Optional. Context type for SEO settings |
| 305 | 324 | * @param int|null $context_id Optional. Context ID for SEO settings |
| 306 | - * @return bool Success status | |
| 325 | + * @return bool|null True on success, false on failure, null when this store | |
| 326 | + * does not own the category (nothing was attempted). | |
| 307 | 327 | */ |
| 308 | - public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): bool { | |
| 328 | + public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): ?bool { | |
| 329 | + // Unknown here means "not this store's category", not "the write | |
| 330 | + // failed" — the caller may still have a dedicated manager that owns it | |
| 331 | + // (#371). Failing closed made those saves report 500 after committing. | |
| 309 | 332 | if (!isset($this->settings_categories[$category])) { |
| 310 | - return false; | |
| 333 | + return null; | |
| 311 | 334 | } |
| 312 | 335 | |
| 313 | 336 | $category_config = $this->settings_categories[$category]; |
| 314 | 337 | |
| 338 | + // Reset here, not only in the core path: a SEO-category save must not | |
| 339 | + // leave a previous core save's failed keys readable. | |
| 340 | + $this->last_failed_keys = []; | |
| 341 | + | |
| 315 | 342 | if ($category_config['manager'] === 'core') { |
| 316 | 343 | return $this->update_core_settings_by_category($settings, $category); |
| 317 | 344 | } else { |
| 318 | 345 | return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id); |
| @@ -319,8 +346,23 @@ | ||
| 319 | 346 | } |
| 320 | 347 | } |
| 321 | 348 | |
| 322 | 349 | /** |
| 350 | + * Keys the most recent update_settings() call could not persist. | |
| 351 | + * | |
| 352 | + * Empty on success, and reset at the start of every update_settings() | |
| 353 | + * call. SEO categories persist through their own manager and do not | |
| 354 | + * report per key, so this stays empty for them. | |
| 355 | + * | |
| 356 | + * @since 1.30.0 | |
| 357 | + * | |
| 358 | + * @return string[] Setting keys that failed to save. | |
| 359 | + */ | |
| 360 | + public function get_last_failed_keys(): array { | |
| 361 | + return $this->last_failed_keys; | |
| 362 | + } | |
| 363 | + | |
| 364 | + /** | |
| 323 | 365 | * Get all settings across categories |
| 324 | 366 | * |
| 325 | 367 | * @since 1.0.0 |
| 326 | 368 | * |
| @@ -408,8 +450,23 @@ | ||
| 408 | 450 | * @since 1.0.0 |
| 409 | 451 | * |
| 410 | 452 | * @return array Categories information |
| 411 | 453 | */ |
| 454 | + /** | |
| 455 | + * The setting keys a category defines. | |
| 456 | + * | |
| 457 | + * Exposed so callers can reject keys a category does not define instead of | |
| 458 | + * persisting whatever they are handed (#395). | |
| 459 | + * | |
| 460 | + * @since 2.0.1 | |
| 461 | + * | |
| 462 | + * @param string $category Category name. | |
| 463 | + * @return string[] Setting keys, or [] when the category is unknown here. | |
| 464 | + */ | |
| 465 | + public function get_category_keys(string $category): array { | |
| 466 | + return $this->settings_categories[$category]['keys'] ?? []; | |
| 467 | + } | |
| 468 | + | |
| 412 | 469 | public function get_categories(): array { |
| 413 | 470 | $categories = []; |
| 414 | 471 | |
| 415 | 472 | foreach ($this->settings_categories as $key => $config) { |
| @@ -583,8 +640,16 @@ | ||
| 583 | 640 | * @return array Core settings for category |
| 584 | 641 | */ |
| 585 | 642 | private function get_core_settings_by_category(string $category): array { |
| 586 | 643 | $category_config = $this->settings_categories[$category]; |
| 644 | + | |
| 645 | + // Prime the option cache in one query before the loop. Every | |
| 646 | + // thinkrank_* option is autoload=off, so WordPress cannot serve them | |
| 647 | + // from `alloptions` and each Settings->get() below was its own | |
| 648 | + // round-trip — 16 of them on every anonymous front-end request, on | |
| 649 | + // pages that use none of the values (#393). | |
| 650 | + $this->core_settings->prime($category_config['keys']); | |
| 651 | + | |
| 587 | 652 | $settings = []; |
| 588 | 653 | |
| 589 | 654 | foreach ($category_config['keys'] as $key) { |
| 590 | 655 | $settings[$key] = $this->core_settings->get($key); |
| @@ -603,24 +668,41 @@ | ||
| 603 | 668 | * @return bool Success status |
| 604 | 669 | */ |
| 605 | 670 | private function update_core_settings_by_category(array $settings, string $category): bool { |
| 606 | 671 | $category_config = $this->settings_categories[$category]; |
| 607 | - $success_count = 0; | |
| 608 | 672 | $total_count = 0; |
| 609 | 673 | |
| 674 | + $this->last_failed_keys = []; | |
| 675 | + | |
| 676 | + // Sanitize per field before persisting. This is unconditional: callers | |
| 677 | + // (including the REST write routes, where the client can ask to skip | |
| 678 | + // validation) must not be able to reach Settings::set with unsanitized | |
| 679 | + // values — Settings::set only key-allowlists, it does not sanitize. | |
| 680 | + $settings = $this->core_settings->sanitize_settings($settings); | |
| 681 | + | |
| 610 | 682 | foreach ($settings as $key => $value) { |
| 611 | 683 | if (in_array($key, $category_config['keys'], true)) { |
| 612 | 684 | $total_count++; |
| 613 | 685 | |
| 614 | - if ($this->core_settings->set($key, $value)) { | |
| 615 | - $success_count++; | |
| 686 | + if (!$this->core_settings->set($key, $value)) { | |
| 687 | + $this->last_failed_keys[] = $key; | |
| 688 | + | |
| 689 | + // Name the key in the log: the UI can only ever show one | |
| 690 | + // message for the batch, so without this a single dropped | |
| 691 | + // setting is indistinguishable from a healthy save. | |
| 692 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic, see above. | |
| 693 | + error_log(sprintf('ThinkRank [%s]: settings save failed — key \'%s\' was not stored', $category, $key)); | |
| 616 | 694 | } |
| 617 | 695 | } |
| 618 | 696 | } |
| 619 | 697 | |
| 620 | - // Consider successful if at least 70% of settings were saved | |
| 621 | - $success_rate = $total_count > 0 ? ($success_count / $total_count) : 0; | |
| 622 | - $success = $success_rate >= 0.7; | |
| 698 | + // Every requested key must persist. A partial save used to pass on a 70% | |
| 699 | + // threshold, so a batch could silently drop up to a third of the user's | |
| 700 | + // settings while the UI reported success and the values were simply gone | |
| 701 | + // (#300). Note the write is not transactional: the keys that did save | |
| 702 | + // stay saved, which is why the failed keys are reported rather than just | |
| 703 | + // a bare false. | |
| 704 | + $success = $total_count > 0 && empty($this->last_failed_keys); | |
| 623 | 705 | |
| 624 | 706 | if ($success) { |
| 625 | 707 | update_option('thinkrank_settings_last_updated', current_time('mysql')); |
| 626 | 708 | } |
| @@ -651,11 +733,12 @@ | ||
| 651 | 733 | * @param array $settings Settings to update |
| 652 | 734 | * @param string $category Category name |
| 653 | 735 | * @param string $context_type Context type |
| 654 | 736 | * @param int|null $context_id Context ID |
| 655 | - * @return bool Success status | |
| 737 | + * @return bool|null True on success, false on failure, null when the SEO | |
| 738 | + * store does not own the category. | |
| 656 | 739 | */ |
| 657 | - private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): bool { | |
| 740 | + private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool { | |
| 658 | 741 | return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category); |
| 659 | 742 | } |
| 660 | 743 | |
| 661 | 744 | /** |
| @@ -749,11 +832,15 @@ | ||
| 749 | 832 | } |
| 750 | 833 | break; |
| 751 | 834 | |
| 752 | 835 | case 'ai_provider': |
| 753 | - if (!in_array($value, ['openai', 'claude', 'gemini', 'openrouter'], true)) { | |
| 836 | + // '' is legal: it is Settings::AI_PROVIDER_NONE, the state a | |
| 837 | + // fresh install starts in and the one a user returns to by | |
| 838 | + // deselecting their provider (#572). | |
| 839 | + if (!in_array($value, \ThinkRank\Core\Settings::selectable_ai_providers(), true)) { | |
| 754 | 840 | $validation['valid'] = false; |
| 755 | - $validation['errors'][] = "ai_provider must be 'openai', 'claude', 'gemini', or 'openrouter'"; | |
| 841 | + $validation['errors'][] = "ai_provider must be empty (no provider) or one of: " | |
| 842 | + . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS); | |
| 756 | 843 | } |
| 757 | 844 | break; |
| 758 | 845 | |
| 759 | 846 | case 'dashboard_widgets': |