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

1,020 lines 39.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 * Why the most recent save_settings() call failed.
62 *
63 * save_settings() returns a bare bool, so the caller that has to tell the
64 * user something ends up printing a generic "failed" string while the real
65 * reason goes only to the error log. Holding it here lets the REST layer
66 * put the actual cause in the response. First failure wins: a rejected
67 * INSERT can cascade across keys, and the first one names the root cause.
68 *
69 * @since 1.32.1
70 * @var string
71 */
72 protected string $last_save_error = '';
73
74 /**
75 * Machine-readable counterpart to $last_save_error.
76 *
77 * @since 1.32.1
78 * @var string
79 */
80 protected string $last_save_error_code = '';
81
82 /**
83 * Supported context types
84 *
85 * @since 1.0.0
86 * @var array
87 */
88 protected array $supported_contexts = ['site', 'post', 'page', 'product'];
89
90 /**
91 * Constructor
92 *
93 * @since 1.0.0
94 *
95 * @param string $manager_type The manager type identifier
96 */
97 public function __construct(string $manager_type) {
98 global $wpdb;
99
100 $this->wpdb = $wpdb;
101 $this->settings_table = $wpdb->prefix . 'thinkrank_seo_settings';
102 $this->manager_type = sanitize_key($manager_type);
103 }
104
105 /**
106 * Get SEO settings for a specific context
107 *
108 * @since 1.0.0
109 *
110 * @param string $context_type The context type
111 * @param int|null $context_id Optional. Context ID
112 * @return array SEO settings array
113 */
114 public function get_settings(string $context_type, ?int $context_id = null): array {
115 $context_type = sanitize_key($context_type);
116
117 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
118 return $this->get_default_settings($context_type);
119 }
120
121 // Convert NULL context_id to 0 for site-wide settings to match save behavior
122 $db_context_id = $context_id === null ? 0 : $context_id;
123
124 // Serve from the object cache when available. This runs on every front-end
125 // request (the_content, thumbnails), so avoiding a DB hit per request matters.
126 $cache_key = $this->get_cache_key($context_type, $context_id);
127 $cached = wp_cache_get($cache_key, 'thinkrank_seo');
128 if (is_array($cached)) {
129 return $cached;
130 }
131
132 // 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
133 $sql = sprintf(
134 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
135 $this->settings_table
136 );
137
138 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
139 $results = $this->wpdb->get_results(
140 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
141 $this->wpdb->prepare(
142 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
143 $sql,
144 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
145 $context_type,
146 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
147 $db_context_id,
148 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
149 $this->manager_type
150 ),
151 ARRAY_A
152 );
153
154 $settings = [];
155 foreach ($results as $row) {
156 $value = maybe_unserialize($row['setting_value']);
157
158 // Ensure proper data type conversion for boolean fields
159 if (in_array($row['setting_key'], $this->boolean_setting_keys(), true)) {
160 // Convert string/numeric boolean representations to actual booleans
161 if (is_string($value)) {
162 $value = in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
163 } elseif (is_numeric($value)) {
164 $value = (bool) $value;
165 }
166 }
167
168 // Ensure cache_duration is an integer
169 if ($row['setting_key'] === 'cache_duration') {
170 $value = (int) $value;
171 }
172
173 $settings[$row['setting_key']] = $value;
174 }
175
176 // Merge with defaults to ensure all required keys exist
177 $merged = array_merge($this->get_default_settings($context_type), $settings);
178
179 // Cache the resolved settings; invalidated on every save via clear_cache().
180 wp_cache_set($cache_key, $merged, 'thinkrank_seo');
181
182 return $merged;
183 }
184
185 /**
186 * Save SEO settings for a specific context
187 *
188 * @since 1.0.0
189 *
190 * @param string $context_type The context type
191 * @param int|null $context_id Optional. Context ID
192 * @param array $settings Settings array to save
193 * @return bool True on success, false on failure
194 */
195 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
196 $context_type = sanitize_key($context_type);
197
198 $this->last_save_error = '';
199 $this->last_save_error_code = '';
200
201 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
202 $this->log_save_failure("unsupported context type '{$context_type}'", 'unsupported_context');
203 return false;
204 }
205
206 // Check if settings table exists. This is the failure a user cannot
207 // diagnose from the UI: on hosts where CREATE TABLE failed (e.g. the
208 // 767-byte InnoDB index limit on MySQL 5.6-era servers), every save in
209 // every manager fails with a generic message while option-backed
210 // features keep working — so name the cause loudly.
211 //
212 // Creation is retried on every request, so a table that stays missing
213 // means the database is refusing the statement. Database_Schema records
214 // that refusal; lead with it, because it is the only text here that
215 // names this site's actual problem.
216 if (!$this->ensure_settings_table_exists()) {
217 $create_error = \ThinkRank\Database\Database_Schema::get_last_create_failure();
218
219 $this->log_save_failure(
220 "settings table '{$this->settings_table}' does not exist. " .
221 ('' !== $create_error
222 ? 'The database refused to create it: ' . $create_error
223 : 'ThinkRank re-attempts creation on every load, so no reactivation is needed. ' .
224 'If the table never appears, the database is rejecting the CREATE TABLE: check that the ' .
225 'database user holds the CREATE privilege, and ask your host for the MySQL/MariaDB version, ' .
226 'as 5.6-era servers cap an index at 767 bytes and reject wider schemas.'),
227 'settings_table_missing'
228 );
229 return false;
230 }
231
232 // Validate settings before saving
233 $validation = $this->validate_settings($settings);
234 if (!$validation['valid']) {
235 $this->log_save_failure(
236 'validation failed: ' . wp_json_encode($validation['errors'] ?? []),
237 'validation_failed'
238 );
239 return false;
240 }
241
242 // Sanitize settings
243 $sanitized_settings = $this->sanitize_settings($settings, $context_type);
244
245 $success = true;
246 foreach ($sanitized_settings as $key => $value) {
247 $sanitized_key = sanitize_key($key);
248 $serialized_value = maybe_serialize($value);
249 $current_time = current_time('mysql');
250
251 // Convert NULL context_id to 0 for site-wide settings to work with UNIQUE constraint
252 // MySQL treats multiple NULL values as distinct in UNIQUE constraints
253 $db_context_id = $context_id === null ? 0 : $context_id;
254
255 // Use INSERT ... ON DUPLICATE KEY UPDATE for proper upsert behavior
256 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $this->settings_table is a validated class property set from $wpdb->prefix.
257 $sql = $this->wpdb->prepare(
258 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
259 "INSERT INTO `{$this->settings_table}`
260 (`context_type`, `context_id`, `setting_category`, `setting_key`, `setting_value`, `is_active`, `created_at`, `updated_at`)
261 VALUES (%s, %d, %s, %s, %s, %d, %s, %s)
262 ON DUPLICATE KEY UPDATE
263 `setting_value` = VALUES(`setting_value`),
264 `is_active` = VALUES(`is_active`),
265 `updated_at` = VALUES(`updated_at`)",
266 $context_type,
267 $db_context_id,
268 $this->manager_type,
269 $sanitized_key,
270 $serialized_value,
271 1,
272 $current_time,
273 $current_time
274 );
275
276 // 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
277 $result = $this->wpdb->query($sql);
278
279 if (false === $result) {
280 $this->log_save_failure(
281 "insert failed for key '{$sanitized_key}'" .
282 ('' !== (string) $this->wpdb->last_error ? '' . $this->wpdb->last_error : ''),
283 'db_insert_failed'
284 );
285 $success = false;
286 }
287 }
288
289 // Clear relevant caches
290 $this->clear_cache($context_type, $context_id);
291
292 return $success;
293 }
294
295 /**
296 * Delete settings for a specific context
297 *
298 * @since 1.0.0
299 *
300 * @param string $context_type The context type
301 * @param int|null $context_id Optional. Context ID
302 * @return bool True on success, false on failure
303 */
304 public function delete_settings(string $context_type, ?int $context_id): bool {
305 $context_type = sanitize_key($context_type);
306
307 // Convert NULL context_id to 0 for site-wide settings to match save behavior
308 $db_context_id = $context_id === null ? 0 : $context_id;
309
310 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings deletion requires direct database access
311 $result = $this->wpdb->delete(
312 $this->settings_table,
313 [
314 'context_type' => $context_type,
315 'context_id' => $db_context_id,
316 'setting_category' => $this->manager_type
317 ],
318 ['%s', '%d', '%s']
319 );
320
321 if ($result !== false) {
322 $this->clear_cache($context_type, $context_id);
323 return true;
324 }
325
326 return false;
327 }
328
329 /**
330 * Check if settings exist for a context
331 *
332 * @since 1.0.0
333 *
334 * @param string $context_type The context type
335 * @param int|null $context_id Optional. Context ID
336 * @return bool True if settings exist, false otherwise
337 */
338 public function has_settings(string $context_type, ?int $context_id): bool {
339 $context_type = sanitize_key($context_type);
340
341 // Convert NULL context_id to 0 for site-wide settings to match save behavior
342 $db_context_id = $context_id === null ? 0 : $context_id;
343
344 // 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
345 $sql = sprintf(
346 'SELECT COUNT(*) FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
347 $this->settings_table
348 );
349
350 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
351 $count = $this->wpdb->get_var(
352 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
353 $this->wpdb->prepare(
354 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
355 $sql,
356 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
357 $context_type,
358 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
359 $db_context_id,
360 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
361 $this->manager_type
362 )
363 );
364
365 return (int) $count > 0;
366 }
367
368 /**
369 * Get supported context types
370 *
371 * @since 1.0.0
372 *
373 * @return array Array of supported context types
374 */
375 public function get_supported_contexts(): array {
376 return $this->supported_contexts;
377 }
378
379 /**
380 * String setting keys whose newlines must be preserved on save.
381 *
382 * @var string[]
383 */
384 private const MULTILINE_STRING_KEYS = ['robots_txt_content'];
385
386 /**
387 * Keys holding %token% TEMPLATES rather than plain text.
388 *
389 * sanitize_text_field() strips anything matching /%[a-f0-9]{2}/ as a
390 * percent-encoded byte, which silently eats the leading characters of any
391 * token whose first two letters are valid hex — %category_title% becomes
392 * "tegory_title%", %date% becomes "te%". These keys therefore go through
393 * sanitize_template_field() instead.
394 */
395 private const TEMPLATE_STRING_KEYS = [
396 'homepage_title', 'post_title', 'page_title', 'category_title', 'tag_title',
397 'search_title', 'archive_title', 'author_title',
398 'homepage_description', 'post_description', 'page_description',
399 'title_template', 'description_template',
400 'alt_format', 'title_format', 'caption_format',
401 'subject_template',
402 ];
403
404 /**
405 * REST envelope keys that must never become stored settings.
406 *
407 * Every settings endpoint answers with
408 * {settings, schema, context_type, context_id}. A caller that posts that
409 * whole envelope back as `settings` writes those four keys as rows, and
410 * because get_settings() returns every stored row, they then round-trip
411 * into the next request forever — the Site Identity payload carried ~6KB
412 * of a serialized copy of itself plus its own JSON schema on every save.
413 * They are not settings in any manager, so drop them on the way in.
414 *
415 * @var string[]
416 */
417 protected const RESERVED_ENVELOPE_KEYS = ['settings', 'schema', 'context_type', 'context_id'];
418
419 /**
420 * Setting keys this manager stores that its defaults do not name.
421 *
422 * get_default_settings() is the natural allow-list, but it is not complete
423 * in every manager: Site Identity declares 16 defaults while the screens
424 * behind it legitimately store 55 keys, and gating on defaults alone would
425 * stop title formats, breadcrumb configuration and business details from
426 * saving at all. A manager whose defaults are complete overrides nothing.
427 *
428 * @since 2.0.1
429 *
430 * @return string[]
431 */
432 protected function additional_setting_keys(): array {
433 return [];
434 }
435
436 /**
437 * Regular expressions matching key FAMILIES this manager stores.
438 *
439 * For settings whose key set is open by design — the schema manager's
440 * per-entity fields, the sitemap's per-post-type inclusion flags — an
441 * enumerated list would go stale the first time a post type is registered.
442 * Patterns are anchored and deliberately narrow: they must describe a
443 * family the manager owns, never a catch-all.
444 *
445 * @since 2.0.1
446 *
447 * @return string[] PCRE patterns, delimiters included.
448 */
449 protected function dynamic_setting_key_patterns(): array {
450 return [];
451 }
452
453 /**
454 * The setting keys this manager accepts.
455 *
456 * @since 2.0.1
457 *
458 * @param string $context_type Context the save is for.
459 * @return string[]
460 */
461 public function get_known_setting_keys(string $context_type = 'site'): array {
462 $keys = array_merge(
463 array_keys($this->get_default_settings($context_type)),
464 $this->additional_setting_keys()
465 );
466
467 $keys = array_values(array_unique(array_filter($keys, 'is_string')));
468
469 /**
470 * Filters the keys a settings category accepts.
471 *
472 * Shared with Settings_Management_Endpoint so an add-on registering
473 * settings against an existing category declares them once.
474 *
475 * @since 2.0.1
476 *
477 * @param string[] $keys Accepted setting keys.
478 * @param string $category Settings category (the manager type).
479 * @param string $context_type Context the save is for.
480 */
481 return apply_filters('thinkrank_known_setting_keys', $keys, $this->manager_type, $context_type);
482 }
483
484 /**
485 * Whether this manager stores a setting under this key.
486 *
487 * Public counterpart of is_known_setting_key() for callers outside the
488 * save path — the schema upgrade that clears rows written before the
489 * allow-list existed, and tests.
490 *
491 * @since 2.0.1
492 *
493 * @param string $key Setting key.
494 * @param string $context_type Context to judge it in.
495 * @return bool
496 */
497 public function accepts_setting_key(string $key, string $context_type = 'site'): bool {
498 return $this->is_known_setting_key(sanitize_key($key), $this->get_known_setting_keys($context_type));
499 }
500
501 /**
502 * Whether a key is one this manager stores.
503 *
504 * @since 2.0.1
505 *
506 * @param string $key Sanitized setting key.
507 * @param array $known Known keys for the context.
508 * @return bool
509 */
510 protected function is_known_setting_key(string $key, array $known): bool {
511 if (in_array($key, $known, true)) {
512 return true;
513 }
514
515 foreach ($this->dynamic_setting_key_patterns() as $pattern) {
516 if (preg_match($pattern, $key)) {
517 return true;
518 }
519 }
520
521 return false;
522 }
523
524 /**
525 * Sanitize settings array
526 *
527 * @since 1.0.0
528 *
529 * @param array $settings Settings to sanitize
530 * @return array Sanitized settings
531 */
532 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
533 $sanitized = [];
534 $known = $this->get_known_setting_keys($context_type);
535
536 foreach ($settings as $key => $value) {
537 $sanitized_key = sanitize_key($key);
538
539 if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
540 continue;
541 }
542
543 // A key no manager declares is not a setting. Stored, it becomes a
544 // row that get_settings() returns forever, so it round-trips into
545 // every later response and is re-posted by the UI on the next save
546 // — which is how the REST envelope came to be stored (#452).
547 if (!$this->is_known_setting_key($sanitized_key, $known)) {
548 $this->log_unknown_setting_key($sanitized_key);
549 continue;
550 }
551
552 if (is_string($value)) {
553 // Multi-line fields must keep their newlines; sanitize_text_field
554 // would flatten them onto a single line.
555 if (in_array($sanitized_key, self::MULTILINE_STRING_KEYS, true)) {
556 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
557 } elseif (in_array($sanitized_key, self::TEMPLATE_STRING_KEYS, true)) {
558 $sanitized[$sanitized_key] = $this->sanitize_template_field($value);
559 } else {
560 $sanitized[$sanitized_key] = sanitize_text_field($value);
561 }
562 } elseif (is_array($value)) {
563 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
564 } elseif (is_numeric($value)) {
565 $sanitized[$sanitized_key] = (float) $value;
566 } elseif (is_bool($value)) {
567 $sanitized[$sanitized_key] = (bool) $value;
568 } else {
569 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
570 }
571 }
572
573 return $sanitized;
574 }
575
576 /**
577 * Record a rejected setting key.
578 *
579 * Dropping silently is the hazard this gate carries: a legitimate key
580 * missing from a manager's declarations would disappear with no trace. On
581 * a debug install it says so; in production it stays quiet, since the
582 * common source is a client posting fields that were never settings.
583 *
584 * @since 2.0.1
585 *
586 * @param string $key Key that was dropped.
587 * @return void
588 */
589 private function log_unknown_setting_key(string $key): void {
590 if (!defined('WP_DEBUG') || !WP_DEBUG) {
591 return;
592 }
593
594 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic; a dropped key is otherwise invisible.
595 error_log(sprintf('ThinkRank [%s]: dropped unknown setting key "%s"', $this->manager_type, $key));
596 }
597
598 /**
599 * Sanitize a %token% template while keeping its tokens intact.
600 *
601 * Applies the same protections as sanitize_text_field() — tag stripping,
602 * invalid-UTF8 rejection, control-character and newline removal — but
603 * deliberately omits its percent-encoding strip, which corrupts tokens like
604 * %category_title% and %date%. Templates are only ever rendered into
605 * escaped output, so no percent sequence here reaches a URL context raw.
606 *
607 * @since 1.20.1
608 *
609 * @param string $value Raw template
610 * @return string Sanitized template
611 */
612 private function sanitize_template_field(string $value): string {
613 $filtered = wp_check_invalid_utf8($value);
614
615 if (strpos($filtered, '<') !== false) {
616 $filtered = wp_pre_kses_less_than($filtered);
617 // Wrap in a paragraph so wp_strip_all_tags() sees a complete node.
618 $filtered = wp_strip_all_tags($filtered, false);
619 $filtered = str_replace("<\n", "&lt;\n", $filtered);
620 }
621
622 // Collapse newlines/tabs to spaces and drop other control characters,
623 // mirroring sanitize_text_field()'s single-line guarantee.
624 $filtered = preg_replace('/[\r\n\t ]+/', ' ', $filtered);
625 $filtered = preg_replace('/[\x00-\x1F\x7F]/u', '', (string) $filtered);
626
627 return trim((string) $filtered);
628 }
629
630 /**
631 * Recursively sanitize array values
632 *
633 * @since 1.0.0
634 *
635 * @param array $input Array to sanitize
636 * @return array Sanitized array
637 */
638 private function sanitize_array_recursive(array $input): array {
639 $sanitized = [];
640
641 foreach ($input as $key => $value) {
642 $sanitized_key = sanitize_key($key);
643
644 if (is_string($value)) {
645 $sanitized[$sanitized_key] = sanitize_text_field($value);
646 } elseif (is_array($value)) {
647 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
648 } elseif (is_numeric($value)) {
649 $sanitized[$sanitized_key] = (float) $value;
650 } elseif (is_bool($value)) {
651 $sanitized[$sanitized_key] = (bool) $value;
652 } elseif ('' === $value || null === $value) {
653 // Handle empty values - preserve as empty string for open/close times, convert to boolean for closed
654 if ($sanitized_key === 'closed') {
655 $sanitized[$sanitized_key] = false;
656 } else {
657 $sanitized[$sanitized_key] = '';
658 }
659 } else {
660 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
661 }
662 }
663
664 return $sanitized;
665 }
666
667 /**
668 * Clear cache for specific context
669 *
670 * @since 1.0.0
671 *
672 * @param string $context_type The context type
673 * @param int|null $context_id Optional. Context ID
674 */
675 protected function clear_cache(string $context_type, ?int $context_id): void {
676 $cache_key = $this->get_cache_key($context_type, $context_id);
677 wp_cache_delete($cache_key, 'thinkrank_seo');
678
679 // Clear related transients
680 delete_transient("thinkrank_seo_{$this->manager_type}_{$context_type}_{$context_id}");
681 }
682
683 /**
684 * Get cache key for context
685 *
686 * @since 1.0.0
687 *
688 * @param string $context_type The context type
689 * @param int|null $context_id Optional. Context ID
690 * @return string Cache key
691 */
692 /**
693 * Setting keys stored as booleans, so a read hands them back as booleans.
694 *
695 * The database stores them as '1' / '', and a manager whose validator
696 * demands a real boolean will then reject its own stored values — which is
697 * exactly what made every save routed through
698 * Seo_Settings_Manager::save_settings_by_category() fail after it merged
699 * the existing settings back in (#395). Subclasses override this so the
700 * read and the validator cannot drift apart.
701 *
702 * @since 2.0.1
703 *
704 * @return string[] Keys to coerce to boolean on read.
705 */
706 protected function boolean_setting_keys(): array {
707 return [
708 'enabled',
709 'auto_generate_schema',
710 'rich_snippets_optimization',
711 'performance_tracking',
712 'auto_deploy',
713 'validation_on_save',
714 'rich_snippets_testing',
715 'organization_schema',
716 'knowledge_graph',
717 'add_missing_alt',
718 'add_missing_title',
719 'save_alt_to_media',
720 'auto_fill_on_upload',
721 'media_alt_overwrite',
722 ];
723 }
724
725 protected function get_cache_key(string $context_type, ?int $context_id): string {
726 // Normalise NULL to 0 so reads (which pass NULL for site-wide) and writes
727 // (which pass 0) resolve to the SAME cache entry — otherwise a save would
728 // never invalidate the value a front-end read cached.
729 $db_context_id = $context_id === null ? 0 : $context_id;
730 return "seo_settings_{$this->manager_type}_{$context_type}_{$db_context_id}";
731 }
732
733 /**
734 * Ensure settings table exists
735 *
736 * @since 1.0.0
737 *
738 * @return bool True if table exists or was created successfully
739 */
740 /**
741 * Record why a save failed, so "Failed to update … settings" in the UI has
742 * a matching, actionable line in the PHP error log.
743 *
744 * A customer cannot act on the generic message, and neither can support
745 * without this — the missing-table case (wizard blocked after migration,
746 * every settings screen failing) looked identical to a validation problem.
747 *
748 * The reason is also kept on the instance so the REST layer can put it in
749 * the response instead of a fixed string — see get_last_save_error().
750 *
751 * @since 1.28.0
752 *
753 * @param string $reason Why the save failed.
754 * @param string $code Optional. Machine-readable failure code.
755 * @return void
756 */
757 protected function log_save_failure(string $reason, string $code = 'save_failed'): void {
758 if ('' === $this->last_save_error) {
759 $this->last_save_error = $reason;
760 $this->last_save_error_code = $code;
761 }
762
763 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic; the UI only shows a generic failure message.
764 error_log(sprintf('ThinkRank [%s]: settings save failed — %s', $this->manager_type, $reason));
765 }
766
767 /**
768 * Why the last save_settings() call returned false.
769 *
770 * @since 1.32.1
771 *
772 * @return string Failure reason, or '' if the last save succeeded.
773 */
774 public function get_last_save_error(): string {
775 return $this->last_save_error;
776 }
777
778 /**
779 * Machine-readable code for the last save failure.
780 *
781 * One of: unsupported_context, settings_table_missing, validation_failed,
782 * db_insert_failed, save_failed.
783 *
784 * @since 1.32.1
785 *
786 * @return string Failure code, or '' if the last save succeeded.
787 */
788 public function get_last_save_error_code(): string {
789 return $this->last_save_error_code;
790 }
791
792 protected function ensure_settings_table_exists(): bool {
793 // Check if table exists
794 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table existence check requires direct database access
795 $table_exists = $this->wpdb->get_var(
796 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
797 $this->wpdb->prepare(
798 "SHOW TABLES LIKE %s",
799 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- settings_table is validated class property
800 $this->settings_table
801 )
802 );
803
804 return $table_exists === $this->settings_table;
805 }
806
807 // Abstract methods that must be implemented by concrete classes
808
809 /**
810 * Validate SEO settings (must be implemented by concrete classes)
811 *
812 * @since 1.0.0
813 *
814 * @param array $settings Settings array to validate
815 * @return array Validation results
816 */
817 abstract public function validate_settings(array $settings): array;
818
819 /**
820 * Get output data for frontend rendering (must be implemented by concrete classes)
821 *
822 * @since 1.0.0
823 *
824 * @param string $context_type The context type
825 * @param int|null $context_id Optional. Context ID
826 * @return array Output data ready for frontend rendering
827 */
828 abstract public function get_output_data(string $context_type, ?int $context_id): array;
829
830 /**
831 * Get default settings for a context type (must be implemented by concrete classes)
832 *
833 * @since 1.0.0
834 *
835 * @param string $context_type The context type to get defaults for
836 * @return array Default settings array
837 */
838 abstract public function get_default_settings(string $context_type): array;
839
840 /**
841 * Get settings schema definition (must be implemented by concrete classes)
842 *
843 * @since 1.0.0
844 *
845 * @param string $context_type The context type to get schema for
846 * @return array Settings schema definition
847 */
848 abstract public function get_settings_schema(string $context_type): array;
849
850 /**
851 * Bulk update settings
852 *
853 * @since 1.0.0
854 *
855 * @param array $bulk_settings Array of settings keyed by context_type:context_id
856 * @return array Results array with success/failure status for each update
857 */
858 public function bulk_update_settings(array $bulk_settings): array {
859 $results = [];
860
861 foreach ($bulk_settings as $context_key => $settings) {
862 // Parse context key (format: "context_type:context_id" or "context_type")
863 $parts = explode(':', $context_key);
864 $context_type = $parts[0];
865 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
866
867 $success = $this->save_settings($context_type, $context_id, $settings);
868 $results[$context_key] = [
869 'success' => $success,
870 'context_type' => $context_type,
871 'context_id' => $context_id,
872 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings'
873 ];
874 }
875
876 return $results;
877 }
878
879 /**
880 * Get settings history
881 *
882 * @since 1.0.0
883 *
884 * @param string $context_type The context type
885 * @param int|null $context_id Optional. Context ID
886 * @param int $limit Optional. Number of revisions to return
887 * @return array Array of settings revisions
888 */
889 public function get_settings_history(string $context_type, ?int $context_id, int $limit = 10): array {
890 // For now, return empty array - history tracking can be implemented later
891 // This would require additional database tables for revision tracking
892 return [];
893 }
894
895 /**
896 * Export settings
897 *
898 * @since 1.0.0
899 *
900 * @param string $context_type Optional. Context type to export
901 * @param int|null $context_id Optional. Context ID to export
902 * @return array Exported settings with metadata
903 */
904 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
905 $export_data = [
906 'version' => '1.0.0',
907 'manager_type' => $this->manager_type,
908 'exported_at' => current_time('mysql'),
909 'settings' => []
910 ];
911
912 if ($context_type !== null) {
913 // Export specific context
914 $settings = $this->get_settings($context_type, $context_id);
915 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
916 } else {
917 // Export all settings for this manager type
918 // 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
919 $sql = sprintf(
920 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
921 $this->settings_table
922 );
923 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
924 $results = $this->wpdb->get_results(
925 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
926 $this->wpdb->prepare(
927 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
928 $sql,
929 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
930 $this->manager_type
931 ),
932 ARRAY_A
933 );
934
935 $grouped_settings = [];
936 foreach ($results as $row) {
937 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
938 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
939 }
940
941 $export_data['settings'] = $grouped_settings;
942 }
943
944 return $export_data;
945 }
946
947 /**
948 * Import settings
949 *
950 * @since 1.0.0
951 *
952 * @param array $import_data Exported settings data
953 * @param array $options Import options
954 * @return array Import results with success/failure details
955 */
956 public function import_settings(array $import_data, array $options = []): array {
957 $results = [
958 'success' => true,
959 'imported_count' => 0,
960 'failed_count' => 0,
961 'details' => []
962 ];
963
964 // Validate import data structure
965 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
966 $results['success'] = false;
967 $results['details'][] = 'Invalid import data structure';
968 return $results;
969 }
970
971 // Default import options
972 $options = array_merge([
973 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
974 'validate' => true
975 ], $options);
976
977 foreach ($import_data['settings'] as $context_key => $settings) {
978 // Parse context key
979 $parts = explode(':', $context_key);
980 $context_type = $parts[0];
981 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
982
983 // Check if settings already exist
984 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
985 $results['details'][] = "Skipped existing settings for {$context_key}";
986 continue;
987 }
988
989 // Merge with existing settings if requested
990 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
991 $existing_settings = $this->get_settings($context_type, $context_id);
992 $settings = array_merge($existing_settings, $settings);
993 }
994
995 // Validate settings if requested
996 if ($options['validate']) {
997 $validation = $this->validate_settings($settings);
998 if (!$validation['valid']) {
999 $results['failed_count']++;
1000 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
1001 continue;
1002 }
1003 }
1004
1005 // Import settings
1006 $success = $this->save_settings($context_type, $context_id, $settings);
1007 if ($success) {
1008 $results['imported_count']++;
1009 $results['details'][] = "Successfully imported settings for {$context_key}";
1010 } else {
1011 $results['failed_count']++;
1012 $results['details'][] = "Failed to import settings for {$context_key}";
1013 }
1014 }
1015
1016 $results['success'] = $results['failed_count'] === 0;
1017 return $results;
1018 }
1019 }
1020