PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.30.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.30.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 / api / class-settings-management-endpoint.php

class-settings-management-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.30.0, at includes/api/class-settings-management-endpoint.php

2,012 lines 69.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings Management API Endpoints Class
4 *
5 * REST API endpoints for centralized settings management across all SEO managers
6 * including global settings CRUD, validation and schema management, import/export
7 * functionality, and backup/restore operations. Provides comprehensive API access
8 * to Settings Manager functionality with proper authentication and validation.
9 *
10 * @package ThinkRank
11 * @subpackage API
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\API;
18
19 use ThinkRank\Core\Settings_Manager;
20 use ThinkRank\SEO\Site_Identity_Manager;
21 use ThinkRank\SEO\Performance_Monitoring_Manager;
22 use ThinkRank\SEO\AI_Content_Analyzer;
23 use ThinkRank\SEO\Content_Optimization_Manager;
24 use ThinkRank\SEO\Schema_Management_System;
25 use ThinkRank\SEO\Social_Meta_Manager;
26 use ThinkRank\SEO\Sitemap_Generator;
27 use WP_REST_Controller;
28 use WP_REST_Request;
29 use WP_REST_Response;
30 use WP_Error;
31
32 // Prevent direct access
33 if (!defined('ABSPATH')) {
34 exit;
35 }
36
37 /**
38 * Settings Management API Endpoints Class
39 *
40 * Provides REST API endpoints for centralized settings management operations
41 * including global settings CRUD, validation, import/export, backup/restore,
42 * and cross-manager settings coordination with proper authentication and validation.
43 *
44 * @since 1.0.0
45 */
46 class Settings_Management_Endpoint extends WP_REST_Controller {
47
48 /**
49 * Settings Manager instance
50 *
51 * @since 1.0.0
52 * @var Settings_Manager
53 */
54 private Settings_Manager $settings_manager;
55
56 /**
57 * Lazily constructed SEO Manager instances, keyed by category
58 *
59 * @since 1.0.0
60 * @var array
61 */
62 private array $seo_managers = [];
63
64 /**
65 * API namespace
66 *
67 * @since 1.0.0
68 * @var string
69 */
70 protected $namespace = 'thinkrank/v1';
71
72 /**
73 * API resource base
74 *
75 * @since 1.0.0
76 * @var string
77 */
78 protected $rest_base = 'settings-management';
79
80 /**
81 * Supported setting categories
82 *
83 * @since 1.0.0
84 * @var array
85 */
86 private array $setting_categories = [
87 'site_identity' => 'Site Identity & Global SEO',
88 'content_analysis' => 'AI Content Analysis',
89 'content_optimization' => 'Content Optimization',
90 'performance_monitoring' => 'Performance Monitoring',
91 'schema_management' => 'Schema Management',
92 'social_media' => 'Social Media & Open Graph',
93 'sitemap' => 'XML Sitemap Management',
94 'integrations' => 'External Integrations',
95 'analytics_integration' => 'Analytics Integration',
96 'seo_analytics' => 'SEO Analytics & Intelligence',
97 'global_defaults' => 'Global Default Settings'
98 ];
99
100 /**
101 * Constructor
102 *
103 * @since 1.0.0
104 */
105 public function __construct() {
106 $this->settings_manager = new Settings_Manager();
107 }
108
109 /**
110 * Category → manager class map. Instances are created lazily: this
111 * endpoint is constructed on every REST request (any namespace), and
112 * eagerly building eight manager chains added measurable overhead to
113 * unrelated requests.
114 *
115 * @var array<string,class-string>
116 */
117 private array $seo_manager_classes = [
118 'site_identity' => Site_Identity_Manager::class,
119 'performance_monitoring' => Performance_Monitoring_Manager::class,
120 'ai_content_analyzer' => AI_Content_Analyzer::class,
121 'content_optimization' => Content_Optimization_Manager::class,
122 'schema_management' => Schema_Management_System::class,
123 'social_media' => Social_Meta_Manager::class,
124 'sitemap' => Sitemap_Generator::class,
125 'analytics_integration' => Performance_Monitoring_Manager::class,
126 ];
127
128 /**
129 * Whether a category has an associated SEO manager
130 *
131 * @param string $category Category key
132 * @return bool
133 */
134 private function has_seo_manager(string $category): bool {
135 return isset($this->seo_manager_classes[$category]);
136 }
137
138 /**
139 * Get (and lazily construct) the SEO manager for a category
140 *
141 * @param string $category Category key
142 * @return object The manager instance
143 */
144 private function get_seo_manager(string $category): object {
145 if (!isset($this->seo_managers[$category])) {
146 $class = $this->seo_manager_classes[$category];
147 $this->seo_managers[$category] = new $class();
148 }
149 return $this->seo_managers[$category];
150 }
151
152 /**
153 * Register API routes
154 *
155 * @since 1.0.0
156 */
157 public function register_routes(): void {
158 // Global settings management
159 register_rest_route(
160 $this->namespace,
161 '/' . $this->rest_base . '/global',
162 [
163 [
164 'methods' => 'GET',
165 'callback' => [$this, 'get_global_settings'],
166 'permission_callback' => [$this, 'check_read_permissions']
167 ],
168 [
169 'methods' => 'POST',
170 'callback' => [$this, 'update_global_settings'],
171 'permission_callback' => [$this, 'check_manage_permissions'],
172 'args' => $this->get_global_settings_args()
173 ]
174 ]
175 );
176
177 // Category-specific settings
178 register_rest_route(
179 $this->namespace,
180 '/' . $this->rest_base . '/category/(?P<category>[a-zA-Z0-9_-]+)',
181 [
182 [
183 'methods' => 'GET',
184 'callback' => [$this, 'get_category_settings'],
185 'permission_callback' => [$this, 'check_read_permissions'],
186 'args' => [
187 'category' => [
188 'required' => true,
189 'type' => 'string',
190 'enum' => array_keys($this->setting_categories)
191 ]
192 ]
193 ],
194 [
195 'methods' => 'POST',
196 'callback' => [$this, 'update_category_settings'],
197 'permission_callback' => [$this, 'check_manage_permissions'],
198 'args' => $this->get_category_settings_args()
199 ]
200 ]
201 );
202
203 // Settings validation and schema
204 register_rest_route(
205 $this->namespace,
206 '/' . $this->rest_base . '/validate',
207 [
208 [
209 'methods' => 'POST',
210 'callback' => [$this, 'validate_settings'],
211 'permission_callback' => [$this, 'check_read_permissions'],
212 'args' => $this->get_validation_args()
213 ]
214 ]
215 );
216
217 // Settings schema management
218 register_rest_route(
219 $this->namespace,
220 '/' . $this->rest_base . '/schema',
221 [
222 [
223 'methods' => 'GET',
224 'callback' => [$this, 'get_settings_schema'],
225 'permission_callback' => [$this, 'check_read_permissions']
226 ]
227 ]
228 );
229
230 // Settings import/export
231 register_rest_route(
232 $this->namespace,
233 '/' . $this->rest_base . '/export',
234 [
235 [
236 'methods' => 'POST',
237 'callback' => [$this, 'export_settings'],
238 'permission_callback' => [$this, 'check_manage_permissions'],
239 'args' => $this->get_export_args()
240 ]
241 ]
242 );
243
244 register_rest_route(
245 $this->namespace,
246 '/' . $this->rest_base . '/import',
247 [
248 [
249 'methods' => 'POST',
250 'callback' => [$this, 'import_settings'],
251 'permission_callback' => [$this, 'check_admin_permissions'],
252 'args' => $this->get_import_args()
253 ]
254 ]
255 );
256
257 // Settings backup/restore
258 register_rest_route(
259 $this->namespace,
260 '/' . $this->rest_base . '/backup',
261 [
262 [
263 'methods' => 'POST',
264 'callback' => [$this, 'create_settings_backup'],
265 'permission_callback' => [$this, 'check_manage_permissions'],
266 'args' => $this->get_backup_args()
267 ]
268 ]
269 );
270
271 register_rest_route(
272 $this->namespace,
273 '/' . $this->rest_base . '/restore',
274 [
275 [
276 'methods' => 'POST',
277 'callback' => [$this, 'restore_settings_backup'],
278 'permission_callback' => [$this, 'check_manage_permissions'],
279 'args' => $this->get_restore_args()
280 ]
281 ]
282 );
283
284 // Settings reset
285 register_rest_route(
286 $this->namespace,
287 '/' . $this->rest_base . '/reset',
288 [
289 [
290 'methods' => 'POST',
291 'callback' => [$this, 'reset_settings'],
292 'permission_callback' => [$this, 'check_admin_permissions'],
293 'args' => $this->get_reset_args()
294 ]
295 ]
296 );
297
298 // Database maintenance operations
299 register_rest_route(
300 $this->namespace,
301 '/' . $this->rest_base . '/maintenance/performance-indexes',
302 [
303 [
304 'methods' => 'POST',
305 'callback' => [$this, 'add_performance_indexes'],
306 'permission_callback' => [$this, 'check_admin_permissions']
307 ]
308 ]
309 );
310 }
311
312 /**
313 * Setting keys that hold secrets (encrypted at rest).
314 *
315 * Mirrors ThinkRank\Core\Settings::$encrypted_keys — keep in sync. These must
316 * never be returned decrypted from the read/export endpoints.
317 *
318 * @var string[]
319 */
320 private const SENSITIVE_SETTING_KEYS = [
321 'openai_api_key',
322 'claude_api_key',
323 'gemini_api_key',
324 'openrouter_api_key',
325 'google_analytics_api_key',
326 'google_search_console_api_key',
327 'google_pagespeed_api_key',
328 'google_access_token',
329 'google_refresh_token',
330 'pinterest_site_verification',
331 'instagram_verification',
332 'tiktok_verification',
333 ];
334
335 /**
336 * Mask a secret value for display: keeps a "has value" signal and the last
337 * four characters, never the secret itself. Empty stays empty.
338 *
339 * @param mixed $value Raw setting value.
340 * @return string Masked value.
341 */
342 private function mask_secret_value($value): string {
343 if (!is_string($value) || $value === '') {
344 return '';
345 }
346 $suffix = strlen($value) > 4 ? substr($value, -4) : '';
347 return '••••' . $suffix;
348 }
349
350 /**
351 * Redact secrets from a category => settings map before it leaves the site.
352 *
353 * Read responses mask secrets (presence + last 4). Exports drop them entirely
354 * so long-lived third-party credentials never land in an export file (and a
355 * masked value can't corrupt the real key on re-import).
356 *
357 * @param array $settings category => [key => value] map.
358 * @param bool $for_export Whether this is an export (drop) vs a read (mask).
359 * @return array Redacted map.
360 */
361 private function redact_sensitive_settings(array $settings, bool $for_export = false): array {
362 foreach ($settings as $category => $values) {
363 if (!is_array($values)) {
364 continue;
365 }
366 foreach ($values as $key => $value) {
367 if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
368 continue;
369 }
370 if ($for_export) {
371 unset($values[$key]);
372 } else {
373 $values[$key] = $this->mask_secret_value($value);
374 }
375 }
376 $settings[$category] = $values;
377 }
378 return $settings;
379 }
380
381 /**
382 * Redact secrets from a single category's flat key => value map.
383 *
384 * Convenience wrapper so the single-category response shapes get the same
385 * treatment as the global map — no response path may return a cleartext
386 * secret.
387 *
388 * @param string $category Category slug.
389 * @param array $settings Flat key => value map for that category.
390 * @return array Redacted flat map.
391 */
392 private function redact_category_settings(string $category, array $settings): array {
393 $redacted = $this->redact_sensitive_settings([$category => $settings]);
394 return $redacted[$category] ?? [];
395 }
396
397 /**
398 * Drop masked secrets from an incoming write payload.
399 *
400 * Read responses return secrets masked ("••••abcd"). A client that GETs a
401 * settings map and POSTs it straight back would otherwise persist the mask
402 * over the real credential. Any sensitive key whose incoming value still
403 * carries the mask marker is removed so the stored value is left untouched;
404 * a genuinely new secret (no marker) writes through normally.
405 *
406 * @param array $settings Flat key => value map from the request.
407 * @return array Map with masked secret values removed.
408 */
409 private function strip_masked_secrets(array $settings): array {
410 foreach ($settings as $key => $value) {
411 if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
412 continue;
413 }
414 if (is_string($value) && strpos($value, '••••') !== false) {
415 unset($settings[$key]);
416 }
417 }
418 return $settings;
419 }
420
421 /**
422 * Get global settings across all categories
423 *
424 * @since 1.0.0
425 *
426 * @param WP_REST_Request $request Request object
427 * @return WP_REST_Response Response object
428 */
429 public function get_global_settings(WP_REST_Request $request): WP_REST_Response {
430 try {
431 $include_categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
432 $include_schema = $request->get_param('include_schema') ?? false;
433
434 $global_settings = [];
435 $settings_schema = [];
436
437 foreach ($include_categories as $category) {
438 if (!isset($this->setting_categories[$category])) {
439 continue;
440 }
441
442 // Get settings for each category using Settings Manager
443 $category_settings = $this->settings_manager->get_settings($category);
444 $global_settings[$category] = $category_settings;
445
446 // Get schema if requested
447 if ($include_schema && $this->has_seo_manager($category)) {
448 $settings_schema[$category] = $this->get_seo_manager($category)->get_settings_schema($category);
449 }
450 }
451
452 // Get global metadata
453 $metadata = [
454 'total_categories' => count($this->setting_categories),
455 'loaded_categories' => count($global_settings),
456 'last_updated' => $this->get_last_settings_update(),
457 'settings_version' => $this->get_settings_version()
458 ];
459
460 return new WP_REST_Response([
461 'success' => true,
462 'data' => [
463 'settings' => $this->redact_sensitive_settings($global_settings),
464 'schema' => $settings_schema,
465 'metadata' => $metadata,
466 'categories' => $this->setting_categories
467 ],
468 'message' => 'Global settings retrieved successfully'
469 ], 200);
470
471 } catch (\Exception $e) {
472 return new WP_REST_Response([
473 'success' => false,
474 'error' => 'Failed to retrieve global settings: ' . $e->getMessage()
475 ], 500);
476 }
477 }
478
479 /**
480 * Update global settings across multiple categories
481 *
482 * @since 1.0.0
483 *
484 * @param WP_REST_Request $request Request object
485 * @return WP_REST_Response|WP_Error Response object or error
486 */
487 public function update_global_settings(WP_REST_Request $request) {
488 try {
489 $settings = $request->get_param('settings');
490 $validate_before_update = $request->get_param('validate') ?? true;
491
492 // Validate settings structure
493 if (empty($settings) || !is_array($settings)) {
494 return new WP_Error(
495 'invalid_settings',
496 'Settings must be provided as an array',
497 ['status' => 400]
498 );
499 }
500
501 // Each per-category value must be an array before it reaches the
502 // strict array-typed manager methods; reject non-array values with a
503 // 400 instead of letting them surface as an uncaught TypeError.
504 foreach ($settings as $category => $category_settings) {
505 if (!is_array($category_settings)) {
506 return new WP_Error(
507 'invalid_settings',
508 "Settings for category '{$category}' must be provided as an object",
509 ['status' => 400]
510 );
511 }
512
513 // Reads mask secrets; never persist a mask back over the real one.
514 $settings[$category] = $this->strip_masked_secrets($category_settings);
515 }
516
517 $validation_results = [];
518 $update_results = [];
519
520 // Validate all settings before updating if requested
521 if ($validate_before_update) {
522 foreach ($settings as $category => $category_settings) {
523 if (!isset($this->setting_categories[$category])) {
524 continue;
525 }
526
527 if ($this->has_seo_manager($category)) {
528 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
529 $validation_results[$category] = $validation;
530
531 if (!$validation['valid']) {
532 return new WP_Error(
533 'validation_failed',
534 "Settings validation failed for category: {$category}",
535 [
536 'status' => 400,
537 'validation_results' => $validation_results
538 ]
539 );
540 }
541 }
542 }
543 }
544
545 // Update settings for each category
546 foreach ($settings as $category => $category_settings) {
547 if (!isset($this->setting_categories[$category])) {
548 continue;
549 }
550
551 try {
552 // Update using Settings Manager
553 $update_success = $this->settings_manager->update_settings($category_settings, $category);
554
555 // Also update through specific SEO manager if available
556 if ($this->has_seo_manager($category)) {
557 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
558 $update_success = $update_success && $manager_update;
559 }
560
561 $update_results[$category] = [
562 'success' => $update_success,
563 'settings_count' => count($category_settings)
564 ];
565
566 } catch (\Exception $e) {
567 $update_results[$category] = [
568 'success' => false,
569 'error' => $e->getMessage()
570 ];
571 }
572 }
573
574 // Update settings version and timestamp
575 $this->update_settings_metadata();
576
577 // Get updated settings
578 $updated_settings = [];
579 foreach (array_keys($settings) as $category) {
580 if (isset($this->setting_categories[$category])) {
581 $updated_settings[$category] = $this->settings_manager->get_settings($category);
582 }
583 }
584
585 return new WP_REST_Response([
586 'success' => true,
587 'data' => [
588 'updated_settings' => $this->redact_sensitive_settings($updated_settings),
589 'validation_results' => $validation_results,
590 'update_results' => $update_results,
591 'settings_version' => $this->get_settings_version()
592 ],
593 'message' => 'Global settings updated successfully'
594 ], 200);
595
596 } catch (\Exception $e) {
597 return new WP_Error(
598 'update_failed',
599 'Global settings update failed: ' . $e->getMessage(),
600 ['status' => 500]
601 );
602 }
603 }
604
605 /**
606 * Get settings for specific category
607 *
608 * @since 1.0.0
609 *
610 * @param WP_REST_Request $request Request object
611 * @return WP_REST_Response|WP_Error Response object or error
612 */
613 public function get_category_settings(WP_REST_Request $request) {
614 try {
615 $category = $request->get_param('category');
616 $include_schema = $request->get_param('include_schema') ?? false;
617
618 // Validate category
619 if (!isset($this->setting_categories[$category])) {
620 return new WP_Error(
621 'invalid_category',
622 'Invalid settings category provided',
623 ['status' => 400]
624 );
625 }
626
627 // Get category settings
628 $category_settings = $this->settings_manager->get_settings($category);
629
630 // Get schema if requested
631 $schema = [];
632 if ($include_schema && $this->has_seo_manager($category)) {
633 $schema = $this->get_seo_manager($category)->get_settings_schema($category);
634 }
635
636 // Get category metadata
637 $metadata = [
638 'category' => $category,
639 'category_name' => $this->setting_categories[$category],
640 'settings_count' => count($category_settings),
641 'last_updated' => $this->get_category_last_update($category),
642 'has_manager' => $this->has_seo_manager($category)
643 ];
644
645 return new WP_REST_Response([
646 'success' => true,
647 'data' => [
648 'settings' => $this->redact_category_settings($category, $category_settings),
649 'schema' => $schema,
650 'metadata' => $metadata
651 ],
652 'message' => "Settings for category '{$category}' retrieved successfully"
653 ], 200);
654
655 } catch (\Exception $e) {
656 return new WP_Error(
657 'retrieval_failed',
658 'Category settings retrieval failed: ' . $e->getMessage(),
659 ['status' => 500]
660 );
661 }
662 }
663
664 /**
665 * Update settings for specific category
666 *
667 * @since 1.0.0
668 *
669 * @param WP_REST_Request $request Request object
670 * @return WP_REST_Response|WP_Error Response object or error
671 */
672 public function update_category_settings(WP_REST_Request $request) {
673 try {
674 $category = $request->get_param('category');
675 $request_data = $request->get_param('settings');
676 $validate_before_update = $request->get_param('validate') ?? true;
677
678 // Extract only the actual settings data, not metadata
679 if (isset($request_data['settings'])) {
680 // If settings are nested under 'settings' key, use that
681 $settings = $request_data['settings'];
682 } else {
683 // Otherwise use the data directly
684 $settings = $request_data;
685 }
686
687 // Validate category
688 if (!isset($this->setting_categories[$category])) {
689 return new WP_Error(
690 'invalid_category',
691 'Invalid settings category provided',
692 ['status' => 400]
693 );
694 }
695
696 // Validate settings
697 if (empty($settings) || !is_array($settings)) {
698 return new WP_Error(
699 'invalid_settings',
700 'Settings must be provided as an array',
701 ['status' => 400]
702 );
703 }
704
705 // Reads mask secrets; never persist a mask back over the real one.
706 $settings = $this->strip_masked_secrets($settings);
707
708 $validation_result = ['valid' => true];
709
710 // Validate settings if requested
711 if ($validate_before_update && $this->has_seo_manager($category)) {
712 $validation_result = $this->get_seo_manager($category)->validate_settings($settings);
713
714 if (!$validation_result['valid']) {
715 return new WP_Error(
716 'validation_failed',
717 "Settings validation failed for category: {$category}",
718 [
719 'status' => 400,
720 'validation_errors' => $validation_result['errors'],
721 'validation_warnings' => $validation_result['warnings']
722 ]
723 );
724 }
725 }
726
727 // Update settings
728 $update_success = $this->settings_manager->update_settings($settings, $category);
729
730 // Also update through specific SEO manager if available
731 if ($this->has_seo_manager($category)) {
732 $context_type = $request->get_param('context_type') ?? 'site';
733 $context_id = $request->get_param('context_id') ?? null;
734 $manager_update = $this->get_seo_manager($category)->save_settings($context_type, $context_id, $settings);
735 $update_success = $update_success && $manager_update;
736 }
737
738 if (!$update_success) {
739 // Name the settings that did not persist. The write is not
740 // transactional, so "failed" can mean some keys saved and others
741 // did not — without the list the UI can only show a generic
742 // error and the user has no idea what to re-enter (#300).
743 $failed_keys = $this->settings_manager->get_last_failed_keys();
744
745 return new WP_Error(
746 'update_failed',
747 empty($failed_keys)
748 ? "Failed to update settings for category: {$category}"
749 : sprintf(
750 'Failed to save %s in category %s. Other settings in this request were saved.',
751 implode(', ', $failed_keys),
752 $category
753 ),
754 [
755 'status' => 500,
756 'failed_keys' => $failed_keys,
757 ]
758 );
759 }
760
761 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
762 if ($category === 'seo_analytics') {
763 foreach (['7d', '30d', '90d'] as $range) {
764 delete_transient("analytics_dashboard_v5_{$range}");
765 delete_transient("seo_opportunities_{$range}");
766 delete_transient("seo_insights_{$range}");
767 }
768 delete_transient('indexing_status');
769 }
770
771 // Update category metadata
772 $this->update_category_metadata($category);
773
774 // Get updated settings
775 $updated_settings = $this->settings_manager->get_settings($category);
776
777 return new WP_REST_Response([
778 'success' => true,
779 'data' => [
780 'category' => $category,
781 'updated_settings' => $this->redact_category_settings($category, $updated_settings),
782 'validation_result' => $validation_result,
783 'settings_count' => count($updated_settings)
784 ],
785 'message' => "Settings for category '{$category}' updated successfully"
786 ], 200);
787
788 } catch (\Exception $e) {
789 return new WP_Error(
790 'update_failed',
791 'Category settings update failed: ' . $e->getMessage(),
792 ['status' => 500]
793 );
794 }
795 }
796
797 /**
798 * Validate settings across categories
799 *
800 * @since 1.0.0
801 *
802 * @param WP_REST_Request $request Request object
803 * @return WP_REST_Response Response object
804 */
805 public function validate_settings(WP_REST_Request $request): WP_REST_Response {
806 try {
807 $settings = $request->get_param('settings');
808 if (!is_array($settings)) {
809 $settings = [];
810 }
811 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
812
813 $validation_results = [];
814 $overall_valid = true;
815
816 foreach ($categories as $category) {
817 if (!isset($this->setting_categories[$category])) {
818 continue;
819 }
820
821 $category_settings = $settings[$category] ?? [];
822 if (!is_array($category_settings)) {
823 $validation_results[$category] = [
824 'valid' => false,
825 'errors' => ['Settings for this category must be an object'],
826 'warnings' => [],
827 'suggestions' => [],
828 ];
829 $overall_valid = false;
830 continue;
831 }
832
833 if ($this->has_seo_manager($category)) {
834 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
835 $validation_results[$category] = $validation;
836
837 if (!$validation['valid']) {
838 $overall_valid = false;
839 }
840 } else {
841 // Basic validation for categories without specific managers
842 $validation_results[$category] = [
843 'valid' => true,
844 'errors' => [],
845 'warnings' => [],
846 'suggestions' => []
847 ];
848 }
849 }
850
851 return new WP_REST_Response([
852 'success' => true,
853 'data' => [
854 'validation_results' => $validation_results,
855 'overall_valid' => $overall_valid,
856 'validated_categories' => count($validation_results),
857 'validation_timestamp' => current_time('mysql')
858 ],
859 'message' => 'Settings validation completed'
860 ], 200);
861
862 } catch (\Exception $e) {
863 return new WP_REST_Response([
864 'success' => false,
865 'error' => 'Settings validation failed: ' . $e->getMessage()
866 ], 500);
867 }
868 }
869
870 /**
871 * Get settings schema for all categories
872 *
873 * @since 1.0.0
874 *
875 * @param WP_REST_Request $request Request object
876 * @return WP_REST_Response Response object
877 */
878 public function get_settings_schema(WP_REST_Request $request): WP_REST_Response {
879 try {
880 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
881
882 $schema_data = [];
883
884 foreach ($categories as $category) {
885 if (!isset($this->setting_categories[$category])) {
886 continue;
887 }
888
889 if ($this->has_seo_manager($category)) {
890 $schema_data[$category] = [
891 'schema' => $this->get_seo_manager($category)->get_settings_schema($category),
892 'defaults' => $this->get_seo_manager($category)->get_default_settings($category),
893 'category_name' => $this->setting_categories[$category]
894 ];
895 } else {
896 $schema_data[$category] = [
897 'schema' => [],
898 'defaults' => [],
899 'category_name' => $this->setting_categories[$category]
900 ];
901 }
902 }
903
904 return new WP_REST_Response([
905 'success' => true,
906 'data' => [
907 'schema' => $schema_data,
908 'categories' => $this->setting_categories,
909 'schema_version' => $this->get_schema_version(),
910 'generated_at' => current_time('mysql')
911 ],
912 'message' => 'Settings schema retrieved successfully'
913 ], 200);
914
915 } catch (\Exception $e) {
916 return new WP_REST_Response([
917 'success' => false,
918 'error' => 'Failed to retrieve settings schema: ' . $e->getMessage()
919 ], 500);
920 }
921 }
922
923 /**
924 * Export settings
925 *
926 * @since 1.0.0
927 *
928 * @param WP_REST_Request $request Request object
929 * @return WP_REST_Response|WP_Error Response object or error
930 */
931 public function export_settings(WP_REST_Request $request) {
932 try {
933 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
934 $format = $request->get_param('format') ?? 'json';
935 $include_metadata = $request->get_param('include_metadata') ?? true;
936
937 // Validate format
938 if (!in_array($format, ['json', 'yaml', 'xml'], true)) {
939 return new WP_Error(
940 'invalid_format',
941 'Invalid export format. Supported formats: json, yaml, xml',
942 ['status' => 400]
943 );
944 }
945
946 $export_data = [];
947
948 // Export settings for each category
949 foreach ($categories as $category) {
950 if (!isset($this->setting_categories[$category])) {
951 continue;
952 }
953
954 $export_data[$category] = $this->settings_manager->get_settings($category);
955 }
956
957 // Never let secrets (API keys, OAuth tokens) leave the site in an
958 // export file — strip them entirely.
959 $export_data = $this->redact_sensitive_settings($export_data, true);
960
961 // Add metadata if requested
962 $metadata = [];
963 if ($include_metadata) {
964 $metadata = [
965 'export_timestamp' => current_time('mysql'),
966 'export_version' => $this->get_settings_version(),
967 'wordpress_version' => get_bloginfo('version'),
968 'thinkrank_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
969 'site_url' => home_url(),
970 'exported_categories' => $categories
971 ];
972 }
973
974 // Format export data
975 $formatted_export = $this->format_export_data($export_data, $metadata, $format);
976
977 return new WP_REST_Response([
978 'success' => true,
979 'data' => [
980 'export_data' => $formatted_export,
981 'format' => $format,
982 'metadata' => $metadata,
983 'exported_categories' => count($export_data)
984 ],
985 'message' => 'Settings exported successfully'
986 ], 200);
987
988 } catch (\Exception $e) {
989 return new WP_Error(
990 'export_failed',
991 'Settings export failed: ' . $e->getMessage(),
992 ['status' => 500]
993 );
994 }
995 }
996
997 /**
998 * Import settings
999 *
1000 * @since 1.0.0
1001 *
1002 * @param WP_REST_Request $request Request object
1003 * @return WP_REST_Response|WP_Error Response object or error
1004 */
1005 public function import_settings(WP_REST_Request $request) {
1006 try {
1007 $import_data = $request->get_param('import_data');
1008 $format = $request->get_param('format') ?? 'json';
1009 $validate_before_import = $request->get_param('validate') ?? true;
1010 $overwrite_existing = $request->get_param('overwrite_existing') ?? false;
1011
1012 // Validate import data
1013 if (empty($import_data)) {
1014 return new WP_Error(
1015 'missing_import_data',
1016 'Import data is required',
1017 ['status' => 400]
1018 );
1019 }
1020
1021 // Parse import data based on format
1022 $parsed_data = $this->parse_import_data($import_data, $format);
1023
1024 if (!$parsed_data) {
1025 return new WP_Error(
1026 'invalid_import_data',
1027 'Failed to parse import data',
1028 ['status' => 400]
1029 );
1030 }
1031
1032 if (!is_array($parsed_data)) {
1033 return new WP_Error(
1034 'invalid_import_data',
1035 'Import data must be an object of settings categories',
1036 ['status' => 400]
1037 );
1038 }
1039
1040 // Reject non-array per-category values before they reach the strict
1041 // array-typed manager methods (avoids an uncaught TypeError).
1042 foreach ($parsed_data as $category => $category_settings) {
1043 if (!is_array($category_settings)) {
1044 return new WP_Error(
1045 'invalid_import_data',
1046 "Settings for category '{$category}' must be an object",
1047 ['status' => 400]
1048 );
1049 }
1050 }
1051
1052 $import_results = [];
1053 $validation_results = [];
1054
1055 // Validate imported settings if requested
1056 if ($validate_before_import) {
1057 foreach ($parsed_data as $category => $category_settings) {
1058 if (!isset($this->setting_categories[$category])) {
1059 continue;
1060 }
1061
1062 if ($this->has_seo_manager($category)) {
1063 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
1064 $validation_results[$category] = $validation;
1065
1066 if (!$validation['valid']) {
1067 return new WP_Error(
1068 'import_validation_failed',
1069 "Import validation failed for category: {$category}",
1070 [
1071 'status' => 400,
1072 'validation_results' => $validation_results
1073 ]
1074 );
1075 }
1076 }
1077 }
1078 }
1079
1080 // Import settings for each category
1081 foreach ($parsed_data as $category => $category_settings) {
1082 if (!isset($this->setting_categories[$category])) {
1083 $import_results[$category] = [
1084 'success' => false,
1085 'error' => 'Invalid category'
1086 ];
1087 continue;
1088 }
1089
1090 try {
1091 // Check if settings exist and handle overwrite
1092 $existing_settings = $this->settings_manager->get_settings($category);
1093
1094 if (!empty($existing_settings) && !$overwrite_existing) {
1095 $import_results[$category] = [
1096 'success' => false,
1097 'error' => 'Settings exist and overwrite is disabled'
1098 ];
1099 continue;
1100 }
1101
1102 // Import settings
1103 $import_success = $this->settings_manager->update_settings($category_settings, $category);
1104
1105 // Also update through specific SEO manager if available
1106 if ($this->has_seo_manager($category)) {
1107 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1108 $import_success = $import_success && $manager_update;
1109 }
1110
1111 $import_results[$category] = [
1112 'success' => $import_success,
1113 'settings_count' => count($category_settings)
1114 ];
1115
1116 } catch (\Exception $e) {
1117 $import_results[$category] = [
1118 'success' => false,
1119 'error' => $e->getMessage()
1120 ];
1121 }
1122 }
1123
1124 // Update settings metadata
1125 $this->update_settings_metadata();
1126
1127 return new WP_REST_Response([
1128 'success' => true,
1129 'data' => [
1130 'import_results' => $import_results,
1131 'validation_results' => $validation_results,
1132 'imported_categories' => count($import_results),
1133 'successful_imports' => count(array_filter($import_results, function($result) {
1134 return $result['success'];
1135 }))
1136 ],
1137 'message' => 'Settings import completed'
1138 ], 200);
1139
1140 } catch (\Exception $e) {
1141 return new WP_Error(
1142 'import_failed',
1143 'Settings import failed: ' . $e->getMessage(),
1144 ['status' => 500]
1145 );
1146 }
1147 }
1148
1149 /**
1150 * Create settings backup
1151 *
1152 * @since 1.0.0
1153 *
1154 * @param WP_REST_Request $request Request object
1155 * @return WP_REST_Response|WP_Error Response object or error
1156 */
1157 public function create_settings_backup(WP_REST_Request $request) {
1158 try {
1159 $backup_name = $request->get_param('backup_name') ?? 'backup_' . gmdate('Y-m-d_H-i-s');
1160 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1161 $description = $request->get_param('description') ?? '';
1162
1163 // Create backup data
1164 $backup_data = [];
1165 foreach ($categories as $category) {
1166 if (isset($this->setting_categories[$category])) {
1167 $backup_data[$category] = $this->settings_manager->get_settings($category);
1168 }
1169 }
1170
1171 // Create backup metadata
1172 $backup_metadata = [
1173 'backup_name' => $backup_name,
1174 'description' => $description,
1175 'created_at' => current_time('mysql'),
1176 'created_by' => get_current_user_id(),
1177 'categories' => $categories,
1178 'settings_version' => $this->get_settings_version(),
1179 'wordpress_version' => get_bloginfo('version')
1180 ];
1181
1182 // Save backup
1183 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1184
1185 if (!$backup_id) {
1186 return new WP_Error(
1187 'backup_failed',
1188 'Failed to create settings backup',
1189 ['status' => 500]
1190 );
1191 }
1192
1193 return new WP_REST_Response([
1194 'success' => true,
1195 'data' => [
1196 'backup_id' => $backup_id,
1197 'backup_name' => $backup_name,
1198 'backup_metadata' => $backup_metadata,
1199 'backed_up_categories' => count($backup_data)
1200 ],
1201 'message' => 'Settings backup created successfully'
1202 ], 200);
1203
1204 } catch (\Exception $e) {
1205 return new WP_Error(
1206 'backup_failed',
1207 'Settings backup failed: ' . $e->getMessage(),
1208 ['status' => 500]
1209 );
1210 }
1211 }
1212
1213 /**
1214 * Restore settings from backup
1215 *
1216 * @since 1.0.0
1217 *
1218 * @param WP_REST_Request $request Request object
1219 * @return WP_REST_Response|WP_Error Response object or error
1220 */
1221 public function restore_settings_backup(WP_REST_Request $request) {
1222 try {
1223 $backup_id = $request->get_param('backup_id');
1224 $categories = $request->get_param('categories') ?? null;
1225 $create_restore_point = $request->get_param('create_restore_point') ?? true;
1226
1227 // Validate backup ID
1228 if (empty($backup_id)) {
1229 return new WP_Error(
1230 'missing_backup_id',
1231 'Backup ID is required',
1232 ['status' => 400]
1233 );
1234 }
1235
1236 // Load backup data
1237 $backup_data = $this->load_settings_backup($backup_id);
1238
1239 if (!$backup_data) {
1240 return new WP_Error(
1241 'backup_not_found',
1242 'Backup not found or could not be loaded',
1243 ['status' => 404]
1244 );
1245 }
1246
1247 // Create restore point if requested. Abort if it couldn't be saved,
1248 // so the current configuration isn't overwritten with no rollback.
1249 $restore_point_id = null;
1250 if ($create_restore_point) {
1251 $restore_point_id = $this->create_restore_point();
1252 if ($restore_point_id === '') {
1253 return new WP_Error(
1254 'restore_point_failed',
1255 'Could not create a restore point; aborting restore to avoid unrecoverable settings loss.',
1256 ['status' => 500]
1257 );
1258 }
1259 }
1260
1261 $restore_results = [];
1262
1263 // Determine categories to restore
1264 $categories_to_restore = $categories ?? array_keys($backup_data['settings']);
1265
1266 // Restore settings for each category
1267 foreach ($categories_to_restore as $category) {
1268 if (!isset($backup_data['settings'][$category])) {
1269 $restore_results[$category] = [
1270 'success' => false,
1271 'error' => 'Category not found in backup'
1272 ];
1273 continue;
1274 }
1275
1276 try {
1277 $category_settings = $backup_data['settings'][$category];
1278
1279 // Restore settings
1280 $restore_success = $this->settings_manager->update_settings($category_settings, $category);
1281
1282 // Also update through specific SEO manager if available
1283 if ($this->has_seo_manager($category)) {
1284 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1285 $restore_success = $restore_success && $manager_update;
1286 }
1287
1288 $restore_results[$category] = [
1289 'success' => $restore_success,
1290 'settings_count' => count($category_settings)
1291 ];
1292
1293 } catch (\Exception $e) {
1294 $restore_results[$category] = [
1295 'success' => false,
1296 'error' => $e->getMessage()
1297 ];
1298 }
1299 }
1300
1301 // Update settings metadata
1302 $this->update_settings_metadata();
1303
1304 return new WP_REST_Response([
1305 'success' => true,
1306 'data' => [
1307 'backup_id' => $backup_id,
1308 'restore_point_id' => $restore_point_id,
1309 'restore_results' => $restore_results,
1310 'restored_categories' => count($restore_results),
1311 'backup_metadata' => $backup_data['metadata']
1312 ],
1313 'message' => 'Settings restored from backup successfully'
1314 ], 200);
1315
1316 } catch (\Exception $e) {
1317 return new WP_Error(
1318 'restore_failed',
1319 'Settings restore failed: ' . $e->getMessage(),
1320 ['status' => 500]
1321 );
1322 }
1323 }
1324
1325 /**
1326 * Reset settings to defaults
1327 *
1328 * @since 1.0.0
1329 *
1330 * @param WP_REST_Request $request Request object
1331 * @return WP_REST_Response|WP_Error Response object or error
1332 */
1333 public function reset_settings(WP_REST_Request $request) {
1334 try {
1335 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1336 $create_backup = $request->get_param('create_backup') ?? true;
1337
1338 // Create backup before reset if requested. If the backup was asked
1339 // for but couldn't be persisted, abort rather than silently wiping
1340 // settings with no rollback — the whole point of the flag is safety.
1341 $backup_id = null;
1342 if ($create_backup) {
1343 $backup_id = $this->create_pre_reset_backup($categories);
1344 if ($backup_id === '') {
1345 return new WP_Error(
1346 'backup_failed',
1347 'Could not create a pre-reset backup; aborting reset to avoid unrecoverable settings loss.',
1348 ['status' => 500]
1349 );
1350 }
1351 }
1352
1353 $reset_results = [];
1354
1355 foreach ($categories as $category) {
1356 if (!isset($this->setting_categories[$category])) {
1357 continue;
1358 }
1359
1360 try {
1361 // Get default settings
1362 $default_settings = [];
1363 if ($this->has_seo_manager($category)) {
1364 $default_settings = $this->get_seo_manager($category)->get_default_settings($category);
1365 }
1366
1367 // Reset to defaults
1368 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
1369
1370 // Also reset through specific SEO manager if available
1371 if ($this->has_seo_manager($category)) {
1372 $manager_reset = $this->get_seo_manager($category)->save_settings('site', null, $default_settings);
1373 $reset_success = $reset_success && $manager_reset;
1374 }
1375
1376 $reset_results[$category] = [
1377 'success' => $reset_success,
1378 'default_settings_count' => count($default_settings)
1379 ];
1380
1381 } catch (\Exception $e) {
1382 $reset_results[$category] = [
1383 'success' => false,
1384 'error' => $e->getMessage()
1385 ];
1386 }
1387 }
1388
1389 // Update settings metadata
1390 $this->update_settings_metadata();
1391
1392 return new WP_REST_Response([
1393 'success' => true,
1394 'data' => [
1395 'reset_results' => $reset_results,
1396 'backup_id' => $backup_id,
1397 'reset_categories' => count($reset_results),
1398 'reset_timestamp' => current_time('mysql')
1399 ],
1400 'message' => 'Settings reset to defaults completed'
1401 ], 200);
1402
1403 } catch (\Exception $e) {
1404 return new WP_Error(
1405 'reset_failed',
1406 'Settings reset failed: ' . $e->getMessage(),
1407 ['status' => 500]
1408 );
1409 }
1410 }
1411
1412 /**
1413 * Add performance indexes to database tables
1414 *
1415 * @since 1.0.0
1416 *
1417 * @param WP_REST_Request $request Request object
1418 * @return WP_REST_Response|WP_Error Response object
1419 */
1420 public function add_performance_indexes(WP_REST_Request $request): WP_REST_Response|WP_Error {
1421 try {
1422 // Import the Database_Schema class
1423 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1424 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
1425 }
1426
1427 $schema = new \ThinkRank\Database\Database_Schema();
1428 $success = $schema->add_performance_indexes();
1429
1430 if ($success) {
1431 return new WP_REST_Response([
1432 'success' => true,
1433 'message' => 'Performance indexes added successfully',
1434 'data' => [
1435 'indexes_added' => true,
1436 'timestamp' => current_time('mysql')
1437 ]
1438 ], 200);
1439 } else {
1440 return new WP_REST_Response([
1441 'success' => false,
1442 'message' => 'Some performance indexes could not be added. Check error logs for details.',
1443 'data' => [
1444 'indexes_added' => false,
1445 'timestamp' => current_time('mysql')
1446 ]
1447 ], 200);
1448 }
1449
1450 } catch (\Exception $e) {
1451 return new WP_Error(
1452 'performance_indexes_failed',
1453 'Failed to add performance indexes: ' . $e->getMessage(),
1454 ['status' => 500]
1455 );
1456 }
1457 }
1458
1459 /**
1460 * Permission callbacks
1461 */
1462
1463 /**
1464 * Check permissions for reading settings data
1465 *
1466 * @since 1.0.0
1467 *
1468 * @return bool Permission status
1469 */
1470 public function check_read_permissions(): bool {
1471 // Plugin SEO/AI config is not subscriber-visible — require the same
1472 // management capability as the write routes.
1473 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1474 }
1475
1476 /**
1477 * Check permissions for managing settings
1478 *
1479 * @since 1.0.0
1480 *
1481 * @return bool Permission status
1482 */
1483 public function check_manage_permissions(): bool {
1484 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1485 }
1486
1487 /**
1488 * Check permissions for administrator-only settings operations.
1489 *
1490 * The Role Manager can delegate `thinkrank_settings` to non-admin roles so
1491 * they can manage the plugin's SEO configuration. Schema-level (DDL) and
1492 * destructive whole-configuration operations — performance indexes, reset,
1493 * import — are a different altitude and stay with site administrators.
1494 *
1495 * @since 1.29.0
1496 *
1497 * @return bool Permission status
1498 */
1499 public function check_admin_permissions(): bool {
1500 return current_user_can('manage_options');
1501 }
1502
1503 /**
1504 * Helper methods
1505 */
1506
1507 /**
1508 * Get last settings update timestamp
1509 *
1510 * @since 1.0.0
1511 *
1512 * @return string|null Last update timestamp
1513 */
1514 private function get_last_settings_update(): ?string {
1515 $result = get_option('thinkrank_settings_last_updated');
1516 return $result !== false ? $result : null;
1517 }
1518
1519 /**
1520 * Get settings version
1521 *
1522 * @since 1.0.0
1523 *
1524 * @return string Settings version
1525 */
1526 private function get_settings_version(): string {
1527 return get_option('thinkrank_settings_version', '1.0.0');
1528 }
1529
1530 /**
1531 * Get schema version
1532 *
1533 * @since 1.0.0
1534 *
1535 * @return string Schema version
1536 */
1537 private function get_schema_version(): string {
1538 return get_option('thinkrank_schema_version', '1.0.0');
1539 }
1540
1541 /**
1542 * Get category last update timestamp
1543 *
1544 * @since 1.0.0
1545 *
1546 * @param string $category Category name
1547 * @return string|null Last update timestamp
1548 */
1549 private function get_category_last_update(string $category): ?string {
1550 $result = get_option("thinkrank_settings_{$category}_last_updated");
1551 return $result !== false ? $result : null;
1552 }
1553
1554 /**
1555 * Update settings metadata
1556 *
1557 * @since 1.0.0
1558 */
1559 private function update_settings_metadata(): void {
1560 update_option('thinkrank_settings_last_updated', current_time('mysql'));
1561
1562 // Increment version
1563 $current_version = $this->get_settings_version();
1564 $version_parts = explode('.', $current_version);
1565 $version_parts[2] = (int)$version_parts[2] + 1;
1566 $new_version = implode('.', $version_parts);
1567
1568 update_option('thinkrank_settings_version', $new_version);
1569 }
1570
1571 /**
1572 * Update category metadata
1573 *
1574 * @since 1.0.0
1575 *
1576 * @param string $category Category name
1577 */
1578 private function update_category_metadata(string $category): void {
1579 update_option("thinkrank_settings_{$category}_last_updated", current_time('mysql'));
1580 }
1581
1582 /**
1583 * Format export data
1584 *
1585 * @since 1.0.0
1586 *
1587 * @param array $export_data Export data
1588 * @param array $metadata Metadata
1589 * @param string $format Export format
1590 * @return string Formatted export data
1591 */
1592 private function format_export_data(array $export_data, array $metadata, string $format): string {
1593 $full_export = [
1594 'metadata' => $metadata,
1595 'settings' => $export_data
1596 ];
1597
1598 switch ($format) {
1599 case 'json':
1600 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1601 case 'yaml':
1602 // Would implement YAML formatting
1603 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1604 case 'xml':
1605 // Would implement XML formatting
1606 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1607 default:
1608 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1609 }
1610 }
1611
1612 /**
1613 * Parse import data
1614 *
1615 * @since 1.0.0
1616 *
1617 * @param string $import_data Import data
1618 * @param string $format Import format
1619 * @return array|false Parsed data or false on failure
1620 */
1621 private function parse_import_data(string $import_data, string $format) {
1622 switch ($format) {
1623 case 'json':
1624 $decoded = json_decode($import_data, true);
1625 return $decoded['settings'] ?? $decoded;
1626 case 'yaml':
1627 // Would implement YAML parsing
1628 $decoded = json_decode($import_data, true);
1629 return $decoded['settings'] ?? $decoded;
1630 case 'xml':
1631 // Would implement XML parsing
1632 $decoded = json_decode($import_data, true);
1633 return $decoded['settings'] ?? $decoded;
1634 default:
1635 return false;
1636 }
1637 }
1638
1639 /**
1640 * Save settings backup
1641 *
1642 * @since 1.0.0
1643 *
1644 * @param array $backup_data Backup data
1645 * @param array $backup_metadata Backup metadata
1646 * @return string|false Backup ID or false on failure
1647 */
1648 private function save_settings_backup(array $backup_data, array $backup_metadata) {
1649 $backup_id = uniqid('backup_', true);
1650
1651 $backup_record = [
1652 'backup_id' => $backup_id,
1653 'metadata' => $backup_metadata,
1654 'settings' => $backup_data
1655 ];
1656
1657 // Store as a NON-autoloaded option — each backup is a full multi-category
1658 // snapshot and must not be loaded into memory on every front-end/admin
1659 // request.
1660 $saved = update_option("thinkrank_backup_{$backup_id}", $backup_record, false);
1661
1662 if ($saved) {
1663 // Add to backup index (also non-autoloaded).
1664 $backup_index = get_option('thinkrank_backup_index', []);
1665 $backup_index[$backup_id] = $backup_metadata;
1666
1667 // Cap the retained set so the backups can't accumulate unbounded.
1668 $backup_index = $this->prune_settings_backups($backup_index);
1669
1670 update_option('thinkrank_backup_index', $backup_index, false);
1671
1672 return $backup_id;
1673 }
1674
1675 return false;
1676 }
1677
1678 /**
1679 * Keep only the most recent settings backups, deleting the option rows for
1680 * any pruned from the index (oldest first).
1681 *
1682 * @param array $backup_index backup_id => metadata map.
1683 * @return array Pruned index.
1684 */
1685 private function prune_settings_backups(array $backup_index): array {
1686 $max_backups = 10;
1687
1688 if (count($backup_index) <= $max_backups) {
1689 return $backup_index;
1690 }
1691
1692 // Oldest first (missing timestamps sort earliest).
1693 uasort($backup_index, static function ($a, $b) {
1694 return strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? ''));
1695 });
1696
1697 // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- the loop shrinks $backup_index, so the count has to be re-read.
1698 while (count($backup_index) > $max_backups) {
1699 $oldest_id = array_key_first($backup_index);
1700 unset($backup_index[$oldest_id]);
1701 delete_option("thinkrank_backup_{$oldest_id}");
1702 }
1703
1704 return $backup_index;
1705 }
1706
1707 /**
1708 * Load settings backup
1709 *
1710 * @since 1.0.0
1711 *
1712 * @param string $backup_id Backup ID
1713 * @return array|false Backup data or false on failure
1714 */
1715 private function load_settings_backup(string $backup_id) {
1716 return get_option("thinkrank_backup_{$backup_id}", false);
1717 }
1718
1719 /**
1720 * Argument validation methods
1721 */
1722
1723 /**
1724 * Get arguments for global settings endpoints
1725 *
1726 * @since 1.0.0
1727 *
1728 * @return array Arguments array
1729 */
1730 private function get_global_settings_args(): array {
1731 return [
1732 'settings' => [
1733 'required' => true,
1734 'type' => 'object',
1735 'description' => 'Global settings to update across categories'
1736 ],
1737 'validate' => [
1738 'required' => false,
1739 'type' => 'boolean',
1740 'default' => true,
1741 'description' => 'Whether to validate settings before updating'
1742 ]
1743 ];
1744 }
1745
1746 /**
1747 * Get arguments for category settings endpoints
1748 *
1749 * @since 1.0.0
1750 *
1751 * @return array Arguments array
1752 */
1753 private function get_category_settings_args(): array {
1754 return [
1755 'settings' => [
1756 'required' => true,
1757 'type' => 'object',
1758 'description' => 'Category settings to update'
1759 ],
1760 'validate' => [
1761 'required' => false,
1762 'type' => 'boolean',
1763 'default' => true,
1764 'description' => 'Whether to validate settings before updating'
1765 ]
1766 ];
1767 }
1768
1769 /**
1770 * Get arguments for validation endpoint
1771 *
1772 * @since 1.0.0
1773 *
1774 * @return array Arguments array
1775 */
1776 private function get_validation_args(): array {
1777 return [
1778 'settings' => [
1779 'required' => true,
1780 'type' => 'object',
1781 'description' => 'Settings to validate'
1782 ],
1783 'categories' => [
1784 'required' => false,
1785 'type' => 'array',
1786 'items' => [
1787 'type' => 'string',
1788 'enum' => array_keys($this->setting_categories)
1789 ],
1790 'description' => 'Categories to validate'
1791 ]
1792 ];
1793 }
1794
1795 /**
1796 * Get arguments for export endpoint
1797 *
1798 * @since 1.0.0
1799 *
1800 * @return array Arguments array
1801 */
1802 private function get_export_args(): array {
1803 return [
1804 'categories' => [
1805 'required' => false,
1806 'type' => 'array',
1807 'items' => [
1808 'type' => 'string',
1809 'enum' => array_keys($this->setting_categories)
1810 ],
1811 'description' => 'Categories to export'
1812 ],
1813 'format' => [
1814 'required' => false,
1815 'type' => 'string',
1816 'enum' => ['json', 'yaml', 'xml'],
1817 'default' => 'json',
1818 'description' => 'Export format'
1819 ],
1820 'include_metadata' => [
1821 'required' => false,
1822 'type' => 'boolean',
1823 'default' => true,
1824 'description' => 'Whether to include metadata in export'
1825 ]
1826 ];
1827 }
1828
1829 /**
1830 * Get arguments for import endpoint
1831 *
1832 * @since 1.0.0
1833 *
1834 * @return array Arguments array
1835 */
1836 private function get_import_args(): array {
1837 return [
1838 'import_data' => [
1839 'required' => true,
1840 'type' => 'string',
1841 'description' => 'Settings data to import'
1842 ],
1843 'format' => [
1844 'required' => false,
1845 'type' => 'string',
1846 'enum' => ['json', 'yaml', 'xml'],
1847 'default' => 'json',
1848 'description' => 'Import format'
1849 ],
1850 'validate' => [
1851 'required' => false,
1852 'type' => 'boolean',
1853 'default' => true,
1854 'description' => 'Whether to validate before importing'
1855 ],
1856 'overwrite_existing' => [
1857 'required' => false,
1858 'type' => 'boolean',
1859 'default' => false,
1860 'description' => 'Whether to overwrite existing settings'
1861 ]
1862 ];
1863 }
1864
1865 /**
1866 * Get arguments for backup endpoint
1867 *
1868 * @since 1.0.0
1869 *
1870 * @return array Arguments array
1871 */
1872 private function get_backup_args(): array {
1873 return [
1874 'backup_name' => [
1875 'required' => false,
1876 'type' => 'string',
1877 'description' => 'Name for the backup'
1878 ],
1879 'categories' => [
1880 'required' => false,
1881 'type' => 'array',
1882 'items' => [
1883 'type' => 'string',
1884 'enum' => array_keys($this->setting_categories)
1885 ],
1886 'description' => 'Categories to backup'
1887 ],
1888 'description' => [
1889 'required' => false,
1890 'type' => 'string',
1891 'description' => 'Backup description'
1892 ]
1893 ];
1894 }
1895
1896 /**
1897 * Get arguments for restore endpoint
1898 *
1899 * @since 1.0.0
1900 *
1901 * @return array Arguments array
1902 */
1903 private function get_restore_args(): array {
1904 return [
1905 'backup_id' => [
1906 'required' => true,
1907 'type' => 'string',
1908 'description' => 'Backup ID to restore from'
1909 ],
1910 'categories' => [
1911 'required' => false,
1912 'type' => 'array',
1913 'items' => [
1914 'type' => 'string',
1915 'enum' => array_keys($this->setting_categories)
1916 ],
1917 'description' => 'Categories to restore'
1918 ],
1919 'create_restore_point' => [
1920 'required' => false,
1921 'type' => 'boolean',
1922 'default' => true,
1923 'description' => 'Whether to create restore point before restoring'
1924 ]
1925 ];
1926 }
1927
1928 /**
1929 * Get arguments for reset endpoint
1930 *
1931 * @since 1.0.0
1932 *
1933 * @return array Arguments array
1934 */
1935 private function get_reset_args(): array {
1936 return [
1937 'categories' => [
1938 'required' => false,
1939 'type' => 'array',
1940 'items' => [
1941 'type' => 'string',
1942 'enum' => array_keys($this->setting_categories)
1943 ],
1944 'description' => 'Categories to reset'
1945 ],
1946 'create_backup' => [
1947 'required' => false,
1948 'type' => 'boolean',
1949 'default' => true,
1950 'description' => 'Whether to create backup before reset'
1951 ]
1952 ];
1953 }
1954
1955 /**
1956 * Snapshot the given categories' current settings into a persisted backup.
1957 *
1958 * Backs the pre-reset backup and restore-point features with real storage
1959 * (via save_settings_backup) instead of a fabricated id, so operators have a
1960 * genuine rollback snapshot before a destructive reset/restore.
1961 *
1962 * @param array $categories Categories to snapshot.
1963 * @param string $label Human-readable label for the backup.
1964 * @return string Backup id, or '' if the snapshot could not be persisted.
1965 */
1966 private function create_settings_snapshot(array $categories, string $label): string {
1967 $backup_data = [];
1968 foreach ($categories as $category) {
1969 if (isset($this->setting_categories[$category])) {
1970 $backup_data[$category] = $this->settings_manager->get_settings($category);
1971 }
1972 }
1973
1974 $backup_metadata = [
1975 'backup_name' => $label . ' ' . gmdate('Y-m-d_H-i-s'),
1976 'description' => $label,
1977 'created_at' => current_time('mysql'),
1978 'created_by' => get_current_user_id(),
1979 'categories' => $categories,
1980 'settings_version' => $this->get_settings_version(),
1981 'wordpress_version' => get_bloginfo('version'),
1982 'automatic' => true,
1983 ];
1984
1985 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1986
1987 return $backup_id ?: '';
1988 }
1989
1990 /**
1991 * Create a full-snapshot restore point before restoring a backup.
1992 *
1993 * @return string Backup id, or '' if it could not be persisted.
1994 */
1995 private function create_restore_point(): string {
1996 return $this->create_settings_snapshot(
1997 array_keys($this->setting_categories),
1998 'Automatic restore point'
1999 );
2000 }
2001
2002 /**
2003 * Create a safety backup of the given categories before a reset.
2004 *
2005 * @param array $categories Categories about to be reset.
2006 * @return string Backup id, or '' if it could not be persisted.
2007 */
2008 private function create_pre_reset_backup(array $categories): string {
2009 return $this->create_settings_snapshot($categories, 'Automatic pre-reset backup');
2010 }
2011 }
2012