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 / seo / class-seo-settings-manager.php

class-seo-settings-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.26.0, at includes/seo/class-seo-settings-manager.php

974 lines 37.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SEO Settings Manager Class
4 *
5 * Universal SEO settings management with context-aware CRUD operations,
6 * validation, and data handling. Implements 2025 SEO best practices with
7 * real industry-standard algorithms and comprehensive settings management.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * SEO Settings Manager Class
25 *
26 * Provides universal settings management for all SEO functionality.
27 * Handles context-aware data operations, validation, and import/export
28 * with industry-standard SEO configuration patterns.
29 *
30 * @since 1.0.0
31 */
32 class SEO_Settings_Manager extends Abstract_SEO_Manager {
33
34 /**
35 * SEO settings categories with their specifications
36 *
37 * @since 1.0.0
38 * @var array
39 */
40 private array $settings_categories = [
41 'general' => [
42 'title' => 'General SEO Settings',
43 'description' => 'Core SEO configuration and global settings',
44 'priority' => 1,
45 'contexts' => ['site', 'post', 'page', 'product']
46 ],
47 'meta' => [
48 'title' => 'Meta Tags Settings',
49 'description' => 'Title tags, meta descriptions, and meta keywords',
50 'priority' => 2,
51 'contexts' => ['site', 'post', 'page', 'product']
52 ],
53 'social' => [
54 'title' => 'Social Media Settings',
55 'description' => 'Open Graph, Twitter Cards, and social sharing',
56 'priority' => 3,
57 'contexts' => ['site', 'post', 'page', 'product']
58 ],
59 'schema' => [
60 'title' => 'Schema Markup Settings',
61 'description' => 'Structured data and Schema.org configuration',
62 'priority' => 4,
63 'contexts' => ['site', 'post', 'page', 'product']
64 ],
65 'robots' => [
66 'title' => 'Robots & Indexing Settings',
67 'description' => 'Robots meta tags, canonical URLs, and indexing control',
68 'priority' => 5,
69 'contexts' => ['site', 'post', 'page', 'product']
70 ],
71 'analytics' => [
72 'title' => 'Analytics & Tracking Settings',
73 'description' => 'Google Analytics, Search Console, and tracking codes',
74 'priority' => 6,
75 'contexts' => ['site']
76 ],
77 'sitemap' => [
78 'title' => 'XML Sitemap Settings',
79 'description' => 'XML sitemap generation and search engine submission',
80 'priority' => 7,
81 'contexts' => ['site']
82 ],
83 'advanced' => [
84 'title' => 'Advanced SEO Settings',
85 'description' => 'Advanced configuration and custom settings',
86 'priority' => 8,
87 'contexts' => ['site', 'post', 'page', 'product']
88 ]
89 ];
90
91 /**
92 * Default SEO settings structure
93 *
94 * @since 1.0.0
95 * @var array
96 */
97 private array $default_settings_structure = [
98 'enabled' => true,
99 'auto_generate' => true,
100 'validation_enabled' => true,
101 'output_enabled' => true,
102 'cache_enabled' => true,
103 'last_updated' => '',
104 'version' => '1.0.0'
105 ];
106
107 /**
108 * Settings validation rules
109 *
110 * @since 1.0.0
111 * @var array
112 */
113 private array $validation_rules = [
114 'boolean_fields' => [
115 'enabled', 'auto_generate', 'validation_enabled',
116 'output_enabled', 'cache_enabled'
117 ],
118 'string_fields' => [
119 'title', 'description', 'keywords', 'canonical_url',
120 'og_title', 'og_description', 'twitter_title', 'twitter_description'
121 ],
122 'url_fields' => [
123 'canonical_url', 'og_image', 'twitter_image', 'site_url'
124 ],
125 'numeric_fields' => [
126 'max_snippet', 'max_video_preview', 'priority', 'score'
127 ],
128 'array_fields' => [
129 'keywords_array', 'custom_meta', 'social_profiles', 'schema_types'
130 ]
131 ];
132
133 /**
134 * Constructor
135 *
136 * @since 1.0.0
137 */
138 public function __construct() {
139 parent::__construct('seo_settings');
140 }
141
142 /**
143 * Get SEO settings with category filtering
144 *
145 * @since 1.0.0
146 *
147 * @param string $context_type The context type
148 * @param int|null $context_id Optional. Context ID
149 * @param string|null $category Optional. Settings category filter
150 * @return array SEO settings array
151 */
152 public function get_settings_by_category(string $context_type, ?int $context_id = null, ?string $category = null): array {
153 $all_settings = $this->get_settings($context_type, $context_id);
154
155 if (null === $category) {
156 return $all_settings;
157 }
158
159 if (!isset($this->settings_categories[$category])) {
160 return [];
161 }
162
163 // Filter settings by category
164 $category_settings = [];
165 foreach ($all_settings as $key => $value) {
166 if (strpos($key, $category . '_') === 0 || $key === $category) {
167 $category_settings[$key] = $value;
168 }
169 }
170
171 return $category_settings;
172 }
173
174 /**
175 * Save SEO settings with category support
176 *
177 * @since 1.0.0
178 *
179 * @param string $context_type The context type
180 * @param int|null $context_id Optional. Context ID
181 * @param array $settings Settings array to save
182 * @param string $category Optional. Settings category
183 * @return bool True on success, false on failure
184 */
185 public function save_settings_by_category(string $context_type, ?int $context_id, array $settings, string $category = 'general'): bool {
186 // Validate category
187 if (!isset($this->settings_categories[$category])) {
188 return false;
189 }
190
191 // Check if context is supported for this category
192 if (!in_array($context_type, $this->settings_categories[$category]['contexts'], true)) {
193 return false;
194 }
195
196 // Get existing settings
197 $existing_settings = $this->get_settings($context_type, $context_id);
198
199 // Merge with new settings
200 $updated_settings = array_merge($existing_settings, $settings);
201
202 // Add metadata
203 $updated_settings['last_updated'] = current_time('mysql');
204 $updated_settings['category'] = $category;
205
206 return $this->save_settings($context_type, $context_id, $updated_settings);
207 }
208
209 /**
210 * Get all available settings categories
211 *
212 * @since 1.0.0
213 *
214 * @param string $context_type Optional. Filter by context type
215 * @return array Available categories
216 */
217 public function get_available_categories(string $context_type = ''): array {
218 if (empty($context_type)) {
219 return $this->settings_categories;
220 }
221
222 $filtered_categories = [];
223 foreach ($this->settings_categories as $key => $category) {
224 if (in_array($context_type, $category['contexts'], true)) {
225 $filtered_categories[$key] = $category;
226 }
227 }
228
229 return $filtered_categories;
230 }
231
232 /**
233 * Bulk update settings across multiple contexts
234 *
235 * @since 1.0.0
236 *
237 * @param array $bulk_data Array of context => settings mappings
238 * @return array Results with success/failure status
239 */
240 public function bulk_update_settings(array $bulk_data): array {
241 $results = [];
242
243 foreach ($bulk_data as $context_key => $settings_data) {
244 // Parse context key (format: "context_type:context_id" or "context_type")
245 $parts = explode(':', $context_key);
246 $context_type = $parts[0];
247 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
248
249 // Extract category if provided
250 $category = $settings_data['category'] ?? 'general';
251 unset($settings_data['category']);
252
253 $success = $this->save_settings_by_category($context_type, $context_id, $settings_data, $category);
254
255 $results[$context_key] = [
256 'success' => $success,
257 'context_type' => $context_type,
258 'context_id' => $context_id,
259 'category' => $category,
260 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings',
261 'timestamp' => current_time('mysql')
262 ];
263 }
264
265 return $results;
266 }
267
268 /**
269 * Get settings statistics and analytics
270 *
271 * @since 1.0.0
272 *
273 * @param string $context_type Optional. Filter by context type
274 * @return array Settings statistics
275 */
276 public function get_settings_statistics(string $context_type = ''): array {
277 $stats = [
278 'total_settings' => 0,
279 'enabled_settings' => 0,
280 'categories_used' => [],
281 'contexts_configured' => [],
282 'last_updated' => '',
283 'validation_score' => 0
284 ];
285
286 // Get all settings
287 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings statistics require direct database access, table name is validated
288 if (!empty($context_type)) {
289 $sql = sprintf(
290 'SELECT context_type, context_id, setting_key, setting_value, updated_at FROM `%s` WHERE setting_category = %%s AND is_active = 1 AND context_type = %%s ORDER BY updated_at DESC',
291 $this->settings_table
292 );
293 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
294 $results = $this->wpdb->get_results(
295 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
296 $this->wpdb->prepare(
297 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
298 $sql,
299 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
300 $this->manager_type,
301 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $context_type is validated and used as parameter
302 $context_type
303 ),
304 ARRAY_A
305 );
306 } else {
307 $sql = sprintf(
308 'SELECT context_type, context_id, setting_key, setting_value, updated_at FROM `%s` WHERE setting_category = %%s AND is_active = 1 ORDER BY updated_at DESC',
309 $this->settings_table
310 );
311 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
312 $results = $this->wpdb->get_results(
313 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
314 $this->wpdb->prepare(
315 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
316 $sql,
317 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
318 $this->manager_type
319 ),
320 ARRAY_A
321 );
322 }
323
324 $stats['total_settings'] = count($results);
325 $categories_used = [];
326 $contexts_configured = [];
327 $enabled_count = 0;
328 $latest_update = '';
329
330 foreach ($results as $row) {
331 $context_key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
332 $contexts_configured[$context_key] = true;
333
334 $value = maybe_unserialize($row['setting_value']);
335
336 // Check if setting is enabled
337 if ($row['setting_key'] === 'enabled' && $value) {
338 $enabled_count++;
339 }
340
341 // Track categories
342 if ($row['setting_key'] === 'category') {
343 $categories_used[$value] = true;
344 }
345
346 // Track latest update
347 if (empty($latest_update) || $row['updated_at'] > $latest_update) {
348 $latest_update = $row['updated_at'];
349 }
350 }
351
352 $stats['enabled_settings'] = $enabled_count;
353 $stats['categories_used'] = array_keys($categories_used);
354 $stats['contexts_configured'] = array_keys($contexts_configured);
355 $stats['last_updated'] = $latest_update;
356
357 // Calculate validation score
358 $stats['validation_score'] = $this->calculate_overall_validation_score($context_type);
359
360 return $stats;
361 }
362
363 /**
364 * Validate SEO settings (implements interface)
365 *
366 * @since 1.0.0
367 *
368 * @param array $settings Settings array to validate
369 * @return array Validation results
370 */
371 public function validate_settings(array $settings): array {
372 $validation = [
373 'valid' => true,
374 'errors' => [],
375 'warnings' => [],
376 'suggestions' => [],
377 'score' => 100
378 ];
379
380 // Validate boolean fields
381 foreach ($this->validation_rules['boolean_fields'] as $field) {
382 if (isset($settings[$field]) && !is_bool($settings[$field])) {
383 $validation['errors'][] = "{$field} must be a boolean value";
384 $validation['valid'] = false;
385 }
386 }
387
388 // Validate string fields
389 foreach ($this->validation_rules['string_fields'] as $field) {
390 if (isset($settings[$field])) {
391 if (!is_string($settings[$field])) {
392 $validation['errors'][] = "{$field} must be a string";
393 $validation['valid'] = false;
394 } elseif (strlen($settings[$field]) > 500) {
395 $validation['warnings'][] = "{$field} is longer than recommended (500 characters)";
396 }
397 }
398 }
399
400 // Validate URL fields
401 foreach ($this->validation_rules['url_fields'] as $field) {
402 if (isset($settings[$field]) && !empty($settings[$field])) {
403 if (!filter_var($settings[$field], FILTER_VALIDATE_URL)) {
404 $validation['errors'][] = "{$field} must be a valid URL";
405 $validation['valid'] = false;
406 }
407 }
408 }
409
410 // Validate numeric fields
411 foreach ($this->validation_rules['numeric_fields'] as $field) {
412 if (isset($settings[$field]) && !is_numeric($settings[$field])) {
413 $validation['errors'][] = "{$field} must be a numeric value";
414 $validation['valid'] = false;
415 }
416 }
417
418 // Validate array fields
419 foreach ($this->validation_rules['array_fields'] as $field) {
420 if (isset($settings[$field]) && !is_array($settings[$field])) {
421 $validation['errors'][] = "{$field} must be an array";
422 $validation['valid'] = false;
423 }
424 }
425
426 // SEO-specific validations
427 $validation = $this->validate_seo_specific_settings($settings, $validation);
428
429 // Calculate validation score
430 $validation['score'] = $this->calculate_validation_score($validation);
431
432 return $validation;
433 }
434
435 /**
436 * Get output data for frontend rendering (implements interface)
437 *
438 * @since 1.0.0
439 *
440 * @param string $context_type The context type
441 * @param int|null $context_id Optional. Context ID
442 * @return array Output data ready for frontend rendering
443 */
444 public function get_output_data(string $context_type, ?int $context_id): array {
445 $settings = $this->get_settings($context_type, $context_id);
446
447 $output = [
448 'settings' => $settings,
449 'categories' => $this->get_available_categories($context_type),
450 'validation' => $this->validate_settings($settings),
451 'statistics' => $this->get_settings_statistics($context_type),
452 'enabled' => $settings['enabled'] ?? true,
453 'last_updated' => $settings['last_updated'] ?? '',
454 'version' => $settings['version'] ?? '1.0.0'
455 ];
456
457 // Add context-specific data
458 $output['context'] = [
459 'type' => $context_type,
460 'id' => $context_id,
461 'supported_categories' => array_keys($this->get_available_categories($context_type))
462 ];
463
464 return $output;
465 }
466
467 /**
468 * Get default settings for a context type (implements interface)
469 *
470 * @since 1.0.0
471 *
472 * @param string $context_type The context type to get defaults for
473 * @return array Default settings array
474 */
475 public function get_default_settings(string $context_type): array {
476 $defaults = $this->default_settings_structure;
477
478 // Context-specific defaults
479 switch ($context_type) {
480 case 'site':
481 $defaults = array_merge($defaults, [
482 'general_enabled' => true,
483 'meta_enabled' => true,
484 'social_enabled' => true,
485 'schema_enabled' => true,
486 'robots_enabled' => true,
487 'analytics_enabled' => false,
488 'advanced_enabled' => false
489 ]);
490 break;
491 case 'post':
492 $defaults = array_merge($defaults, [
493 'general_enabled' => true,
494 'meta_enabled' => true,
495 'social_enabled' => true,
496 'schema_enabled' => true,
497 'robots_enabled' => true,
498 'advanced_enabled' => false
499 ]);
500 break;
501 case 'page':
502 $defaults = array_merge($defaults, [
503 'general_enabled' => true,
504 'meta_enabled' => true,
505 'social_enabled' => true,
506 'schema_enabled' => false,
507 'robots_enabled' => true,
508 'advanced_enabled' => false
509 ]);
510 break;
511 case 'product':
512 $defaults = array_merge($defaults, [
513 'general_enabled' => true,
514 'meta_enabled' => true,
515 'social_enabled' => true,
516 'schema_enabled' => true,
517 'robots_enabled' => true,
518 'advanced_enabled' => true
519 ]);
520 break;
521 }
522
523 return $defaults;
524 }
525
526 /**
527 * Get settings schema definition (implements interface)
528 *
529 * @since 1.0.0
530 *
531 * @param string $context_type The context type to get schema for
532 * @return array Settings schema definition
533 */
534 public function get_settings_schema(string $context_type): array {
535 $base_schema = [
536 'enabled' => [
537 'type' => 'boolean',
538 'title' => 'Enable SEO Settings',
539 'description' => 'Enable or disable SEO functionality for this context',
540 'default' => true
541 ],
542 'auto_generate' => [
543 'type' => 'boolean',
544 'title' => 'Auto-generate SEO Data',
545 'description' => 'Automatically generate SEO meta tags and data',
546 'default' => true
547 ],
548 'validation_enabled' => [
549 'type' => 'boolean',
550 'title' => 'Enable Validation',
551 'description' => 'Enable real-time validation of SEO settings',
552 'default' => true
553 ],
554 'output_enabled' => [
555 'type' => 'boolean',
556 'title' => 'Enable Output',
557 'description' => 'Enable output of SEO meta tags in frontend',
558 'default' => true
559 ],
560 'cache_enabled' => [
561 'type' => 'boolean',
562 'title' => 'Enable Caching',
563 'description' => 'Enable caching of SEO data for performance',
564 'default' => true
565 ]
566 ];
567
568 // Add category-specific schemas
569 $categories = $this->get_available_categories($context_type);
570 foreach ($categories as $category_key => $category) {
571 $base_schema[$category_key . '_enabled'] = [
572 'type' => 'boolean',
573 'title' => 'Enable ' . $category['title'],
574 'description' => $category['description'],
575 'default' => true
576 ];
577 }
578
579 return $base_schema;
580 }
581
582 /**
583 * Import settings (implements parent interface)
584 *
585 * @since 1.0.0
586 *
587 * @param array $import_data Exported settings data
588 * @param array $options Import options
589 * @return array Import results with success/failure details
590 */
591 public function import_settings(array $import_data, array $options = []): array {
592 $results = [
593 'success' => true,
594 'imported_count' => 0,
595 'failed_count' => 0,
596 'details' => []
597 ];
598
599 // Validate import data structure
600 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
601 $results['success'] = false;
602 $results['details'][] = 'Invalid import data structure';
603 return $results;
604 }
605
606 // Default import options
607 $options = array_merge([
608 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
609 'validate' => true,
610 'source' => 'manual'
611 ], $options);
612
613 foreach ($import_data['settings'] as $context_key => $settings) {
614 // Parse context key
615 $parts = explode(':', $context_key);
616 $context_type = $parts[0];
617 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
618
619 // Check if settings already exist
620 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
621 $results['details'][] = "Skipped existing settings for {$context_key}";
622 continue;
623 }
624
625 // Merge with existing settings if requested
626 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
627 $existing_settings = $this->get_settings($context_type, $context_id);
628 $settings = array_merge($existing_settings, $settings);
629 }
630
631 // Validate settings if requested
632 if ($options['validate']) {
633 $validation = $this->validate_settings($settings);
634 if (!$validation['valid']) {
635 $results['failed_count']++;
636 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
637 continue;
638 }
639 }
640
641 // Add import metadata
642 $settings['imported_from'] = $options['source'];
643 $settings['imported_at'] = current_time('mysql');
644
645 // Import settings
646 $success = $this->save_settings($context_type, $context_id, $settings);
647 if ($success) {
648 $results['imported_count']++;
649 $results['details'][] = "Successfully imported settings for {$context_key}";
650 } else {
651 $results['failed_count']++;
652 $results['details'][] = "Failed to import settings for {$context_key}";
653 }
654 }
655
656 $results['success'] = $results['failed_count'] === 0;
657 return $results;
658 }
659
660 /**
661 * Export settings (implements parent interface)
662 *
663 * @since 1.0.0
664 *
665 * @param string $context_type Optional. Context type to export (null for all)
666 * @param int|null $context_id Optional. Context ID to export (null for all in context_type)
667 * @return array Exported settings with metadata
668 */
669 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
670 $export_data = [
671 'version' => '1.0.0',
672 'manager_type' => $this->manager_type,
673 'exported_at' => current_time('mysql'),
674 'exported_by' => get_current_user_id(),
675 'settings' => []
676 ];
677
678 if ($context_type !== null) {
679 // Export specific context
680 $settings = $this->get_settings($context_type, $context_id);
681 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
682 } else {
683 // Export all settings for this manager type
684 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings export requires direct database access, table name is validated
685 $sql = sprintf(
686 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1 ORDER BY context_type, context_id, setting_key',
687 $this->settings_table
688 );
689 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
690 $results = $this->wpdb->get_results(
691 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
692 $this->wpdb->prepare(
693 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
694 $sql,
695 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
696 $this->manager_type
697 ),
698 ARRAY_A
699 );
700
701 $grouped_settings = [];
702 foreach ($results as $row) {
703 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
704 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
705 }
706
707 $export_data['settings'] = $grouped_settings;
708 }
709
710 return $export_data;
711 }
712
713 /**
714 * Export settings to external format (additional method for format support)
715 *
716 * @since 1.0.0
717 *
718 * @param string $context_type Optional. Filter by context type
719 * @param string $format Export format ('json', 'array')
720 * @return array|string Export data
721 */
722 public function export_settings_formatted(string $context_type = '', string $format = 'array') {
723 $context_type_param = !empty($context_type) ? $context_type : null;
724 $export_data = $this->export_settings($context_type_param);
725
726 return $format === 'json' ? wp_json_encode($export_data, JSON_PRETTY_PRINT) : $export_data;
727 }
728
729 /**
730 * Validate SEO-specific settings
731 *
732 * @since 1.0.0
733 *
734 * @param array $settings Settings to validate
735 * @param array $validation Current validation results
736 * @return array Updated validation results
737 */
738 private function validate_seo_specific_settings(array $settings, array $validation): array {
739 // Validate title length (SEO best practice: 50-60 characters)
740 if (isset($settings['title']) && !empty($settings['title'])) {
741 $title_length = strlen($settings['title']);
742 if ($title_length > 60) {
743 $validation['warnings'][] = 'Title is longer than 60 characters, may be truncated in search results';
744 } elseif ($title_length < 30) {
745 $validation['suggestions'][] = 'Consider making title longer (30-60 characters) for better SEO';
746 }
747 }
748
749 // Validate meta description length (SEO best practice: 150-160 characters)
750 if (isset($settings['description']) && !empty($settings['description'])) {
751 $desc_length = strlen($settings['description']);
752 if ($desc_length > 160) {
753 $validation['warnings'][] = 'Meta description is longer than 160 characters, may be truncated';
754 } elseif ($desc_length < 120) {
755 $validation['suggestions'][] = 'Consider making meta description longer (120-160 characters)';
756 }
757 }
758
759 // Validate keywords (2025 SEO: focus on semantic keywords)
760 if (isset($settings['keywords']) && !empty($settings['keywords'])) {
761 $keywords = is_array($settings['keywords']) ? $settings['keywords'] : explode(',', $settings['keywords']);
762 $keyword_count = count($keywords);
763
764 if ($keyword_count > 5) {
765 $validation['warnings'][] = 'Too many keywords may dilute SEO focus. Consider 3-5 primary keywords.';
766 } elseif ($keyword_count === 0) {
767 $validation['suggestions'][] = 'Add relevant keywords to improve content targeting';
768 }
769 }
770
771 // Validate canonical URL
772 if (isset($settings['canonical_url']) && !empty($settings['canonical_url'])) {
773 $current_domain = wp_parse_url(home_url(), PHP_URL_HOST);
774 $canonical_domain = wp_parse_url($settings['canonical_url'], PHP_URL_HOST);
775
776 if ($canonical_domain !== $current_domain) {
777 $validation['warnings'][] = 'Canonical URL points to external domain - ensure this is intentional';
778 }
779 }
780
781 // Check for required SEO elements
782 if (empty($settings['title'])) {
783 $validation['suggestions'][] = 'Add a title tag for better search engine visibility';
784 }
785
786 if (empty($settings['description'])) {
787 $validation['suggestions'][] = 'Add a meta description to improve click-through rates';
788 }
789
790 return $validation;
791 }
792
793 /**
794 * Calculate validation score
795 *
796 * @since 1.0.0
797 *
798 * @param array $validation Validation results
799 * @return int Score (0-100)
800 */
801 private function calculate_validation_score(array $validation): int {
802 $score = 100;
803
804 // Deduct points for errors and warnings
805 $score -= count($validation['errors']) * 20;
806 $score -= count($validation['warnings']) * 10;
807 $score -= count($validation['suggestions']) * 5;
808
809 return max(0, $score);
810 }
811
812 /**
813 * Calculate overall validation score for context
814 *
815 * @since 1.0.0
816 *
817 * @param string $context_type Context type to calculate score for
818 * @return int Overall validation score
819 */
820 private function calculate_overall_validation_score(string $context_type): int {
821 // Get all settings for context type
822 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO validation score calculation requires direct database access, table name is validated
823 if (!empty($context_type)) {
824 $sql = sprintf(
825 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1 AND context_type = %%s',
826 $this->settings_table
827 );
828 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
829 $results = $this->wpdb->get_results(
830 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
831 $this->wpdb->prepare(
832 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
833 $sql,
834 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
835 $this->manager_type,
836 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $context_type is validated and used as parameter
837 $context_type
838 ),
839 ARRAY_A
840 );
841 } else {
842 $sql = sprintf(
843 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
844 $this->settings_table
845 );
846 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
847 $results = $this->wpdb->get_results(
848 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
849 $this->wpdb->prepare(
850 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
851 $sql,
852 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
853 $this->manager_type
854 ),
855 ARRAY_A
856 );
857 }
858
859 if (empty($results)) {
860 return 0;
861 }
862
863 // Group settings by context
864 $contexts = [];
865 foreach ($results as $row) {
866 $context_key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
867 if (!isset($contexts[$context_key])) {
868 $contexts[$context_key] = [];
869 }
870 $contexts[$context_key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
871 }
872
873 // Calculate average score across all contexts
874 $total_score = 0;
875 $context_count = 0;
876
877 foreach ($contexts as $settings) {
878 $validation = $this->validate_settings($settings);
879 $total_score += $validation['score'];
880 $context_count++;
881 }
882
883 return $context_count > 0 ? (int) round($total_score / $context_count) : 0;
884 }
885
886 /**
887 * Reset settings to defaults
888 *
889 * @since 1.0.0
890 *
891 * @param string $context_type Context type
892 * @param int|null $context_id Optional. Context ID
893 * @return bool Success status
894 */
895 public function reset_to_defaults(string $context_type, ?int $context_id = null): bool {
896 $default_settings = $this->get_default_settings($context_type);
897 $default_settings['reset_at'] = current_time('mysql');
898 $default_settings['reset_by'] = get_current_user_id();
899
900 return $this->save_settings($context_type, $context_id, $default_settings);
901 }
902
903 /**
904 * Get settings migration status
905 *
906 * @since 1.0.0
907 *
908 * @return array Migration status information
909 */
910 public function get_migration_status(): array {
911 return [
912 'current_version' => '1.0.0',
913 'database_version' => get_option('thinkrank_seo_db_version', '0.0.0'),
914 'migration_needed' => version_compare(get_option('thinkrank_seo_db_version', '0.0.0'), '1.0.0', '<'),
915 'last_migration' => get_option('thinkrank_seo_last_migration', ''),
916 'migration_log' => get_option('thinkrank_seo_migration_log', [])
917 ];
918 }
919
920 /**
921 * Perform settings migration
922 *
923 * @since 1.0.0
924 *
925 * @param string $from_version Source version
926 * @param string $to_version Target version
927 * @return array Migration results
928 */
929 public function migrate_settings(string $from_version, string $to_version): array {
930 $migration_results = [
931 'success' => false,
932 'migrated_count' => 0,
933 'errors' => [],
934 'from_version' => $from_version,
935 'to_version' => $to_version,
936 'started_at' => current_time('mysql')
937 ];
938
939 try {
940 // Perform version-specific migrations
941 switch ($from_version) {
942 case '0.0.0':
943 // Initial migration - set up default settings
944 $contexts = ['site', 'post', 'page', 'product'];
945 foreach ($contexts as $context) {
946 $defaults = $this->get_default_settings($context);
947 $this->save_settings($context, null, $defaults);
948 $migration_results['migrated_count']++;
949 }
950 break;
951 default:
952 $migration_results['errors'][] = "No migration path defined for version {$from_version}";
953 break;
954 }
955
956 if (empty($migration_results['errors'])) {
957 $migration_results['success'] = true;
958 update_option('thinkrank_seo_db_version', $to_version);
959 update_option('thinkrank_seo_last_migration', current_time('mysql'));
960
961 // Log migration
962 $migration_log = get_option('thinkrank_seo_migration_log', []);
963 $migration_log[] = $migration_results;
964 update_option('thinkrank_seo_migration_log', array_slice($migration_log, -10)); // Keep last 10 migrations
965 }
966 } catch (\Exception $e) {
967 $migration_results['errors'][] = 'Migration failed: ' . $e->getMessage();
968 }
969
970 $migration_results['completed_at'] = current_time('mysql');
971 return $migration_results;
972 }
973 }
974