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

1,934 lines 66.3 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_manage_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_manage_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_manage_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 * Get global settings across all categories
383 *
384 * @since 1.0.0
385 *
386 * @param WP_REST_Request $request Request object
387 * @return WP_REST_Response Response object
388 */
389 public function get_global_settings(WP_REST_Request $request): WP_REST_Response {
390 try {
391 $include_categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
392 $include_schema = $request->get_param('include_schema') ?? false;
393
394 $global_settings = [];
395 $settings_schema = [];
396
397 foreach ($include_categories as $category) {
398 if (!isset($this->setting_categories[$category])) {
399 continue;
400 }
401
402 // Get settings for each category using Settings Manager
403 $category_settings = $this->settings_manager->get_settings($category);
404 $global_settings[$category] = $category_settings;
405
406 // Get schema if requested
407 if ($include_schema && $this->has_seo_manager($category)) {
408 $settings_schema[$category] = $this->get_seo_manager($category)->get_settings_schema($category);
409 }
410 }
411
412 // Get global metadata
413 $metadata = [
414 'total_categories' => count($this->setting_categories),
415 'loaded_categories' => count($global_settings),
416 'last_updated' => $this->get_last_settings_update(),
417 'settings_version' => $this->get_settings_version()
418 ];
419
420 return new WP_REST_Response([
421 'success' => true,
422 'data' => [
423 'settings' => $this->redact_sensitive_settings($global_settings),
424 'schema' => $settings_schema,
425 'metadata' => $metadata,
426 'categories' => $this->setting_categories
427 ],
428 'message' => 'Global settings retrieved successfully'
429 ], 200);
430
431 } catch (\Exception $e) {
432 return new WP_REST_Response([
433 'success' => false,
434 'error' => 'Failed to retrieve global settings: ' . $e->getMessage()
435 ], 500);
436 }
437 }
438
439 /**
440 * Update global settings across multiple categories
441 *
442 * @since 1.0.0
443 *
444 * @param WP_REST_Request $request Request object
445 * @return WP_REST_Response|WP_Error Response object or error
446 */
447 public function update_global_settings(WP_REST_Request $request) {
448 try {
449 $settings = $request->get_param('settings');
450 $validate_before_update = $request->get_param('validate') ?? true;
451
452 // Validate settings structure
453 if (empty($settings) || !is_array($settings)) {
454 return new WP_Error(
455 'invalid_settings',
456 'Settings must be provided as an array',
457 ['status' => 400]
458 );
459 }
460
461 // Each per-category value must be an array before it reaches the
462 // strict array-typed manager methods; reject non-array values with a
463 // 400 instead of letting them surface as an uncaught TypeError.
464 foreach ($settings as $category => $category_settings) {
465 if (!is_array($category_settings)) {
466 return new WP_Error(
467 'invalid_settings',
468 "Settings for category '{$category}' must be provided as an object",
469 ['status' => 400]
470 );
471 }
472 }
473
474 $validation_results = [];
475 $update_results = [];
476
477 // Validate all settings before updating if requested
478 if ($validate_before_update) {
479 foreach ($settings as $category => $category_settings) {
480 if (!isset($this->setting_categories[$category])) {
481 continue;
482 }
483
484 if ($this->has_seo_manager($category)) {
485 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
486 $validation_results[$category] = $validation;
487
488 if (!$validation['valid']) {
489 return new WP_Error(
490 'validation_failed',
491 "Settings validation failed for category: {$category}",
492 [
493 'status' => 400,
494 'validation_results' => $validation_results
495 ]
496 );
497 }
498 }
499 }
500 }
501
502 // Update settings for each category
503 foreach ($settings as $category => $category_settings) {
504 if (!isset($this->setting_categories[$category])) {
505 continue;
506 }
507
508 try {
509 // Update using Settings Manager
510 $update_success = $this->settings_manager->update_settings($category_settings, $category);
511
512 // Also update through specific SEO manager if available
513 if ($this->has_seo_manager($category)) {
514 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
515 $update_success = $update_success && $manager_update;
516 }
517
518 $update_results[$category] = [
519 'success' => $update_success,
520 'settings_count' => count($category_settings)
521 ];
522
523 } catch (\Exception $e) {
524 $update_results[$category] = [
525 'success' => false,
526 'error' => $e->getMessage()
527 ];
528 }
529 }
530
531 // Update settings version and timestamp
532 $this->update_settings_metadata();
533
534 // Get updated settings
535 $updated_settings = [];
536 foreach (array_keys($settings) as $category) {
537 if (isset($this->setting_categories[$category])) {
538 $updated_settings[$category] = $this->settings_manager->get_settings($category);
539 }
540 }
541
542 return new WP_REST_Response([
543 'success' => true,
544 'data' => [
545 'updated_settings' => $updated_settings,
546 'validation_results' => $validation_results,
547 'update_results' => $update_results,
548 'settings_version' => $this->get_settings_version()
549 ],
550 'message' => 'Global settings updated successfully'
551 ], 200);
552
553 } catch (\Exception $e) {
554 return new WP_Error(
555 'update_failed',
556 'Global settings update failed: ' . $e->getMessage(),
557 ['status' => 500]
558 );
559 }
560 }
561
562 /**
563 * Get settings for specific category
564 *
565 * @since 1.0.0
566 *
567 * @param WP_REST_Request $request Request object
568 * @return WP_REST_Response|WP_Error Response object or error
569 */
570 public function get_category_settings(WP_REST_Request $request) {
571 try {
572 $category = $request->get_param('category');
573 $include_schema = $request->get_param('include_schema') ?? false;
574
575 // Validate category
576 if (!isset($this->setting_categories[$category])) {
577 return new WP_Error(
578 'invalid_category',
579 'Invalid settings category provided',
580 ['status' => 400]
581 );
582 }
583
584 // Get category settings
585 $category_settings = $this->settings_manager->get_settings($category);
586
587 // Get schema if requested
588 $schema = [];
589 if ($include_schema && $this->has_seo_manager($category)) {
590 $schema = $this->get_seo_manager($category)->get_settings_schema($category);
591 }
592
593 // Get category metadata
594 $metadata = [
595 'category' => $category,
596 'category_name' => $this->setting_categories[$category],
597 'settings_count' => count($category_settings),
598 'last_updated' => $this->get_category_last_update($category),
599 'has_manager' => $this->has_seo_manager($category)
600 ];
601
602 return new WP_REST_Response([
603 'success' => true,
604 'data' => [
605 'settings' => $category_settings,
606 'schema' => $schema,
607 'metadata' => $metadata
608 ],
609 'message' => "Settings for category '{$category}' retrieved successfully"
610 ], 200);
611
612 } catch (\Exception $e) {
613 return new WP_Error(
614 'retrieval_failed',
615 'Category settings retrieval failed: ' . $e->getMessage(),
616 ['status' => 500]
617 );
618 }
619 }
620
621 /**
622 * Update settings for specific category
623 *
624 * @since 1.0.0
625 *
626 * @param WP_REST_Request $request Request object
627 * @return WP_REST_Response|WP_Error Response object or error
628 */
629 public function update_category_settings(WP_REST_Request $request) {
630 try {
631 $category = $request->get_param('category');
632 $request_data = $request->get_param('settings');
633 $validate_before_update = $request->get_param('validate') ?? true;
634
635 // Extract only the actual settings data, not metadata
636 if (isset($request_data['settings'])) {
637 // If settings are nested under 'settings' key, use that
638 $settings = $request_data['settings'];
639 } else {
640 // Otherwise use the data directly
641 $settings = $request_data;
642 }
643
644 // Validate category
645 if (!isset($this->setting_categories[$category])) {
646 return new WP_Error(
647 'invalid_category',
648 'Invalid settings category provided',
649 ['status' => 400]
650 );
651 }
652
653 // Validate settings
654 if (empty($settings) || !is_array($settings)) {
655 return new WP_Error(
656 'invalid_settings',
657 'Settings must be provided as an array',
658 ['status' => 400]
659 );
660 }
661
662 $validation_result = ['valid' => true];
663
664 // Validate settings if requested
665 if ($validate_before_update && $this->has_seo_manager($category)) {
666 $validation_result = $this->get_seo_manager($category)->validate_settings($settings);
667
668 if (!$validation_result['valid']) {
669 return new WP_Error(
670 'validation_failed',
671 "Settings validation failed for category: {$category}",
672 [
673 'status' => 400,
674 'validation_errors' => $validation_result['errors'],
675 'validation_warnings' => $validation_result['warnings']
676 ]
677 );
678 }
679 }
680
681 // Update settings
682 $update_success = $this->settings_manager->update_settings($settings, $category);
683
684 // Also update through specific SEO manager if available
685 if ($this->has_seo_manager($category)) {
686 $context_type = $request->get_param('context_type') ?? 'site';
687 $context_id = $request->get_param('context_id') ?? null;
688 $manager_update = $this->get_seo_manager($category)->save_settings($context_type, $context_id, $settings);
689 $update_success = $update_success && $manager_update;
690 }
691
692 if (!$update_success) {
693 return new WP_Error(
694 'update_failed',
695 "Failed to update settings for category: {$category}",
696 ['status' => 500]
697 );
698 }
699
700 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
701 if ($category === 'seo_analytics') {
702 foreach (['7d', '30d', '90d'] as $range) {
703 delete_transient("analytics_dashboard_v5_{$range}");
704 delete_transient("seo_opportunities_{$range}");
705 delete_transient("seo_insights_{$range}");
706 }
707 delete_transient('indexing_status');
708 }
709
710 // Update category metadata
711 $this->update_category_metadata($category);
712
713 // Get updated settings
714 $updated_settings = $this->settings_manager->get_settings($category);
715
716 return new WP_REST_Response([
717 'success' => true,
718 'data' => [
719 'category' => $category,
720 'updated_settings' => $updated_settings,
721 'validation_result' => $validation_result,
722 'settings_count' => count($updated_settings)
723 ],
724 'message' => "Settings for category '{$category}' updated successfully"
725 ], 200);
726
727 } catch (\Exception $e) {
728 return new WP_Error(
729 'update_failed',
730 'Category settings update failed: ' . $e->getMessage(),
731 ['status' => 500]
732 );
733 }
734 }
735
736 /**
737 * Validate settings across categories
738 *
739 * @since 1.0.0
740 *
741 * @param WP_REST_Request $request Request object
742 * @return WP_REST_Response Response object
743 */
744 public function validate_settings(WP_REST_Request $request): WP_REST_Response {
745 try {
746 $settings = $request->get_param('settings');
747 if (!is_array($settings)) {
748 $settings = [];
749 }
750 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
751
752 $validation_results = [];
753 $overall_valid = true;
754
755 foreach ($categories as $category) {
756 if (!isset($this->setting_categories[$category])) {
757 continue;
758 }
759
760 $category_settings = $settings[$category] ?? [];
761 if (!is_array($category_settings)) {
762 $validation_results[$category] = [
763 'valid' => false,
764 'errors' => ['Settings for this category must be an object'],
765 'warnings' => [],
766 'suggestions' => [],
767 ];
768 $overall_valid = false;
769 continue;
770 }
771
772 if ($this->has_seo_manager($category)) {
773 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
774 $validation_results[$category] = $validation;
775
776 if (!$validation['valid']) {
777 $overall_valid = false;
778 }
779 } else {
780 // Basic validation for categories without specific managers
781 $validation_results[$category] = [
782 'valid' => true,
783 'errors' => [],
784 'warnings' => [],
785 'suggestions' => []
786 ];
787 }
788 }
789
790 return new WP_REST_Response([
791 'success' => true,
792 'data' => [
793 'validation_results' => $validation_results,
794 'overall_valid' => $overall_valid,
795 'validated_categories' => count($validation_results),
796 'validation_timestamp' => current_time('mysql')
797 ],
798 'message' => 'Settings validation completed'
799 ], 200);
800
801 } catch (\Exception $e) {
802 return new WP_REST_Response([
803 'success' => false,
804 'error' => 'Settings validation failed: ' . $e->getMessage()
805 ], 500);
806 }
807 }
808
809 /**
810 * Get settings schema for all categories
811 *
812 * @since 1.0.0
813 *
814 * @param WP_REST_Request $request Request object
815 * @return WP_REST_Response Response object
816 */
817 public function get_settings_schema(WP_REST_Request $request): WP_REST_Response {
818 try {
819 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
820
821 $schema_data = [];
822
823 foreach ($categories as $category) {
824 if (!isset($this->setting_categories[$category])) {
825 continue;
826 }
827
828 if ($this->has_seo_manager($category)) {
829 $schema_data[$category] = [
830 'schema' => $this->get_seo_manager($category)->get_settings_schema($category),
831 'defaults' => $this->get_seo_manager($category)->get_default_settings($category),
832 'category_name' => $this->setting_categories[$category]
833 ];
834 } else {
835 $schema_data[$category] = [
836 'schema' => [],
837 'defaults' => [],
838 'category_name' => $this->setting_categories[$category]
839 ];
840 }
841 }
842
843 return new WP_REST_Response([
844 'success' => true,
845 'data' => [
846 'schema' => $schema_data,
847 'categories' => $this->setting_categories,
848 'schema_version' => $this->get_schema_version(),
849 'generated_at' => current_time('mysql')
850 ],
851 'message' => 'Settings schema retrieved successfully'
852 ], 200);
853
854 } catch (\Exception $e) {
855 return new WP_REST_Response([
856 'success' => false,
857 'error' => 'Failed to retrieve settings schema: ' . $e->getMessage()
858 ], 500);
859 }
860 }
861
862 /**
863 * Export settings
864 *
865 * @since 1.0.0
866 *
867 * @param WP_REST_Request $request Request object
868 * @return WP_REST_Response|WP_Error Response object or error
869 */
870 public function export_settings(WP_REST_Request $request) {
871 try {
872 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
873 $format = $request->get_param('format') ?? 'json';
874 $include_metadata = $request->get_param('include_metadata') ?? true;
875
876 // Validate format
877 if (!in_array($format, ['json', 'yaml', 'xml'], true)) {
878 return new WP_Error(
879 'invalid_format',
880 'Invalid export format. Supported formats: json, yaml, xml',
881 ['status' => 400]
882 );
883 }
884
885 $export_data = [];
886
887 // Export settings for each category
888 foreach ($categories as $category) {
889 if (!isset($this->setting_categories[$category])) {
890 continue;
891 }
892
893 $export_data[$category] = $this->settings_manager->get_settings($category);
894 }
895
896 // Never let secrets (API keys, OAuth tokens) leave the site in an
897 // export file — strip them entirely.
898 $export_data = $this->redact_sensitive_settings($export_data, true);
899
900 // Add metadata if requested
901 $metadata = [];
902 if ($include_metadata) {
903 $metadata = [
904 'export_timestamp' => current_time('mysql'),
905 'export_version' => $this->get_settings_version(),
906 'wordpress_version' => get_bloginfo('version'),
907 'thinkrank_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
908 'site_url' => home_url(),
909 'exported_categories' => $categories
910 ];
911 }
912
913 // Format export data
914 $formatted_export = $this->format_export_data($export_data, $metadata, $format);
915
916 return new WP_REST_Response([
917 'success' => true,
918 'data' => [
919 'export_data' => $formatted_export,
920 'format' => $format,
921 'metadata' => $metadata,
922 'exported_categories' => count($export_data)
923 ],
924 'message' => 'Settings exported successfully'
925 ], 200);
926
927 } catch (\Exception $e) {
928 return new WP_Error(
929 'export_failed',
930 'Settings export failed: ' . $e->getMessage(),
931 ['status' => 500]
932 );
933 }
934 }
935
936 /**
937 * Import settings
938 *
939 * @since 1.0.0
940 *
941 * @param WP_REST_Request $request Request object
942 * @return WP_REST_Response|WP_Error Response object or error
943 */
944 public function import_settings(WP_REST_Request $request) {
945 try {
946 $import_data = $request->get_param('import_data');
947 $format = $request->get_param('format') ?? 'json';
948 $validate_before_import = $request->get_param('validate') ?? true;
949 $overwrite_existing = $request->get_param('overwrite_existing') ?? false;
950
951 // Validate import data
952 if (empty($import_data)) {
953 return new WP_Error(
954 'missing_import_data',
955 'Import data is required',
956 ['status' => 400]
957 );
958 }
959
960 // Parse import data based on format
961 $parsed_data = $this->parse_import_data($import_data, $format);
962
963 if (!$parsed_data) {
964 return new WP_Error(
965 'invalid_import_data',
966 'Failed to parse import data',
967 ['status' => 400]
968 );
969 }
970
971 if (!is_array($parsed_data)) {
972 return new WP_Error(
973 'invalid_import_data',
974 'Import data must be an object of settings categories',
975 ['status' => 400]
976 );
977 }
978
979 // Reject non-array per-category values before they reach the strict
980 // array-typed manager methods (avoids an uncaught TypeError).
981 foreach ($parsed_data as $category => $category_settings) {
982 if (!is_array($category_settings)) {
983 return new WP_Error(
984 'invalid_import_data',
985 "Settings for category '{$category}' must be an object",
986 ['status' => 400]
987 );
988 }
989 }
990
991 $import_results = [];
992 $validation_results = [];
993
994 // Validate imported settings if requested
995 if ($validate_before_import) {
996 foreach ($parsed_data as $category => $category_settings) {
997 if (!isset($this->setting_categories[$category])) {
998 continue;
999 }
1000
1001 if ($this->has_seo_manager($category)) {
1002 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
1003 $validation_results[$category] = $validation;
1004
1005 if (!$validation['valid']) {
1006 return new WP_Error(
1007 'import_validation_failed',
1008 "Import validation failed for category: {$category}",
1009 [
1010 'status' => 400,
1011 'validation_results' => $validation_results
1012 ]
1013 );
1014 }
1015 }
1016 }
1017 }
1018
1019 // Import settings for each category
1020 foreach ($parsed_data as $category => $category_settings) {
1021 if (!isset($this->setting_categories[$category])) {
1022 $import_results[$category] = [
1023 'success' => false,
1024 'error' => 'Invalid category'
1025 ];
1026 continue;
1027 }
1028
1029 try {
1030 // Check if settings exist and handle overwrite
1031 $existing_settings = $this->settings_manager->get_settings($category);
1032
1033 if (!empty($existing_settings) && !$overwrite_existing) {
1034 $import_results[$category] = [
1035 'success' => false,
1036 'error' => 'Settings exist and overwrite is disabled'
1037 ];
1038 continue;
1039 }
1040
1041 // Import settings
1042 $import_success = $this->settings_manager->update_settings($category_settings, $category);
1043
1044 // Also update through specific SEO manager if available
1045 if ($this->has_seo_manager($category)) {
1046 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1047 $import_success = $import_success && $manager_update;
1048 }
1049
1050 $import_results[$category] = [
1051 'success' => $import_success,
1052 'settings_count' => count($category_settings)
1053 ];
1054
1055 } catch (\Exception $e) {
1056 $import_results[$category] = [
1057 'success' => false,
1058 'error' => $e->getMessage()
1059 ];
1060 }
1061 }
1062
1063 // Update settings metadata
1064 $this->update_settings_metadata();
1065
1066 return new WP_REST_Response([
1067 'success' => true,
1068 'data' => [
1069 'import_results' => $import_results,
1070 'validation_results' => $validation_results,
1071 'imported_categories' => count($import_results),
1072 'successful_imports' => count(array_filter($import_results, function($result) {
1073 return $result['success'];
1074 }))
1075 ],
1076 'message' => 'Settings import completed'
1077 ], 200);
1078
1079 } catch (\Exception $e) {
1080 return new WP_Error(
1081 'import_failed',
1082 'Settings import failed: ' . $e->getMessage(),
1083 ['status' => 500]
1084 );
1085 }
1086 }
1087
1088 /**
1089 * Create settings backup
1090 *
1091 * @since 1.0.0
1092 *
1093 * @param WP_REST_Request $request Request object
1094 * @return WP_REST_Response|WP_Error Response object or error
1095 */
1096 public function create_settings_backup(WP_REST_Request $request) {
1097 try {
1098 $backup_name = $request->get_param('backup_name') ?? 'backup_' . gmdate('Y-m-d_H-i-s');
1099 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1100 $description = $request->get_param('description') ?? '';
1101
1102 // Create backup data
1103 $backup_data = [];
1104 foreach ($categories as $category) {
1105 if (isset($this->setting_categories[$category])) {
1106 $backup_data[$category] = $this->settings_manager->get_settings($category);
1107 }
1108 }
1109
1110 // Create backup metadata
1111 $backup_metadata = [
1112 'backup_name' => $backup_name,
1113 'description' => $description,
1114 'created_at' => current_time('mysql'),
1115 'created_by' => get_current_user_id(),
1116 'categories' => $categories,
1117 'settings_version' => $this->get_settings_version(),
1118 'wordpress_version' => get_bloginfo('version')
1119 ];
1120
1121 // Save backup
1122 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1123
1124 if (!$backup_id) {
1125 return new WP_Error(
1126 'backup_failed',
1127 'Failed to create settings backup',
1128 ['status' => 500]
1129 );
1130 }
1131
1132 return new WP_REST_Response([
1133 'success' => true,
1134 'data' => [
1135 'backup_id' => $backup_id,
1136 'backup_name' => $backup_name,
1137 'backup_metadata' => $backup_metadata,
1138 'backed_up_categories' => count($backup_data)
1139 ],
1140 'message' => 'Settings backup created successfully'
1141 ], 200);
1142
1143 } catch (\Exception $e) {
1144 return new WP_Error(
1145 'backup_failed',
1146 'Settings backup failed: ' . $e->getMessage(),
1147 ['status' => 500]
1148 );
1149 }
1150 }
1151
1152 /**
1153 * Restore settings from backup
1154 *
1155 * @since 1.0.0
1156 *
1157 * @param WP_REST_Request $request Request object
1158 * @return WP_REST_Response|WP_Error Response object or error
1159 */
1160 public function restore_settings_backup(WP_REST_Request $request) {
1161 try {
1162 $backup_id = $request->get_param('backup_id');
1163 $categories = $request->get_param('categories') ?? null;
1164 $create_restore_point = $request->get_param('create_restore_point') ?? true;
1165
1166 // Validate backup ID
1167 if (empty($backup_id)) {
1168 return new WP_Error(
1169 'missing_backup_id',
1170 'Backup ID is required',
1171 ['status' => 400]
1172 );
1173 }
1174
1175 // Load backup data
1176 $backup_data = $this->load_settings_backup($backup_id);
1177
1178 if (!$backup_data) {
1179 return new WP_Error(
1180 'backup_not_found',
1181 'Backup not found or could not be loaded',
1182 ['status' => 404]
1183 );
1184 }
1185
1186 // Create restore point if requested. Abort if it couldn't be saved,
1187 // so the current configuration isn't overwritten with no rollback.
1188 $restore_point_id = null;
1189 if ($create_restore_point) {
1190 $restore_point_id = $this->create_restore_point();
1191 if ($restore_point_id === '') {
1192 return new WP_Error(
1193 'restore_point_failed',
1194 'Could not create a restore point; aborting restore to avoid unrecoverable settings loss.',
1195 ['status' => 500]
1196 );
1197 }
1198 }
1199
1200 $restore_results = [];
1201
1202 // Determine categories to restore
1203 $categories_to_restore = $categories ?? array_keys($backup_data['settings']);
1204
1205 // Restore settings for each category
1206 foreach ($categories_to_restore as $category) {
1207 if (!isset($backup_data['settings'][$category])) {
1208 $restore_results[$category] = [
1209 'success' => false,
1210 'error' => 'Category not found in backup'
1211 ];
1212 continue;
1213 }
1214
1215 try {
1216 $category_settings = $backup_data['settings'][$category];
1217
1218 // Restore settings
1219 $restore_success = $this->settings_manager->update_settings($category_settings, $category);
1220
1221 // Also update through specific SEO manager if available
1222 if ($this->has_seo_manager($category)) {
1223 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1224 $restore_success = $restore_success && $manager_update;
1225 }
1226
1227 $restore_results[$category] = [
1228 'success' => $restore_success,
1229 'settings_count' => count($category_settings)
1230 ];
1231
1232 } catch (\Exception $e) {
1233 $restore_results[$category] = [
1234 'success' => false,
1235 'error' => $e->getMessage()
1236 ];
1237 }
1238 }
1239
1240 // Update settings metadata
1241 $this->update_settings_metadata();
1242
1243 return new WP_REST_Response([
1244 'success' => true,
1245 'data' => [
1246 'backup_id' => $backup_id,
1247 'restore_point_id' => $restore_point_id,
1248 'restore_results' => $restore_results,
1249 'restored_categories' => count($restore_results),
1250 'backup_metadata' => $backup_data['metadata']
1251 ],
1252 'message' => 'Settings restored from backup successfully'
1253 ], 200);
1254
1255 } catch (\Exception $e) {
1256 return new WP_Error(
1257 'restore_failed',
1258 'Settings restore failed: ' . $e->getMessage(),
1259 ['status' => 500]
1260 );
1261 }
1262 }
1263
1264 /**
1265 * Reset settings to defaults
1266 *
1267 * @since 1.0.0
1268 *
1269 * @param WP_REST_Request $request Request object
1270 * @return WP_REST_Response|WP_Error Response object or error
1271 */
1272 public function reset_settings(WP_REST_Request $request) {
1273 try {
1274 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1275 $create_backup = $request->get_param('create_backup') ?? true;
1276
1277 // Create backup before reset if requested. If the backup was asked
1278 // for but couldn't be persisted, abort rather than silently wiping
1279 // settings with no rollback — the whole point of the flag is safety.
1280 $backup_id = null;
1281 if ($create_backup) {
1282 $backup_id = $this->create_pre_reset_backup($categories);
1283 if ($backup_id === '') {
1284 return new WP_Error(
1285 'backup_failed',
1286 'Could not create a pre-reset backup; aborting reset to avoid unrecoverable settings loss.',
1287 ['status' => 500]
1288 );
1289 }
1290 }
1291
1292 $reset_results = [];
1293
1294 foreach ($categories as $category) {
1295 if (!isset($this->setting_categories[$category])) {
1296 continue;
1297 }
1298
1299 try {
1300 // Get default settings
1301 $default_settings = [];
1302 if ($this->has_seo_manager($category)) {
1303 $default_settings = $this->get_seo_manager($category)->get_default_settings($category);
1304 }
1305
1306 // Reset to defaults
1307 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
1308
1309 // Also reset through specific SEO manager if available
1310 if ($this->has_seo_manager($category)) {
1311 $manager_reset = $this->get_seo_manager($category)->save_settings('site', null, $default_settings);
1312 $reset_success = $reset_success && $manager_reset;
1313 }
1314
1315 $reset_results[$category] = [
1316 'success' => $reset_success,
1317 'default_settings_count' => count($default_settings)
1318 ];
1319
1320 } catch (\Exception $e) {
1321 $reset_results[$category] = [
1322 'success' => false,
1323 'error' => $e->getMessage()
1324 ];
1325 }
1326 }
1327
1328 // Update settings metadata
1329 $this->update_settings_metadata();
1330
1331 return new WP_REST_Response([
1332 'success' => true,
1333 'data' => [
1334 'reset_results' => $reset_results,
1335 'backup_id' => $backup_id,
1336 'reset_categories' => count($reset_results),
1337 'reset_timestamp' => current_time('mysql')
1338 ],
1339 'message' => 'Settings reset to defaults completed'
1340 ], 200);
1341
1342 } catch (\Exception $e) {
1343 return new WP_Error(
1344 'reset_failed',
1345 'Settings reset failed: ' . $e->getMessage(),
1346 ['status' => 500]
1347 );
1348 }
1349 }
1350
1351 /**
1352 * Add performance indexes to database tables
1353 *
1354 * @since 1.0.0
1355 *
1356 * @param WP_REST_Request $request Request object
1357 * @return WP_REST_Response|WP_Error Response object
1358 */
1359 public function add_performance_indexes(WP_REST_Request $request): WP_REST_Response|WP_Error {
1360 try {
1361 // Import the Database_Schema class
1362 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1363 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
1364 }
1365
1366 $schema = new \ThinkRank\Database\Database_Schema();
1367 $success = $schema->add_performance_indexes();
1368
1369 if ($success) {
1370 return new WP_REST_Response([
1371 'success' => true,
1372 'message' => 'Performance indexes added successfully',
1373 'data' => [
1374 'indexes_added' => true,
1375 'timestamp' => current_time('mysql')
1376 ]
1377 ], 200);
1378 } else {
1379 return new WP_REST_Response([
1380 'success' => false,
1381 'message' => 'Some performance indexes could not be added. Check error logs for details.',
1382 'data' => [
1383 'indexes_added' => false,
1384 'timestamp' => current_time('mysql')
1385 ]
1386 ], 200);
1387 }
1388
1389 } catch (\Exception $e) {
1390 return new WP_Error(
1391 'performance_indexes_failed',
1392 'Failed to add performance indexes: ' . $e->getMessage(),
1393 ['status' => 500]
1394 );
1395 }
1396 }
1397
1398 /**
1399 * Permission callbacks
1400 */
1401
1402 /**
1403 * Check permissions for reading settings data
1404 *
1405 * @since 1.0.0
1406 *
1407 * @return bool Permission status
1408 */
1409 public function check_read_permissions(): bool {
1410 // Plugin SEO/AI config is not subscriber-visible — require the same
1411 // management capability as the write routes.
1412 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1413 }
1414
1415 /**
1416 * Check permissions for managing settings
1417 *
1418 * @since 1.0.0
1419 *
1420 * @return bool Permission status
1421 */
1422 public function check_manage_permissions(): bool {
1423 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1424 }
1425
1426 /**
1427 * Helper methods
1428 */
1429
1430 /**
1431 * Get last settings update timestamp
1432 *
1433 * @since 1.0.0
1434 *
1435 * @return string|null Last update timestamp
1436 */
1437 private function get_last_settings_update(): ?string {
1438 $result = get_option('thinkrank_settings_last_updated');
1439 return $result !== false ? $result : null;
1440 }
1441
1442 /**
1443 * Get settings version
1444 *
1445 * @since 1.0.0
1446 *
1447 * @return string Settings version
1448 */
1449 private function get_settings_version(): string {
1450 return get_option('thinkrank_settings_version', '1.0.0');
1451 }
1452
1453 /**
1454 * Get schema version
1455 *
1456 * @since 1.0.0
1457 *
1458 * @return string Schema version
1459 */
1460 private function get_schema_version(): string {
1461 return get_option('thinkrank_schema_version', '1.0.0');
1462 }
1463
1464 /**
1465 * Get category last update timestamp
1466 *
1467 * @since 1.0.0
1468 *
1469 * @param string $category Category name
1470 * @return string|null Last update timestamp
1471 */
1472 private function get_category_last_update(string $category): ?string {
1473 $result = get_option("thinkrank_settings_{$category}_last_updated");
1474 return $result !== false ? $result : null;
1475 }
1476
1477 /**
1478 * Update settings metadata
1479 *
1480 * @since 1.0.0
1481 */
1482 private function update_settings_metadata(): void {
1483 update_option('thinkrank_settings_last_updated', current_time('mysql'));
1484
1485 // Increment version
1486 $current_version = $this->get_settings_version();
1487 $version_parts = explode('.', $current_version);
1488 $version_parts[2] = (int)$version_parts[2] + 1;
1489 $new_version = implode('.', $version_parts);
1490
1491 update_option('thinkrank_settings_version', $new_version);
1492 }
1493
1494 /**
1495 * Update category metadata
1496 *
1497 * @since 1.0.0
1498 *
1499 * @param string $category Category name
1500 */
1501 private function update_category_metadata(string $category): void {
1502 update_option("thinkrank_settings_{$category}_last_updated", current_time('mysql'));
1503 }
1504
1505 /**
1506 * Format export data
1507 *
1508 * @since 1.0.0
1509 *
1510 * @param array $export_data Export data
1511 * @param array $metadata Metadata
1512 * @param string $format Export format
1513 * @return string Formatted export data
1514 */
1515 private function format_export_data(array $export_data, array $metadata, string $format): string {
1516 $full_export = [
1517 'metadata' => $metadata,
1518 'settings' => $export_data
1519 ];
1520
1521 switch ($format) {
1522 case 'json':
1523 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1524 case 'yaml':
1525 // Would implement YAML formatting
1526 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1527 case 'xml':
1528 // Would implement XML formatting
1529 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1530 default:
1531 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1532 }
1533 }
1534
1535 /**
1536 * Parse import data
1537 *
1538 * @since 1.0.0
1539 *
1540 * @param string $import_data Import data
1541 * @param string $format Import format
1542 * @return array|false Parsed data or false on failure
1543 */
1544 private function parse_import_data(string $import_data, string $format) {
1545 switch ($format) {
1546 case 'json':
1547 $decoded = json_decode($import_data, true);
1548 return $decoded['settings'] ?? $decoded;
1549 case 'yaml':
1550 // Would implement YAML parsing
1551 $decoded = json_decode($import_data, true);
1552 return $decoded['settings'] ?? $decoded;
1553 case 'xml':
1554 // Would implement XML parsing
1555 $decoded = json_decode($import_data, true);
1556 return $decoded['settings'] ?? $decoded;
1557 default:
1558 return false;
1559 }
1560 }
1561
1562 /**
1563 * Save settings backup
1564 *
1565 * @since 1.0.0
1566 *
1567 * @param array $backup_data Backup data
1568 * @param array $backup_metadata Backup metadata
1569 * @return string|false Backup ID or false on failure
1570 */
1571 private function save_settings_backup(array $backup_data, array $backup_metadata) {
1572 $backup_id = uniqid('backup_', true);
1573
1574 $backup_record = [
1575 'backup_id' => $backup_id,
1576 'metadata' => $backup_metadata,
1577 'settings' => $backup_data
1578 ];
1579
1580 // Store as a NON-autoloaded option — each backup is a full multi-category
1581 // snapshot and must not be loaded into memory on every front-end/admin
1582 // request.
1583 $saved = update_option("thinkrank_backup_{$backup_id}", $backup_record, false);
1584
1585 if ($saved) {
1586 // Add to backup index (also non-autoloaded).
1587 $backup_index = get_option('thinkrank_backup_index', []);
1588 $backup_index[$backup_id] = $backup_metadata;
1589
1590 // Cap the retained set so the backups can't accumulate unbounded.
1591 $backup_index = $this->prune_settings_backups($backup_index);
1592
1593 update_option('thinkrank_backup_index', $backup_index, false);
1594
1595 return $backup_id;
1596 }
1597
1598 return false;
1599 }
1600
1601 /**
1602 * Keep only the most recent settings backups, deleting the option rows for
1603 * any pruned from the index (oldest first).
1604 *
1605 * @param array $backup_index backup_id => metadata map.
1606 * @return array Pruned index.
1607 */
1608 private function prune_settings_backups(array $backup_index): array {
1609 $max_backups = 10;
1610
1611 if (count($backup_index) <= $max_backups) {
1612 return $backup_index;
1613 }
1614
1615 // Oldest first (missing timestamps sort earliest).
1616 uasort($backup_index, static function ($a, $b) {
1617 return strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? ''));
1618 });
1619
1620 while (count($backup_index) > $max_backups) {
1621 $oldest_id = array_key_first($backup_index);
1622 unset($backup_index[$oldest_id]);
1623 delete_option("thinkrank_backup_{$oldest_id}");
1624 }
1625
1626 return $backup_index;
1627 }
1628
1629 /**
1630 * Load settings backup
1631 *
1632 * @since 1.0.0
1633 *
1634 * @param string $backup_id Backup ID
1635 * @return array|false Backup data or false on failure
1636 */
1637 private function load_settings_backup(string $backup_id) {
1638 return get_option("thinkrank_backup_{$backup_id}", false);
1639 }
1640
1641 /**
1642 * Argument validation methods
1643 */
1644
1645 /**
1646 * Get arguments for global settings endpoints
1647 *
1648 * @since 1.0.0
1649 *
1650 * @return array Arguments array
1651 */
1652 private function get_global_settings_args(): array {
1653 return [
1654 'settings' => [
1655 'required' => true,
1656 'type' => 'object',
1657 'description' => 'Global settings to update across categories'
1658 ],
1659 'validate' => [
1660 'required' => false,
1661 'type' => 'boolean',
1662 'default' => true,
1663 'description' => 'Whether to validate settings before updating'
1664 ]
1665 ];
1666 }
1667
1668 /**
1669 * Get arguments for category settings endpoints
1670 *
1671 * @since 1.0.0
1672 *
1673 * @return array Arguments array
1674 */
1675 private function get_category_settings_args(): array {
1676 return [
1677 'settings' => [
1678 'required' => true,
1679 'type' => 'object',
1680 'description' => 'Category settings to update'
1681 ],
1682 'validate' => [
1683 'required' => false,
1684 'type' => 'boolean',
1685 'default' => true,
1686 'description' => 'Whether to validate settings before updating'
1687 ]
1688 ];
1689 }
1690
1691 /**
1692 * Get arguments for validation endpoint
1693 *
1694 * @since 1.0.0
1695 *
1696 * @return array Arguments array
1697 */
1698 private function get_validation_args(): array {
1699 return [
1700 'settings' => [
1701 'required' => true,
1702 'type' => 'object',
1703 'description' => 'Settings to validate'
1704 ],
1705 'categories' => [
1706 'required' => false,
1707 'type' => 'array',
1708 'items' => [
1709 'type' => 'string',
1710 'enum' => array_keys($this->setting_categories)
1711 ],
1712 'description' => 'Categories to validate'
1713 ]
1714 ];
1715 }
1716
1717 /**
1718 * Get arguments for export endpoint
1719 *
1720 * @since 1.0.0
1721 *
1722 * @return array Arguments array
1723 */
1724 private function get_export_args(): array {
1725 return [
1726 'categories' => [
1727 'required' => false,
1728 'type' => 'array',
1729 'items' => [
1730 'type' => 'string',
1731 'enum' => array_keys($this->setting_categories)
1732 ],
1733 'description' => 'Categories to export'
1734 ],
1735 'format' => [
1736 'required' => false,
1737 'type' => 'string',
1738 'enum' => ['json', 'yaml', 'xml'],
1739 'default' => 'json',
1740 'description' => 'Export format'
1741 ],
1742 'include_metadata' => [
1743 'required' => false,
1744 'type' => 'boolean',
1745 'default' => true,
1746 'description' => 'Whether to include metadata in export'
1747 ]
1748 ];
1749 }
1750
1751 /**
1752 * Get arguments for import endpoint
1753 *
1754 * @since 1.0.0
1755 *
1756 * @return array Arguments array
1757 */
1758 private function get_import_args(): array {
1759 return [
1760 'import_data' => [
1761 'required' => true,
1762 'type' => 'string',
1763 'description' => 'Settings data to import'
1764 ],
1765 'format' => [
1766 'required' => false,
1767 'type' => 'string',
1768 'enum' => ['json', 'yaml', 'xml'],
1769 'default' => 'json',
1770 'description' => 'Import format'
1771 ],
1772 'validate' => [
1773 'required' => false,
1774 'type' => 'boolean',
1775 'default' => true,
1776 'description' => 'Whether to validate before importing'
1777 ],
1778 'overwrite_existing' => [
1779 'required' => false,
1780 'type' => 'boolean',
1781 'default' => false,
1782 'description' => 'Whether to overwrite existing settings'
1783 ]
1784 ];
1785 }
1786
1787 /**
1788 * Get arguments for backup endpoint
1789 *
1790 * @since 1.0.0
1791 *
1792 * @return array Arguments array
1793 */
1794 private function get_backup_args(): array {
1795 return [
1796 'backup_name' => [
1797 'required' => false,
1798 'type' => 'string',
1799 'description' => 'Name for the backup'
1800 ],
1801 'categories' => [
1802 'required' => false,
1803 'type' => 'array',
1804 'items' => [
1805 'type' => 'string',
1806 'enum' => array_keys($this->setting_categories)
1807 ],
1808 'description' => 'Categories to backup'
1809 ],
1810 'description' => [
1811 'required' => false,
1812 'type' => 'string',
1813 'description' => 'Backup description'
1814 ]
1815 ];
1816 }
1817
1818 /**
1819 * Get arguments for restore endpoint
1820 *
1821 * @since 1.0.0
1822 *
1823 * @return array Arguments array
1824 */
1825 private function get_restore_args(): array {
1826 return [
1827 'backup_id' => [
1828 'required' => true,
1829 'type' => 'string',
1830 'description' => 'Backup ID to restore from'
1831 ],
1832 'categories' => [
1833 'required' => false,
1834 'type' => 'array',
1835 'items' => [
1836 'type' => 'string',
1837 'enum' => array_keys($this->setting_categories)
1838 ],
1839 'description' => 'Categories to restore'
1840 ],
1841 'create_restore_point' => [
1842 'required' => false,
1843 'type' => 'boolean',
1844 'default' => true,
1845 'description' => 'Whether to create restore point before restoring'
1846 ]
1847 ];
1848 }
1849
1850 /**
1851 * Get arguments for reset endpoint
1852 *
1853 * @since 1.0.0
1854 *
1855 * @return array Arguments array
1856 */
1857 private function get_reset_args(): array {
1858 return [
1859 'categories' => [
1860 'required' => false,
1861 'type' => 'array',
1862 'items' => [
1863 'type' => 'string',
1864 'enum' => array_keys($this->setting_categories)
1865 ],
1866 'description' => 'Categories to reset'
1867 ],
1868 'create_backup' => [
1869 'required' => false,
1870 'type' => 'boolean',
1871 'default' => true,
1872 'description' => 'Whether to create backup before reset'
1873 ]
1874 ];
1875 }
1876
1877 /**
1878 * Snapshot the given categories' current settings into a persisted backup.
1879 *
1880 * Backs the pre-reset backup and restore-point features with real storage
1881 * (via save_settings_backup) instead of a fabricated id, so operators have a
1882 * genuine rollback snapshot before a destructive reset/restore.
1883 *
1884 * @param array $categories Categories to snapshot.
1885 * @param string $label Human-readable label for the backup.
1886 * @return string Backup id, or '' if the snapshot could not be persisted.
1887 */
1888 private function create_settings_snapshot(array $categories, string $label): string {
1889 $backup_data = [];
1890 foreach ($categories as $category) {
1891 if (isset($this->setting_categories[$category])) {
1892 $backup_data[$category] = $this->settings_manager->get_settings($category);
1893 }
1894 }
1895
1896 $backup_metadata = [
1897 'backup_name' => $label . ' ' . gmdate('Y-m-d_H-i-s'),
1898 'description' => $label,
1899 'created_at' => current_time('mysql'),
1900 'created_by' => get_current_user_id(),
1901 'categories' => $categories,
1902 'settings_version' => $this->get_settings_version(),
1903 'wordpress_version' => get_bloginfo('version'),
1904 'automatic' => true,
1905 ];
1906
1907 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1908
1909 return $backup_id ?: '';
1910 }
1911
1912 /**
1913 * Create a full-snapshot restore point before restoring a backup.
1914 *
1915 * @return string Backup id, or '' if it could not be persisted.
1916 */
1917 private function create_restore_point(): string {
1918 return $this->create_settings_snapshot(
1919 array_keys($this->setting_categories),
1920 'Automatic restore point'
1921 );
1922 }
1923
1924 /**
1925 * Create a safety backup of the given categories before a reset.
1926 *
1927 * @param array $categories Categories about to be reset.
1928 * @return string Backup id, or '' if it could not be persisted.
1929 */
1930 private function create_pre_reset_backup(array $categories): string {
1931 return $this->create_settings_snapshot($categories, 'Automatic pre-reset backup');
1932 }
1933 }
1934