| @@ -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 | } |