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 +230 -45 1.0.22.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Centralized Settings Manager Class
4 5 *
5 6 * Coordinates settings management across all ThinkRank components including
@@ -18,8 +19,13 @@
18 19
19 20 use ThinkRank\Core\Settings;
20 21 use ThinkRank\SEO\SEO_Settings_Manager;
21 22
23 +// Prevent direct access
24 +if (!defined('ABSPATH')) {
25 + exit;
26 +}
27 +
22 28 /**
23 29 * Centralized Settings Manager Class
24 30 *
25 31 * Provides unified settings management interface that coordinates between
@@ -46,8 +52,20 @@
46 52 */
47 53 private SEO_Settings_Manager $seo_settings;
48 54
49 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 + /**
50 68 * Settings categories mapping
51 69 *
52 70 * @since 1.0.0
53 71 * @var array
@@ -56,12 +74,35 @@
56 74 'core' => [
57 75 'name' => 'Core Plugin Settings',
58 76 'manager' => 'core',
59 77 'keys' => [
60 - 'ai_provider', 'openai_api_key', 'openai_model', 'claude_api_key', 'claude_model', 'gemini_api_key', 'gemini_model',
61 - 'max_tokens', 'temperature', 'cache_duration', 'max_requests_per_minute',
62 - 'enable_logging', 'debug_mode', 'api_timeout', 'retry_attempts', 'rate_limit_enabled',
63 - 'data_retention_days', 'anonymize_logs', 'share_usage_data', 'keep_data_on_uninstall'
78 + 'ai_provider',
79 + 'openai_api_key',
80 + 'openai_model',
81 + 'claude_api_key',
82 + 'claude_model',
83 + 'gemini_api_key',
84 + 'gemini_model',
85 + 'openrouter_api_key',
86 + 'openrouter_model',
87 + 'max_tokens',
88 + 'temperature',
89 + 'cache_duration',
90 + 'max_requests_per_minute',
91 + 'enable_logging',
92 + 'debug_mode',
93 + 'api_timeout',
94 + 'retry_attempts',
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).
101 + 'data_retention_days',
102 + 'anonymize_logs',
103 + 'share_usage_data',
104 + 'keep_data_on_uninstall'
64 105 ]
65 106 ],
66 107 'seo' => [
67 108 'name' => 'SEO Settings',
@@ -66,9 +107,12 @@
66 107 'seo' => [
67 108 'name' => 'SEO Settings',
68 109 'manager' => 'seo',
69 110 'keys' => [
70 - 'auto_optimize', 'seo_score_threshold', 'enable_meta_generation', 'enable_schema_markup'
111 + 'auto_optimize',
112 + 'seo_score_threshold',
113 + 'enable_meta_generation',
114 + 'enable_schema_markup'
71 115 ]
72 116 ],
73 117 'ui' => [
74 118 'name' => 'User Interface Settings',
@@ -73,9 +117,11 @@
73 117 'ui' => [
74 118 'name' => 'User Interface Settings',
75 119 'manager' => 'core',
76 120 'keys' => [
77 - 'show_welcome_message', 'dashboard_widgets', 'editor_panel_position'
121 + 'show_welcome_message',
122 + 'dashboard_widgets',
123 + 'editor_panel_position'
78 124 ]
79 125 ],
80 126 'basic_integrations' => [
81 127 'name' => 'Basic Integration Settings',
@@ -80,9 +126,10 @@
80 126 'basic_integrations' => [
81 127 'name' => 'Basic Integration Settings',
82 128 'manager' => 'core',
83 129 'keys' => [
84 - 'google_analytics_id', 'search_console_property'
130 + 'google_analytics_id',
131 + 'search_console_property'
85 132 ]
86 133 ],
87 134 'social_media' => [
88 135 'name' => 'Social Media & Open Graph',
@@ -87,15 +134,38 @@
87 134 'social_media' => [
88 135 'name' => 'Social Media & Open Graph',
89 136 'manager' => 'seo',
90 137 'keys' => [
91 - 'enabled', 'enable_open_graph', 'og_site_name', 'og_description', 'og_type', 'og_locale',
92 - 'default_og_image', 'og_image_width', 'og_image_height', 'enable_twitter_cards',
93 - 'twitter_username', 'twitter_card_type', 'default_twitter_image', 'facebook_app_id',
94 - 'facebook_admins', 'enable_linkedin', 'enable_pinterest', 'pinterest_site_verification',
95 - 'enable_instagram', 'instagram_verification', 'enable_tiktok', 'tiktok_verification',
96 - 'enable_youtube', 'youtube_channel_id', 'enable_whatsapp', 'whatsapp_business_id',
97 - 'auto_generate_descriptions', 'fallback_to_excerpt', 'strip_html_tags', 'max_description_length'
138 + 'enabled',
139 + 'enable_open_graph',
140 + 'og_site_name',
141 + 'og_description',
142 + 'og_type',
143 + 'og_locale',
144 + 'default_og_image',
145 + 'og_image_width',
146 + 'og_image_height',
147 + 'enable_twitter_cards',
148 + 'twitter_username',
149 + 'twitter_card_type',
150 + 'default_twitter_image',
151 + 'facebook_app_id',
152 + 'facebook_admins',
153 + 'enable_linkedin',
154 + 'enable_pinterest',
155 + 'pinterest_site_verification',
156 + 'enable_instagram',
157 + 'instagram_verification',
158 + 'enable_tiktok',
159 + 'tiktok_verification',
160 + 'enable_youtube',
161 + 'youtube_channel_id',
162 + 'enable_whatsapp',
163 + 'whatsapp_business_id',
164 + 'auto_generate_descriptions',
165 + 'fallback_to_excerpt',
166 + 'strip_html_tags',
167 + 'max_description_length'
98 168 ]
99 169 ],
100 170 'sitemap' => [
101 171 'name' => 'XML Sitemap Management',
@@ -100,12 +170,26 @@
100 170 'sitemap' => [
101 171 'name' => 'XML Sitemap Management',
102 172 'manager' => 'seo',
103 173 'keys' => [
104 - 'enabled', 'include_posts', 'include_pages', 'include_categories', 'include_tags',
105 - 'auto_generate', 'ping_search_engines', 'last_generated', 'exclude_posts', 'exclude_terms',
106 - 'exclude_password_protected', 'exclude_private_posts', 'enable_styling', 'custom_url_pattern',
107 - 'links_per_sitemap', 'include_images', 'include_featured_images', 'use_sitemap_index'
174 + 'enabled',
175 + 'include_posts',
176 + 'include_pages',
177 + 'include_categories',
178 + 'include_tags',
179 + 'auto_generate',
180 + 'ping_search_engines',
181 + 'last_generated',
182 + 'exclude_posts',
183 + 'exclude_terms',
184 + 'exclude_password_protected',
185 + 'exclude_private_posts',
186 + 'enable_styling',
187 + 'custom_url_pattern',
188 + 'links_per_sitemap',
189 + 'include_images',
190 + 'include_featured_images',
191 + 'use_sitemap_index'
108 192 ]
109 193 ],
110 194 'site_identity' => [
111 195 'name' => 'Site Identity & Global SEO',
@@ -136,13 +220,24 @@
136 220 'name' => 'Integrations',
137 221 'manager' => 'core',
138 222 'keys' => [
139 223 // Google API Keys
140 - 'google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key',
224 + 'google_analytics_api_key',
225 + 'google_search_console_api_key',
226 + 'google_pagespeed_api_key',
227 + // Google OAuth Tokens
228 + 'google_access_token',
229 + 'google_refresh_token',
230 + 'google_token_expires_in',
231 + 'google_token_created',
232 + 'google_account_connected',
141 233 // API Configuration
142 - 'api_timeout', 'enable_rate_limiting', 'cache_duration',
234 + 'api_timeout',
235 + 'enable_rate_limiting',
236 + 'cache_duration',
143 237 // Connection settings
144 - 'auto_test_connections', 'retry_failed_requests'
238 + 'auto_test_connections',
239 + 'retry_failed_requests'
145 240 ]
146 241 ],
147 242 'seo_analytics' => [
148 243 'name' => 'SEO Analytics & Intelligence',
@@ -148,24 +243,38 @@
148 243 'name' => 'SEO Analytics & Intelligence',
149 244 'manager' => 'core',
150 245 'keys' => [
151 246 // Core settings
152 - 'seo_analytics_enabled', 'seo_analytics_setup_completed',
247 + 'seo_analytics_enabled',
248 + 'seo_analytics_setup_completed',
153 249
154 - // 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.
155 257 'seo_analytics_google_analytics_property_id',
258 + 'ga_analytics_account_id',
259 + 'ga_analytics_data_stream_id',
156 260
157 261 // Search Console configuration
158 262 'search_console_property',
159 263
160 264 // AI features
161 - 'seo_analytics_enable_ai_insights', 'seo_analytics_enable_automated_alerts', 'seo_analytics_enable_predictive_analysis',
265 + 'seo_analytics_enable_ai_insights',
266 + 'seo_analytics_enable_automated_alerts',
267 + 'seo_analytics_enable_predictive_analysis',
162 268
163 269 // Monitoring settings
164 - 'seo_analytics_monitoring_frequency', 'seo_analytics_alert_thresholds', 'seo_analytics_report_schedule',
270 + 'seo_analytics_monitoring_frequency',
271 + 'seo_analytics_alert_thresholds',
272 + 'seo_analytics_report_schedule',
165 273
166 274 // Data retention
167 - 'seo_analytics_data_retention_days', 'seo_analytics_cache_analytics_data'
275 + 'seo_analytics_data_retention_days',
276 + 'seo_analytics_cache_analytics_data'
168 277 ]
169 278 ],
170 279
171 280 ];
@@ -175,9 +284,9 @@
175 284 *
176 285 * @since 1.0.0
177 286 */
178 287 public function __construct() {
179 - $this->core_settings = new Settings();
288 + $this->core_settings = Settings::instance();
180 289 $this->seo_settings = new SEO_Settings_Manager();
181 290 }
182 291
183 292 /**
@@ -212,17 +321,25 @@
212 321 * @param array $settings Settings to update
213 322 * @param string $category Settings category
214 323 * @param string $context_type Optional. Context type for SEO settings
215 324 * @param int|null $context_id Optional. Context ID for SEO settings
216 - * @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).
217 327 */
218 - 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.
219 332 if (!isset($this->settings_categories[$category])) {
220 - return false;
333 + return null;
221 334 }
222 335
223 336 $category_config = $this->settings_categories[$category];
224 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 +
225 342 if ($category_config['manager'] === 'core') {
226 343 return $this->update_core_settings_by_category($settings, $category);
227 344 } else {
228 345 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
@@ -229,8 +346,23 @@
229 346 }
230 347 }
231 348
232 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 + /**
233 365 * Get all settings across categories
234 366 *
235 367 * @since 1.0.0
236 368 *
@@ -318,8 +450,23 @@
318 450 * @since 1.0.0
319 451 *
320 452 * @return array Categories information
321 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 +
322 469 public function get_categories(): array {
323 470 $categories = [];
324 471
325 472 foreach ($this->settings_categories as $key => $config) {
@@ -344,9 +491,9 @@
344 491 public function export_settings(array $categories = []): array {
345 492 $export_data = [
346 493 'metadata' => [
347 494 'export_timestamp' => current_time('mysql'),
348 - 'plugin_version' => '1.0.0',
495 + 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '1.0.0',
349 496 'wordpress_version' => get_bloginfo('version'),
350 497 'site_url' => home_url(),
351 498 'exported_categories' => empty($categories) ? array_keys($this->settings_categories) : $categories
352 499 ],
@@ -371,9 +518,9 @@
371 518
372 519 // Validate if requested
373 520 if ($validate_before_import) {
374 521 $validation_results = $this->validate_settings($settings);
375 -
522 +
376 523 foreach ($validation_results as $category => $validation) {
377 524 if (!$validation['valid']) {
378 525 $import_results[$category] = [
379 526 'success' => false,
@@ -493,8 +640,16 @@
493 640 * @return array Core settings for category
494 641 */
495 642 private function get_core_settings_by_category(string $category): array {
496 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 +
497 652 $settings = [];
498 653
499 654 foreach ($category_config['keys'] as $key) {
500 655 $settings[$key] = $this->core_settings->get($key);
@@ -513,24 +668,41 @@
513 668 * @return bool Success status
514 669 */
515 670 private function update_core_settings_by_category(array $settings, string $category): bool {
516 671 $category_config = $this->settings_categories[$category];
517 - $success_count = 0;
518 672 $total_count = 0;
519 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 +
520 682 foreach ($settings as $key => $value) {
521 683 if (in_array($key, $category_config['keys'], true)) {
522 684 $total_count++;
523 685
524 - if ($this->core_settings->set($key, $value)) {
525 - $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));
526 694 }
527 695 }
528 696 }
529 697
530 - // Consider successful if at least 70% of settings were saved
531 - $success_rate = $total_count > 0 ? ($success_count / $total_count) : 0;
532 - $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);
533 705
534 706 if ($success) {
535 707 update_option('thinkrank_settings_last_updated', current_time('mysql'));
536 708 }
@@ -561,11 +733,12 @@
561 733 * @param array $settings Settings to update
562 734 * @param string $category Category name
563 735 * @param string $context_type Context type
564 736 * @param int|null $context_id Context ID
565 - * @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.
566 739 */
567 - 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 {
568 741 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
569 742 }
570 743
571 744 /**
@@ -631,8 +804,9 @@
631 804
632 805 switch ($key) {
633 806 case 'openai_api_key':
634 807 case 'claude_api_key':
808 + case 'openrouter_api_key':
635 809 if (!empty($value) && !is_string($value)) {
636 810 $validation['valid'] = false;
637 811 $validation['errors'][] = "{$key} must be a string";
638 812 }
@@ -658,11 +832,15 @@
658 832 }
659 833 break;
660 834
661 835 case 'ai_provider':
662 - if (!in_array($value, ['openai', 'claude', 'gemini'], 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)) {
663 840 $validation['valid'] = false;
664 - $validation['errors'][] = "ai_provider must be 'openai', 'claude', or 'gemini'";
841 + $validation['errors'][] = "ai_provider must be empty (no provider) or one of: "
842 + . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS);
665 843 }
666 844 break;
667 845
668 846 case 'dashboard_widgets':
@@ -705,9 +883,16 @@
705 883 * @param string $category Category name
706 884 * @return bool Success status
707 885 */
708 886 private function reset_seo_settings(string $category): bool {
709 - // For SEO settings, we would need to implement reset functionality
710 - // in the SEO Settings Manager. For now, return true as placeholder.
711 - return true;
887 + try {
888 + // Map the settings category to the SEO context type
889 + return $this->seo_settings->reset_to_defaults('site');
890 + } catch (\Exception $e) {
891 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
892 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
893 + error_log('ThinkRank: Failed to reset SEO settings for category "' . $category . '": ' . $e->getMessage());
894 + }
895 + return false;
896 + }
712 897 }
713 898 }