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