PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/core/class-settings-manager.php +99 -18 1.28.02.7.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
@@ -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'
@@ -240,12 +257,8 @@
240 257 'seo_analytics_google_analytics_property_id',
241 258 'ga_analytics_account_id',
242 259 'ga_analytics_data_stream_id',
243 260
244 - // GA4 Tracking Code Injection (Pro)
245 - 'ga4_auto_inject',
246 - 'ga4_measurement_id',
247 -
248 261 // Search Console configuration
249 262 'search_console_property',
250 263
251 264 // AI features
@@ -308,17 +321,25 @@
308 321 * @param array $settings Settings to update
309 322 * @param string $category Settings category
310 323 * @param string $context_type Optional. Context type for SEO settings
311 324 * @param int|null $context_id Optional. Context ID for SEO settings
312 - * @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).
313 327 */
314 - 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.
315 332 if (!isset($this->settings_categories[$category])) {
316 - return false;
333 + return null;
317 334 }
318 335
319 336 $category_config = $this->settings_categories[$category];
320 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 +
321 342 if ($category_config['manager'] === 'core') {
322 343 return $this->update_core_settings_by_category($settings, $category);
323 344 } else {
324 345 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
@@ -325,8 +346,23 @@
325 346 }
326 347 }
327 348
328 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 + /**
329 365 * Get all settings across categories
330 366 *
331 367 * @since 1.0.0
332 368 *
@@ -414,8 +450,23 @@
414 450 * @since 1.0.0
415 451 *
416 452 * @return array Categories information
417 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 +
418 469 public function get_categories(): array {
419 470 $categories = [];
420 471
421 472 foreach ($this->settings_categories as $key => $config) {
@@ -589,8 +640,16 @@
589 640 * @return array Core settings for category
590 641 */
591 642 private function get_core_settings_by_category(string $category): array {
592 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 +
593 652 $settings = [];
594 653
595 654 foreach ($category_config['keys'] as $key) {
596 655 $settings[$key] = $this->core_settings->get($key);
@@ -609,24 +668,41 @@
609 668 * @return bool Success status
610 669 */
611 670 private function update_core_settings_by_category(array $settings, string $category): bool {
612 671 $category_config = $this->settings_categories[$category];
613 - $success_count = 0;
614 672 $total_count = 0;
615 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 +
616 682 foreach ($settings as $key => $value) {
617 683 if (in_array($key, $category_config['keys'], true)) {
618 684 $total_count++;
619 685
620 - if ($this->core_settings->set($key, $value)) {
621 - $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));
622 694 }
623 695 }
624 696 }
625 697
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;
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);
629 705
630 706 if ($success) {
631 707 update_option('thinkrank_settings_last_updated', current_time('mysql'));
632 708 }
@@ -657,11 +733,12 @@
657 733 * @param array $settings Settings to update
658 734 * @param string $category Category name
659 735 * @param string $context_type Context type
660 736 * @param int|null $context_id Context ID
661 - * @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.
662 739 */
663 - 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 {
664 741 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
665 742 }
666 743
667 744 /**
@@ -755,11 +832,15 @@
755 832 }
756 833 break;
757 834
758 835 case 'ai_provider':
759 - 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)) {
760 840 $validation['valid'] = false;
761 - $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);
762 843 }
763 844 break;
764 845
765 846 case 'dashboard_widgets':