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 +255 -45 1.0.1 → 2.10.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,44 @@
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 + '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',
94 + 'max_tokens',
95 + 'temperature',
96 + 'cache_duration',
97 + 'max_requests_per_minute',
98 + 'ai_daily_request_limit',
99 + 'ai_paused',
100 + 'enable_logging',
101 + 'debug_mode',
102 + 'api_timeout',
103 + 'retry_attempts',
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).
110 + 'data_retention_days',
111 + 'anonymize_logs',
112 + 'share_usage_data',
113 + 'keep_data_on_uninstall'
64 114 ]
65 115 ],
66 116 'seo' => [
67 117 'name' => 'SEO Settings',
@@ -66,9 +116,12 @@
66 116 'seo' => [
67 117 'name' => 'SEO Settings',
68 118 'manager' => 'seo',
69 119 'keys' => [
70 - 'auto_optimize', 'seo_score_threshold', 'enable_meta_generation', 'enable_schema_markup'
120 + 'auto_optimize',
121 + 'seo_score_threshold',
122 + 'enable_meta_generation',
123 + 'enable_schema_markup'
71 124 ]
72 125 ],
73 126 'ui' => [
74 127 'name' => 'User Interface Settings',
@@ -73,9 +126,11 @@
73 126 'ui' => [
74 127 'name' => 'User Interface Settings',
75 128 'manager' => 'core',
76 129 'keys' => [
77 - 'show_welcome_message', 'dashboard_widgets', 'editor_panel_position'
130 + 'show_welcome_message',
131 + 'dashboard_widgets',
132 + 'editor_panel_position'
78 133 ]
79 134 ],
80 135 'basic_integrations' => [
81 136 'name' => 'Basic Integration Settings',
@@ -80,9 +135,10 @@
80 135 'basic_integrations' => [
81 136 'name' => 'Basic Integration Settings',
82 137 'manager' => 'core',
83 138 'keys' => [
84 - 'google_analytics_id', 'search_console_property'
139 + 'google_analytics_id',
140 + 'search_console_property'
85 141 ]
86 142 ],
87 143 'social_media' => [
88 144 'name' => 'Social Media & Open Graph',
@@ -87,15 +143,38 @@
87 143 'social_media' => [
88 144 'name' => 'Social Media & Open Graph',
89 145 'manager' => 'seo',
90 146 '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'
147 + 'enabled',
148 + 'enable_open_graph',
149 + 'og_site_name',
150 + 'og_description',
151 + 'og_type',
152 + 'og_locale',
153 + 'default_og_image',
154 + 'og_image_width',
155 + 'og_image_height',
156 + 'enable_twitter_cards',
157 + 'twitter_username',
158 + 'twitter_card_type',
159 + 'default_twitter_image',
160 + 'facebook_app_id',
161 + 'facebook_admins',
162 + 'enable_linkedin',
163 + 'enable_pinterest',
164 + 'pinterest_site_verification',
165 + 'enable_instagram',
166 + 'instagram_verification',
167 + 'enable_tiktok',
168 + 'tiktok_verification',
169 + 'enable_youtube',
170 + 'youtube_channel_id',
171 + 'enable_whatsapp',
172 + 'whatsapp_business_id',
173 + 'auto_generate_descriptions',
174 + 'fallback_to_excerpt',
175 + 'strip_html_tags',
176 + 'max_description_length'
98 177 ]
99 178 ],
100 179 'sitemap' => [
101 180 'name' => 'XML Sitemap Management',
@@ -100,12 +179,26 @@
100 179 'sitemap' => [
101 180 'name' => 'XML Sitemap Management',
102 181 'manager' => 'seo',
103 182 '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'
183 + 'enabled',
184 + 'include_posts',
185 + 'include_pages',
186 + 'include_categories',
187 + 'include_tags',
188 + 'auto_generate',
189 + 'ping_search_engines',
190 + 'last_generated',
191 + 'exclude_posts',
192 + 'exclude_terms',
193 + 'exclude_password_protected',
194 + 'exclude_private_posts',
195 + 'enable_styling',
196 + 'custom_url_pattern',
197 + 'links_per_sitemap',
198 + 'include_images',
199 + 'include_featured_images',
200 + 'use_sitemap_index'
108 201 ]
109 202 ],
110 203 'site_identity' => [
111 204 'name' => 'Site Identity & Global SEO',
@@ -136,13 +229,24 @@
136 229 'name' => 'Integrations',
137 230 'manager' => 'core',
138 231 'keys' => [
139 232 // Google API Keys
140 - 'google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key',
233 + 'google_analytics_api_key',
234 + 'google_search_console_api_key',
235 + 'google_pagespeed_api_key',
236 + // Google OAuth Tokens
237 + 'google_access_token',
238 + 'google_refresh_token',
239 + 'google_token_expires_in',
240 + 'google_token_created',
241 + 'google_account_connected',
141 242 // API Configuration
142 - 'api_timeout', 'enable_rate_limiting', 'cache_duration',
243 + 'api_timeout',
244 + 'enable_rate_limiting',
245 + 'cache_duration',
143 246 // Connection settings
144 - 'auto_test_connections', 'retry_failed_requests'
247 + 'auto_test_connections',
248 + 'retry_failed_requests'
145 249 ]
146 250 ],
147 251 'seo_analytics' => [
148 252 'name' => 'SEO Analytics & Intelligence',
@@ -148,24 +252,38 @@
148 252 'name' => 'SEO Analytics & Intelligence',
149 253 'manager' => 'core',
150 254 'keys' => [
151 255 // Core settings
152 - 'seo_analytics_enabled', 'seo_analytics_setup_completed',
256 + 'seo_analytics_enabled',
257 + 'seo_analytics_setup_completed',
153 258
154 - // Google Analytics configuration
259 + // Google Analytics configuration. NOTE: the account/property/
260 + // data-stream picker that reads AND writes these three keys is
261 + // thinkrank-pro's GoogleAnalyticsSettings.js (via this plugin's
262 + // settings-management endpoint) — a free-repo grep will find no
263 + // consumer. ga_analytics_data_stream_id was once removed as a
264 + // "dead key" on that basis, which silently broke the Pro
265 + // picker's stream selection persisting across reloads.
155 266 'seo_analytics_google_analytics_property_id',
267 + 'ga_analytics_account_id',
268 + 'ga_analytics_data_stream_id',
156 269
157 270 // Search Console configuration
158 271 'search_console_property',
159 272
160 273 // AI features
161 - 'seo_analytics_enable_ai_insights', 'seo_analytics_enable_automated_alerts', 'seo_analytics_enable_predictive_analysis',
274 + 'seo_analytics_enable_ai_insights',
275 + 'seo_analytics_enable_automated_alerts',
276 + 'seo_analytics_enable_predictive_analysis',
162 277
163 278 // Monitoring settings
164 - 'seo_analytics_monitoring_frequency', 'seo_analytics_alert_thresholds', 'seo_analytics_report_schedule',
279 + 'seo_analytics_monitoring_frequency',
280 + 'seo_analytics_alert_thresholds',
281 + 'seo_analytics_report_schedule',
165 282
166 283 // Data retention
167 - 'seo_analytics_data_retention_days', 'seo_analytics_cache_analytics_data'
284 + 'seo_analytics_data_retention_days',
285 + 'seo_analytics_cache_analytics_data'
168 286 ]
169 287 ],
170 288
171 289 ];
@@ -175,9 +293,9 @@
175 293 *
176 294 * @since 1.0.0
177 295 */
178 296 public function __construct() {
179 - $this->core_settings = new Settings();
297 + $this->core_settings = Settings::instance();
180 298 $this->seo_settings = new SEO_Settings_Manager();
181 299 }
182 300
183 301 /**
@@ -212,17 +330,25 @@
212 330 * @param array $settings Settings to update
213 331 * @param string $category Settings category
214 332 * @param string $context_type Optional. Context type for SEO settings
215 333 * @param int|null $context_id Optional. Context ID for SEO settings
216 - * @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).
217 336 */
218 - 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.
219 341 if (!isset($this->settings_categories[$category])) {
220 - return false;
342 + return null;
221 343 }
222 344
223 345 $category_config = $this->settings_categories[$category];
224 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 +
225 351 if ($category_config['manager'] === 'core') {
226 352 return $this->update_core_settings_by_category($settings, $category);
227 353 } else {
228 354 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
@@ -229,8 +355,23 @@
229 355 }
230 356 }
231 357
232 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 + /**
233 374 * Get all settings across categories
234 375 *
235 376 * @since 1.0.0
236 377 *
@@ -318,8 +459,23 @@
318 459 * @since 1.0.0
319 460 *
320 461 * @return array Categories information
321 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 +
322 478 public function get_categories(): array {
323 479 $categories = [];
324 480
325 481 foreach ($this->settings_categories as $key => $config) {
@@ -344,9 +500,9 @@
344 500 public function export_settings(array $categories = []): array {
345 501 $export_data = [
346 502 'metadata' => [
347 503 'export_timestamp' => current_time('mysql'),
348 - 'plugin_version' => '1.0.0',
504 + 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '1.0.0',
349 505 'wordpress_version' => get_bloginfo('version'),
350 506 'site_url' => home_url(),
351 507 'exported_categories' => empty($categories) ? array_keys($this->settings_categories) : $categories
352 508 ],
@@ -371,9 +527,9 @@
371 527
372 528 // Validate if requested
373 529 if ($validate_before_import) {
374 530 $validation_results = $this->validate_settings($settings);
375 -
531 +
376 532 foreach ($validation_results as $category => $validation) {
377 533 if (!$validation['valid']) {
378 534 $import_results[$category] = [
379 535 'success' => false,
@@ -493,8 +649,16 @@
493 649 * @return array Core settings for category
494 650 */
495 651 private function get_core_settings_by_category(string $category): array {
496 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 +
497 661 $settings = [];
498 662
499 663 foreach ($category_config['keys'] as $key) {
500 664 $settings[$key] = $this->core_settings->get($key);
@@ -513,24 +677,41 @@
513 677 * @return bool Success status
514 678 */
515 679 private function update_core_settings_by_category(array $settings, string $category): bool {
516 680 $category_config = $this->settings_categories[$category];
517 - $success_count = 0;
518 681 $total_count = 0;
519 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 +
520 691 foreach ($settings as $key => $value) {
521 692 if (in_array($key, $category_config['keys'], true)) {
522 693 $total_count++;
523 694
524 - if ($this->core_settings->set($key, $value)) {
525 - $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));
526 703 }
527 704 }
528 705 }
529 706
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;
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);
533 714
534 715 if ($success) {
535 716 update_option('thinkrank_settings_last_updated', current_time('mysql'));
536 717 }
@@ -561,11 +742,12 @@
561 742 * @param array $settings Settings to update
562 743 * @param string $category Category name
563 744 * @param string $context_type Context type
564 745 * @param int|null $context_id Context ID
565 - * @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.
566 748 */
567 - 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 {
568 750 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
569 751 }
570 752
571 753 /**
@@ -631,8 +813,11 @@
631 813
632 814 switch ($key) {
633 815 case 'openai_api_key':
634 816 case 'claude_api_key':
817 + case 'openrouter_api_key':
818 + case 'openai_compatible_api_key':
819 + case 'openai_compatible_model':
635 820 if (!empty($value) && !is_string($value)) {
636 821 $validation['valid'] = false;
637 822 $validation['errors'][] = "{$key} must be a string";
638 823 }
@@ -637,15 +822,29 @@
637 822 $validation['errors'][] = "{$key} must be a string";
638 823 }
639 824 break;
640 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 +
641 838 case 'max_tokens':
642 839 case 'cache_duration':
643 840 case 'max_requests_per_minute':
841 + case 'ai_daily_request_limit':
644 842 case 'seo_score_threshold':
645 843 case 'api_timeout':
646 844 case 'retry_attempts':
647 845 case 'data_retention_days':
846 + case 'openai_compatible_timeout':
648 847 if (!is_numeric($value) || $value < 0) {
649 848 $validation['valid'] = false;
650 849 $validation['errors'][] = "{$key} must be a positive number";
651 850 }
@@ -658,11 +857,15 @@
658 857 }
659 858 break;
660 859
661 860 case 'ai_provider':
662 - if (!in_array($value, ['openai', 'claude', 'gemini'], 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)) {
663 865 $validation['valid'] = false;
664 - $validation['errors'][] = "ai_provider must be 'openai', 'claude', or 'gemini'";
866 + $validation['errors'][] = "ai_provider must be empty (no provider) or one of: "
867 + . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS);
665 868 }
666 869 break;
667 870
668 871 case 'dashboard_widgets':
@@ -705,9 +908,16 @@
705 908 * @param string $category Category name
706 909 * @return bool Success status
707 910 */
708 911 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;
912 + try {
913 + // Map the settings category to the SEO context type
914 + return $this->seo_settings->reset_to_defaults('site');
915 + } catch (\Exception $e) {
916 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
917 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
918 + error_log('ThinkRank: Failed to reset SEO settings for category "' . $category . '": ' . $e->getMessage());
919 + }
920 + return false;
921 + }
712 922 }
713 923 }