PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / 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.29.0, at includes/api/class-settings-management-endpoint.php

1,997 lines 69.0 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 return new WP_Error(
740 'update_failed',
741 "Failed to update settings for category: {$category}",
742 ['status' => 500]
743 );
744 }
745
746 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
747 if ($category === 'seo_analytics') {
748 foreach (['7d', '30d', '90d'] as $range) {
749 delete_transient("analytics_dashboard_v5_{$range}");
750 delete_transient("seo_opportunities_{$range}");
751 delete_transient("seo_insights_{$range}");
752 }
753 delete_transient('indexing_status');
754 }
755
756 // Update category metadata
757 $this->update_category_metadata($category);
758
759 // Get updated settings
760 $updated_settings = $this->settings_manager->get_settings($category);
761
762 return new WP_REST_Response([
763 'success' => true,
764 'data' => [
765 'category' => $category,
766 'updated_settings' => $this->redact_category_settings($category, $updated_settings),
767 'validation_result' => $validation_result,
768 'settings_count' => count($updated_settings)
769 ],
770 'message' => "Settings for category '{$category}' updated successfully"
771 ], 200);
772
773 } catch (\Exception $e) {
774 return new WP_Error(
775 'update_failed',
776 'Category settings update failed: ' . $e->getMessage(),
777 ['status' => 500]
778 );
779 }
780 }
781
782 /**
783 * Validate settings across categories
784 *
785 * @since 1.0.0
786 *
787 * @param WP_REST_Request $request Request object
788 * @return WP_REST_Response Response object
789 */
790 public function validate_settings(WP_REST_Request $request): WP_REST_Response {
791 try {
792 $settings = $request->get_param('settings');
793 if (!is_array($settings)) {
794 $settings = [];
795 }
796 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
797
798 $validation_results = [];
799 $overall_valid = true;
800
801 foreach ($categories as $category) {
802 if (!isset($this->setting_categories[$category])) {
803 continue;
804 }
805
806 $category_settings = $settings[$category] ?? [];
807 if (!is_array($category_settings)) {
808 $validation_results[$category] = [
809 'valid' => false,
810 'errors' => ['Settings for this category must be an object'],
811 'warnings' => [],
812 'suggestions' => [],
813 ];
814 $overall_valid = false;
815 continue;
816 }
817
818 if ($this->has_seo_manager($category)) {
819 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
820 $validation_results[$category] = $validation;
821
822 if (!$validation['valid']) {
823 $overall_valid = false;
824 }
825 } else {
826 // Basic validation for categories without specific managers
827 $validation_results[$category] = [
828 'valid' => true,
829 'errors' => [],
830 'warnings' => [],
831 'suggestions' => []
832 ];
833 }
834 }
835
836 return new WP_REST_Response([
837 'success' => true,
838 'data' => [
839 'validation_results' => $validation_results,
840 'overall_valid' => $overall_valid,
841 'validated_categories' => count($validation_results),
842 'validation_timestamp' => current_time('mysql')
843 ],
844 'message' => 'Settings validation completed'
845 ], 200);
846
847 } catch (\Exception $e) {
848 return new WP_REST_Response([
849 'success' => false,
850 'error' => 'Settings validation failed: ' . $e->getMessage()
851 ], 500);
852 }
853 }
854
855 /**
856 * Get settings schema for all categories
857 *
858 * @since 1.0.0
859 *
860 * @param WP_REST_Request $request Request object
861 * @return WP_REST_Response Response object
862 */
863 public function get_settings_schema(WP_REST_Request $request): WP_REST_Response {
864 try {
865 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
866
867 $schema_data = [];
868
869 foreach ($categories as $category) {
870 if (!isset($this->setting_categories[$category])) {
871 continue;
872 }
873
874 if ($this->has_seo_manager($category)) {
875 $schema_data[$category] = [
876 'schema' => $this->get_seo_manager($category)->get_settings_schema($category),
877 'defaults' => $this->get_seo_manager($category)->get_default_settings($category),
878 'category_name' => $this->setting_categories[$category]
879 ];
880 } else {
881 $schema_data[$category] = [
882 'schema' => [],
883 'defaults' => [],
884 'category_name' => $this->setting_categories[$category]
885 ];
886 }
887 }
888
889 return new WP_REST_Response([
890 'success' => true,
891 'data' => [
892 'schema' => $schema_data,
893 'categories' => $this->setting_categories,
894 'schema_version' => $this->get_schema_version(),
895 'generated_at' => current_time('mysql')
896 ],
897 'message' => 'Settings schema retrieved successfully'
898 ], 200);
899
900 } catch (\Exception $e) {
901 return new WP_REST_Response([
902 'success' => false,
903 'error' => 'Failed to retrieve settings schema: ' . $e->getMessage()
904 ], 500);
905 }
906 }
907
908 /**
909 * Export settings
910 *
911 * @since 1.0.0
912 *
913 * @param WP_REST_Request $request Request object
914 * @return WP_REST_Response|WP_Error Response object or error
915 */
916 public function export_settings(WP_REST_Request $request) {
917 try {
918 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
919 $format = $request->get_param('format') ?? 'json';
920 $include_metadata = $request->get_param('include_metadata') ?? true;
921
922 // Validate format
923 if (!in_array($format, ['json', 'yaml', 'xml'], true)) {
924 return new WP_Error(
925 'invalid_format',
926 'Invalid export format. Supported formats: json, yaml, xml',
927 ['status' => 400]
928 );
929 }
930
931 $export_data = [];
932
933 // Export settings for each category
934 foreach ($categories as $category) {
935 if (!isset($this->setting_categories[$category])) {
936 continue;
937 }
938
939 $export_data[$category] = $this->settings_manager->get_settings($category);
940 }
941
942 // Never let secrets (API keys, OAuth tokens) leave the site in an
943 // export file — strip them entirely.
944 $export_data = $this->redact_sensitive_settings($export_data, true);
945
946 // Add metadata if requested
947 $metadata = [];
948 if ($include_metadata) {
949 $metadata = [
950 'export_timestamp' => current_time('mysql'),
951 'export_version' => $this->get_settings_version(),
952 'wordpress_version' => get_bloginfo('version'),
953 'thinkrank_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
954 'site_url' => home_url(),
955 'exported_categories' => $categories
956 ];
957 }
958
959 // Format export data
960 $formatted_export = $this->format_export_data($export_data, $metadata, $format);
961
962 return new WP_REST_Response([
963 'success' => true,
964 'data' => [
965 'export_data' => $formatted_export,
966 'format' => $format,
967 'metadata' => $metadata,
968 'exported_categories' => count($export_data)
969 ],
970 'message' => 'Settings exported successfully'
971 ], 200);
972
973 } catch (\Exception $e) {
974 return new WP_Error(
975 'export_failed',
976 'Settings export failed: ' . $e->getMessage(),
977 ['status' => 500]
978 );
979 }
980 }
981
982 /**
983 * Import settings
984 *
985 * @since 1.0.0
986 *
987 * @param WP_REST_Request $request Request object
988 * @return WP_REST_Response|WP_Error Response object or error
989 */
990 public function import_settings(WP_REST_Request $request) {
991 try {
992 $import_data = $request->get_param('import_data');
993 $format = $request->get_param('format') ?? 'json';
994 $validate_before_import = $request->get_param('validate') ?? true;
995 $overwrite_existing = $request->get_param('overwrite_existing') ?? false;
996
997 // Validate import data
998 if (empty($import_data)) {
999 return new WP_Error(
1000 'missing_import_data',
1001 'Import data is required',
1002 ['status' => 400]
1003 );
1004 }
1005
1006 // Parse import data based on format
1007 $parsed_data = $this->parse_import_data($import_data, $format);
1008
1009 if (!$parsed_data) {
1010 return new WP_Error(
1011 'invalid_import_data',
1012 'Failed to parse import data',
1013 ['status' => 400]
1014 );
1015 }
1016
1017 if (!is_array($parsed_data)) {
1018 return new WP_Error(
1019 'invalid_import_data',
1020 'Import data must be an object of settings categories',
1021 ['status' => 400]
1022 );
1023 }
1024
1025 // Reject non-array per-category values before they reach the strict
1026 // array-typed manager methods (avoids an uncaught TypeError).
1027 foreach ($parsed_data as $category => $category_settings) {
1028 if (!is_array($category_settings)) {
1029 return new WP_Error(
1030 'invalid_import_data',
1031 "Settings for category '{$category}' must be an object",
1032 ['status' => 400]
1033 );
1034 }
1035 }
1036
1037 $import_results = [];
1038 $validation_results = [];
1039
1040 // Validate imported settings if requested
1041 if ($validate_before_import) {
1042 foreach ($parsed_data as $category => $category_settings) {
1043 if (!isset($this->setting_categories[$category])) {
1044 continue;
1045 }
1046
1047 if ($this->has_seo_manager($category)) {
1048 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
1049 $validation_results[$category] = $validation;
1050
1051 if (!$validation['valid']) {
1052 return new WP_Error(
1053 'import_validation_failed',
1054 "Import validation failed for category: {$category}",
1055 [
1056 'status' => 400,
1057 'validation_results' => $validation_results
1058 ]
1059 );
1060 }
1061 }
1062 }
1063 }
1064
1065 // Import settings for each category
1066 foreach ($parsed_data as $category => $category_settings) {
1067 if (!isset($this->setting_categories[$category])) {
1068 $import_results[$category] = [
1069 'success' => false,
1070 'error' => 'Invalid category'
1071 ];
1072 continue;
1073 }
1074
1075 try {
1076 // Check if settings exist and handle overwrite
1077 $existing_settings = $this->settings_manager->get_settings($category);
1078
1079 if (!empty($existing_settings) && !$overwrite_existing) {
1080 $import_results[$category] = [
1081 'success' => false,
1082 'error' => 'Settings exist and overwrite is disabled'
1083 ];
1084 continue;
1085 }
1086
1087 // Import settings
1088 $import_success = $this->settings_manager->update_settings($category_settings, $category);
1089
1090 // Also update through specific SEO manager if available
1091 if ($this->has_seo_manager($category)) {
1092 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1093 $import_success = $import_success && $manager_update;
1094 }
1095
1096 $import_results[$category] = [
1097 'success' => $import_success,
1098 'settings_count' => count($category_settings)
1099 ];
1100
1101 } catch (\Exception $e) {
1102 $import_results[$category] = [
1103 'success' => false,
1104 'error' => $e->getMessage()
1105 ];
1106 }
1107 }
1108
1109 // Update settings metadata
1110 $this->update_settings_metadata();
1111
1112 return new WP_REST_Response([
1113 'success' => true,
1114 'data' => [
1115 'import_results' => $import_results,
1116 'validation_results' => $validation_results,
1117 'imported_categories' => count($import_results),
1118 'successful_imports' => count(array_filter($import_results, function($result) {
1119 return $result['success'];
1120 }))
1121 ],
1122 'message' => 'Settings import completed'
1123 ], 200);
1124
1125 } catch (\Exception $e) {
1126 return new WP_Error(
1127 'import_failed',
1128 'Settings import failed: ' . $e->getMessage(),
1129 ['status' => 500]
1130 );
1131 }
1132 }
1133
1134 /**
1135 * Create settings backup
1136 *
1137 * @since 1.0.0
1138 *
1139 * @param WP_REST_Request $request Request object
1140 * @return WP_REST_Response|WP_Error Response object or error
1141 */
1142 public function create_settings_backup(WP_REST_Request $request) {
1143 try {
1144 $backup_name = $request->get_param('backup_name') ?? 'backup_' . gmdate('Y-m-d_H-i-s');
1145 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1146 $description = $request->get_param('description') ?? '';
1147
1148 // Create backup data
1149 $backup_data = [];
1150 foreach ($categories as $category) {
1151 if (isset($this->setting_categories[$category])) {
1152 $backup_data[$category] = $this->settings_manager->get_settings($category);
1153 }
1154 }
1155
1156 // Create backup metadata
1157 $backup_metadata = [
1158 'backup_name' => $backup_name,
1159 'description' => $description,
1160 'created_at' => current_time('mysql'),
1161 'created_by' => get_current_user_id(),
1162 'categories' => $categories,
1163 'settings_version' => $this->get_settings_version(),
1164 'wordpress_version' => get_bloginfo('version')
1165 ];
1166
1167 // Save backup
1168 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1169
1170 if (!$backup_id) {
1171 return new WP_Error(
1172 'backup_failed',
1173 'Failed to create settings backup',
1174 ['status' => 500]
1175 );
1176 }
1177
1178 return new WP_REST_Response([
1179 'success' => true,
1180 'data' => [
1181 'backup_id' => $backup_id,
1182 'backup_name' => $backup_name,
1183 'backup_metadata' => $backup_metadata,
1184 'backed_up_categories' => count($backup_data)
1185 ],
1186 'message' => 'Settings backup created successfully'
1187 ], 200);
1188
1189 } catch (\Exception $e) {
1190 return new WP_Error(
1191 'backup_failed',
1192 'Settings backup failed: ' . $e->getMessage(),
1193 ['status' => 500]
1194 );
1195 }
1196 }
1197
1198 /**
1199 * Restore settings from backup
1200 *
1201 * @since 1.0.0
1202 *
1203 * @param WP_REST_Request $request Request object
1204 * @return WP_REST_Response|WP_Error Response object or error
1205 */
1206 public function restore_settings_backup(WP_REST_Request $request) {
1207 try {
1208 $backup_id = $request->get_param('backup_id');
1209 $categories = $request->get_param('categories') ?? null;
1210 $create_restore_point = $request->get_param('create_restore_point') ?? true;
1211
1212 // Validate backup ID
1213 if (empty($backup_id)) {
1214 return new WP_Error(
1215 'missing_backup_id',
1216 'Backup ID is required',
1217 ['status' => 400]
1218 );
1219 }
1220
1221 // Load backup data
1222 $backup_data = $this->load_settings_backup($backup_id);
1223
1224 if (!$backup_data) {
1225 return new WP_Error(
1226 'backup_not_found',
1227 'Backup not found or could not be loaded',
1228 ['status' => 404]
1229 );
1230 }
1231
1232 // Create restore point if requested. Abort if it couldn't be saved,
1233 // so the current configuration isn't overwritten with no rollback.
1234 $restore_point_id = null;
1235 if ($create_restore_point) {
1236 $restore_point_id = $this->create_restore_point();
1237 if ($restore_point_id === '') {
1238 return new WP_Error(
1239 'restore_point_failed',
1240 'Could not create a restore point; aborting restore to avoid unrecoverable settings loss.',
1241 ['status' => 500]
1242 );
1243 }
1244 }
1245
1246 $restore_results = [];
1247
1248 // Determine categories to restore
1249 $categories_to_restore = $categories ?? array_keys($backup_data['settings']);
1250
1251 // Restore settings for each category
1252 foreach ($categories_to_restore as $category) {
1253 if (!isset($backup_data['settings'][$category])) {
1254 $restore_results[$category] = [
1255 'success' => false,
1256 'error' => 'Category not found in backup'
1257 ];
1258 continue;
1259 }
1260
1261 try {
1262 $category_settings = $backup_data['settings'][$category];
1263
1264 // Restore settings
1265 $restore_success = $this->settings_manager->update_settings($category_settings, $category);
1266
1267 // Also update through specific SEO manager if available
1268 if ($this->has_seo_manager($category)) {
1269 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1270 $restore_success = $restore_success && $manager_update;
1271 }
1272
1273 $restore_results[$category] = [
1274 'success' => $restore_success,
1275 'settings_count' => count($category_settings)
1276 ];
1277
1278 } catch (\Exception $e) {
1279 $restore_results[$category] = [
1280 'success' => false,
1281 'error' => $e->getMessage()
1282 ];
1283 }
1284 }
1285
1286 // Update settings metadata
1287 $this->update_settings_metadata();
1288
1289 return new WP_REST_Response([
1290 'success' => true,
1291 'data' => [
1292 'backup_id' => $backup_id,
1293 'restore_point_id' => $restore_point_id,
1294 'restore_results' => $restore_results,
1295 'restored_categories' => count($restore_results),
1296 'backup_metadata' => $backup_data['metadata']
1297 ],
1298 'message' => 'Settings restored from backup successfully'
1299 ], 200);
1300
1301 } catch (\Exception $e) {
1302 return new WP_Error(
1303 'restore_failed',
1304 'Settings restore failed: ' . $e->getMessage(),
1305 ['status' => 500]
1306 );
1307 }
1308 }
1309
1310 /**
1311 * Reset settings to defaults
1312 *
1313 * @since 1.0.0
1314 *
1315 * @param WP_REST_Request $request Request object
1316 * @return WP_REST_Response|WP_Error Response object or error
1317 */
1318 public function reset_settings(WP_REST_Request $request) {
1319 try {
1320 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1321 $create_backup = $request->get_param('create_backup') ?? true;
1322
1323 // Create backup before reset if requested. If the backup was asked
1324 // for but couldn't be persisted, abort rather than silently wiping
1325 // settings with no rollback — the whole point of the flag is safety.
1326 $backup_id = null;
1327 if ($create_backup) {
1328 $backup_id = $this->create_pre_reset_backup($categories);
1329 if ($backup_id === '') {
1330 return new WP_Error(
1331 'backup_failed',
1332 'Could not create a pre-reset backup; aborting reset to avoid unrecoverable settings loss.',
1333 ['status' => 500]
1334 );
1335 }
1336 }
1337
1338 $reset_results = [];
1339
1340 foreach ($categories as $category) {
1341 if (!isset($this->setting_categories[$category])) {
1342 continue;
1343 }
1344
1345 try {
1346 // Get default settings
1347 $default_settings = [];
1348 if ($this->has_seo_manager($category)) {
1349 $default_settings = $this->get_seo_manager($category)->get_default_settings($category);
1350 }
1351
1352 // Reset to defaults
1353 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
1354
1355 // Also reset through specific SEO manager if available
1356 if ($this->has_seo_manager($category)) {
1357 $manager_reset = $this->get_seo_manager($category)->save_settings('site', null, $default_settings);
1358 $reset_success = $reset_success && $manager_reset;
1359 }
1360
1361 $reset_results[$category] = [
1362 'success' => $reset_success,
1363 'default_settings_count' => count($default_settings)
1364 ];
1365
1366 } catch (\Exception $e) {
1367 $reset_results[$category] = [
1368 'success' => false,
1369 'error' => $e->getMessage()
1370 ];
1371 }
1372 }
1373
1374 // Update settings metadata
1375 $this->update_settings_metadata();
1376
1377 return new WP_REST_Response([
1378 'success' => true,
1379 'data' => [
1380 'reset_results' => $reset_results,
1381 'backup_id' => $backup_id,
1382 'reset_categories' => count($reset_results),
1383 'reset_timestamp' => current_time('mysql')
1384 ],
1385 'message' => 'Settings reset to defaults completed'
1386 ], 200);
1387
1388 } catch (\Exception $e) {
1389 return new WP_Error(
1390 'reset_failed',
1391 'Settings reset failed: ' . $e->getMessage(),
1392 ['status' => 500]
1393 );
1394 }
1395 }
1396
1397 /**
1398 * Add performance indexes to database tables
1399 *
1400 * @since 1.0.0
1401 *
1402 * @param WP_REST_Request $request Request object
1403 * @return WP_REST_Response|WP_Error Response object
1404 */
1405 public function add_performance_indexes(WP_REST_Request $request): WP_REST_Response|WP_Error {
1406 try {
1407 // Import the Database_Schema class
1408 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1409 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
1410 }
1411
1412 $schema = new \ThinkRank\Database\Database_Schema();
1413 $success = $schema->add_performance_indexes();
1414
1415 if ($success) {
1416 return new WP_REST_Response([
1417 'success' => true,
1418 'message' => 'Performance indexes added successfully',
1419 'data' => [
1420 'indexes_added' => true,
1421 'timestamp' => current_time('mysql')
1422 ]
1423 ], 200);
1424 } else {
1425 return new WP_REST_Response([
1426 'success' => false,
1427 'message' => 'Some performance indexes could not be added. Check error logs for details.',
1428 'data' => [
1429 'indexes_added' => false,
1430 'timestamp' => current_time('mysql')
1431 ]
1432 ], 200);
1433 }
1434
1435 } catch (\Exception $e) {
1436 return new WP_Error(
1437 'performance_indexes_failed',
1438 'Failed to add performance indexes: ' . $e->getMessage(),
1439 ['status' => 500]
1440 );
1441 }
1442 }
1443
1444 /**
1445 * Permission callbacks
1446 */
1447
1448 /**
1449 * Check permissions for reading settings data
1450 *
1451 * @since 1.0.0
1452 *
1453 * @return bool Permission status
1454 */
1455 public function check_read_permissions(): bool {
1456 // Plugin SEO/AI config is not subscriber-visible — require the same
1457 // management capability as the write routes.
1458 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1459 }
1460
1461 /**
1462 * Check permissions for managing settings
1463 *
1464 * @since 1.0.0
1465 *
1466 * @return bool Permission status
1467 */
1468 public function check_manage_permissions(): bool {
1469 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
1470 }
1471
1472 /**
1473 * Check permissions for administrator-only settings operations.
1474 *
1475 * The Role Manager can delegate `thinkrank_settings` to non-admin roles so
1476 * they can manage the plugin's SEO configuration. Schema-level (DDL) and
1477 * destructive whole-configuration operations — performance indexes, reset,
1478 * import — are a different altitude and stay with site administrators.
1479 *
1480 * @since 1.29.0
1481 *
1482 * @return bool Permission status
1483 */
1484 public function check_admin_permissions(): bool {
1485 return current_user_can('manage_options');
1486 }
1487
1488 /**
1489 * Helper methods
1490 */
1491
1492 /**
1493 * Get last settings update timestamp
1494 *
1495 * @since 1.0.0
1496 *
1497 * @return string|null Last update timestamp
1498 */
1499 private function get_last_settings_update(): ?string {
1500 $result = get_option('thinkrank_settings_last_updated');
1501 return $result !== false ? $result : null;
1502 }
1503
1504 /**
1505 * Get settings version
1506 *
1507 * @since 1.0.0
1508 *
1509 * @return string Settings version
1510 */
1511 private function get_settings_version(): string {
1512 return get_option('thinkrank_settings_version', '1.0.0');
1513 }
1514
1515 /**
1516 * Get schema version
1517 *
1518 * @since 1.0.0
1519 *
1520 * @return string Schema version
1521 */
1522 private function get_schema_version(): string {
1523 return get_option('thinkrank_schema_version', '1.0.0');
1524 }
1525
1526 /**
1527 * Get category last update timestamp
1528 *
1529 * @since 1.0.0
1530 *
1531 * @param string $category Category name
1532 * @return string|null Last update timestamp
1533 */
1534 private function get_category_last_update(string $category): ?string {
1535 $result = get_option("thinkrank_settings_{$category}_last_updated");
1536 return $result !== false ? $result : null;
1537 }
1538
1539 /**
1540 * Update settings metadata
1541 *
1542 * @since 1.0.0
1543 */
1544 private function update_settings_metadata(): void {
1545 update_option('thinkrank_settings_last_updated', current_time('mysql'));
1546
1547 // Increment version
1548 $current_version = $this->get_settings_version();
1549 $version_parts = explode('.', $current_version);
1550 $version_parts[2] = (int)$version_parts[2] + 1;
1551 $new_version = implode('.', $version_parts);
1552
1553 update_option('thinkrank_settings_version', $new_version);
1554 }
1555
1556 /**
1557 * Update category metadata
1558 *
1559 * @since 1.0.0
1560 *
1561 * @param string $category Category name
1562 */
1563 private function update_category_metadata(string $category): void {
1564 update_option("thinkrank_settings_{$category}_last_updated", current_time('mysql'));
1565 }
1566
1567 /**
1568 * Format export data
1569 *
1570 * @since 1.0.0
1571 *
1572 * @param array $export_data Export data
1573 * @param array $metadata Metadata
1574 * @param string $format Export format
1575 * @return string Formatted export data
1576 */
1577 private function format_export_data(array $export_data, array $metadata, string $format): string {
1578 $full_export = [
1579 'metadata' => $metadata,
1580 'settings' => $export_data
1581 ];
1582
1583 switch ($format) {
1584 case 'json':
1585 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1586 case 'yaml':
1587 // Would implement YAML formatting
1588 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1589 case 'xml':
1590 // Would implement XML formatting
1591 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1592 default:
1593 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1594 }
1595 }
1596
1597 /**
1598 * Parse import data
1599 *
1600 * @since 1.0.0
1601 *
1602 * @param string $import_data Import data
1603 * @param string $format Import format
1604 * @return array|false Parsed data or false on failure
1605 */
1606 private function parse_import_data(string $import_data, string $format) {
1607 switch ($format) {
1608 case 'json':
1609 $decoded = json_decode($import_data, true);
1610 return $decoded['settings'] ?? $decoded;
1611 case 'yaml':
1612 // Would implement YAML parsing
1613 $decoded = json_decode($import_data, true);
1614 return $decoded['settings'] ?? $decoded;
1615 case 'xml':
1616 // Would implement XML parsing
1617 $decoded = json_decode($import_data, true);
1618 return $decoded['settings'] ?? $decoded;
1619 default:
1620 return false;
1621 }
1622 }
1623
1624 /**
1625 * Save settings backup
1626 *
1627 * @since 1.0.0
1628 *
1629 * @param array $backup_data Backup data
1630 * @param array $backup_metadata Backup metadata
1631 * @return string|false Backup ID or false on failure
1632 */
1633 private function save_settings_backup(array $backup_data, array $backup_metadata) {
1634 $backup_id = uniqid('backup_', true);
1635
1636 $backup_record = [
1637 'backup_id' => $backup_id,
1638 'metadata' => $backup_metadata,
1639 'settings' => $backup_data
1640 ];
1641
1642 // Store as a NON-autoloaded option — each backup is a full multi-category
1643 // snapshot and must not be loaded into memory on every front-end/admin
1644 // request.
1645 $saved = update_option("thinkrank_backup_{$backup_id}", $backup_record, false);
1646
1647 if ($saved) {
1648 // Add to backup index (also non-autoloaded).
1649 $backup_index = get_option('thinkrank_backup_index', []);
1650 $backup_index[$backup_id] = $backup_metadata;
1651
1652 // Cap the retained set so the backups can't accumulate unbounded.
1653 $backup_index = $this->prune_settings_backups($backup_index);
1654
1655 update_option('thinkrank_backup_index', $backup_index, false);
1656
1657 return $backup_id;
1658 }
1659
1660 return false;
1661 }
1662
1663 /**
1664 * Keep only the most recent settings backups, deleting the option rows for
1665 * any pruned from the index (oldest first).
1666 *
1667 * @param array $backup_index backup_id => metadata map.
1668 * @return array Pruned index.
1669 */
1670 private function prune_settings_backups(array $backup_index): array {
1671 $max_backups = 10;
1672
1673 if (count($backup_index) <= $max_backups) {
1674 return $backup_index;
1675 }
1676
1677 // Oldest first (missing timestamps sort earliest).
1678 uasort($backup_index, static function ($a, $b) {
1679 return strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? ''));
1680 });
1681
1682 // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- the loop shrinks $backup_index, so the count has to be re-read.
1683 while (count($backup_index) > $max_backups) {
1684 $oldest_id = array_key_first($backup_index);
1685 unset($backup_index[$oldest_id]);
1686 delete_option("thinkrank_backup_{$oldest_id}");
1687 }
1688
1689 return $backup_index;
1690 }
1691
1692 /**
1693 * Load settings backup
1694 *
1695 * @since 1.0.0
1696 *
1697 * @param string $backup_id Backup ID
1698 * @return array|false Backup data or false on failure
1699 */
1700 private function load_settings_backup(string $backup_id) {
1701 return get_option("thinkrank_backup_{$backup_id}", false);
1702 }
1703
1704 /**
1705 * Argument validation methods
1706 */
1707
1708 /**
1709 * Get arguments for global settings endpoints
1710 *
1711 * @since 1.0.0
1712 *
1713 * @return array Arguments array
1714 */
1715 private function get_global_settings_args(): array {
1716 return [
1717 'settings' => [
1718 'required' => true,
1719 'type' => 'object',
1720 'description' => 'Global settings to update across categories'
1721 ],
1722 'validate' => [
1723 'required' => false,
1724 'type' => 'boolean',
1725 'default' => true,
1726 'description' => 'Whether to validate settings before updating'
1727 ]
1728 ];
1729 }
1730
1731 /**
1732 * Get arguments for category settings endpoints
1733 *
1734 * @since 1.0.0
1735 *
1736 * @return array Arguments array
1737 */
1738 private function get_category_settings_args(): array {
1739 return [
1740 'settings' => [
1741 'required' => true,
1742 'type' => 'object',
1743 'description' => 'Category settings to update'
1744 ],
1745 'validate' => [
1746 'required' => false,
1747 'type' => 'boolean',
1748 'default' => true,
1749 'description' => 'Whether to validate settings before updating'
1750 ]
1751 ];
1752 }
1753
1754 /**
1755 * Get arguments for validation endpoint
1756 *
1757 * @since 1.0.0
1758 *
1759 * @return array Arguments array
1760 */
1761 private function get_validation_args(): array {
1762 return [
1763 'settings' => [
1764 'required' => true,
1765 'type' => 'object',
1766 'description' => 'Settings to validate'
1767 ],
1768 'categories' => [
1769 'required' => false,
1770 'type' => 'array',
1771 'items' => [
1772 'type' => 'string',
1773 'enum' => array_keys($this->setting_categories)
1774 ],
1775 'description' => 'Categories to validate'
1776 ]
1777 ];
1778 }
1779
1780 /**
1781 * Get arguments for export endpoint
1782 *
1783 * @since 1.0.0
1784 *
1785 * @return array Arguments array
1786 */
1787 private function get_export_args(): array {
1788 return [
1789 'categories' => [
1790 'required' => false,
1791 'type' => 'array',
1792 'items' => [
1793 'type' => 'string',
1794 'enum' => array_keys($this->setting_categories)
1795 ],
1796 'description' => 'Categories to export'
1797 ],
1798 'format' => [
1799 'required' => false,
1800 'type' => 'string',
1801 'enum' => ['json', 'yaml', 'xml'],
1802 'default' => 'json',
1803 'description' => 'Export format'
1804 ],
1805 'include_metadata' => [
1806 'required' => false,
1807 'type' => 'boolean',
1808 'default' => true,
1809 'description' => 'Whether to include metadata in export'
1810 ]
1811 ];
1812 }
1813
1814 /**
1815 * Get arguments for import endpoint
1816 *
1817 * @since 1.0.0
1818 *
1819 * @return array Arguments array
1820 */
1821 private function get_import_args(): array {
1822 return [
1823 'import_data' => [
1824 'required' => true,
1825 'type' => 'string',
1826 'description' => 'Settings data to import'
1827 ],
1828 'format' => [
1829 'required' => false,
1830 'type' => 'string',
1831 'enum' => ['json', 'yaml', 'xml'],
1832 'default' => 'json',
1833 'description' => 'Import format'
1834 ],
1835 'validate' => [
1836 'required' => false,
1837 'type' => 'boolean',
1838 'default' => true,
1839 'description' => 'Whether to validate before importing'
1840 ],
1841 'overwrite_existing' => [
1842 'required' => false,
1843 'type' => 'boolean',
1844 'default' => false,
1845 'description' => 'Whether to overwrite existing settings'
1846 ]
1847 ];
1848 }
1849
1850 /**
1851 * Get arguments for backup endpoint
1852 *
1853 * @since 1.0.0
1854 *
1855 * @return array Arguments array
1856 */
1857 private function get_backup_args(): array {
1858 return [
1859 'backup_name' => [
1860 'required' => false,
1861 'type' => 'string',
1862 'description' => 'Name for the backup'
1863 ],
1864 'categories' => [
1865 'required' => false,
1866 'type' => 'array',
1867 'items' => [
1868 'type' => 'string',
1869 'enum' => array_keys($this->setting_categories)
1870 ],
1871 'description' => 'Categories to backup'
1872 ],
1873 'description' => [
1874 'required' => false,
1875 'type' => 'string',
1876 'description' => 'Backup description'
1877 ]
1878 ];
1879 }
1880
1881 /**
1882 * Get arguments for restore endpoint
1883 *
1884 * @since 1.0.0
1885 *
1886 * @return array Arguments array
1887 */
1888 private function get_restore_args(): array {
1889 return [
1890 'backup_id' => [
1891 'required' => true,
1892 'type' => 'string',
1893 'description' => 'Backup ID to restore from'
1894 ],
1895 'categories' => [
1896 'required' => false,
1897 'type' => 'array',
1898 'items' => [
1899 'type' => 'string',
1900 'enum' => array_keys($this->setting_categories)
1901 ],
1902 'description' => 'Categories to restore'
1903 ],
1904 'create_restore_point' => [
1905 'required' => false,
1906 'type' => 'boolean',
1907 'default' => true,
1908 'description' => 'Whether to create restore point before restoring'
1909 ]
1910 ];
1911 }
1912
1913 /**
1914 * Get arguments for reset endpoint
1915 *
1916 * @since 1.0.0
1917 *
1918 * @return array Arguments array
1919 */
1920 private function get_reset_args(): array {
1921 return [
1922 'categories' => [
1923 'required' => false,
1924 'type' => 'array',
1925 'items' => [
1926 'type' => 'string',
1927 'enum' => array_keys($this->setting_categories)
1928 ],
1929 'description' => 'Categories to reset'
1930 ],
1931 'create_backup' => [
1932 'required' => false,
1933 'type' => 'boolean',
1934 'default' => true,
1935 'description' => 'Whether to create backup before reset'
1936 ]
1937 ];
1938 }
1939
1940 /**
1941 * Snapshot the given categories' current settings into a persisted backup.
1942 *
1943 * Backs the pre-reset backup and restore-point features with real storage
1944 * (via save_settings_backup) instead of a fabricated id, so operators have a
1945 * genuine rollback snapshot before a destructive reset/restore.
1946 *
1947 * @param array $categories Categories to snapshot.
1948 * @param string $label Human-readable label for the backup.
1949 * @return string Backup id, or '' if the snapshot could not be persisted.
1950 */
1951 private function create_settings_snapshot(array $categories, string $label): string {
1952 $backup_data = [];
1953 foreach ($categories as $category) {
1954 if (isset($this->setting_categories[$category])) {
1955 $backup_data[$category] = $this->settings_manager->get_settings($category);
1956 }
1957 }
1958
1959 $backup_metadata = [
1960 'backup_name' => $label . ' ' . gmdate('Y-m-d_H-i-s'),
1961 'description' => $label,
1962 'created_at' => current_time('mysql'),
1963 'created_by' => get_current_user_id(),
1964 'categories' => $categories,
1965 'settings_version' => $this->get_settings_version(),
1966 'wordpress_version' => get_bloginfo('version'),
1967 'automatic' => true,
1968 ];
1969
1970 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1971
1972 return $backup_id ?: '';
1973 }
1974
1975 /**
1976 * Create a full-snapshot restore point before restoring a backup.
1977 *
1978 * @return string Backup id, or '' if it could not be persisted.
1979 */
1980 private function create_restore_point(): string {
1981 return $this->create_settings_snapshot(
1982 array_keys($this->setting_categories),
1983 'Automatic restore point'
1984 );
1985 }
1986
1987 /**
1988 * Create a safety backup of the given categories before a reset.
1989 *
1990 * @param array $categories Categories about to be reset.
1991 * @return string Backup id, or '' if it could not be persisted.
1992 */
1993 private function create_pre_reset_backup(array $categories): string {
1994 return $this->create_settings_snapshot($categories, 'Automatic pre-reset backup');
1995 }
1996 }
1997