PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.26.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.26.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-abstract-seo-manager.php

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

742 lines 29.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Abstract SEO Manager Base Class
5 *
6 * Provides common functionality for all SEO managers including database operations,
7 * validation patterns, and utility methods. All concrete SEO managers should extend
8 * this class to ensure consistent behavior and reduce code duplication.
9 *
10 * @package ThinkRank
11 * @subpackage SEO
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO;
18
19 use ThinkRank\SEO\Interfaces\SEO_Manager_Interface;
20
21 // Prevent direct access
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 /**
27 * Abstract SEO Manager Base Class
28 *
29 * Implements common functionality for all SEO managers following DRY principles.
30 * Provides database operations, validation utilities, and standardized patterns.
31 *
32 * @since 1.0.0
33 */
34 abstract class Abstract_SEO_Manager implements SEO_Manager_Interface {
35
36 /**
37 * WordPress database instance
38 *
39 * @since 1.0.0
40 * @var \wpdb
41 */
42 protected \wpdb $wpdb;
43
44 /**
45 * Settings table name
46 *
47 * @since 1.0.0
48 * @var string
49 */
50 protected string $settings_table;
51
52 /**
53 * Manager type identifier
54 *
55 * @since 1.0.0
56 * @var string
57 */
58 protected string $manager_type;
59
60 /**
61 * Supported context types
62 *
63 * @since 1.0.0
64 * @var array
65 */
66 protected array $supported_contexts = ['site', 'post', 'page', 'product'];
67
68 /**
69 * Constructor
70 *
71 * @since 1.0.0
72 *
73 * @param string $manager_type The manager type identifier
74 */
75 public function __construct(string $manager_type) {
76 global $wpdb;
77
78 $this->wpdb = $wpdb;
79 $this->settings_table = $wpdb->prefix . 'thinkrank_seo_settings';
80 $this->manager_type = sanitize_key($manager_type);
81 }
82
83 /**
84 * Get SEO settings for a specific context
85 *
86 * @since 1.0.0
87 *
88 * @param string $context_type The context type
89 * @param int|null $context_id Optional. Context ID
90 * @return array SEO settings array
91 */
92 public function get_settings(string $context_type, ?int $context_id = null): array {
93 $context_type = sanitize_key($context_type);
94
95 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
96 return $this->get_default_settings($context_type);
97 }
98
99 // Convert NULL context_id to 0 for site-wide settings to match save behavior
100 $db_context_id = $context_id === null ? 0 : $context_id;
101
102 // Serve from the object cache when available. This runs on every front-end
103 // request (the_content, thumbnails), so avoiding a DB hit per request matters.
104 $cache_key = $this->get_cache_key($context_type, $context_id);
105 $cached = wp_cache_get($cache_key, 'thinkrank_seo');
106 if (is_array($cached)) {
107 return $cached;
108 }
109
110 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings require direct database access for real-time data, table name is validated
111 $sql = sprintf(
112 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
113 $this->settings_table
114 );
115
116 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
117 $results = $this->wpdb->get_results(
118 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
119 $this->wpdb->prepare(
120 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
121 $sql,
122 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
123 $context_type,
124 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
125 $db_context_id,
126 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
127 $this->manager_type
128 ),
129 ARRAY_A
130 );
131
132 $settings = [];
133 foreach ($results as $row) {
134 $value = maybe_unserialize($row['setting_value']);
135
136 // Ensure proper data type conversion for common boolean fields
137 if (in_array($row['setting_key'], [
138 'enabled',
139 'auto_generate_schema',
140 'rich_snippets_optimization',
141 'performance_tracking',
142 'auto_deploy',
143 'validation_on_save',
144 'rich_snippets_testing',
145 'organization_schema',
146 'knowledge_graph',
147 'add_missing_alt',
148 'add_missing_title',
149 'save_alt_to_media',
150 'auto_fill_on_upload',
151 'media_alt_overwrite'
152 ], true)) {
153 // Convert string/numeric boolean representations to actual booleans
154 if (is_string($value)) {
155 $value = in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
156 } elseif (is_numeric($value)) {
157 $value = (bool) $value;
158 }
159 }
160
161 // Ensure cache_duration is an integer
162 if ($row['setting_key'] === 'cache_duration') {
163 $value = (int) $value;
164 }
165
166 $settings[$row['setting_key']] = $value;
167 }
168
169 // Merge with defaults to ensure all required keys exist
170 $merged = array_merge($this->get_default_settings($context_type), $settings);
171
172 // Cache the resolved settings; invalidated on every save via clear_cache().
173 wp_cache_set($cache_key, $merged, 'thinkrank_seo');
174
175 return $merged;
176 }
177
178 /**
179 * Save SEO settings for a specific context
180 *
181 * @since 1.0.0
182 *
183 * @param string $context_type The context type
184 * @param int|null $context_id Optional. Context ID
185 * @param array $settings Settings array to save
186 * @return bool True on success, false on failure
187 */
188 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
189 $context_type = sanitize_key($context_type);
190
191 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
192 // Unsupported context type - validation failed
193 return false;
194 }
195
196 // Check if settings table exists
197 if (!$this->ensure_settings_table_exists()) {
198 // Settings table creation failed
199 return false;
200 }
201
202 // Validate settings before saving
203 $validation = $this->validate_settings($settings);
204 if (!$validation['valid']) {
205 // Settings validation failed - error details available in validation response
206 return false;
207 }
208
209 // Sanitize settings
210 $sanitized_settings = $this->sanitize_settings($settings);
211
212 $success = true;
213 foreach ($sanitized_settings as $key => $value) {
214 $sanitized_key = sanitize_key($key);
215 $serialized_value = maybe_serialize($value);
216 $current_time = current_time('mysql');
217
218 // Convert NULL context_id to 0 for site-wide settings to work with UNIQUE constraint
219 // MySQL treats multiple NULL values as distinct in UNIQUE constraints
220 $db_context_id = $context_id === null ? 0 : $context_id;
221
222 // Use INSERT ... ON DUPLICATE KEY UPDATE for proper upsert behavior
223 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $this->settings_table is a validated class property set from $wpdb->prefix.
224 $sql = $this->wpdb->prepare(
225 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
226 "INSERT INTO `{$this->settings_table}`
227 (`context_type`, `context_id`, `setting_category`, `setting_key`, `setting_value`, `is_active`, `created_at`, `updated_at`)
228 VALUES (%s, %d, %s, %s, %s, %d, %s, %s)
229 ON DUPLICATE KEY UPDATE
230 `setting_value` = VALUES(`setting_value`),
231 `is_active` = VALUES(`is_active`),
232 `updated_at` = VALUES(`updated_at`)",
233 $context_type,
234 $db_context_id,
235 $this->manager_type,
236 $sanitized_key,
237 $serialized_value,
238 1,
239 $current_time,
240 $current_time
241 );
242
243 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings require direct database access, SQL is properly prepared
244 $result = $this->wpdb->query($sql);
245
246 if (false === $result) {
247 // Database operation failed - error details available in wpdb->last_error
248 $success = false;
249 }
250 }
251
252 // Clear relevant caches
253 $this->clear_cache($context_type, $context_id);
254
255 return $success;
256 }
257
258 /**
259 * Delete settings for a specific context
260 *
261 * @since 1.0.0
262 *
263 * @param string $context_type The context type
264 * @param int|null $context_id Optional. Context ID
265 * @return bool True on success, false on failure
266 */
267 public function delete_settings(string $context_type, ?int $context_id): bool {
268 $context_type = sanitize_key($context_type);
269
270 // Convert NULL context_id to 0 for site-wide settings to match save behavior
271 $db_context_id = $context_id === null ? 0 : $context_id;
272
273 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings deletion requires direct database access
274 $result = $this->wpdb->delete(
275 $this->settings_table,
276 [
277 'context_type' => $context_type,
278 'context_id' => $db_context_id,
279 'setting_category' => $this->manager_type
280 ],
281 ['%s', '%d', '%s']
282 );
283
284 if ($result !== false) {
285 $this->clear_cache($context_type, $context_id);
286 return true;
287 }
288
289 return false;
290 }
291
292 /**
293 * Check if settings exist for a context
294 *
295 * @since 1.0.0
296 *
297 * @param string $context_type The context type
298 * @param int|null $context_id Optional. Context ID
299 * @return bool True if settings exist, false otherwise
300 */
301 public function has_settings(string $context_type, ?int $context_id): bool {
302 $context_type = sanitize_key($context_type);
303
304 // Convert NULL context_id to 0 for site-wide settings to match save behavior
305 $db_context_id = $context_id === null ? 0 : $context_id;
306
307 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings existence check requires direct database access, table name is validated
308 $sql = sprintf(
309 'SELECT COUNT(*) FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
310 $this->settings_table
311 );
312
313 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
314 $count = $this->wpdb->get_var(
315 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
316 $this->wpdb->prepare(
317 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
318 $sql,
319 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
320 $context_type,
321 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
322 $db_context_id,
323 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
324 $this->manager_type
325 )
326 );
327
328 return (int) $count > 0;
329 }
330
331 /**
332 * Get supported context types
333 *
334 * @since 1.0.0
335 *
336 * @return array Array of supported context types
337 */
338 public function get_supported_contexts(): array {
339 return $this->supported_contexts;
340 }
341
342 /**
343 * String setting keys whose newlines must be preserved on save.
344 *
345 * @var string[]
346 */
347 private const MULTILINE_STRING_KEYS = ['robots_txt_content'];
348
349 /**
350 * Keys holding %token% TEMPLATES rather than plain text.
351 *
352 * sanitize_text_field() strips anything matching /%[a-f0-9]{2}/ as a
353 * percent-encoded byte, which silently eats the leading characters of any
354 * token whose first two letters are valid hex — %category_title% becomes
355 * "tegory_title%", %date% becomes "te%". These keys therefore go through
356 * sanitize_template_field() instead.
357 */
358 private const TEMPLATE_STRING_KEYS = [
359 'homepage_title', 'post_title', 'page_title', 'category_title', 'tag_title',
360 'search_title', 'archive_title', 'author_title',
361 'homepage_description', 'post_description', 'page_description',
362 'title_template', 'description_template',
363 'alt_format', 'title_format', 'caption_format',
364 'subject_template',
365 ];
366
367 /**
368 * Sanitize settings array
369 *
370 * @since 1.0.0
371 *
372 * @param array $settings Settings to sanitize
373 * @return array Sanitized settings
374 */
375 protected function sanitize_settings(array $settings): array {
376 $sanitized = [];
377
378 foreach ($settings as $key => $value) {
379 $sanitized_key = sanitize_key($key);
380
381 if (is_string($value)) {
382 // Multi-line fields must keep their newlines; sanitize_text_field
383 // would flatten them onto a single line.
384 if (in_array($sanitized_key, self::MULTILINE_STRING_KEYS, true)) {
385 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
386 } elseif (in_array($sanitized_key, self::TEMPLATE_STRING_KEYS, true)) {
387 $sanitized[$sanitized_key] = $this->sanitize_template_field($value);
388 } else {
389 $sanitized[$sanitized_key] = sanitize_text_field($value);
390 }
391 } elseif (is_array($value)) {
392 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
393 } elseif (is_numeric($value)) {
394 $sanitized[$sanitized_key] = (float) $value;
395 } elseif (is_bool($value)) {
396 $sanitized[$sanitized_key] = (bool) $value;
397 } else {
398 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
399 }
400 }
401
402 return $sanitized;
403 }
404
405 /**
406 * Sanitize a %token% template while keeping its tokens intact.
407 *
408 * Applies the same protections as sanitize_text_field() — tag stripping,
409 * invalid-UTF8 rejection, control-character and newline removal — but
410 * deliberately omits its percent-encoding strip, which corrupts tokens like
411 * %category_title% and %date%. Templates are only ever rendered into
412 * escaped output, so no percent sequence here reaches a URL context raw.
413 *
414 * @since 1.20.1
415 *
416 * @param string $value Raw template
417 * @return string Sanitized template
418 */
419 private function sanitize_template_field(string $value): string {
420 $filtered = wp_check_invalid_utf8($value);
421
422 if (strpos($filtered, '<') !== false) {
423 $filtered = wp_pre_kses_less_than($filtered);
424 // Wrap in a paragraph so wp_strip_all_tags() sees a complete node.
425 $filtered = wp_strip_all_tags($filtered, false);
426 $filtered = str_replace("<\n", "&lt;\n", $filtered);
427 }
428
429 // Collapse newlines/tabs to spaces and drop other control characters,
430 // mirroring sanitize_text_field()'s single-line guarantee.
431 $filtered = preg_replace('/[\r\n\t ]+/', ' ', $filtered);
432 $filtered = preg_replace('/[\x00-\x1F\x7F]/u', '', (string) $filtered);
433
434 return trim((string) $filtered);
435 }
436
437 /**
438 * Recursively sanitize array values
439 *
440 * @since 1.0.0
441 *
442 * @param array $array Array to sanitize
443 * @return array Sanitized array
444 */
445 private function sanitize_array_recursive(array $array): array {
446 $sanitized = [];
447
448 foreach ($array as $key => $value) {
449 $sanitized_key = sanitize_key($key);
450
451 if (is_string($value)) {
452 $sanitized[$sanitized_key] = sanitize_text_field($value);
453 } elseif (is_array($value)) {
454 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
455 } elseif (is_numeric($value)) {
456 $sanitized[$sanitized_key] = (float) $value;
457 } elseif (is_bool($value)) {
458 $sanitized[$sanitized_key] = (bool) $value;
459 } elseif ('' === $value || null === $value) {
460 // Handle empty values - preserve as empty string for open/close times, convert to boolean for closed
461 if ($sanitized_key === 'closed') {
462 $sanitized[$sanitized_key] = false;
463 } else {
464 $sanitized[$sanitized_key] = '';
465 }
466 } else {
467 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
468 }
469 }
470
471 return $sanitized;
472 }
473
474 /**
475 * Clear cache for specific context
476 *
477 * @since 1.0.0
478 *
479 * @param string $context_type The context type
480 * @param int|null $context_id Optional. Context ID
481 */
482 protected function clear_cache(string $context_type, ?int $context_id): void {
483 $cache_key = $this->get_cache_key($context_type, $context_id);
484 wp_cache_delete($cache_key, 'thinkrank_seo');
485
486 // Clear related transients
487 delete_transient("thinkrank_seo_{$this->manager_type}_{$context_type}_{$context_id}");
488 }
489
490 /**
491 * Get cache key for context
492 *
493 * @since 1.0.0
494 *
495 * @param string $context_type The context type
496 * @param int|null $context_id Optional. Context ID
497 * @return string Cache key
498 */
499 protected function get_cache_key(string $context_type, ?int $context_id): string {
500 // Normalise NULL to 0 so reads (which pass NULL for site-wide) and writes
501 // (which pass 0) resolve to the SAME cache entry — otherwise a save would
502 // never invalidate the value a front-end read cached.
503 $db_context_id = $context_id === null ? 0 : $context_id;
504 return "seo_settings_{$this->manager_type}_{$context_type}_{$db_context_id}";
505 }
506
507 /**
508 * Ensure settings table exists
509 *
510 * @since 1.0.0
511 *
512 * @return bool True if table exists or was created successfully
513 */
514 protected function ensure_settings_table_exists(): bool {
515 // Check if table exists
516 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table existence check requires direct database access
517 $table_exists = $this->wpdb->get_var(
518 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
519 $this->wpdb->prepare(
520 "SHOW TABLES LIKE %s",
521 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- settings_table is validated class property
522 $this->settings_table
523 )
524 );
525
526 return $table_exists === $this->settings_table;
527 }
528
529 // Abstract methods that must be implemented by concrete classes
530
531 /**
532 * Validate SEO settings (must be implemented by concrete classes)
533 *
534 * @since 1.0.0
535 *
536 * @param array $settings Settings array to validate
537 * @return array Validation results
538 */
539 abstract public function validate_settings(array $settings): array;
540
541 /**
542 * Get output data for frontend rendering (must be implemented by concrete classes)
543 *
544 * @since 1.0.0
545 *
546 * @param string $context_type The context type
547 * @param int|null $context_id Optional. Context ID
548 * @return array Output data ready for frontend rendering
549 */
550 abstract public function get_output_data(string $context_type, ?int $context_id): array;
551
552 /**
553 * Get default settings for a context type (must be implemented by concrete classes)
554 *
555 * @since 1.0.0
556 *
557 * @param string $context_type The context type to get defaults for
558 * @return array Default settings array
559 */
560 abstract public function get_default_settings(string $context_type): array;
561
562 /**
563 * Get settings schema definition (must be implemented by concrete classes)
564 *
565 * @since 1.0.0
566 *
567 * @param string $context_type The context type to get schema for
568 * @return array Settings schema definition
569 */
570 abstract public function get_settings_schema(string $context_type): array;
571
572 /**
573 * Bulk update settings
574 *
575 * @since 1.0.0
576 *
577 * @param array $bulk_settings Array of settings keyed by context_type:context_id
578 * @return array Results array with success/failure status for each update
579 */
580 public function bulk_update_settings(array $bulk_settings): array {
581 $results = [];
582
583 foreach ($bulk_settings as $context_key => $settings) {
584 // Parse context key (format: "context_type:context_id" or "context_type")
585 $parts = explode(':', $context_key);
586 $context_type = $parts[0];
587 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
588
589 $success = $this->save_settings($context_type, $context_id, $settings);
590 $results[$context_key] = [
591 'success' => $success,
592 'context_type' => $context_type,
593 'context_id' => $context_id,
594 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings'
595 ];
596 }
597
598 return $results;
599 }
600
601 /**
602 * Get settings history
603 *
604 * @since 1.0.0
605 *
606 * @param string $context_type The context type
607 * @param int|null $context_id Optional. Context ID
608 * @param int $limit Optional. Number of revisions to return
609 * @return array Array of settings revisions
610 */
611 public function get_settings_history(string $context_type, ?int $context_id, int $limit = 10): array {
612 // For now, return empty array - history tracking can be implemented later
613 // This would require additional database tables for revision tracking
614 return [];
615 }
616
617 /**
618 * Export settings
619 *
620 * @since 1.0.0
621 *
622 * @param string $context_type Optional. Context type to export
623 * @param int|null $context_id Optional. Context ID to export
624 * @return array Exported settings with metadata
625 */
626 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
627 $export_data = [
628 'version' => '1.0.0',
629 'manager_type' => $this->manager_type,
630 'exported_at' => current_time('mysql'),
631 'settings' => []
632 ];
633
634 if ($context_type !== null) {
635 // Export specific context
636 $settings = $this->get_settings($context_type, $context_id);
637 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
638 } else {
639 // Export all settings for this manager type
640 // 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
641 $sql = sprintf(
642 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
643 $this->settings_table
644 );
645 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
646 $results = $this->wpdb->get_results(
647 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
648 $this->wpdb->prepare(
649 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
650 $sql,
651 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
652 $this->manager_type
653 ),
654 ARRAY_A
655 );
656
657 $grouped_settings = [];
658 foreach ($results as $row) {
659 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
660 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
661 }
662
663 $export_data['settings'] = $grouped_settings;
664 }
665
666 return $export_data;
667 }
668
669 /**
670 * Import settings
671 *
672 * @since 1.0.0
673 *
674 * @param array $import_data Exported settings data
675 * @param array $options Import options
676 * @return array Import results with success/failure details
677 */
678 public function import_settings(array $import_data, array $options = []): array {
679 $results = [
680 'success' => true,
681 'imported_count' => 0,
682 'failed_count' => 0,
683 'details' => []
684 ];
685
686 // Validate import data structure
687 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
688 $results['success'] = false;
689 $results['details'][] = 'Invalid import data structure';
690 return $results;
691 }
692
693 // Default import options
694 $options = array_merge([
695 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
696 'validate' => true
697 ], $options);
698
699 foreach ($import_data['settings'] as $context_key => $settings) {
700 // Parse context key
701 $parts = explode(':', $context_key);
702 $context_type = $parts[0];
703 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
704
705 // Check if settings already exist
706 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
707 $results['details'][] = "Skipped existing settings for {$context_key}";
708 continue;
709 }
710
711 // Merge with existing settings if requested
712 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
713 $existing_settings = $this->get_settings($context_type, $context_id);
714 $settings = array_merge($existing_settings, $settings);
715 }
716
717 // Validate settings if requested
718 if ($options['validate']) {
719 $validation = $this->validate_settings($settings);
720 if (!$validation['valid']) {
721 $results['failed_count']++;
722 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
723 continue;
724 }
725 }
726
727 // Import settings
728 $success = $this->save_settings($context_type, $context_id, $settings);
729 if ($success) {
730 $results['imported_count']++;
731 $results['details'][] = "Successfully imported settings for {$context_key}";
732 } else {
733 $results['failed_count']++;
734 $results['details'][] = "Failed to import settings for {$context_key}";
735 }
736 }
737
738 $results['success'] = $results['failed_count'] === 0;
739 return $results;
740 }
741 }
742