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

824 lines 26.0 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 * Settings categories mapping
57 *
58 * @since 1.0.0
59 * @var array
60 */
61 private array $settings_categories = [
62 'core' => [
63 'name' => 'Core Plugin Settings',
64 'manager' => 'core',
65 'keys' => [
66 'ai_provider',
67 'openai_api_key',
68 'openai_model',
69 'claude_api_key',
70 'claude_model',
71 'gemini_api_key',
72 'gemini_model',
73 'openrouter_api_key',
74 'openrouter_model',
75 'max_tokens',
76 'temperature',
77 'cache_duration',
78 'max_requests_per_minute',
79 'enable_logging',
80 'debug_mode',
81 'api_timeout',
82 'retry_attempts',
83 'rate_limit_enabled',
84 'data_retention_days',
85 'anonymize_logs',
86 'share_usage_data',
87 'keep_data_on_uninstall'
88 ]
89 ],
90 'seo' => [
91 'name' => 'SEO Settings',
92 'manager' => 'seo',
93 'keys' => [
94 'auto_optimize',
95 'seo_score_threshold',
96 'enable_meta_generation',
97 'enable_schema_markup'
98 ]
99 ],
100 'ui' => [
101 'name' => 'User Interface Settings',
102 'manager' => 'core',
103 'keys' => [
104 'show_welcome_message',
105 'dashboard_widgets',
106 'editor_panel_position'
107 ]
108 ],
109 'basic_integrations' => [
110 'name' => 'Basic Integration Settings',
111 'manager' => 'core',
112 'keys' => [
113 'google_analytics_id',
114 'search_console_property'
115 ]
116 ],
117 'social_media' => [
118 'name' => 'Social Media & Open Graph',
119 'manager' => 'seo',
120 'keys' => [
121 'enabled',
122 'enable_open_graph',
123 'og_site_name',
124 'og_description',
125 'og_type',
126 'og_locale',
127 'default_og_image',
128 'og_image_width',
129 'og_image_height',
130 'enable_twitter_cards',
131 'twitter_username',
132 'twitter_card_type',
133 'default_twitter_image',
134 'facebook_app_id',
135 'facebook_admins',
136 'enable_linkedin',
137 'enable_pinterest',
138 'pinterest_site_verification',
139 'enable_instagram',
140 'instagram_verification',
141 'enable_tiktok',
142 'tiktok_verification',
143 'enable_youtube',
144 'youtube_channel_id',
145 'enable_whatsapp',
146 'whatsapp_business_id',
147 'auto_generate_descriptions',
148 'fallback_to_excerpt',
149 'strip_html_tags',
150 'max_description_length'
151 ]
152 ],
153 'sitemap' => [
154 'name' => 'XML Sitemap Management',
155 'manager' => 'seo',
156 'keys' => [
157 'enabled',
158 'include_posts',
159 'include_pages',
160 'include_categories',
161 'include_tags',
162 'auto_generate',
163 'ping_search_engines',
164 'last_generated',
165 'exclude_posts',
166 'exclude_terms',
167 'exclude_password_protected',
168 'exclude_private_posts',
169 'enable_styling',
170 'custom_url_pattern',
171 'links_per_sitemap',
172 'include_images',
173 'include_featured_images',
174 'use_sitemap_index'
175 ]
176 ],
177 'site_identity' => [
178 'name' => 'Site Identity & Global SEO',
179 'manager' => 'seo',
180 'keys' => []
181 ],
182 'content_analysis' => [
183 'name' => 'AI Content Analysis',
184 'manager' => 'seo',
185 'keys' => []
186 ],
187 'content_optimization' => [
188 'name' => 'Content Optimization',
189 'manager' => 'seo',
190 'keys' => []
191 ],
192 'performance_monitoring' => [
193 'name' => 'Performance Monitoring',
194 'manager' => 'seo',
195 'keys' => []
196 ],
197 'schema_management' => [
198 'name' => 'Schema Management',
199 'manager' => 'seo',
200 'keys' => []
201 ],
202 'integrations' => [
203 'name' => 'Integrations',
204 'manager' => 'core',
205 'keys' => [
206 // Google API Keys
207 'google_analytics_api_key',
208 'google_search_console_api_key',
209 'google_pagespeed_api_key',
210 // Google OAuth Tokens
211 'google_access_token',
212 'google_refresh_token',
213 'google_token_expires_in',
214 'google_token_created',
215 'google_account_connected',
216 // API Configuration
217 'api_timeout',
218 'enable_rate_limiting',
219 'cache_duration',
220 // Connection settings
221 'auto_test_connections',
222 'retry_failed_requests'
223 ]
224 ],
225 'seo_analytics' => [
226 'name' => 'SEO Analytics & Intelligence',
227 'manager' => 'core',
228 'keys' => [
229 // Core settings
230 'seo_analytics_enabled',
231 'seo_analytics_setup_completed',
232
233 // Google Analytics configuration. NOTE: the account/property/
234 // data-stream picker that reads AND writes these three keys is
235 // thinkrank-pro's GoogleAnalyticsSettings.js (via this plugin's
236 // settings-management endpoint) — a free-repo grep will find no
237 // consumer. ga_analytics_data_stream_id was once removed as a
238 // "dead key" on that basis, which silently broke the Pro
239 // picker's stream selection persisting across reloads.
240 'seo_analytics_google_analytics_property_id',
241 'ga_analytics_account_id',
242 'ga_analytics_data_stream_id',
243
244 // GA4 Tracking Code Injection (Pro)
245 'ga4_auto_inject',
246 'ga4_measurement_id',
247
248 // Search Console configuration
249 'search_console_property',
250
251 // AI features
252 'seo_analytics_enable_ai_insights',
253 'seo_analytics_enable_automated_alerts',
254 'seo_analytics_enable_predictive_analysis',
255
256 // Monitoring settings
257 'seo_analytics_monitoring_frequency',
258 'seo_analytics_alert_thresholds',
259 'seo_analytics_report_schedule',
260
261 // Data retention
262 'seo_analytics_data_retention_days',
263 'seo_analytics_cache_analytics_data'
264 ]
265 ],
266
267 ];
268
269 /**
270 * Constructor
271 *
272 * @since 1.0.0
273 */
274 public function __construct() {
275 $this->core_settings = Settings::instance();
276 $this->seo_settings = new SEO_Settings_Manager();
277 }
278
279 /**
280 * Get settings for a specific category
281 *
282 * @since 1.0.0
283 *
284 * @param string $category Settings category
285 * @param string $context_type Optional. Context type for SEO settings
286 * @param int|null $context_id Optional. Context ID for SEO settings
287 * @return array Settings array
288 */
289 public function get_settings(string $category, string $context_type = 'site', ?int $context_id = null): array {
290 if (!isset($this->settings_categories[$category])) {
291 return [];
292 }
293
294 $category_config = $this->settings_categories[$category];
295
296 if ($category_config['manager'] === 'core') {
297 return $this->get_core_settings_by_category($category);
298 } else {
299 return $this->get_seo_settings_by_category($category, $context_type, $context_id);
300 }
301 }
302
303 /**
304 * Update settings for a specific category
305 *
306 * @since 1.0.0
307 *
308 * @param array $settings Settings to update
309 * @param string $category Settings category
310 * @param string $context_type Optional. Context type for SEO settings
311 * @param int|null $context_id Optional. Context ID for SEO settings
312 * @return bool Success status
313 */
314 public function update_settings(array $settings, string $category, string $context_type = 'site', ?int $context_id = null): bool {
315 if (!isset($this->settings_categories[$category])) {
316 return false;
317 }
318
319 $category_config = $this->settings_categories[$category];
320
321 if ($category_config['manager'] === 'core') {
322 return $this->update_core_settings_by_category($settings, $category);
323 } else {
324 return $this->update_seo_settings_by_category($settings, $category, $context_type, $context_id);
325 }
326 }
327
328 /**
329 * Get all settings across categories
330 *
331 * @since 1.0.0
332 *
333 * @param array $categories Optional. Specific categories to retrieve
334 * @return array All settings organized by category
335 */
336 public function get_all_settings(array $categories = []): array {
337 $all_settings = [];
338 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
339
340 foreach ($target_categories as $category) {
341 if (isset($this->settings_categories[$category])) {
342 $all_settings[$category] = $this->get_settings($category);
343 }
344 }
345
346 return $all_settings;
347 }
348
349 /**
350 * Update multiple categories of settings
351 *
352 * @since 1.0.0
353 *
354 * @param array $settings_by_category Settings organized by category
355 * @return array Update results for each category
356 */
357 public function update_multiple_settings(array $settings_by_category): array {
358 $results = [];
359
360 foreach ($settings_by_category as $category => $settings) {
361 try {
362 $success = $this->update_settings($settings, $category);
363 $results[$category] = [
364 'success' => $success,
365 'settings_count' => count($settings)
366 ];
367 } catch (\Exception $e) {
368 $results[$category] = [
369 'success' => false,
370 'error' => $e->getMessage()
371 ];
372 }
373 }
374
375 return $results;
376 }
377
378 /**
379 * Validate settings across categories
380 *
381 * @since 1.0.0
382 *
383 * @param array $settings_by_category Settings organized by category
384 * @return array Validation results for each category
385 */
386 public function validate_settings(array $settings_by_category): array {
387 $validation_results = [];
388
389 foreach ($settings_by_category as $category => $settings) {
390 if (!isset($this->settings_categories[$category])) {
391 $validation_results[$category] = [
392 'valid' => false,
393 'errors' => ['Invalid category'],
394 'warnings' => []
395 ];
396 continue;
397 }
398
399 $category_config = $this->settings_categories[$category];
400
401 if ($category_config['manager'] === 'core') {
402 $validation_results[$category] = $this->validate_core_settings($settings, $category);
403 } else {
404 $validation_results[$category] = $this->validate_seo_settings($settings, $category);
405 }
406 }
407
408 return $validation_results;
409 }
410
411 /**
412 * Get settings categories information
413 *
414 * @since 1.0.0
415 *
416 * @return array Categories information
417 */
418 public function get_categories(): array {
419 $categories = [];
420
421 foreach ($this->settings_categories as $key => $config) {
422 $categories[$key] = [
423 'name' => $config['name'],
424 'manager' => $config['manager'],
425 'key_count' => count($config['keys'])
426 ];
427 }
428
429 return $categories;
430 }
431
432 /**
433 * Export settings
434 *
435 * @since 1.0.0
436 *
437 * @param array $categories Optional. Categories to export
438 * @return array Export data with metadata
439 */
440 public function export_settings(array $categories = []): array {
441 $export_data = [
442 'metadata' => [
443 'export_timestamp' => current_time('mysql'),
444 'plugin_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '1.0.0',
445 'wordpress_version' => get_bloginfo('version'),
446 'site_url' => home_url(),
447 'exported_categories' => empty($categories) ? array_keys($this->settings_categories) : $categories
448 ],
449 'settings' => $this->get_all_settings($categories)
450 ];
451
452 return $export_data;
453 }
454
455 /**
456 * Import settings
457 *
458 * @since 1.0.0
459 *
460 * @param array $import_data Import data with settings
461 * @param bool $validate_before_import Whether to validate before importing
462 * @return array Import results
463 */
464 public function import_settings(array $import_data, bool $validate_before_import = true): array {
465 $settings = $import_data['settings'] ?? $import_data;
466 $import_results = [];
467
468 // Validate if requested
469 if ($validate_before_import) {
470 $validation_results = $this->validate_settings($settings);
471
472 foreach ($validation_results as $category => $validation) {
473 if (!$validation['valid']) {
474 $import_results[$category] = [
475 'success' => false,
476 'error' => 'Validation failed',
477 'validation_errors' => $validation['errors']
478 ];
479 continue;
480 }
481 }
482 }
483
484 // Import settings for each category
485 foreach ($settings as $category => $category_settings) {
486 if (isset($import_results[$category])) {
487 continue; // Skip if validation failed
488 }
489
490 try {
491 $success = $this->update_settings($category_settings, $category);
492 $import_results[$category] = [
493 'success' => $success,
494 'settings_count' => count($category_settings)
495 ];
496 } catch (\Exception $e) {
497 $import_results[$category] = [
498 'success' => false,
499 'error' => $e->getMessage()
500 ];
501 }
502 }
503
504 return $import_results;
505 }
506
507 /**
508 * Reset settings to defaults
509 *
510 * @since 1.0.0
511 *
512 * @param array $categories Optional. Categories to reset
513 * @return array Reset results
514 */
515 public function reset_settings(array $categories = []): array {
516 $target_categories = empty($categories) ? array_keys($this->settings_categories) : $categories;
517 $reset_results = [];
518
519 foreach ($target_categories as $category) {
520 if (!isset($this->settings_categories[$category])) {
521 continue;
522 }
523
524 try {
525 $category_config = $this->settings_categories[$category];
526
527 if ($category_config['manager'] === 'core') {
528 $success = $this->reset_core_settings($category);
529 } else {
530 $success = $this->reset_seo_settings($category);
531 }
532
533 $reset_results[$category] = [
534 'success' => $success,
535 'reset_to_defaults' => true
536 ];
537 } catch (\Exception $e) {
538 $reset_results[$category] = [
539 'success' => false,
540 'error' => $e->getMessage()
541 ];
542 }
543 }
544
545 return $reset_results;
546 }
547
548 /**
549 * Get settings statistics
550 *
551 * @since 1.0.0
552 *
553 * @return array Settings statistics
554 */
555 public function get_settings_statistics(): array {
556 $stats = [
557 'total_categories' => count($this->settings_categories),
558 'core_categories' => 0,
559 'seo_categories' => 0,
560 'total_settings' => 0,
561 'last_updated' => get_option('thinkrank_settings_last_updated'),
562 'version' => get_option('thinkrank_settings_version', '1.0.0')
563 ];
564
565 foreach ($this->settings_categories as $category => $config) {
566 if ($config['manager'] === 'core') {
567 $stats['core_categories']++;
568 } else {
569 $stats['seo_categories']++;
570 }
571
572 $category_settings = $this->get_settings($category);
573 $stats['total_settings'] += count($category_settings);
574 }
575
576 return $stats;
577 }
578
579 /**
580 * Helper methods for core settings management
581 */
582
583 /**
584 * Get core settings by category
585 *
586 * @since 1.0.0
587 *
588 * @param string $category Category name
589 * @return array Core settings for category
590 */
591 private function get_core_settings_by_category(string $category): array {
592 $category_config = $this->settings_categories[$category];
593 $settings = [];
594
595 foreach ($category_config['keys'] as $key) {
596 $settings[$key] = $this->core_settings->get($key);
597 }
598
599 return $settings;
600 }
601
602 /**
603 * Update core settings by category
604 *
605 * @since 1.0.0
606 *
607 * @param array $settings Settings to update
608 * @param string $category Category name
609 * @return bool Success status
610 */
611 private function update_core_settings_by_category(array $settings, string $category): bool {
612 $category_config = $this->settings_categories[$category];
613 $success_count = 0;
614 $total_count = 0;
615
616 // Sanitize per field before persisting. This is unconditional: callers
617 // (including the REST write routes, where the client can ask to skip
618 // validation) must not be able to reach Settings::set with unsanitized
619 // values — Settings::set only key-allowlists, it does not sanitize.
620 $settings = $this->core_settings->sanitize_settings($settings);
621
622 foreach ($settings as $key => $value) {
623 if (in_array($key, $category_config['keys'], true)) {
624 $total_count++;
625
626 if ($this->core_settings->set($key, $value)) {
627 $success_count++;
628 }
629 }
630 }
631
632 // Consider successful if at least 70% of settings were saved
633 $success_rate = $total_count > 0 ? ($success_count / $total_count) : 0;
634 $success = $success_rate >= 0.7;
635
636 if ($success) {
637 update_option('thinkrank_settings_last_updated', current_time('mysql'));
638 }
639
640 return $success;
641 }
642
643 /**
644 * Get SEO settings by category
645 *
646 * @since 1.0.0
647 *
648 * @param string $category Category name
649 * @param string $context_type Context type
650 * @param int|null $context_id Context ID
651 * @return array SEO settings for category
652 */
653 private function get_seo_settings_by_category(string $category, string $context_type, ?int $context_id): array {
654 // For SEO categories, delegate to SEO Settings Manager
655 return $this->seo_settings->get_settings_by_category($context_type, $context_id, $category);
656 }
657
658 /**
659 * Update SEO settings by category
660 *
661 * @since 1.0.0
662 *
663 * @param array $settings Settings to update
664 * @param string $category Category name
665 * @param string $context_type Context type
666 * @param int|null $context_id Context ID
667 * @return bool Success status
668 */
669 private function update_seo_settings_by_category(array $settings, string $category, string $context_type, ?int $context_id): bool {
670 return $this->seo_settings->save_settings_by_category($context_type, $context_id, $settings, $category);
671 }
672
673 /**
674 * Validate core settings
675 *
676 * @since 1.0.0
677 *
678 * @param array $settings Settings to validate
679 * @param string $category Category name
680 * @return array Validation results
681 */
682 private function validate_core_settings(array $settings, string $category): array {
683 $validation = [
684 'valid' => true,
685 'errors' => [],
686 'warnings' => []
687 ];
688
689 $category_config = $this->settings_categories[$category];
690
691 foreach ($settings as $key => $value) {
692 if (!in_array($key, $category_config['keys'], true)) {
693 $validation['warnings'][] = "Unknown setting key: {$key}";
694 continue;
695 }
696
697 // Validate specific core settings
698 $field_validation = $this->validate_core_setting($key, $value);
699 if (!$field_validation['valid']) {
700 $validation['valid'] = false;
701 $validation['errors'] = array_merge($validation['errors'], $field_validation['errors']);
702 }
703 }
704
705 return $validation;
706 }
707
708 /**
709 * Validate SEO settings
710 *
711 * @since 1.0.0
712 *
713 * @param array $settings Settings to validate
714 * @param string $category Category name
715 * @return array Validation results
716 */
717 private function validate_seo_settings(array $settings, string $category): array {
718 // Delegate to SEO Settings Manager validation
719 return $this->seo_settings->validate_settings($settings);
720 }
721
722 /**
723 * Validate individual core setting
724 *
725 * @since 1.0.0
726 *
727 * @param string $key Setting key
728 * @param mixed $value Setting value
729 * @return array Validation result
730 */
731 private function validate_core_setting(string $key, $value): array {
732 $validation = ['valid' => true, 'errors' => []];
733
734 switch ($key) {
735 case 'openai_api_key':
736 case 'claude_api_key':
737 case 'openrouter_api_key':
738 if (!empty($value) && !is_string($value)) {
739 $validation['valid'] = false;
740 $validation['errors'][] = "{$key} must be a string";
741 }
742 break;
743
744 case 'max_tokens':
745 case 'cache_duration':
746 case 'max_requests_per_minute':
747 case 'seo_score_threshold':
748 case 'api_timeout':
749 case 'retry_attempts':
750 case 'data_retention_days':
751 if (!is_numeric($value) || $value < 0) {
752 $validation['valid'] = false;
753 $validation['errors'][] = "{$key} must be a positive number";
754 }
755 break;
756
757 case 'temperature':
758 if (!is_numeric($value) || $value < 0 || $value > 2) {
759 $validation['valid'] = false;
760 $validation['errors'][] = "temperature must be between 0 and 2";
761 }
762 break;
763
764 case 'ai_provider':
765 if (!in_array($value, ['openai', 'claude', 'gemini', 'openrouter'], true)) {
766 $validation['valid'] = false;
767 $validation['errors'][] = "ai_provider must be 'openai', 'claude', 'gemini', or 'openrouter'";
768 }
769 break;
770
771 case 'dashboard_widgets':
772 if (!is_array($value)) {
773 $validation['valid'] = false;
774 $validation['errors'][] = "dashboard_widgets must be an array";
775 }
776 break;
777 }
778
779 return $validation;
780 }
781
782 /**
783 * Reset core settings for category
784 *
785 * @since 1.0.0
786 *
787 * @param string $category Category name
788 * @return bool Success status
789 */
790 private function reset_core_settings(string $category): bool {
791 $category_config = $this->settings_categories[$category];
792 $success = true;
793
794 foreach ($category_config['keys'] as $key) {
795 if (!$this->core_settings->delete($key)) {
796 $success = false;
797 }
798 }
799
800 return $success;
801 }
802
803 /**
804 * Reset SEO settings for category
805 *
806 * @since 1.0.0
807 *
808 * @param string $category Category name
809 * @return bool Success status
810 */
811 private function reset_seo_settings(string $category): bool {
812 try {
813 // Map the settings category to the SEO context type
814 return $this->seo_settings->reset_to_defaults('site');
815 } catch (\Exception $e) {
816 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
817 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
818 error_log('ThinkRank: Failed to reset SEO settings for category "' . $category . '": ' . $e->getMessage());
819 }
820 return false;
821 }
822 }
823 }
824