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 +106 -19 1.26.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'
@@ -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':