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

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