PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/core/class-settings-manager.php

899 lines 29.8 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 // Search Console configuration
262 'search_console_property',
263
264 // AI features
265 'seo_analytics_enable_ai_insights',
266 'seo_analytics_enable_automated_alerts',
267 'seo_analytics_enable_predictive_analysis',
268
269 // Monitoring settings
270 'seo_analytics_monitoring_frequency',
271 'seo_analytics_alert_thresholds',
272 'seo_analytics_report_schedule',
273
274 // Data retention
275 'seo_analytics_data_retention_days',
276 'seo_analytics_cache_analytics_data'
277 ]
278 ],
279
280 ];
281
282 /**
283 * Constructor
284 *
285 * @since 1.0.0
286 */
287 public function __construct() {
288 $this->core_settings = Settings::instance();
289 $this->seo_settings = new SEO_Settings_Manager();
290 }
291
292 /**
293 * Get settings for a specific category
294 *
295 * @since 1.0.0
296 *
297 * @param string $category Settings category
298 * @param string $context_type Optional. Context type for SEO settings
299 * @param int|null $context_id Optional. Context ID for SEO settings
300 * @return array Settings array
301 */
302 public function get_settings(string $category, string $context_type = 'site', ?int $context_id = null): array {
303 if (!isset($this->settings_categories[$category])) {
304 return [];
305 }
306
307 $category_config = $this->settings_categories[$category];
308
309 if ($category_config['manager'] === 'core') {
310 return $this->get_core_settings_by_category($category);
311 } else {
312 return $this->get_seo_settings_by_category($category, $context_type, $context_id);
313 }
314 }
315
316 /**
317 * Update settings for a specific category
318 *
319 * @since 1.0.0
320 *
321 * @param array $settings Settings to update
322 * @param string $category Settings category
323 * @param string $context_type Optional. Context type for SEO settings
324 * @param int|null $context_id Optional. Context ID for SEO settings
325 * @return bool|null True on success, false on failure, null when this store
326 * does not own the category (nothing was attempted).
327 */
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.
332 if (!isset($this->settings_categories[$category])) {
333 return null;
334 }
335
336 $category_config = $this->settings_categories[$category];
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
342 if ($category_config['manager'] === 'core') {
343 return $this->update_core_settings_by_category($settings, $category);
344 } else {
345 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
346 }
347 }
348
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 /**
365 * Get all settings across categories
366 *
367 * @since 1.0.0
368 *
369 * @param array $categories Optional. Specific categories to retrieve
370 * @return array All settings organized by category
371 */
372 public function get_all_settings(array $categories = []): array {
373 $all_settings = [];
374 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
375
376 foreach ($target_categories as $category) {
377 if (isset($this->settings_categories[$category])) {
378 $all_settings[$category] = $this->get_settings($category);
379 }
380 }
381
382 return $all_settings;
383 }
384
385 /**
386 * Update multiple categories of settings
387 *
388 * @since 1.0.0
389 *
390 * @param array $settings_by_category Settings organized by category
391 * @return array Update results for each category
392 */
393 public function update_multiple_settings(array $settings_by_category): array {
394 $results = [];
395
396 foreach ($settings_by_category as $category => $settings) {
397 try {
398 $success = $this->update_settings($settings, $category);
399 $results[$category] = [
400 'success' => $success,
401 'settings_count' => count($settings)
402 ];
403 } catch (\Exception $e) {
404 $results[$category] = [
405 'success' => false,
406 'error' => $e->getMessage()
407 ];
408 }
409 }
410
411 return $results;
412 }
413
414 /**
415 * Validate settings across categories
416 *
417 * @since 1.0.0
418 *
419 * @param array $settings_by_category Settings organized by category
420 * @return array Validation results for each category
421 */
422 public function validate_settings(array $settings_by_category): array {
423 $validation_results = [];
424
425 foreach ($settings_by_category as $category => $settings) {
426 if (!isset($this->settings_categories[$category])) {
427 $validation_results[$category] = [
428 'valid' => false,
429 'errors' => ['Invalid category'],
430 'warnings' => []
431 ];
432 continue;
433 }
434
435 $category_config = $this->settings_categories[$category];
436
437 if ($category_config['manager'] === 'core') {
438 $validation_results[$category] = $this->validate_core_settings($settings, $category);
439 } else {
440 $validation_results[$category] = $this->validate_seo_settings($settings, $category);
441 }
442 }
443
444 return $validation_results;
445 }
446
447 /**
448 * Get settings categories information
449 *
450 * @since 1.0.0
451 *
452 * @return array Categories information
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
469 public function get_categories(): array {
470 $categories = [];
471
472 foreach ($this->settings_categories as $key => $config) {
473 $categories[$key] = [
474 'name' => $config['name'],
475 'manager' => $config['manager'],
476 'key_count' => count($config['keys'])
477 ];
478 }
479
480 return $categories;
481 }
482
483 /**
484 * Export settings
485 *
486 * @since 1.0.0
487 *
488 * @param array $categories Optional. Categories to export
489 * @return array Export data with metadata
490 */
491 public function export_settings(array $categories = []): array {
492 $export_data = [
493 'metadata' => [
494 'export_timestamp' => current_time('mysql'),
495 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '1.0.0',
496 'wordpress_version' => get_bloginfo('version'),
497 'site_url' => home_url(),
498 'exported_categories' => empty($categories) ? array_keys($this->settings_categories) : $categories
499 ],
500 'settings' => $this->get_all_settings($categories)
501 ];
502
503 return $export_data;
504 }
505
506 /**
507 * Import settings
508 *
509 * @since 1.0.0
510 *
511 * @param array $import_data Import data with settings
512 * @param bool $validate_before_import Whether to validate before importing
513 * @return array Import results
514 */
515 public function import_settings(array $import_data, bool $validate_before_import = true): array {
516 $settings = $import_data['settings'] ?? $import_data;
517 $import_results = [];
518
519 // Validate if requested
520 if ($validate_before_import) {
521 $validation_results = $this->validate_settings($settings);
522
523 foreach ($validation_results as $category => $validation) {
524 if (!$validation['valid']) {
525 $import_results[$category] = [
526 'success' => false,
527 'error' => 'Validation failed',
528 'validation_errors' => $validation['errors']
529 ];
530 continue;
531 }
532 }
533 }
534
535 // Import settings for each category
536 foreach ($settings as $category => $category_settings) {
537 if (isset($import_results[$category])) {
538 continue; // Skip if validation failed
539 }
540
541 try {
542 $success = $this->update_settings($category_settings, $category);
543 $import_results[$category] = [
544 'success' => $success,
545 'settings_count' => count($category_settings)
546 ];
547 } catch (\Exception $e) {
548 $import_results[$category] = [
549 'success' => false,
550 'error' => $e->getMessage()
551 ];
552 }
553 }
554
555 return $import_results;
556 }
557
558 /**
559 * Reset settings to defaults
560 *
561 * @since 1.0.0
562 *
563 * @param array $categories Optional. Categories to reset
564 * @return array Reset results
565 */
566 public function reset_settings(array $categories = []): array {
567 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
568 $reset_results = [];
569
570 foreach ($target_categories as $category) {
571 if (!isset($this->settings_categories[$category])) {
572 continue;
573 }
574
575 try {
576 $category_config = $this->settings_categories[$category];
577
578 if ($category_config['manager'] === 'core') {
579 $success = $this->reset_core_settings($category);
580 } else {
581 $success = $this->reset_seo_settings($category);
582 }
583
584 $reset_results[$category] = [
585 'success' => $success,
586 'reset_to_defaults' => true
587 ];
588 } catch (\Exception $e) {
589 $reset_results[$category] = [
590 'success' => false,
591 'error' => $e->getMessage()
592 ];
593 }
594 }
595
596 return $reset_results;
597 }
598
599 /**
600 * Get settings statistics
601 *
602 * @since 1.0.0
603 *
604 * @return array Settings statistics
605 */
606 public function get_settings_statistics(): array {
607 $stats = [
608 'total_categories' => count($this->settings_categories),
609 'core_categories' => 0,
610 'seo_categories' => 0,
611 'total_settings' => 0,
612 'last_updated' => get_option('thinkrank_settings_last_updated'),
613 'version' => get_option('thinkrank_settings_version', '1.0.0')
614 ];
615
616 foreach ($this->settings_categories as $category => $config) {
617 if ($config['manager'] === 'core') {
618 $stats['core_categories']++;
619 } else {
620 $stats['seo_categories']++;
621 }
622
623 $category_settings = $this->get_settings($category);
624 $stats['total_settings'] += count($category_settings);
625 }
626
627 return $stats;
628 }
629
630 /**
631 * Helper methods for core settings management
632 */
633
634 /**
635 * Get core settings by category
636 *
637 * @since 1.0.0
638 *
639 * @param string $category Category name
640 * @return array Core settings for category
641 */
642 private function get_core_settings_by_category(string $category): array {
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
652 $settings = [];
653
654 foreach ($category_config['keys'] as $key) {
655 $settings[$key] = $this->core_settings->get($key);
656 }
657
658 return $settings;
659 }
660
661 /**
662 * Update core settings by category
663 *
664 * @since 1.0.0
665 *
666 * @param array $settings Settings to update
667 * @param string $category Category name
668 * @return bool Success status
669 */
670 private function update_core_settings_by_category(array $settings, string $category): bool {
671 $category_config = $this->settings_categories[$category];
672 $total_count = 0;
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
682 foreach ($settings as $key => $value) {
683 if (in_array($key, $category_config['keys'], true)) {
684 $total_count++;
685
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));
694 }
695 }
696 }
697
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);
705
706 if ($success) {
707 update_option('thinkrank_settings_last_updated', current_time('mysql'));
708 }
709
710 return $success;
711 }
712
713 /**
714 * Get SEO settings by category
715 *
716 * @since 1.0.0
717 *
718 * @param string $category Category name
719 * @param string $context_type Context type
720 * @param int|null $context_id Context ID
721 * @return array SEO settings for category
722 */
723 private function get_seo_settings_by_category(string $category, string $context_type, ?int $context_id): array {
724 // For SEO categories, delegate to SEO Settings Manager
725 return $this->seo_settings->get_settings_by_category($context_type, $context_id, $category);
726 }
727
728 /**
729 * Update SEO settings by category
730 *
731 * @since 1.0.0
732 *
733 * @param array $settings Settings to update
734 * @param string $category Category name
735 * @param string $context_type Context type
736 * @param int|null $context_id Context ID
737 * @return bool|null True on success, false on failure, null when the SEO
738 * store does not own the category.
739 */
740 private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool {
741 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
742 }
743
744 /**
745 * Validate core settings
746 *
747 * @since 1.0.0
748 *
749 * @param array $settings Settings to validate
750 * @param string $category Category name
751 * @return array Validation results
752 */
753 private function validate_core_settings(array $settings, string $category): array {
754 $validation = [
755 'valid' => true,
756 'errors' => [],
757 'warnings' => []
758 ];
759
760 $category_config = $this->settings_categories[$category];
761
762 foreach ($settings as $key => $value) {
763 if (!in_array($key, $category_config['keys'], true)) {
764 $validation['warnings'][] = "Unknown setting key: {$key}";
765 continue;
766 }
767
768 // Validate specific core settings
769 $field_validation = $this->validate_core_setting($key, $value);
770 if (!$field_validation['valid']) {
771 $validation['valid'] = false;
772 $validation['errors'] = array_merge($validation['errors'], $field_validation['errors']);
773 }
774 }
775
776 return $validation;
777 }
778
779 /**
780 * Validate SEO settings
781 *
782 * @since 1.0.0
783 *
784 * @param array $settings Settings to validate
785 * @param string $category Category name
786 * @return array Validation results
787 */
788 private function validate_seo_settings(array $settings, string $category): array {
789 // Delegate to SEO Settings Manager validation
790 return $this->seo_settings->validate_settings($settings);
791 }
792
793 /**
794 * Validate individual core setting
795 *
796 * @since 1.0.0
797 *
798 * @param string $key Setting key
799 * @param mixed $value Setting value
800 * @return array Validation result
801 */
802 private function validate_core_setting(string $key, $value): array {
803 $validation = ['valid' => true, 'errors' => []];
804
805 switch ($key) {
806 case 'openai_api_key':
807 case 'claude_api_key':
808 case 'openrouter_api_key':
809 if (!empty($value) && !is_string($value)) {
810 $validation['valid'] = false;
811 $validation['errors'][] = "{$key} must be a string";
812 }
813 break;
814
815 case 'max_tokens':
816 case 'cache_duration':
817 case 'max_requests_per_minute':
818 case 'seo_score_threshold':
819 case 'api_timeout':
820 case 'retry_attempts':
821 case 'data_retention_days':
822 if (!is_numeric($value) || $value < 0) {
823 $validation['valid'] = false;
824 $validation['errors'][] = "{$key} must be a positive number";
825 }
826 break;
827
828 case 'temperature':
829 if (!is_numeric($value) || $value < 0 || $value > 2) {
830 $validation['valid'] = false;
831 $validation['errors'][] = "temperature must be between 0 and 2";
832 }
833 break;
834
835 case 'ai_provider':
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)) {
840 $validation['valid'] = false;
841 $validation['errors'][] = "ai_provider must be empty (no provider) or one of: "
842 . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS);
843 }
844 break;
845
846 case 'dashboard_widgets':
847 if (!is_array($value)) {
848 $validation['valid'] = false;
849 $validation['errors'][] = "dashboard_widgets must be an array";
850 }
851 break;
852 }
853
854 return $validation;
855 }
856
857 /**
858 * Reset core settings for category
859 *
860 * @since 1.0.0
861 *
862 * @param string $category Category name
863 * @return bool Success status
864 */
865 private function reset_core_settings(string $category): bool {
866 $category_config = $this->settings_categories[$category];
867 $success = true;
868
869 foreach ($category_config['keys'] as $key) {
870 if (!$this->core_settings->delete($key)) {
871 $success = false;
872 }
873 }
874
875 return $success;
876 }
877
878 /**
879 * Reset SEO settings for category
880 *
881 * @since 1.0.0
882 *
883 * @param string $category Category name
884 * @return bool Success status
885 */
886 private function reset_seo_settings(string $category): bool {
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 }
897 }
898 }
899