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/core/class-settings-manager.php +124 -18 1.28.0 → 2.10.0 View file →
@@ -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
@@ -71,17 +83,31 @@
71 83 'gemini_api_key',
72 84 'gemini_model',
73 85 'openrouter_api_key',
74 86 'openrouter_model',
87 + 'openai_compatible_base_url',
88 + 'openai_compatible_api_key',
89 + 'openai_compatible_model',
90 + 'openai_compatible_timeout',
91 + 'openai_compatible_supports_images',
92 + 'openai_compatible_json_mode',
93 + 'openai_compatible_price_per_million',
75 94 'max_tokens',
76 95 'temperature',
77 96 'cache_duration',
78 97 'max_requests_per_minute',
98 + 'ai_daily_request_limit',
99 + 'ai_paused',
79 100 'enable_logging',
80 101 'debug_mode',
81 102 'api_timeout',
82 103 'retry_attempts',
83 - 'rate_limit_enabled',
104 + // 'rate_limit_enabled' used to be listed here, but it has no
105 + // entry in Settings::defaults, so Settings::set() rejected it on
106 + // every save and no code ever read it. Under the old 70%
107 + // threshold that silent rejection was reported as success;
108 + // all-or-nothing reporting would now fail every core save that
109 + // carried it, so the dead key goes rather than the save (#300).
84 110 'data_retention_days',
85 111 'anonymize_logs',
86 112 'share_usage_data',
87 113 'keep_data_on_uninstall'
@@ -240,12 +266,8 @@
240 266 'seo_analytics_google_analytics_property_id',
241 267 'ga_analytics_account_id',
242 268 'ga_analytics_data_stream_id',
243 269
244 - // GA4 Tracking Code Injection (Pro)
245 - 'ga4_auto_inject',
246 - 'ga4_measurement_id',
247 -
248 270 // Search Console configuration
249 271 'search_console_property',
250 272
251 273 // AI features
@@ -308,17 +330,25 @@
308 330 * @param array $settings Settings to update
309 331 * @param string $category Settings category
310 332 * @param string $context_type Optional. Context type for SEO settings
311 333 * @param int|null $context_id Optional. Context ID for SEO settings
312 - * @return bool Success status
334 + * @return bool|null True on success, false on failure, null when this store
335 + * does not own the category (nothing was attempted).
313 336 */
314 - public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): bool {
337 + public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): ?bool {
338 + // Unknown here means "not this store's category", not "the write
339 + // failed" — the caller may still have a dedicated manager that owns it
340 + // (#371). Failing closed made those saves report 500 after committing.
315 341 if (!isset($this->settings_categories[$category])) {
316 - return false;
342 + return null;
317 343 }
318 344
319 345 $category_config = $this->settings_categories[$category];
320 346
347 + // Reset here, not only in the core path: a SEO-category save must not
348 + // leave a previous core save's failed keys readable.
349 + $this->last_failed_keys = [];
350 +
321 351 if ($category_config['manager'] === 'core') {
322 352 return $this->update_core_settings_by_category($settings, $category);
323 353 } else {
324 354 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
@@ -325,8 +355,23 @@
325 355 }
326 356 }
327 357
328 358 /**
359 + * Keys the most recent update_settings() call could not persist.
360 + *
361 + * Empty on success, and reset at the start of every update_settings()
362 + * call. SEO categories persist through their own manager and do not
363 + * report per key, so this stays empty for them.
364 + *
365 + * @since 1.30.0
366 + *
367 + * @return string[] Setting keys that failed to save.
368 + */
369 + public function get_last_failed_keys(): array {
370 + return $this->last_failed_keys;
371 + }
372 +
373 + /**
329 374 * Get all settings across categories
330 375 *
331 376 * @since 1.0.0
332 377 *
@@ -414,8 +459,23 @@
414 459 * @since 1.0.0
415 460 *
416 461 * @return array Categories information
417 462 */
463 + /**
464 + * The setting keys a category defines.
465 + *
466 + * Exposed so callers can reject keys a category does not define instead of
467 + * persisting whatever they are handed (#395).
468 + *
469 + * @since 2.0.1
470 + *
471 + * @param string $category Category name.
472 + * @return string[] Setting keys, or [] when the category is unknown here.
473 + */
474 + public function get_category_keys(string $category): array {
475 + return $this->settings_categories[$category]['keys'] ?? [];
476 + }
477 +
418 478 public function get_categories(): array {
419 479 $categories = [];
420 480
421 481 foreach ($this->settings_categories as $key => $config) {
@@ -589,8 +649,16 @@
589 649 * @return array Core settings for category
590 650 */
591 651 private function get_core_settings_by_category(string $category): array {
592 652 $category_config = $this->settings_categories[$category];
653 +
654 + // Prime the option cache in one query before the loop. Every
655 + // thinkrank_* option is autoload=off, so WordPress cannot serve them
656 + // from `alloptions` and each Settings->get() below was its own
657 + // round-trip — 16 of them on every anonymous front-end request, on
658 + // pages that use none of the values (#393).
659 + $this->core_settings->prime($category_config['keys']);
660 +
593 661 $settings = [];
594 662
595 663 foreach ($category_config['keys'] as $key) {
596 664 $settings[$key] = $this->core_settings->get($key);
@@ -609,24 +677,41 @@
609 677 * @return bool Success status
610 678 */
611 679 private function update_core_settings_by_category(array $settings, string $category): bool {
612 680 $category_config = $this->settings_categories[$category];
613 - $success_count = 0;
614 681 $total_count = 0;
615 682
683 + $this->last_failed_keys = [];
684 +
685 + // Sanitize per field before persisting. This is unconditional: callers
686 + // (including the REST write routes, where the client can ask to skip
687 + // validation) must not be able to reach Settings::set with unsanitized
688 + // values — Settings::set only key-allowlists, it does not sanitize.
689 + $settings = $this->core_settings->sanitize_settings($settings);
690 +
616 691 foreach ($settings as $key => $value) {
617 692 if (in_array($key, $category_config['keys'], true)) {
618 693 $total_count++;
619 694
620 - if ($this->core_settings->set($key, $value)) {
621 - $success_count++;
695 + if (!$this->core_settings->set($key, $value)) {
696 + $this->last_failed_keys[] = $key;
697 +
698 + // Name the key in the log: the UI can only ever show one
699 + // message for the batch, so without this a single dropped
700 + // setting is indistinguishable from a healthy save.
701 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic, see above.
702 + error_log(sprintf('ThinkRank [%s]: settings save failed — key \'%s\' was not stored', $category, $key));
622 703 }
623 704 }
624 705 }
625 706
626 - // Consider successful if at least 70% of settings were saved
627 - $success_rate = $total_count > 0 ? ($success_count / $total_count) : 0;
628 - $success = $success_rate >= 0.7;
707 + // Every requested key must persist. A partial save used to pass on a 70%
708 + // threshold, so a batch could silently drop up to a third of the user's
709 + // settings while the UI reported success and the values were simply gone
710 + // (#300). Note the write is not transactional: the keys that did save
711 + // stay saved, which is why the failed keys are reported rather than just
712 + // a bare false.
713 + $success = $total_count > 0 && empty($this->last_failed_keys);
629 714
630 715 if ($success) {
631 716 update_option('thinkrank_settings_last_updated', current_time('mysql'));
632 717 }
@@ -657,11 +742,12 @@
657 742 * @param array $settings Settings to update
658 743 * @param string $category Category name
659 744 * @param string $context_type Context type
660 745 * @param int|null $context_id Context ID
661 - * @return bool Success status
746 + * @return bool|null True on success, false on failure, null when the SEO
747 + * store does not own the category.
662 748 */
663 - private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): bool {
749 + private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool {
664 750 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
665 751 }
666 752
667 753 /**
@@ -728,8 +814,10 @@
728 814 switch ($key) {
729 815 case 'openai_api_key':
730 816 case 'claude_api_key':
731 817 case 'openrouter_api_key':
818 + case 'openai_compatible_api_key':
819 + case 'openai_compatible_model':
732 820 if (!empty($value) && !is_string($value)) {
733 821 $validation['valid'] = false;
734 822 $validation['errors'][] = "{$key} must be a string";
735 823 }
@@ -734,15 +822,29 @@
734 822 $validation['errors'][] = "{$key} must be a string";
735 823 }
736 824 break;
737 825
826 + case 'openai_compatible_base_url':
827 + // An unreachable or dangerous URL is refused with a reason
828 + // rather than quietly stored (see Endpoint_URL_Validator).
829 + if (!empty($value)) {
830 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate((string) $value);
831 + if (is_wp_error($validated)) {
832 + $validation['valid'] = false;
833 + $validation['errors'][] = $validated->get_error_message();
834 + }
835 + }
836 + break;
837 +
738 838 case 'max_tokens':
739 839 case 'cache_duration':
740 840 case 'max_requests_per_minute':
841 + case 'ai_daily_request_limit':
741 842 case 'seo_score_threshold':
742 843 case 'api_timeout':
743 844 case 'retry_attempts':
744 845 case 'data_retention_days':
846 + case 'openai_compatible_timeout':
745 847 if (!is_numeric($value) || $value < 0) {
746 848 $validation['valid'] = false;
747 849 $validation['errors'][] = "{$key} must be a positive number";
748 850 }
@@ -755,11 +857,15 @@
755 857 }
756 858 break;
757 859
758 860 case 'ai_provider':
759 - if (!in_array($value, ['openai', 'claude', 'gemini', 'openrouter'], true)) {
861 + // '' is legal: it is Settings::AI_PROVIDER_NONE, the state a
862 + // fresh install starts in and the one a user returns to by
863 + // deselecting their provider (#572).
864 + if (!in_array($value, \ThinkRank\Core\Settings::selectable_ai_providers(), true)) {
760 865 $validation['valid'] = false;
761 - $validation['errors'][] = "ai_provider must be 'openai', 'claude', 'gemini', or 'openrouter'";
866 + $validation['errors'][] = "ai_provider must be empty (no provider) or one of: "
867 + . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS);
762 868 }
763 869 break;
764 870
765 871 case 'dashboard_widgets':