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

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