PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
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.0.1, at includes/core/class-settings-manager.php

911 lines 30.2 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 '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'
105 ]
106 ],
107 'seo' => [
108 'name' => 'SEO Settings',
109 'manager' => 'seo',
110 'keys' => [
111 'auto_optimize',
112 'seo_score_threshold',
113 'enable_meta_generation',
114 'enable_schema_markup'
115 ]
116 ],
117 'ui' => [
118 'name' => 'User Interface Settings',
119 'manager' => 'core',
120 'keys' => [
121 'show_welcome_message',
122 'dashboard_widgets',
123 'editor_panel_position'
124 ]
125 ],
126 'basic_integrations' => [
127 'name' => 'Basic Integration Settings',
128 'manager' => 'core',
129 'keys' => [
130 'google_analytics_id',
131 'search_console_property'
132 ]
133 ],
134 'social_media' => [
135 'name' => 'Social Media & Open Graph',
136 'manager' => 'seo',
137 'keys' => [
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'
168 ]
169 ],
170 'sitemap' => [
171 'name' => 'XML Sitemap Management',
172 'manager' => 'seo',
173 'keys' => [
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'
192 ]
193 ],
194 'site_identity' => [
195 'name' => 'Site Identity & Global SEO',
196 'manager' => 'seo',
197 'keys' => []
198 ],
199 'content_analysis' => [
200 'name' => 'AI Content Analysis',
201 'manager' => 'seo',
202 'keys' => []
203 ],
204 'content_optimization' => [
205 'name' => 'Content Optimization',
206 'manager' => 'seo',
207 'keys' => []
208 ],
209 'performance_monitoring' => [
210 'name' => 'Performance Monitoring',
211 'manager' => 'seo',
212 'keys' => []
213 ],
214 'schema_management' => [
215 'name' => 'Schema Management',
216 'manager' => 'seo',
217 'keys' => []
218 ],
219 'integrations' => [
220 'name' => 'Integrations',
221 'manager' => 'core',
222 'keys' => [
223 // Google API Keys
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',
233 // API Configuration
234 'api_timeout',
235 'enable_rate_limiting',
236 'cache_duration',
237 // Connection settings
238 'auto_test_connections',
239 'retry_failed_requests'
240 ]
241 ],
242 'seo_analytics' => [
243 'name' => 'SEO Analytics & Intelligence',
244 'manager' => 'core',
245 'keys' => [
246 // Core settings
247 'seo_analytics_enabled',
248 'seo_analytics_setup_completed',
249
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.
257 'seo_analytics_google_analytics_property_id',
258 'ga_analytics_account_id',
259 'ga_analytics_data_stream_id',
260
261 // GA4 Tracking Code Injection (Pro)
262 'ga4_auto_inject',
263 'ga4_measurement_id',
264
265 // Search Console configuration
266 'search_console_property',
267
268 // AI features
269 'seo_analytics_enable_ai_insights',
270 'seo_analytics_enable_automated_alerts',
271 'seo_analytics_enable_predictive_analysis',
272
273 // Monitoring settings
274 'seo_analytics_monitoring_frequency',
275 'seo_analytics_alert_thresholds',
276 'seo_analytics_report_schedule',
277
278 // Data retention
279 'seo_analytics_data_retention_days',
280 'seo_analytics_cache_analytics_data'
281 ]
282 ],
283
284 ];
285
286 /**
287 * Constructor
288 *
289 * @since 1.0.0
290 */
291 public function __construct() {
292 $this->core_settings = Settings::instance();
293 $this->seo_settings = new SEO_Settings_Manager();
294 }
295
296 /**
297 * Get settings for a specific category
298 *
299 * @since 1.0.0
300 *
301 * @param string $category Settings category
302 * @param string $context_type Optional. Context type for SEO settings
303 * @param int|null $context_id Optional. Context ID for SEO settings
304 * @return array Settings array
305 */
306 public function get_settings(string $category, string $context_type = 'site', ?int $context_id = null): array {
307 if (!isset($this->settings_categories[$category])) {
308 return [];
309 }
310
311 $category_config = $this->settings_categories[$category];
312
313 if ($category_config['manager'] === 'core') {
314 return $this->get_core_settings_by_category($category);
315 } else {
316 return $this->get_seo_settings_by_category($category, $context_type, $context_id);
317 }
318 }
319
320 /**
321 * Update settings for a specific category
322 *
323 * @since 1.0.0
324 *
325 * @param array $settings Settings to update
326 * @param string $category Settings category
327 * @param string $context_type Optional. Context type for SEO settings
328 * @param int|null $context_id Optional. Context ID for SEO settings
329 * @return bool|null True on success, false on failure, null when this store
330 * does not own the category (nothing was attempted).
331 */
332 public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): ?bool {
333 // Unknown here means "not this store's category", not "the write
334 // failed" — the caller may still have a dedicated manager that owns it
335 // (#371). Failing closed made those saves report 500 after committing.
336 if (!isset($this->settings_categories[$category])) {
337 return null;
338 }
339
340 $category_config = $this->settings_categories[$category];
341
342 // Reset here, not only in the core path: a SEO-category save must not
343 // leave a previous core save's failed keys readable.
344 $this->last_failed_keys = [];
345
346 if ($category_config['manager'] === 'core') {
347 return $this->update_core_settings_by_category($settings, $category);
348 } else {
349 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
350 }
351 }
352
353 /**
354 * Keys the most recent update_settings() call could not persist.
355 *
356 * Empty on success, and reset at the start of every update_settings()
357 * call. SEO categories persist through their own manager and do not
358 * report per key, so this stays empty for them.
359 *
360 * @since 1.30.0
361 *
362 * @return string[] Setting keys that failed to save.
363 */
364 public function get_last_failed_keys(): array {
365 return $this->last_failed_keys;
366 }
367
368 /**
369 * Get all settings across categories
370 *
371 * @since 1.0.0
372 *
373 * @param array $categories Optional. Specific categories to retrieve
374 * @return array All settings organized by category
375 */
376 public function get_all_settings(array $categories = []): array {
377 $all_settings = [];
378 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
379
380 foreach ($target_categories as $category) {
381 if (isset($this->settings_categories[$category])) {
382 $all_settings[$category] = $this->get_settings($category);
383 }
384 }
385
386 return $all_settings;
387 }
388
389 /**
390 * Update multiple categories of settings
391 *
392 * @since 1.0.0
393 *
394 * @param array $settings_by_category Settings organized by category
395 * @return array Update results for each category
396 */
397 public function update_multiple_settings(array $settings_by_category): array {
398 $results = [];
399
400 foreach ($settings_by_category as $category => $settings) {
401 try {
402 $success = $this->update_settings($settings, $category);
403 $results[$category] = [
404 'success' => $success,
405 'settings_count' => count($settings)
406 ];
407 } catch (\Exception $e) {
408 $results[$category] = [
409 'success' => false,
410 'error' => $e->getMessage()
411 ];
412 }
413 }
414
415 return $results;
416 }
417
418 /**
419 * Validate settings across categories
420 *
421 * @since 1.0.0
422 *
423 * @param array $settings_by_category Settings organized by category
424 * @return array Validation results for each category
425 */
426 public function validate_settings(array $settings_by_category): array {
427 $validation_results = [];
428
429 foreach ($settings_by_category as $category => $settings) {
430 if (!isset($this->settings_categories[$category])) {
431 $validation_results[$category] = [
432 'valid' => false,
433 'errors' => ['Invalid category'],
434 'warnings' => []
435 ];
436 continue;
437 }
438
439 $category_config = $this->settings_categories[$category];
440
441 if ($category_config['manager'] === 'core') {
442 $validation_results[$category] = $this->validate_core_settings($settings, $category);
443 } else {
444 $validation_results[$category] = $this->validate_seo_settings($settings, $category);
445 }
446 }
447
448 return $validation_results;
449 }
450
451 /**
452 * Get settings categories information
453 *
454 * @since 1.0.0
455 *
456 * @return array Categories information
457 */
458 /**
459 * The setting keys a category defines.
460 *
461 * Exposed so callers can reject keys a category does not define instead of
462 * persisting whatever they are handed (#395).
463 *
464 * @since 2.0.1
465 *
466 * @param string $category Category name.
467 * @return string[] Setting keys, or [] when the category is unknown here.
468 */
469 public function get_category_keys(string $category): array {
470 return $this->settings_categories[$category]['keys'] ?? [];
471 }
472
473 public function get_categories(): array {
474 $categories = [];
475
476 foreach ($this->settings_categories as $key => $config) {
477 $categories[$key] = [
478 'name' => $config['name'],
479 'manager' => $config['manager'],
480 'key_count' => count($config['keys'])
481 ];
482 }
483
484 return $categories;
485 }
486
487 /**
488 * Export settings
489 *
490 * @since 1.0.0
491 *
492 * @param array $categories Optional. Categories to export
493 * @return array Export data with metadata
494 */
495 public function export_settings(array $categories = []): array {
496 $export_data = [
497 'metadata' => [
498 'export_timestamp' => current_time('mysql'),
499 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '1.0.0',
500 'wordpress_version' => get_bloginfo('version'),
501 'site_url' => home_url(),
502 'exported_categories' => empty($categories) ? array_keys($this->settings_categories) : $categories
503 ],
504 'settings' => $this->get_all_settings($categories)
505 ];
506
507 return $export_data;
508 }
509
510 /**
511 * Import settings
512 *
513 * @since 1.0.0
514 *
515 * @param array $import_data Import data with settings
516 * @param bool $validate_before_import Whether to validate before importing
517 * @return array Import results
518 */
519 public function import_settings(array $import_data, bool $validate_before_import = true): array {
520 $settings = $import_data['settings'] ?? $import_data;
521 $import_results = [];
522
523 // Validate if requested
524 if ($validate_before_import) {
525 $validation_results = $this->validate_settings($settings);
526
527 foreach ($validation_results as $category => $validation) {
528 if (!$validation['valid']) {
529 $import_results[$category] = [
530 'success' => false,
531 'error' => 'Validation failed',
532 'validation_errors' => $validation['errors']
533 ];
534 continue;
535 }
536 }
537 }
538
539 // Import settings for each category
540 foreach ($settings as $category => $category_settings) {
541 if (isset($import_results[$category])) {
542 continue; // Skip if validation failed
543 }
544
545 try {
546 $success = $this->update_settings($category_settings, $category);
547 $import_results[$category] = [
548 'success' => $success,
549 'settings_count' => count($category_settings)
550 ];
551 } catch (\Exception $e) {
552 $import_results[$category] = [
553 'success' => false,
554 'error' => $e->getMessage()
555 ];
556 }
557 }
558
559 return $import_results;
560 }
561
562 /**
563 * Reset settings to defaults
564 *
565 * @since 1.0.0
566 *
567 * @param array $categories Optional. Categories to reset
568 * @return array Reset results
569 */
570 public function reset_settings(array $categories = []): array {
571 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
572 $reset_results = [];
573
574 foreach ($target_categories as $category) {
575 if (!isset($this->settings_categories[$category])) {
576 continue;
577 }
578
579 try {
580 $category_config = $this->settings_categories[$category];
581
582 if ($category_config['manager'] === 'core') {
583 $success = $this->reset_core_settings($category);
584 } else {
585 $success = $this->reset_seo_settings($category);
586 }
587
588 $reset_results[$category] = [
589 'success' => $success,
590 'reset_to_defaults' => true
591 ];
592 } catch (\Exception $e) {
593 $reset_results[$category] = [
594 'success' => false,
595 'error' => $e->getMessage()
596 ];
597 }
598 }
599
600 return $reset_results;
601 }
602
603 /**
604 * Get settings statistics
605 *
606 * @since 1.0.0
607 *
608 * @return array Settings statistics
609 */
610 public function get_settings_statistics(): array {
611 $stats = [
612 'total_categories' => count($this->settings_categories),
613 'core_categories' => 0,
614 'seo_categories' => 0,
615 'total_settings' => 0,
616 'last_updated' => get_option('thinkrank_settings_last_updated'),
617 'version' => get_option('thinkrank_settings_version', '1.0.0')
618 ];
619
620 foreach ($this->settings_categories as $category => $config) {
621 if ($config['manager'] === 'core') {
622 $stats['core_categories']++;
623 } else {
624 $stats['seo_categories']++;
625 }
626
627 $category_settings = $this->get_settings($category);
628 $stats['total_settings'] += count($category_settings);
629 }
630
631 return $stats;
632 }
633
634 /**
635 * Helper methods for core settings management
636 */
637
638 /**
639 * Get core settings by category
640 *
641 * @since 1.0.0
642 *
643 * @param string $category Category name
644 * @return array Core settings for category
645 */
646 private function get_core_settings_by_category(string $category): array {
647 $category_config = $this->settings_categories[$category];
648
649 // Prime the option cache in one query before the loop. Every
650 // thinkrank_* option is autoload=off, so WordPress cannot serve them
651 // from `alloptions` and each Settings->get() below was its own
652 // round-trip — 16 of them on every anonymous front-end request, on
653 // pages that use none of the values. Settings::get() memoizes within a
654 // request, so only the first read of each key ever hit the database;
655 // this collapses those first reads into a single query (#393).
656 //
657 // wp_prime_option_caches() is WP 6.4+; the plugin supports 6.0, so an
658 // older site simply keeps the previous behaviour.
659 if (function_exists('wp_prime_option_caches')) {
660 wp_prime_option_caches(array_map(
661 static function (string $key): string {
662 return 'thinkrank_' . $key;
663 },
664 $category_config['keys']
665 ));
666 }
667
668 $settings = [];
669
670 foreach ($category_config['keys'] as $key) {
671 $settings[$key] = $this->core_settings->get($key);
672 }
673
674 return $settings;
675 }
676
677 /**
678 * Update core settings by category
679 *
680 * @since 1.0.0
681 *
682 * @param array $settings Settings to update
683 * @param string $category Category name
684 * @return bool Success status
685 */
686 private function update_core_settings_by_category(array $settings, string $category): bool {
687 $category_config = $this->settings_categories[$category];
688 $total_count = 0;
689
690 $this->last_failed_keys = [];
691
692 // Sanitize per field before persisting. This is unconditional: callers
693 // (including the REST write routes, where the client can ask to skip
694 // validation) must not be able to reach Settings::set with unsanitized
695 // values — Settings::set only key-allowlists, it does not sanitize.
696 $settings = $this->core_settings->sanitize_settings($settings);
697
698 foreach ($settings as $key => $value) {
699 if (in_array($key, $category_config['keys'], true)) {
700 $total_count++;
701
702 if (!$this->core_settings->set($key, $value)) {
703 $this->last_failed_keys[] = $key;
704
705 // Name the key in the log: the UI can only ever show one
706 // message for the batch, so without this a single dropped
707 // setting is indistinguishable from a healthy save.
708 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic, see above.
709 error_log(sprintf('ThinkRank [%s]: settings save failed — key \'%s\' was not stored', $category, $key));
710 }
711 }
712 }
713
714 // Every requested key must persist. A partial save used to pass on a 70%
715 // threshold, so a batch could silently drop up to a third of the user's
716 // settings while the UI reported success and the values were simply gone
717 // (#300). Note the write is not transactional: the keys that did save
718 // stay saved, which is why the failed keys are reported rather than just
719 // a bare false.
720 $success = $total_count > 0 && empty($this->last_failed_keys);
721
722 if ($success) {
723 update_option('thinkrank_settings_last_updated', current_time('mysql'));
724 }
725
726 return $success;
727 }
728
729 /**
730 * Get SEO settings by category
731 *
732 * @since 1.0.0
733 *
734 * @param string $category Category name
735 * @param string $context_type Context type
736 * @param int|null $context_id Context ID
737 * @return array SEO settings for category
738 */
739 private function get_seo_settings_by_category(string $category, string $context_type, ?int $context_id): array {
740 // For SEO categories, delegate to SEO Settings Manager
741 return $this->seo_settings->get_settings_by_category($context_type, $context_id, $category);
742 }
743
744 /**
745 * Update SEO settings by category
746 *
747 * @since 1.0.0
748 *
749 * @param array $settings Settings to update
750 * @param string $category Category name
751 * @param string $context_type Context type
752 * @param int|null $context_id Context ID
753 * @return bool|null True on success, false on failure, null when the SEO
754 * store does not own the category.
755 */
756 private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool {
757 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
758 }
759
760 /**
761 * Validate core settings
762 *
763 * @since 1.0.0
764 *
765 * @param array $settings Settings to validate
766 * @param string $category Category name
767 * @return array Validation results
768 */
769 private function validate_core_settings(array $settings, string $category): array {
770 $validation = [
771 'valid' => true,
772 'errors' => [],
773 'warnings' => []
774 ];
775
776 $category_config = $this->settings_categories[$category];
777
778 foreach ($settings as $key => $value) {
779 if (!in_array($key, $category_config['keys'], true)) {
780 $validation['warnings'][] = "Unknown setting key: {$key}";
781 continue;
782 }
783
784 // Validate specific core settings
785 $field_validation = $this->validate_core_setting($key, $value);
786 if (!$field_validation['valid']) {
787 $validation['valid'] = false;
788 $validation['errors'] = array_merge($validation['errors'], $field_validation['errors']);
789 }
790 }
791
792 return $validation;
793 }
794
795 /**
796 * Validate SEO settings
797 *
798 * @since 1.0.0
799 *
800 * @param array $settings Settings to validate
801 * @param string $category Category name
802 * @return array Validation results
803 */
804 private function validate_seo_settings(array $settings, string $category): array {
805 // Delegate to SEO Settings Manager validation
806 return $this->seo_settings->validate_settings($settings);
807 }
808
809 /**
810 * Validate individual core setting
811 *
812 * @since 1.0.0
813 *
814 * @param string $key Setting key
815 * @param mixed $value Setting value
816 * @return array Validation result
817 */
818 private function validate_core_setting(string $key, $value): array {
819 $validation = ['valid' => true, 'errors' => []];
820
821 switch ($key) {
822 case 'openai_api_key':
823 case 'claude_api_key':
824 case 'openrouter_api_key':
825 if (!empty($value) && !is_string($value)) {
826 $validation['valid'] = false;
827 $validation['errors'][] = "{$key} must be a string";
828 }
829 break;
830
831 case 'max_tokens':
832 case 'cache_duration':
833 case 'max_requests_per_minute':
834 case 'seo_score_threshold':
835 case 'api_timeout':
836 case 'retry_attempts':
837 case 'data_retention_days':
838 if (!is_numeric($value) || $value < 0) {
839 $validation['valid'] = false;
840 $validation['errors'][] = "{$key} must be a positive number";
841 }
842 break;
843
844 case 'temperature':
845 if (!is_numeric($value) || $value < 0 || $value > 2) {
846 $validation['valid'] = false;
847 $validation['errors'][] = "temperature must be between 0 and 2";
848 }
849 break;
850
851 case 'ai_provider':
852 if (!in_array($value, ['openai', 'claude', 'gemini', 'openrouter'], true)) {
853 $validation['valid'] = false;
854 $validation['errors'][] = "ai_provider must be 'openai', 'claude', 'gemini', or 'openrouter'";
855 }
856 break;
857
858 case 'dashboard_widgets':
859 if (!is_array($value)) {
860 $validation['valid'] = false;
861 $validation['errors'][] = "dashboard_widgets must be an array";
862 }
863 break;
864 }
865
866 return $validation;
867 }
868
869 /**
870 * Reset core settings for category
871 *
872 * @since 1.0.0
873 *
874 * @param string $category Category name
875 * @return bool Success status
876 */
877 private function reset_core_settings(string $category): bool {
878 $category_config = $this->settings_categories[$category];
879 $success = true;
880
881 foreach ($category_config['keys'] as $key) {
882 if (!$this->core_settings->delete($key)) {
883 $success = false;
884 }
885 }
886
887 return $success;
888 }
889
890 /**
891 * Reset SEO settings for category
892 *
893 * @since 1.0.0
894 *
895 * @param string $category Category name
896 * @return bool Success status
897 */
898 private function reset_seo_settings(string $category): bool {
899 try {
900 // Map the settings category to the SEO context type
901 return $this->seo_settings->reset_to_defaults('site');
902 } catch (\Exception $e) {
903 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
904 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
905 error_log('ThinkRank: Failed to reset SEO settings for category "' . $category . '": ' . $e->getMessage());
906 }
907 return false;
908 }
909 }
910 }
911