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
thinkrank / includes / core / class-settings-manager.php

class-settings-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/core/class-settings-manager.php

924 lines 30.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Centralized Settings Manager Class
5 *
6 * Coordinates settings management across all ThinkRank components including
7 * core plugin settings, SEO-specific settings, and cross-manager coordination.
8 * Provides unified interface for the Settings Management API Endpoints with
9 * proper validation, import/export, and backup/restore functionality.
10 *
11 * @package ThinkRank
12 * @subpackage Core
13 * @since 1.0.0
14 */
15
16 declare(strict_types=1);
17
18 namespace ThinkRank\Core;
19
20 use ThinkRank\Core\Settings;
21 use ThinkRank\SEO\SEO_Settings_Manager;
22
23 // Prevent direct access
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * Centralized Settings Manager Class
30 *
31 * Provides unified settings management interface that coordinates between
32 * core plugin settings and SEO-specific settings. Handles cross-manager
33 * validation, import/export, backup/restore, and conflict resolution.
34 *
35 * @since 1.0.0
36 */
37 class Settings_Manager {
38
39 /**
40 * Core Settings instance
41 *
42 * @since 1.0.0
43 * @var Settings
44 */
45 private Settings $core_settings;
46
47 /**
48 * SEO Settings Manager instance
49 *
50 * @since 1.0.0
51 * @var SEO_Settings_Manager
52 */
53 private SEO_Settings_Manager $seo_settings;
54
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 /**
68 * Settings categories mapping
69 *
70 * @since 1.0.0
71 * @var array
72 */
73 private array $settings_categories = [
74 'core' => [
75 'name' => 'Core Plugin Settings',
76 'manager' => 'core',
77 'keys' => [
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'
114 ]
115 ],
116 'seo' => [
117 'name' => 'SEO Settings',
118 'manager' => 'seo',
119 'keys' => [
120 'auto_optimize',
121 'seo_score_threshold',
122 'enable_meta_generation',
123 'enable_schema_markup'
124 ]
125 ],
126 'ui' => [
127 'name' => 'User Interface Settings',
128 'manager' => 'core',
129 'keys' => [
130 'show_welcome_message',
131 'dashboard_widgets',
132 'editor_panel_position'
133 ]
134 ],
135 'basic_integrations' => [
136 'name' => 'Basic Integration Settings',
137 'manager' => 'core',
138 'keys' => [
139 'google_analytics_id',
140 'search_console_property'
141 ]
142 ],
143 'social_media' => [
144 'name' => 'Social Media & Open Graph',
145 'manager' => 'seo',
146 'keys' => [
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'
177 ]
178 ],
179 'sitemap' => [
180 'name' => 'XML Sitemap Management',
181 'manager' => 'seo',
182 'keys' => [
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'
201 ]
202 ],
203 'site_identity' => [
204 'name' => 'Site Identity & Global SEO',
205 'manager' => 'seo',
206 'keys' => []
207 ],
208 'content_analysis' => [
209 'name' => 'AI Content Analysis',
210 'manager' => 'seo',
211 'keys' => []
212 ],
213 'content_optimization' => [
214 'name' => 'Content Optimization',
215 'manager' => 'seo',
216 'keys' => []
217 ],
218 'performance_monitoring' => [
219 'name' => 'Performance Monitoring',
220 'manager' => 'seo',
221 'keys' => []
222 ],
223 'schema_management' => [
224 'name' => 'Schema Management',
225 'manager' => 'seo',
226 'keys' => []
227 ],
228 'integrations' => [
229 'name' => 'Integrations',
230 'manager' => 'core',
231 'keys' => [
232 // Google API Keys
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',
242 // API Configuration
243 'api_timeout',
244 'enable_rate_limiting',
245 'cache_duration',
246 // Connection settings
247 'auto_test_connections',
248 'retry_failed_requests'
249 ]
250 ],
251 'seo_analytics' => [
252 'name' => 'SEO Analytics & Intelligence',
253 'manager' => 'core',
254 'keys' => [
255 // Core settings
256 'seo_analytics_enabled',
257 'seo_analytics_setup_completed',
258
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.
266 'seo_analytics_google_analytics_property_id',
267 'ga_analytics_account_id',
268 'ga_analytics_data_stream_id',
269
270 // Search Console configuration
271 'search_console_property',
272
273 // AI features
274 'seo_analytics_enable_ai_insights',
275 'seo_analytics_enable_automated_alerts',
276 'seo_analytics_enable_predictive_analysis',
277
278 // Monitoring settings
279 'seo_analytics_monitoring_frequency',
280 'seo_analytics_alert_thresholds',
281 'seo_analytics_report_schedule',
282
283 // Data retention
284 'seo_analytics_data_retention_days',
285 'seo_analytics_cache_analytics_data'
286 ]
287 ],
288
289 ];
290
291 /**
292 * Constructor
293 *
294 * @since 1.0.0
295 */
296 public function __construct() {
297 $this->core_settings = Settings::instance();
298 $this->seo_settings = new SEO_Settings_Manager();
299 }
300
301 /**
302 * Get settings for a specific category
303 *
304 * @since 1.0.0
305 *
306 * @param string $category Settings category
307 * @param string $context_type Optional. Context type for SEO settings
308 * @param int|null $context_id Optional. Context ID for SEO settings
309 * @return array Settings array
310 */
311 public function get_settings(string $category, string $context_type = 'site', ?int $context_id = null): array {
312 if (!isset($this->settings_categories[$category])) {
313 return [];
314 }
315
316 $category_config = $this->settings_categories[$category];
317
318 if ($category_config['manager'] === 'core') {
319 return $this->get_core_settings_by_category($category);
320 } else {
321 return $this->get_seo_settings_by_category($category, $context_type, $context_id);
322 }
323 }
324
325 /**
326 * Update settings for a specific category
327 *
328 * @since 1.0.0
329 *
330 * @param array $settings Settings to update
331 * @param string $category Settings category
332 * @param string $context_type Optional. Context type for SEO settings
333 * @param int|null $context_id Optional. Context ID for SEO settings
334 * @return bool|null True on success, false on failure, null when this store
335 * does not own the category (nothing was attempted).
336 */
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.
341 if (!isset($this->settings_categories[$category])) {
342 return null;
343 }
344
345 $category_config = $this->settings_categories[$category];
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
351 if ($category_config['manager'] === 'core') {
352 return $this->update_core_settings_by_category($settings, $category);
353 } else {
354 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
355 }
356 }
357
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 /**
374 * Get all settings across categories
375 *
376 * @since 1.0.0
377 *
378 * @param array $categories Optional. Specific categories to retrieve
379 * @return array All settings organized by category
380 */
381 public function get_all_settings(array $categories = []): array {
382 $all_settings = [];
383 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
384
385 foreach ($target_categories as $category) {
386 if (isset($this->settings_categories[$category])) {
387 $all_settings[$category] = $this->get_settings($category);
388 }
389 }
390
391 return $all_settings;
392 }
393
394 /**
395 * Update multiple categories of settings
396 *
397 * @since 1.0.0
398 *
399 * @param array $settings_by_category Settings organized by category
400 * @return array Update results for each category
401 */
402 public function update_multiple_settings(array $settings_by_category): array {
403 $results = [];
404
405 foreach ($settings_by_category as $category => $settings) {
406 try {
407 $success = $this->update_settings($settings, $category);
408 $results[$category] = [
409 'success' => $success,
410 'settings_count' => count($settings)
411 ];
412 } catch (\Exception $e) {
413 $results[$category] = [
414 'success' => false,
415 'error' => $e->getMessage()
416 ];
417 }
418 }
419
420 return $results;
421 }
422
423 /**
424 * Validate settings across categories
425 *
426 * @since 1.0.0
427 *
428 * @param array $settings_by_category Settings organized by category
429 * @return array Validation results for each category
430 */
431 public function validate_settings(array $settings_by_category): array {
432 $validation_results = [];
433
434 foreach ($settings_by_category as $category => $settings) {
435 if (!isset($this->settings_categories[$category])) {
436 $validation_results[$category] = [
437 'valid' => false,
438 'errors' => ['Invalid category'],
439 'warnings' => []
440 ];
441 continue;
442 }
443
444 $category_config = $this->settings_categories[$category];
445
446 if ($category_config['manager'] === 'core') {
447 $validation_results[$category] = $this->validate_core_settings($settings, $category);
448 } else {
449 $validation_results[$category] = $this->validate_seo_settings($settings, $category);
450 }
451 }
452
453 return $validation_results;
454 }
455
456 /**
457 * Get settings categories information
458 *
459 * @since 1.0.0
460 *
461 * @return array Categories information
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
478 public function get_categories(): array {
479 $categories = [];
480
481 foreach ($this->settings_categories as $key => $config) {
482 $categories[$key] = [
483 'name' => $config['name'],
484 'manager' => $config['manager'],
485 'key_count' => count($config['keys'])
486 ];
487 }
488
489 return $categories;
490 }
491
492 /**
493 * Export settings
494 *
495 * @since 1.0.0
496 *
497 * @param array $categories Optional. Categories to export
498 * @return array Export data with metadata
499 */
500 public function export_settings(array $categories = []): array {
501 $export_data = [
502 'metadata' => [
503 'export_timestamp' => current_time('mysql'),
504 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '1.0.0',
505 'wordpress_version' => get_bloginfo('version'),
506 'site_url' => home_url(),
507 'exported_categories' => empty($categories) ? array_keys($this->settings_categories) : $categories
508 ],
509 'settings' => $this->get_all_settings($categories)
510 ];
511
512 return $export_data;
513 }
514
515 /**
516 * Import settings
517 *
518 * @since 1.0.0
519 *
520 * @param array $import_data Import data with settings
521 * @param bool $validate_before_import Whether to validate before importing
522 * @return array Import results
523 */
524 public function import_settings(array $import_data, bool $validate_before_import = true): array {
525 $settings = $import_data['settings'] ?? $import_data;
526 $import_results = [];
527
528 // Validate if requested
529 if ($validate_before_import) {
530 $validation_results = $this->validate_settings($settings);
531
532 foreach ($validation_results as $category => $validation) {
533 if (!$validation['valid']) {
534 $import_results[$category] = [
535 'success' => false,
536 'error' => 'Validation failed',
537 'validation_errors' => $validation['errors']
538 ];
539 continue;
540 }
541 }
542 }
543
544 // Import settings for each category
545 foreach ($settings as $category => $category_settings) {
546 if (isset($import_results[$category])) {
547 continue; // Skip if validation failed
548 }
549
550 try {
551 $success = $this->update_settings($category_settings, $category);
552 $import_results[$category] = [
553 'success' => $success,
554 'settings_count' => count($category_settings)
555 ];
556 } catch (\Exception $e) {
557 $import_results[$category] = [
558 'success' => false,
559 'error' => $e->getMessage()
560 ];
561 }
562 }
563
564 return $import_results;
565 }
566
567 /**
568 * Reset settings to defaults
569 *
570 * @since 1.0.0
571 *
572 * @param array $categories Optional. Categories to reset
573 * @return array Reset results
574 */
575 public function reset_settings(array $categories = []): array {
576 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
577 $reset_results = [];
578
579 foreach ($target_categories as $category) {
580 if (!isset($this->settings_categories[$category])) {
581 continue;
582 }
583
584 try {
585 $category_config = $this->settings_categories[$category];
586
587 if ($category_config['manager'] === 'core') {
588 $success = $this->reset_core_settings($category);
589 } else {
590 $success = $this->reset_seo_settings($category);
591 }
592
593 $reset_results[$category] = [
594 'success' => $success,
595 'reset_to_defaults' => true
596 ];
597 } catch (\Exception $e) {
598 $reset_results[$category] = [
599 'success' => false,
600 'error' => $e->getMessage()
601 ];
602 }
603 }
604
605 return $reset_results;
606 }
607
608 /**
609 * Get settings statistics
610 *
611 * @since 1.0.0
612 *
613 * @return array Settings statistics
614 */
615 public function get_settings_statistics(): array {
616 $stats = [
617 'total_categories' => count($this->settings_categories),
618 'core_categories' => 0,
619 'seo_categories' => 0,
620 'total_settings' => 0,
621 'last_updated' => get_option('thinkrank_settings_last_updated'),
622 'version' => get_option('thinkrank_settings_version', '1.0.0')
623 ];
624
625 foreach ($this->settings_categories as $category => $config) {
626 if ($config['manager'] === 'core') {
627 $stats['core_categories']++;
628 } else {
629 $stats['seo_categories']++;
630 }
631
632 $category_settings = $this->get_settings($category);
633 $stats['total_settings'] += count($category_settings);
634 }
635
636 return $stats;
637 }
638
639 /**
640 * Helper methods for core settings management
641 */
642
643 /**
644 * Get core settings by category
645 *
646 * @since 1.0.0
647 *
648 * @param string $category Category name
649 * @return array Core settings for category
650 */
651 private function get_core_settings_by_category(string $category): array {
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
661 $settings = [];
662
663 foreach ($category_config['keys'] as $key) {
664 $settings[$key] = $this->core_settings->get($key);
665 }
666
667 return $settings;
668 }
669
670 /**
671 * Update core settings by category
672 *
673 * @since 1.0.0
674 *
675 * @param array $settings Settings to update
676 * @param string $category Category name
677 * @return bool Success status
678 */
679 private function update_core_settings_by_category(array $settings, string $category): bool {
680 $category_config = $this->settings_categories[$category];
681 $total_count = 0;
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
691 foreach ($settings as $key => $value) {
692 if (in_array($key, $category_config['keys'], true)) {
693 $total_count++;
694
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));
703 }
704 }
705 }
706
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);
714
715 if ($success) {
716 update_option('thinkrank_settings_last_updated', current_time('mysql'));
717 }
718
719 return $success;
720 }
721
722 /**
723 * Get SEO settings by category
724 *
725 * @since 1.0.0
726 *
727 * @param string $category Category name
728 * @param string $context_type Context type
729 * @param int|null $context_id Context ID
730 * @return array SEO settings for category
731 */
732 private function get_seo_settings_by_category(string $category, string $context_type, ?int $context_id): array {
733 // For SEO categories, delegate to SEO Settings Manager
734 return $this->seo_settings->get_settings_by_category($context_type, $context_id, $category);
735 }
736
737 /**
738 * Update SEO settings by category
739 *
740 * @since 1.0.0
741 *
742 * @param array $settings Settings to update
743 * @param string $category Category name
744 * @param string $context_type Context type
745 * @param int|null $context_id Context ID
746 * @return bool|null True on success, false on failure, null when the SEO
747 * store does not own the category.
748 */
749 private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool {
750 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
751 }
752
753 /**
754 * Validate core settings
755 *
756 * @since 1.0.0
757 *
758 * @param array $settings Settings to validate
759 * @param string $category Category name
760 * @return array Validation results
761 */
762 private function validate_core_settings(array $settings, string $category): array {
763 $validation = [
764 'valid' => true,
765 'errors' => [],
766 'warnings' => []
767 ];
768
769 $category_config = $this->settings_categories[$category];
770
771 foreach ($settings as $key => $value) {
772 if (!in_array($key, $category_config['keys'], true)) {
773 $validation['warnings'][] = "Unknown setting key: {$key}";
774 continue;
775 }
776
777 // Validate specific core settings
778 $field_validation = $this->validate_core_setting($key, $value);
779 if (!$field_validation['valid']) {
780 $validation['valid'] = false;
781 $validation['errors'] = array_merge($validation['errors'], $field_validation['errors']);
782 }
783 }
784
785 return $validation;
786 }
787
788 /**
789 * Validate SEO settings
790 *
791 * @since 1.0.0
792 *
793 * @param array $settings Settings to validate
794 * @param string $category Category name
795 * @return array Validation results
796 */
797 private function validate_seo_settings(array $settings, string $category): array {
798 // Delegate to SEO Settings Manager validation
799 return $this->seo_settings->validate_settings($settings);
800 }
801
802 /**
803 * Validate individual core setting
804 *
805 * @since 1.0.0
806 *
807 * @param string $key Setting key
808 * @param mixed $value Setting value
809 * @return array Validation result
810 */
811 private function validate_core_setting(string $key, $value): array {
812 $validation = ['valid' => true, 'errors' => []];
813
814 switch ($key) {
815 case 'openai_api_key':
816 case 'claude_api_key':
817 case 'openrouter_api_key':
818 case 'openai_compatible_api_key':
819 case 'openai_compatible_model':
820 if (!empty($value) && !is_string($value)) {
821 $validation['valid'] = false;
822 $validation['errors'][] = "{$key} must be a string";
823 }
824 break;
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
838 case 'max_tokens':
839 case 'cache_duration':
840 case 'max_requests_per_minute':
841 case 'ai_daily_request_limit':
842 case 'seo_score_threshold':
843 case 'api_timeout':
844 case 'retry_attempts':
845 case 'data_retention_days':
846 case 'openai_compatible_timeout':
847 if (!is_numeric($value) || $value < 0) {
848 $validation['valid'] = false;
849 $validation['errors'][] = "{$key} must be a positive number";
850 }
851 break;
852
853 case 'temperature':
854 if (!is_numeric($value) || $value < 0 || $value > 2) {
855 $validation['valid'] = false;
856 $validation['errors'][] = "temperature must be between 0 and 2";
857 }
858 break;
859
860 case 'ai_provider':
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)) {
865 $validation['valid'] = false;
866 $validation['errors'][] = "ai_provider must be empty (no provider) or one of: "
867 . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS);
868 }
869 break;
870
871 case 'dashboard_widgets':
872 if (!is_array($value)) {
873 $validation['valid'] = false;
874 $validation['errors'][] = "dashboard_widgets must be an array";
875 }
876 break;
877 }
878
879 return $validation;
880 }
881
882 /**
883 * Reset core settings for category
884 *
885 * @since 1.0.0
886 *
887 * @param string $category Category name
888 * @return bool Success status
889 */
890 private function reset_core_settings(string $category): bool {
891 $category_config = $this->settings_categories[$category];
892 $success = true;
893
894 foreach ($category_config['keys'] as $key) {
895 if (!$this->core_settings->delete($key)) {
896 $success = false;
897 }
898 }
899
900 return $success;
901 }
902
903 /**
904 * Reset SEO settings for category
905 *
906 * @since 1.0.0
907 *
908 * @param string $category Category name
909 * @return bool Success status
910 */
911 private function reset_seo_settings(string $category): bool {
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 }
922 }
923 }
924