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

984 lines 37.9 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|null True on success, false on failure, null when this store
184 * does not own the category (nothing was attempted).
185 */
186 public function save_settings_by_category(string $context_type, ?int $context_id, array $settings, string $category = 'general'): ?bool {
187 // Not this store's category. Callers address categories by the
188 // endpoint's vocabulary (`social_media`, `site_identity`, …) while this
189 // store registers its own (`social`, `general`, …), so an unrecognised
190 // name is routine and must NOT be reported as a failed write — the
191 // caller's dedicated manager is the store of record for those (#371).
192 // Returning false here made every such save answer 500 while the
193 // manager's row had already committed.
194 if (!isset($this->settings_categories[$category])) {
195 return null;
196 }
197
198 // Check if context is supported for this category
199 if (!in_array($context_type, $this->settings_categories[$category]['contexts'], true)) {
200 return false;
201 }
202
203 // Get existing settings
204 $existing_settings = $this->get_settings($context_type, $context_id);
205
206 // Merge with new settings
207 $updated_settings = array_merge($existing_settings, $settings);
208
209 // Add metadata
210 $updated_settings['last_updated'] = current_time('mysql');
211 $updated_settings['category'] = $category;
212
213 return $this->save_settings($context_type, $context_id, $updated_settings);
214 }
215
216 /**
217 * Get all available settings categories
218 *
219 * @since 1.0.0
220 *
221 * @param string $context_type Optional. Filter by context type
222 * @return array Available categories
223 */
224 public function get_available_categories(string $context_type = ''): array {
225 if (empty($context_type)) {
226 return $this->settings_categories;
227 }
228
229 $filtered_categories = [];
230 foreach ($this->settings_categories as $key => $category) {
231 if (in_array($context_type, $category['contexts'], true)) {
232 $filtered_categories[$key] = $category;
233 }
234 }
235
236 return $filtered_categories;
237 }
238
239 /**
240 * Bulk update settings across multiple contexts
241 *
242 * @since 1.0.0
243 *
244 * @param array $bulk_data Array of context => settings mappings
245 * @return array Results with success/failure status
246 */
247 public function bulk_update_settings(array $bulk_data): array {
248 $results = [];
249
250 foreach ($bulk_data as $context_key => $settings_data) {
251 // Parse context key (format: "context_type:context_id" or "context_type")
252 $parts = explode(':', $context_key);
253 $context_type = $parts[0];
254 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
255
256 // Extract category if provided
257 $category = $settings_data['category'] ?? 'general';
258 unset($settings_data['category']);
259
260 // Normalise the tri-state to a boolean: an unknown category (null)
261 // persisted nothing here, and this bulk API has no dedicated-manager
262 // fallback, so it is a failure from this caller's point of view.
263 $success = true === $this->save_settings_by_category($context_type, $context_id, $settings_data, $category);
264
265 $results[$context_key] = [
266 'success' => $success,
267 'context_type' => $context_type,
268 'context_id' => $context_id,
269 'category' => $category,
270 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings',
271 'timestamp' => current_time('mysql')
272 ];
273 }
274
275 return $results;
276 }
277
278 /**
279 * Get settings statistics and analytics
280 *
281 * @since 1.0.0
282 *
283 * @param string $context_type Optional. Filter by context type
284 * @return array Settings statistics
285 */
286 public function get_settings_statistics(string $context_type = ''): array {
287 $stats = [
288 'total_settings' => 0,
289 'enabled_settings' => 0,
290 'categories_used' => [],
291 'contexts_configured' => [],
292 'last_updated' => '',
293 'validation_score' => 0
294 ];
295
296 // Get all settings
297 // 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
298 if (!empty($context_type)) {
299 $sql = sprintf(
300 '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',
301 $this->settings_table
302 );
303 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
304 $results = $this->wpdb->get_results(
305 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
306 $this->wpdb->prepare(
307 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
308 $sql,
309 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
310 $this->manager_type,
311 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $context_type is validated and used as parameter
312 $context_type
313 ),
314 ARRAY_A
315 );
316 } else {
317 $sql = sprintf(
318 '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',
319 $this->settings_table
320 );
321 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
322 $results = $this->wpdb->get_results(
323 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
324 $this->wpdb->prepare(
325 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
326 $sql,
327 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
328 $this->manager_type
329 ),
330 ARRAY_A
331 );
332 }
333
334 $stats['total_settings'] = count($results);
335 $categories_used = [];
336 $contexts_configured = [];
337 $enabled_count = 0;
338 $latest_update = '';
339
340 foreach ($results as $row) {
341 $context_key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
342 $contexts_configured[$context_key] = true;
343
344 $value = maybe_unserialize($row['setting_value']);
345
346 // Check if setting is enabled
347 if ($row['setting_key'] === 'enabled' && $value) {
348 $enabled_count++;
349 }
350
351 // Track categories
352 if ($row['setting_key'] === 'category') {
353 $categories_used[$value] = true;
354 }
355
356 // Track latest update
357 if (empty($latest_update) || $row['updated_at'] > $latest_update) {
358 $latest_update = $row['updated_at'];
359 }
360 }
361
362 $stats['enabled_settings'] = $enabled_count;
363 $stats['categories_used'] = array_keys($categories_used);
364 $stats['contexts_configured'] = array_keys($contexts_configured);
365 $stats['last_updated'] = $latest_update;
366
367 // Calculate validation score
368 $stats['validation_score'] = $this->calculate_overall_validation_score($context_type);
369
370 return $stats;
371 }
372
373 /**
374 * Validate SEO settings (implements interface)
375 *
376 * @since 1.0.0
377 *
378 * @param array $settings Settings array to validate
379 * @return array Validation results
380 */
381 public function validate_settings(array $settings): array {
382 $validation = [
383 'valid' => true,
384 'errors' => [],
385 'warnings' => [],
386 'suggestions' => [],
387 'score' => 100
388 ];
389
390 // Validate boolean fields
391 foreach ($this->validation_rules['boolean_fields'] as $field) {
392 if (isset($settings[$field]) && !is_bool($settings[$field])) {
393 $validation['errors'][] = "{$field} must be a boolean value";
394 $validation['valid'] = false;
395 }
396 }
397
398 // Validate string fields
399 foreach ($this->validation_rules['string_fields'] as $field) {
400 if (isset($settings[$field])) {
401 if (!is_string($settings[$field])) {
402 $validation['errors'][] = "{$field} must be a string";
403 $validation['valid'] = false;
404 } elseif (strlen($settings[$field]) > 500) {
405 $validation['warnings'][] = "{$field} is longer than recommended (500 characters)";
406 }
407 }
408 }
409
410 // Validate URL fields
411 foreach ($this->validation_rules['url_fields'] as $field) {
412 if (isset($settings[$field]) && !empty($settings[$field])) {
413 if (!filter_var($settings[$field], FILTER_VALIDATE_URL)) {
414 $validation['errors'][] = "{$field} must be a valid URL";
415 $validation['valid'] = false;
416 }
417 }
418 }
419
420 // Validate numeric fields
421 foreach ($this->validation_rules['numeric_fields'] as $field) {
422 if (isset($settings[$field]) && !is_numeric($settings[$field])) {
423 $validation['errors'][] = "{$field} must be a numeric value";
424 $validation['valid'] = false;
425 }
426 }
427
428 // Validate array fields
429 foreach ($this->validation_rules['array_fields'] as $field) {
430 if (isset($settings[$field]) && !is_array($settings[$field])) {
431 $validation['errors'][] = "{$field} must be an array";
432 $validation['valid'] = false;
433 }
434 }
435
436 // SEO-specific validations
437 $validation = $this->validate_seo_specific_settings($settings, $validation);
438
439 // Calculate validation score
440 $validation['score'] = $this->calculate_validation_score($validation);
441
442 return $validation;
443 }
444
445 /**
446 * Get output data for frontend rendering (implements interface)
447 *
448 * @since 1.0.0
449 *
450 * @param string $context_type The context type
451 * @param int|null $context_id Optional. Context ID
452 * @return array Output data ready for frontend rendering
453 */
454 public function get_output_data(string $context_type, ?int $context_id): array {
455 $settings = $this->get_settings($context_type, $context_id);
456
457 $output = [
458 'settings' => $settings,
459 'categories' => $this->get_available_categories($context_type),
460 'validation' => $this->validate_settings($settings),
461 'statistics' => $this->get_settings_statistics($context_type),
462 'enabled' => $settings['enabled'] ?? true,
463 'last_updated' => $settings['last_updated'] ?? '',
464 'version' => $settings['version'] ?? '1.0.0'
465 ];
466
467 // Add context-specific data
468 $output['context'] = [
469 'type' => $context_type,
470 'id' => $context_id,
471 'supported_categories' => array_keys($this->get_available_categories($context_type))
472 ];
473
474 return $output;
475 }
476
477 /**
478 * Get default settings for a context type (implements interface)
479 *
480 * @since 1.0.0
481 *
482 * @param string $context_type The context type to get defaults for
483 * @return array Default settings array
484 */
485 public function get_default_settings(string $context_type): array {
486 $defaults = $this->default_settings_structure;
487
488 // Context-specific defaults
489 switch ($context_type) {
490 case 'site':
491 $defaults = array_merge($defaults, [
492 'general_enabled' => true,
493 'meta_enabled' => true,
494 'social_enabled' => true,
495 'schema_enabled' => true,
496 'robots_enabled' => true,
497 'analytics_enabled' => false,
498 'advanced_enabled' => false
499 ]);
500 break;
501 case 'post':
502 $defaults = array_merge($defaults, [
503 'general_enabled' => true,
504 'meta_enabled' => true,
505 'social_enabled' => true,
506 'schema_enabled' => true,
507 'robots_enabled' => true,
508 'advanced_enabled' => false
509 ]);
510 break;
511 case 'page':
512 $defaults = array_merge($defaults, [
513 'general_enabled' => true,
514 'meta_enabled' => true,
515 'social_enabled' => true,
516 'schema_enabled' => false,
517 'robots_enabled' => true,
518 'advanced_enabled' => false
519 ]);
520 break;
521 case 'product':
522 $defaults = array_merge($defaults, [
523 'general_enabled' => true,
524 'meta_enabled' => true,
525 'social_enabled' => true,
526 'schema_enabled' => true,
527 'robots_enabled' => true,
528 'advanced_enabled' => true
529 ]);
530 break;
531 }
532
533 return $defaults;
534 }
535
536 /**
537 * Get settings schema definition (implements interface)
538 *
539 * @since 1.0.0
540 *
541 * @param string $context_type The context type to get schema for
542 * @return array Settings schema definition
543 */
544 public function get_settings_schema(string $context_type): array {
545 $base_schema = [
546 'enabled' => [
547 'type' => 'boolean',
548 'title' => 'Enable SEO Settings',
549 'description' => 'Enable or disable SEO functionality for this context',
550 'default' => true
551 ],
552 'auto_generate' => [
553 'type' => 'boolean',
554 'title' => 'Auto-generate SEO Data',
555 'description' => 'Automatically generate SEO meta tags and data',
556 'default' => true
557 ],
558 'validation_enabled' => [
559 'type' => 'boolean',
560 'title' => 'Enable Validation',
561 'description' => 'Enable real-time validation of SEO settings',
562 'default' => true
563 ],
564 'output_enabled' => [
565 'type' => 'boolean',
566 'title' => 'Enable Output',
567 'description' => 'Enable output of SEO meta tags in frontend',
568 'default' => true
569 ],
570 'cache_enabled' => [
571 'type' => 'boolean',
572 'title' => 'Enable Caching',
573 'description' => 'Enable caching of SEO data for performance',
574 'default' => true
575 ]
576 ];
577
578 // Add category-specific schemas
579 $categories = $this->get_available_categories($context_type);
580 foreach ($categories as $category_key => $category) {
581 $base_schema[$category_key . '_enabled'] = [
582 'type' => 'boolean',
583 'title' => 'Enable ' . $category['title'],
584 'description' => $category['description'],
585 'default' => true
586 ];
587 }
588
589 return $base_schema;
590 }
591
592 /**
593 * Import settings (implements parent interface)
594 *
595 * @since 1.0.0
596 *
597 * @param array $import_data Exported settings data
598 * @param array $options Import options
599 * @return array Import results with success/failure details
600 */
601 public function import_settings(array $import_data, array $options = []): array {
602 $results = [
603 'success' => true,
604 'imported_count' => 0,
605 'failed_count' => 0,
606 'details' => []
607 ];
608
609 // Validate import data structure
610 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
611 $results['success'] = false;
612 $results['details'][] = 'Invalid import data structure';
613 return $results;
614 }
615
616 // Default import options
617 $options = array_merge([
618 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
619 'validate' => true,
620 'source' => 'manual'
621 ], $options);
622
623 foreach ($import_data['settings'] as $context_key => $settings) {
624 // Parse context key
625 $parts = explode(':', $context_key);
626 $context_type = $parts[0];
627 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
628
629 // Check if settings already exist
630 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
631 $results['details'][] = "Skipped existing settings for {$context_key}";
632 continue;
633 }
634
635 // Merge with existing settings if requested
636 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
637 $existing_settings = $this->get_settings($context_type, $context_id);
638 $settings = array_merge($existing_settings, $settings);
639 }
640
641 // Validate settings if requested
642 if ($options['validate']) {
643 $validation = $this->validate_settings($settings);
644 if (!$validation['valid']) {
645 $results['failed_count']++;
646 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
647 continue;
648 }
649 }
650
651 // Add import metadata
652 $settings['imported_from'] = $options['source'];
653 $settings['imported_at'] = current_time('mysql');
654
655 // Import settings
656 $success = $this->save_settings($context_type, $context_id, $settings);
657 if ($success) {
658 $results['imported_count']++;
659 $results['details'][] = "Successfully imported settings for {$context_key}";
660 } else {
661 $results['failed_count']++;
662 $results['details'][] = "Failed to import settings for {$context_key}";
663 }
664 }
665
666 $results['success'] = $results['failed_count'] === 0;
667 return $results;
668 }
669
670 /**
671 * Export settings (implements parent interface)
672 *
673 * @since 1.0.0
674 *
675 * @param string $context_type Optional. Context type to export (null for all)
676 * @param int|null $context_id Optional. Context ID to export (null for all in context_type)
677 * @return array Exported settings with metadata
678 */
679 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
680 $export_data = [
681 'version' => '1.0.0',
682 'manager_type' => $this->manager_type,
683 'exported_at' => current_time('mysql'),
684 'exported_by' => get_current_user_id(),
685 'settings' => []
686 ];
687
688 if ($context_type !== null) {
689 // Export specific context
690 $settings = $this->get_settings($context_type, $context_id);
691 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
692 } else {
693 // Export all settings for this manager type
694 // 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
695 $sql = sprintf(
696 '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',
697 $this->settings_table
698 );
699 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
700 $results = $this->wpdb->get_results(
701 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
702 $this->wpdb->prepare(
703 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
704 $sql,
705 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
706 $this->manager_type
707 ),
708 ARRAY_A
709 );
710
711 $grouped_settings = [];
712 foreach ($results as $row) {
713 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
714 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
715 }
716
717 $export_data['settings'] = $grouped_settings;
718 }
719
720 return $export_data;
721 }
722
723 /**
724 * Export settings to external format (additional method for format support)
725 *
726 * @since 1.0.0
727 *
728 * @param string $context_type Optional. Filter by context type
729 * @param string $format Export format ('json', 'array')
730 * @return array|string Export data
731 */
732 public function export_settings_formatted(string $context_type = '', string $format = 'array') {
733 $context_type_param = !empty($context_type) ? $context_type : null;
734 $export_data = $this->export_settings($context_type_param);
735
736 return $format === 'json' ? wp_json_encode($export_data, JSON_PRETTY_PRINT) : $export_data;
737 }
738
739 /**
740 * Validate SEO-specific settings
741 *
742 * @since 1.0.0
743 *
744 * @param array $settings Settings to validate
745 * @param array $validation Current validation results
746 * @return array Updated validation results
747 */
748 private function validate_seo_specific_settings(array $settings, array $validation): array {
749 // Validate title length (SEO best practice: 50-60 characters)
750 if (isset($settings['title']) && !empty($settings['title'])) {
751 $title_length = strlen($settings['title']);
752 if ($title_length > 60) {
753 $validation['warnings'][] = 'Title is longer than 60 characters, may be truncated in search results';
754 } elseif ($title_length < 30) {
755 $validation['suggestions'][] = 'Consider making title longer (30-60 characters) for better SEO';
756 }
757 }
758
759 // Validate meta description length (SEO best practice: 150-160 characters)
760 if (isset($settings['description']) && !empty($settings['description'])) {
761 $desc_length = strlen($settings['description']);
762 if ($desc_length > 160) {
763 $validation['warnings'][] = 'Meta description is longer than 160 characters, may be truncated';
764 } elseif ($desc_length < 120) {
765 $validation['suggestions'][] = 'Consider making meta description longer (120-160 characters)';
766 }
767 }
768
769 // Validate keywords (2025 SEO: focus on semantic keywords)
770 if (isset($settings['keywords']) && !empty($settings['keywords'])) {
771 $keywords = is_array($settings['keywords']) ? $settings['keywords'] : explode(',', $settings['keywords']);
772 $keyword_count = count($keywords);
773
774 if ($keyword_count > 5) {
775 $validation['warnings'][] = 'Too many keywords may dilute SEO focus. Consider 3-5 primary keywords.';
776 } elseif ($keyword_count === 0) {
777 $validation['suggestions'][] = 'Add relevant keywords to improve content targeting';
778 }
779 }
780
781 // Validate canonical URL
782 if (isset($settings['canonical_url']) && !empty($settings['canonical_url'])) {
783 $current_domain = wp_parse_url(home_url(), PHP_URL_HOST);
784 $canonical_domain = wp_parse_url($settings['canonical_url'], PHP_URL_HOST);
785
786 if ($canonical_domain !== $current_domain) {
787 $validation['warnings'][] = 'Canonical URL points to external domain - ensure this is intentional';
788 }
789 }
790
791 // Check for required SEO elements
792 if (empty($settings['title'])) {
793 $validation['suggestions'][] = 'Add a title tag for better search engine visibility';
794 }
795
796 if (empty($settings['description'])) {
797 $validation['suggestions'][] = 'Add a meta description to improve click-through rates';
798 }
799
800 return $validation;
801 }
802
803 /**
804 * Calculate validation score
805 *
806 * @since 1.0.0
807 *
808 * @param array $validation Validation results
809 * @return int Score (0-100)
810 */
811 private function calculate_validation_score(array $validation): int {
812 $score = 100;
813
814 // Deduct points for errors and warnings
815 $score -= count($validation['errors']) * 20;
816 $score -= count($validation['warnings']) * 10;
817 $score -= count($validation['suggestions']) * 5;
818
819 return max(0, $score);
820 }
821
822 /**
823 * Calculate overall validation score for context
824 *
825 * @since 1.0.0
826 *
827 * @param string $context_type Context type to calculate score for
828 * @return int Overall validation score
829 */
830 private function calculate_overall_validation_score(string $context_type): int {
831 // Get all settings for context type
832 // 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
833 if (!empty($context_type)) {
834 $sql = sprintf(
835 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1 AND context_type = %%s',
836 $this->settings_table
837 );
838 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
839 $results = $this->wpdb->get_results(
840 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
841 $this->wpdb->prepare(
842 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
843 $sql,
844 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
845 $this->manager_type,
846 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $context_type is validated and used as parameter
847 $context_type
848 ),
849 ARRAY_A
850 );
851 } else {
852 $sql = sprintf(
853 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
854 $this->settings_table
855 );
856 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql from sprintf with validated table name.
857 $results = $this->wpdb->get_results(
858 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
859 $this->wpdb->prepare(
860 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
861 $sql,
862 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameter is validated and used as placeholder
863 $this->manager_type
864 ),
865 ARRAY_A
866 );
867 }
868
869 if (empty($results)) {
870 return 0;
871 }
872
873 // Group settings by context
874 $contexts = [];
875 foreach ($results as $row) {
876 $context_key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
877 if (!isset($contexts[$context_key])) {
878 $contexts[$context_key] = [];
879 }
880 $contexts[$context_key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
881 }
882
883 // Calculate average score across all contexts
884 $total_score = 0;
885 $context_count = 0;
886
887 foreach ($contexts as $settings) {
888 $validation = $this->validate_settings($settings);
889 $total_score += $validation['score'];
890 $context_count++;
891 }
892
893 return $context_count > 0 ? (int) round($total_score / $context_count) : 0;
894 }
895
896 /**
897 * Reset settings to defaults
898 *
899 * @since 1.0.0
900 *
901 * @param string $context_type Context type
902 * @param int|null $context_id Optional. Context ID
903 * @return bool Success status
904 */
905 public function reset_to_defaults(string $context_type, ?int $context_id = null): bool {
906 $default_settings = $this->get_default_settings($context_type);
907 $default_settings['reset_at'] = current_time('mysql');
908 $default_settings['reset_by'] = get_current_user_id();
909
910 return $this->save_settings($context_type, $context_id, $default_settings);
911 }
912
913 /**
914 * Get settings migration status
915 *
916 * @since 1.0.0
917 *
918 * @return array Migration status information
919 */
920 public function get_migration_status(): array {
921 return [
922 'current_version' => '1.0.0',
923 'database_version' => get_option('thinkrank_seo_db_version', '0.0.0'),
924 'migration_needed' => version_compare(get_option('thinkrank_seo_db_version', '0.0.0'), '1.0.0', '<'),
925 'last_migration' => get_option('thinkrank_seo_last_migration', ''),
926 'migration_log' => get_option('thinkrank_seo_migration_log', [])
927 ];
928 }
929
930 /**
931 * Perform settings migration
932 *
933 * @since 1.0.0
934 *
935 * @param string $from_version Source version
936 * @param string $to_version Target version
937 * @return array Migration results
938 */
939 public function migrate_settings(string $from_version, string $to_version): array {
940 $migration_results = [
941 'success' => false,
942 'migrated_count' => 0,
943 'errors' => [],
944 'from_version' => $from_version,
945 'to_version' => $to_version,
946 'started_at' => current_time('mysql')
947 ];
948
949 try {
950 // Perform version-specific migrations
951 switch ($from_version) {
952 case '0.0.0':
953 // Initial migration - set up default settings
954 $contexts = ['site', 'post', 'page', 'product'];
955 foreach ($contexts as $context) {
956 $defaults = $this->get_default_settings($context);
957 $this->save_settings($context, null, $defaults);
958 $migration_results['migrated_count']++;
959 }
960 break;
961 default:
962 $migration_results['errors'][] = "No migration path defined for version {$from_version}";
963 break;
964 }
965
966 if (empty($migration_results['errors'])) {
967 $migration_results['success'] = true;
968 update_option('thinkrank_seo_db_version', $to_version);
969 update_option('thinkrank_seo_last_migration', current_time('mysql'));
970
971 // Log migration
972 $migration_log = get_option('thinkrank_seo_migration_log', []);
973 $migration_log[] = $migration_results;
974 update_option('thinkrank_seo_migration_log', array_slice($migration_log, -10)); // Keep last 10 migrations
975 }
976 } catch (\Exception $e) {
977 $migration_results['errors'][] = 'Migration failed: ' . $e->getMessage();
978 }
979
980 $migration_results['completed_at'] = current_time('mysql');
981 return $migration_results;
982 }
983 }
984