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

903 lines 29.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 '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 (#393).
654 $this->core_settings->prime($category_config['keys']);
655
656 $settings = [];
657
658 foreach ($category_config['keys'] as $key) {
659 $settings[$key] = $this->core_settings->get($key);
660 }
661
662 return $settings;
663 }
664
665 /**
666 * Update core settings by category
667 *
668 * @since 1.0.0
669 *
670 * @param array $settings Settings to update
671 * @param string $category Category name
672 * @return bool Success status
673 */
674 private function update_core_settings_by_category(array $settings, string $category): bool {
675 $category_config = $this->settings_categories[$category];
676 $total_count = 0;
677
678 $this->last_failed_keys = [];
679
680 // Sanitize per field before persisting. This is unconditional: callers
681 // (including the REST write routes, where the client can ask to skip
682 // validation) must not be able to reach Settings::set with unsanitized
683 // values — Settings::set only key-allowlists, it does not sanitize.
684 $settings = $this->core_settings->sanitize_settings($settings);
685
686 foreach ($settings as $key => $value) {
687 if (in_array($key, $category_config['keys'], true)) {
688 $total_count++;
689
690 if (!$this->core_settings->set($key, $value)) {
691 $this->last_failed_keys[] = $key;
692
693 // Name the key in the log: the UI can only ever show one
694 // message for the batch, so without this a single dropped
695 // setting is indistinguishable from a healthy save.
696 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic, see above.
697 error_log(sprintf('ThinkRank [%s]: settings save failed — key \'%s\' was not stored', $category, $key));
698 }
699 }
700 }
701
702 // Every requested key must persist. A partial save used to pass on a 70%
703 // threshold, so a batch could silently drop up to a third of the user's
704 // settings while the UI reported success and the values were simply gone
705 // (#300). Note the write is not transactional: the keys that did save
706 // stay saved, which is why the failed keys are reported rather than just
707 // a bare false.
708 $success = $total_count > 0 && empty($this->last_failed_keys);
709
710 if ($success) {
711 update_option('thinkrank_settings_last_updated', current_time('mysql'));
712 }
713
714 return $success;
715 }
716
717 /**
718 * Get SEO settings by category
719 *
720 * @since 1.0.0
721 *
722 * @param string $category Category name
723 * @param string $context_type Context type
724 * @param int|null $context_id Context ID
725 * @return array SEO settings for category
726 */
727 private function get_seo_settings_by_category(string $category, string $context_type, ?int $context_id): array {
728 // For SEO categories, delegate to SEO Settings Manager
729 return $this->seo_settings->get_settings_by_category($context_type, $context_id, $category);
730 }
731
732 /**
733 * Update SEO settings by category
734 *
735 * @since 1.0.0
736 *
737 * @param array $settings Settings to update
738 * @param string $category Category name
739 * @param string $context_type Context type
740 * @param int|null $context_id Context ID
741 * @return bool|null True on success, false on failure, null when the SEO
742 * store does not own the category.
743 */
744 private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): ?bool {
745 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
746 }
747
748 /**
749 * Validate core settings
750 *
751 * @since 1.0.0
752 *
753 * @param array $settings Settings to validate
754 * @param string $category Category name
755 * @return array Validation results
756 */
757 private function validate_core_settings(array $settings, string $category): array {
758 $validation = [
759 'valid' => true,
760 'errors' => [],
761 'warnings' => []
762 ];
763
764 $category_config = $this->settings_categories[$category];
765
766 foreach ($settings as $key => $value) {
767 if (!in_array($key, $category_config['keys'], true)) {
768 $validation['warnings'][] = "Unknown setting key: {$key}";
769 continue;
770 }
771
772 // Validate specific core settings
773 $field_validation = $this->validate_core_setting($key, $value);
774 if (!$field_validation['valid']) {
775 $validation['valid'] = false;
776 $validation['errors'] = array_merge($validation['errors'], $field_validation['errors']);
777 }
778 }
779
780 return $validation;
781 }
782
783 /**
784 * Validate SEO settings
785 *
786 * @since 1.0.0
787 *
788 * @param array $settings Settings to validate
789 * @param string $category Category name
790 * @return array Validation results
791 */
792 private function validate_seo_settings(array $settings, string $category): array {
793 // Delegate to SEO Settings Manager validation
794 return $this->seo_settings->validate_settings($settings);
795 }
796
797 /**
798 * Validate individual core setting
799 *
800 * @since 1.0.0
801 *
802 * @param string $key Setting key
803 * @param mixed $value Setting value
804 * @return array Validation result
805 */
806 private function validate_core_setting(string $key, $value): array {
807 $validation = ['valid' => true, 'errors' => []];
808
809 switch ($key) {
810 case 'openai_api_key':
811 case 'claude_api_key':
812 case 'openrouter_api_key':
813 if (!empty($value) && !is_string($value)) {
814 $validation['valid'] = false;
815 $validation['errors'][] = "{$key} must be a string";
816 }
817 break;
818
819 case 'max_tokens':
820 case 'cache_duration':
821 case 'max_requests_per_minute':
822 case 'seo_score_threshold':
823 case 'api_timeout':
824 case 'retry_attempts':
825 case 'data_retention_days':
826 if (!is_numeric($value) || $value < 0) {
827 $validation['valid'] = false;
828 $validation['errors'][] = "{$key} must be a positive number";
829 }
830 break;
831
832 case 'temperature':
833 if (!is_numeric($value) || $value < 0 || $value > 2) {
834 $validation['valid'] = false;
835 $validation['errors'][] = "temperature must be between 0 and 2";
836 }
837 break;
838
839 case 'ai_provider':
840 // '' is legal: it is Settings::AI_PROVIDER_NONE, the state a
841 // fresh install starts in and the one a user returns to by
842 // deselecting their provider (#572).
843 if (!in_array($value, \ThinkRank\Core\Settings::selectable_ai_providers(), true)) {
844 $validation['valid'] = false;
845 $validation['errors'][] = "ai_provider must be empty (no provider) or one of: "
846 . implode(', ', \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS);
847 }
848 break;
849
850 case 'dashboard_widgets':
851 if (!is_array($value)) {
852 $validation['valid'] = false;
853 $validation['errors'][] = "dashboard_widgets must be an array";
854 }
855 break;
856 }
857
858 return $validation;
859 }
860
861 /**
862 * Reset core settings for category
863 *
864 * @since 1.0.0
865 *
866 * @param string $category Category name
867 * @return bool Success status
868 */
869 private function reset_core_settings(string $category): bool {
870 $category_config = $this->settings_categories[$category];
871 $success = true;
872
873 foreach ($category_config['keys'] as $key) {
874 if (!$this->core_settings->delete($key)) {
875 $success = false;
876 }
877 }
878
879 return $success;
880 }
881
882 /**
883 * Reset SEO 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_seo_settings(string $category): bool {
891 try {
892 // Map the settings category to the SEO context type
893 return $this->seo_settings->reset_to_defaults('site');
894 } catch (\Exception $e) {
895 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
896 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
897 error_log('ThinkRank: Failed to reset SEO settings for category "' . $category . '": ' . $e->getMessage());
898 }
899 return false;
900 }
901 }
902 }
903