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-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 2.0.0, at includes/seo/class-abstract-seo-manager.php

772 lines 30.8 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 $this->log_save_failure("unsupported context type '{$context_type}'");
193 return false;
194 }
195
196 // Check if settings table exists. This is the failure a user cannot
197 // diagnose from the UI: on hosts where CREATE TABLE failed (e.g. the
198 // 767-byte InnoDB index limit on MySQL 5.6-era servers), every save in
199 // every manager fails with a generic message while option-backed
200 // features keep working — so name the cause loudly.
201 if (!$this->ensure_settings_table_exists()) {
202 $this->log_save_failure(
203 "settings table '{$this->settings_table}' does not exist. " .
204 'ThinkRank re-attempts creation on every load, so no reactivation is needed — and on the server most likely to be causing this, ' .
205 'reactivation fails outright and leaves the plugin deactivated. If the table never appears, the database is rejecting the CREATE TABLE: ' .
206 'ask your host for the MySQL/MariaDB version, as 5.6-era servers cap an index at 767 bytes and reject wider schemas.'
207 );
208 return false;
209 }
210
211 // Validate settings before saving
212 $validation = $this->validate_settings($settings);
213 if (!$validation['valid']) {
214 $this->log_save_failure('validation failed: ' . wp_json_encode($validation['errors'] ?? []));
215 return false;
216 }
217
218 // Sanitize settings
219 $sanitized_settings = $this->sanitize_settings($settings);
220
221 $success = true;
222 foreach ($sanitized_settings as $key => $value) {
223 $sanitized_key = sanitize_key($key);
224 $serialized_value = maybe_serialize($value);
225 $current_time = current_time('mysql');
226
227 // Convert NULL context_id to 0 for site-wide settings to work with UNIQUE constraint
228 // MySQL treats multiple NULL values as distinct in UNIQUE constraints
229 $db_context_id = $context_id === null ? 0 : $context_id;
230
231 // Use INSERT ... ON DUPLICATE KEY UPDATE for proper upsert behavior
232 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $this->settings_table is a validated class property set from $wpdb->prefix.
233 $sql = $this->wpdb->prepare(
234 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
235 "INSERT INTO `{$this->settings_table}`
236 (`context_type`, `context_id`, `setting_category`, `setting_key`, `setting_value`, `is_active`, `created_at`, `updated_at`)
237 VALUES (%s, %d, %s, %s, %s, %d, %s, %s)
238 ON DUPLICATE KEY UPDATE
239 `setting_value` = VALUES(`setting_value`),
240 `is_active` = VALUES(`is_active`),
241 `updated_at` = VALUES(`updated_at`)",
242 $context_type,
243 $db_context_id,
244 $this->manager_type,
245 $sanitized_key,
246 $serialized_value,
247 1,
248 $current_time,
249 $current_time
250 );
251
252 // 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
253 $result = $this->wpdb->query($sql);
254
255 if (false === $result) {
256 $this->log_save_failure(
257 "insert failed for key '{$sanitized_key}'" .
258 ('' !== (string) $this->wpdb->last_error ? '' . $this->wpdb->last_error : '')
259 );
260 $success = false;
261 }
262 }
263
264 // Clear relevant caches
265 $this->clear_cache($context_type, $context_id);
266
267 return $success;
268 }
269
270 /**
271 * Delete settings for a specific context
272 *
273 * @since 1.0.0
274 *
275 * @param string $context_type The context type
276 * @param int|null $context_id Optional. Context ID
277 * @return bool True on success, false on failure
278 */
279 public function delete_settings(string $context_type, ?int $context_id): bool {
280 $context_type = sanitize_key($context_type);
281
282 // Convert NULL context_id to 0 for site-wide settings to match save behavior
283 $db_context_id = $context_id === null ? 0 : $context_id;
284
285 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings deletion requires direct database access
286 $result = $this->wpdb->delete(
287 $this->settings_table,
288 [
289 'context_type' => $context_type,
290 'context_id' => $db_context_id,
291 'setting_category' => $this->manager_type
292 ],
293 ['%s', '%d', '%s']
294 );
295
296 if ($result !== false) {
297 $this->clear_cache($context_type, $context_id);
298 return true;
299 }
300
301 return false;
302 }
303
304 /**
305 * Check if settings exist for a context
306 *
307 * @since 1.0.0
308 *
309 * @param string $context_type The context type
310 * @param int|null $context_id Optional. Context ID
311 * @return bool True if settings exist, false otherwise
312 */
313 public function has_settings(string $context_type, ?int $context_id): bool {
314 $context_type = sanitize_key($context_type);
315
316 // Convert NULL context_id to 0 for site-wide settings to match save behavior
317 $db_context_id = $context_id === null ? 0 : $context_id;
318
319 // 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
320 $sql = sprintf(
321 'SELECT COUNT(*) FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
322 $this->settings_table
323 );
324
325 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
326 $count = $this->wpdb->get_var(
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 $context_type,
333 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
334 $db_context_id,
335 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
336 $this->manager_type
337 )
338 );
339
340 return (int) $count > 0;
341 }
342
343 /**
344 * Get supported context types
345 *
346 * @since 1.0.0
347 *
348 * @return array Array of supported context types
349 */
350 public function get_supported_contexts(): array {
351 return $this->supported_contexts;
352 }
353
354 /**
355 * String setting keys whose newlines must be preserved on save.
356 *
357 * @var string[]
358 */
359 private const MULTILINE_STRING_KEYS = ['robots_txt_content'];
360
361 /**
362 * Keys holding %token% TEMPLATES rather than plain text.
363 *
364 * sanitize_text_field() strips anything matching /%[a-f0-9]{2}/ as a
365 * percent-encoded byte, which silently eats the leading characters of any
366 * token whose first two letters are valid hex — %category_title% becomes
367 * "tegory_title%", %date% becomes "te%". These keys therefore go through
368 * sanitize_template_field() instead.
369 */
370 private const TEMPLATE_STRING_KEYS = [
371 'homepage_title', 'post_title', 'page_title', 'category_title', 'tag_title',
372 'search_title', 'archive_title', 'author_title',
373 'homepage_description', 'post_description', 'page_description',
374 'title_template', 'description_template',
375 'alt_format', 'title_format', 'caption_format',
376 'subject_template',
377 ];
378
379 /**
380 * Sanitize settings array
381 *
382 * @since 1.0.0
383 *
384 * @param array $settings Settings to sanitize
385 * @return array Sanitized settings
386 */
387 protected function sanitize_settings(array $settings): array {
388 $sanitized = [];
389
390 foreach ($settings as $key => $value) {
391 $sanitized_key = sanitize_key($key);
392
393 if (is_string($value)) {
394 // Multi-line fields must keep their newlines; sanitize_text_field
395 // would flatten them onto a single line.
396 if (in_array($sanitized_key, self::MULTILINE_STRING_KEYS, true)) {
397 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
398 } elseif (in_array($sanitized_key, self::TEMPLATE_STRING_KEYS, true)) {
399 $sanitized[$sanitized_key] = $this->sanitize_template_field($value);
400 } else {
401 $sanitized[$sanitized_key] = sanitize_text_field($value);
402 }
403 } elseif (is_array($value)) {
404 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
405 } elseif (is_numeric($value)) {
406 $sanitized[$sanitized_key] = (float) $value;
407 } elseif (is_bool($value)) {
408 $sanitized[$sanitized_key] = (bool) $value;
409 } else {
410 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
411 }
412 }
413
414 return $sanitized;
415 }
416
417 /**
418 * Sanitize a %token% template while keeping its tokens intact.
419 *
420 * Applies the same protections as sanitize_text_field() — tag stripping,
421 * invalid-UTF8 rejection, control-character and newline removal — but
422 * deliberately omits its percent-encoding strip, which corrupts tokens like
423 * %category_title% and %date%. Templates are only ever rendered into
424 * escaped output, so no percent sequence here reaches a URL context raw.
425 *
426 * @since 1.20.1
427 *
428 * @param string $value Raw template
429 * @return string Sanitized template
430 */
431 private function sanitize_template_field(string $value): string {
432 $filtered = wp_check_invalid_utf8($value);
433
434 if (strpos($filtered, '<') !== false) {
435 $filtered = wp_pre_kses_less_than($filtered);
436 // Wrap in a paragraph so wp_strip_all_tags() sees a complete node.
437 $filtered = wp_strip_all_tags($filtered, false);
438 $filtered = str_replace("<\n", "&lt;\n", $filtered);
439 }
440
441 // Collapse newlines/tabs to spaces and drop other control characters,
442 // mirroring sanitize_text_field()'s single-line guarantee.
443 $filtered = preg_replace('/[\r\n\t ]+/', ' ', $filtered);
444 $filtered = preg_replace('/[\x00-\x1F\x7F]/u', '', (string) $filtered);
445
446 return trim((string) $filtered);
447 }
448
449 /**
450 * Recursively sanitize array values
451 *
452 * @since 1.0.0
453 *
454 * @param array $input Array to sanitize
455 * @return array Sanitized array
456 */
457 private function sanitize_array_recursive(array $input): array {
458 $sanitized = [];
459
460 foreach ($input as $key => $value) {
461 $sanitized_key = sanitize_key($key);
462
463 if (is_string($value)) {
464 $sanitized[$sanitized_key] = sanitize_text_field($value);
465 } elseif (is_array($value)) {
466 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
467 } elseif (is_numeric($value)) {
468 $sanitized[$sanitized_key] = (float) $value;
469 } elseif (is_bool($value)) {
470 $sanitized[$sanitized_key] = (bool) $value;
471 } elseif ('' === $value || null === $value) {
472 // Handle empty values - preserve as empty string for open/close times, convert to boolean for closed
473 if ($sanitized_key === 'closed') {
474 $sanitized[$sanitized_key] = false;
475 } else {
476 $sanitized[$sanitized_key] = '';
477 }
478 } else {
479 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
480 }
481 }
482
483 return $sanitized;
484 }
485
486 /**
487 * Clear cache for specific context
488 *
489 * @since 1.0.0
490 *
491 * @param string $context_type The context type
492 * @param int|null $context_id Optional. Context ID
493 */
494 protected function clear_cache(string $context_type, ?int $context_id): void {
495 $cache_key = $this->get_cache_key($context_type, $context_id);
496 wp_cache_delete($cache_key, 'thinkrank_seo');
497
498 // Clear related transients
499 delete_transient("thinkrank_seo_{$this->manager_type}_{$context_type}_{$context_id}");
500 }
501
502 /**
503 * Get cache key for context
504 *
505 * @since 1.0.0
506 *
507 * @param string $context_type The context type
508 * @param int|null $context_id Optional. Context ID
509 * @return string Cache key
510 */
511 protected function get_cache_key(string $context_type, ?int $context_id): string {
512 // Normalise NULL to 0 so reads (which pass NULL for site-wide) and writes
513 // (which pass 0) resolve to the SAME cache entry — otherwise a save would
514 // never invalidate the value a front-end read cached.
515 $db_context_id = $context_id === null ? 0 : $context_id;
516 return "seo_settings_{$this->manager_type}_{$context_type}_{$db_context_id}";
517 }
518
519 /**
520 * Ensure settings table exists
521 *
522 * @since 1.0.0
523 *
524 * @return bool True if table exists or was created successfully
525 */
526 /**
527 * Record why a save failed, so "Failed to update … settings" in the UI has
528 * a matching, actionable line in the PHP error log.
529 *
530 * A customer cannot act on the generic message, and neither can support
531 * without this — the missing-table case (wizard blocked after migration,
532 * every settings screen failing) looked identical to a validation problem.
533 *
534 * @since 1.28.0
535 *
536 * @param string $reason Why the save failed.
537 * @return void
538 */
539 protected function log_save_failure(string $reason): void {
540 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic; the UI only shows a generic failure message.
541 error_log(sprintf('ThinkRank [%s]: settings save failed — %s', $this->manager_type, $reason));
542 }
543
544 protected function ensure_settings_table_exists(): bool {
545 // Check if table exists
546 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table existence check requires direct database access
547 $table_exists = $this->wpdb->get_var(
548 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
549 $this->wpdb->prepare(
550 "SHOW TABLES LIKE %s",
551 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- settings_table is validated class property
552 $this->settings_table
553 )
554 );
555
556 return $table_exists === $this->settings_table;
557 }
558
559 // Abstract methods that must be implemented by concrete classes
560
561 /**
562 * Validate SEO settings (must be implemented by concrete classes)
563 *
564 * @since 1.0.0
565 *
566 * @param array $settings Settings array to validate
567 * @return array Validation results
568 */
569 abstract public function validate_settings(array $settings): array;
570
571 /**
572 * Get output data for frontend rendering (must be implemented by concrete classes)
573 *
574 * @since 1.0.0
575 *
576 * @param string $context_type The context type
577 * @param int|null $context_id Optional. Context ID
578 * @return array Output data ready for frontend rendering
579 */
580 abstract public function get_output_data(string $context_type, ?int $context_id): array;
581
582 /**
583 * Get default settings for a context type (must be implemented by concrete classes)
584 *
585 * @since 1.0.0
586 *
587 * @param string $context_type The context type to get defaults for
588 * @return array Default settings array
589 */
590 abstract public function get_default_settings(string $context_type): array;
591
592 /**
593 * Get settings schema definition (must be implemented by concrete classes)
594 *
595 * @since 1.0.0
596 *
597 * @param string $context_type The context type to get schema for
598 * @return array Settings schema definition
599 */
600 abstract public function get_settings_schema(string $context_type): array;
601
602 /**
603 * Bulk update settings
604 *
605 * @since 1.0.0
606 *
607 * @param array $bulk_settings Array of settings keyed by context_type:context_id
608 * @return array Results array with success/failure status for each update
609 */
610 public function bulk_update_settings(array $bulk_settings): array {
611 $results = [];
612
613 foreach ($bulk_settings as $context_key => $settings) {
614 // Parse context key (format: "context_type:context_id" or "context_type")
615 $parts = explode(':', $context_key);
616 $context_type = $parts[0];
617 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
618
619 $success = $this->save_settings($context_type, $context_id, $settings);
620 $results[$context_key] = [
621 'success' => $success,
622 'context_type' => $context_type,
623 'context_id' => $context_id,
624 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings'
625 ];
626 }
627
628 return $results;
629 }
630
631 /**
632 * Get settings history
633 *
634 * @since 1.0.0
635 *
636 * @param string $context_type The context type
637 * @param int|null $context_id Optional. Context ID
638 * @param int $limit Optional. Number of revisions to return
639 * @return array Array of settings revisions
640 */
641 public function get_settings_history(string $context_type, ?int $context_id, int $limit = 10): array {
642 // For now, return empty array - history tracking can be implemented later
643 // This would require additional database tables for revision tracking
644 return [];
645 }
646
647 /**
648 * Export settings
649 *
650 * @since 1.0.0
651 *
652 * @param string $context_type Optional. Context type to export
653 * @param int|null $context_id Optional. Context ID to export
654 * @return array Exported settings with metadata
655 */
656 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
657 $export_data = [
658 'version' => '1.0.0',
659 'manager_type' => $this->manager_type,
660 'exported_at' => current_time('mysql'),
661 'settings' => []
662 ];
663
664 if ($context_type !== null) {
665 // Export specific context
666 $settings = $this->get_settings($context_type, $context_id);
667 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
668 } else {
669 // Export all settings for this manager type
670 // 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
671 $sql = sprintf(
672 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
673 $this->settings_table
674 );
675 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
676 $results = $this->wpdb->get_results(
677 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
678 $this->wpdb->prepare(
679 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
680 $sql,
681 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
682 $this->manager_type
683 ),
684 ARRAY_A
685 );
686
687 $grouped_settings = [];
688 foreach ($results as $row) {
689 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
690 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
691 }
692
693 $export_data['settings'] = $grouped_settings;
694 }
695
696 return $export_data;
697 }
698
699 /**
700 * Import settings
701 *
702 * @since 1.0.0
703 *
704 * @param array $import_data Exported settings data
705 * @param array $options Import options
706 * @return array Import results with success/failure details
707 */
708 public function import_settings(array $import_data, array $options = []): array {
709 $results = [
710 'success' => true,
711 'imported_count' => 0,
712 'failed_count' => 0,
713 'details' => []
714 ];
715
716 // Validate import data structure
717 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
718 $results['success'] = false;
719 $results['details'][] = 'Invalid import data structure';
720 return $results;
721 }
722
723 // Default import options
724 $options = array_merge([
725 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
726 'validate' => true
727 ], $options);
728
729 foreach ($import_data['settings'] as $context_key => $settings) {
730 // Parse context key
731 $parts = explode(':', $context_key);
732 $context_type = $parts[0];
733 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
734
735 // Check if settings already exist
736 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
737 $results['details'][] = "Skipped existing settings for {$context_key}";
738 continue;
739 }
740
741 // Merge with existing settings if requested
742 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
743 $existing_settings = $this->get_settings($context_type, $context_id);
744 $settings = array_merge($existing_settings, $settings);
745 }
746
747 // Validate settings if requested
748 if ($options['validate']) {
749 $validation = $this->validate_settings($settings);
750 if (!$validation['valid']) {
751 $results['failed_count']++;
752 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
753 continue;
754 }
755 }
756
757 // Import settings
758 $success = $this->save_settings($context_type, $context_id, $settings);
759 if ($success) {
760 $results['imported_count']++;
761 $results['details'][] = "Successfully imported settings for {$context_key}";
762 } else {
763 $results['failed_count']++;
764 $results['details'][] = "Failed to import settings for {$context_key}";
765 }
766 }
767
768 $results['success'] = $results['failed_count'] === 0;
769 return $results;
770 }
771 }
772