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

1,043 lines 40.6 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 /**
293 * Fires after a settings category has been written.
294 *
295 * Lets one manager react to another's save — the Schema Manager uses it
296 * to refresh LocalBusiness when Site Identity's Business Info changes,
297 * since those fields live in a different category and never appear in a
298 * schema settings payload (#455).
299 *
300 * @since 2.0.2
301 *
302 * @param string $manager_type Settings category that was saved.
303 * @param array $settings The sanitized settings that were written.
304 * @param string $context_type Context type.
305 * @param int|null $context_id Context ID.
306 */
307 do_action(
308 'thinkrank_seo_settings_saved',
309 $this->manager_type,
310 $sanitized_settings,
311 $context_type,
312 $context_id
313 );
314
315 return $success;
316 }
317
318 /**
319 * Delete settings for a specific context
320 *
321 * @since 1.0.0
322 *
323 * @param string $context_type The context type
324 * @param int|null $context_id Optional. Context ID
325 * @return bool True on success, false on failure
326 */
327 public function delete_settings(string $context_type, ?int $context_id): bool {
328 $context_type = sanitize_key($context_type);
329
330 // Convert NULL context_id to 0 for site-wide settings to match save behavior
331 $db_context_id = $context_id === null ? 0 : $context_id;
332
333 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings deletion requires direct database access
334 $result = $this->wpdb->delete(
335 $this->settings_table,
336 [
337 'context_type' => $context_type,
338 'context_id' => $db_context_id,
339 'setting_category' => $this->manager_type
340 ],
341 ['%s', '%d', '%s']
342 );
343
344 if ($result !== false) {
345 $this->clear_cache($context_type, $context_id);
346 return true;
347 }
348
349 return false;
350 }
351
352 /**
353 * Check if settings exist for a context
354 *
355 * @since 1.0.0
356 *
357 * @param string $context_type The context type
358 * @param int|null $context_id Optional. Context ID
359 * @return bool True if settings exist, false otherwise
360 */
361 public function has_settings(string $context_type, ?int $context_id): bool {
362 $context_type = sanitize_key($context_type);
363
364 // Convert NULL context_id to 0 for site-wide settings to match save behavior
365 $db_context_id = $context_id === null ? 0 : $context_id;
366
367 // 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
368 $sql = sprintf(
369 'SELECT COUNT(*) FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
370 $this->settings_table
371 );
372
373 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
374 $count = $this->wpdb->get_var(
375 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
376 $this->wpdb->prepare(
377 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
378 $sql,
379 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
380 $context_type,
381 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
382 $db_context_id,
383 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
384 $this->manager_type
385 )
386 );
387
388 return (int) $count > 0;
389 }
390
391 /**
392 * Get supported context types
393 *
394 * @since 1.0.0
395 *
396 * @return array Array of supported context types
397 */
398 public function get_supported_contexts(): array {
399 return $this->supported_contexts;
400 }
401
402 /**
403 * String setting keys whose newlines must be preserved on save.
404 *
405 * @var string[]
406 */
407 private const MULTILINE_STRING_KEYS = ['robots_txt_content'];
408
409 /**
410 * Keys holding %token% TEMPLATES rather than plain text.
411 *
412 * sanitize_text_field() strips anything matching /%[a-f0-9]{2}/ as a
413 * percent-encoded byte, which silently eats the leading characters of any
414 * token whose first two letters are valid hex — %category_title% becomes
415 * "tegory_title%", %date% becomes "te%". These keys therefore go through
416 * sanitize_template_field() instead.
417 */
418 private const TEMPLATE_STRING_KEYS = [
419 'homepage_title', 'post_title', 'page_title', 'category_title', 'tag_title',
420 'search_title', 'archive_title', 'author_title',
421 'homepage_description', 'post_description', 'page_description',
422 'title_template', 'description_template',
423 'alt_format', 'title_format', 'caption_format',
424 'subject_template',
425 ];
426
427 /**
428 * REST envelope keys that must never become stored settings.
429 *
430 * Every settings endpoint answers with
431 * {settings, schema, context_type, context_id}. A caller that posts that
432 * whole envelope back as `settings` writes those four keys as rows, and
433 * because get_settings() returns every stored row, they then round-trip
434 * into the next request forever — the Site Identity payload carried ~6KB
435 * of a serialized copy of itself plus its own JSON schema on every save.
436 * They are not settings in any manager, so drop them on the way in.
437 *
438 * @var string[]
439 */
440 protected const RESERVED_ENVELOPE_KEYS = ['settings', 'schema', 'context_type', 'context_id'];
441
442 /**
443 * Setting keys this manager stores that its defaults do not name.
444 *
445 * get_default_settings() is the natural allow-list, but it is not complete
446 * in every manager: Site Identity declares 16 defaults while the screens
447 * behind it legitimately store 55 keys, and gating on defaults alone would
448 * stop title formats, breadcrumb configuration and business details from
449 * saving at all. A manager whose defaults are complete overrides nothing.
450 *
451 * @since 2.0.1
452 *
453 * @return string[]
454 */
455 protected function additional_setting_keys(): array {
456 return [];
457 }
458
459 /**
460 * Regular expressions matching key FAMILIES this manager stores.
461 *
462 * For settings whose key set is open by design — the schema manager's
463 * per-entity fields, the sitemap's per-post-type inclusion flags — an
464 * enumerated list would go stale the first time a post type is registered.
465 * Patterns are anchored and deliberately narrow: they must describe a
466 * family the manager owns, never a catch-all.
467 *
468 * @since 2.0.1
469 *
470 * @return string[] PCRE patterns, delimiters included.
471 */
472 protected function dynamic_setting_key_patterns(): array {
473 return [];
474 }
475
476 /**
477 * The setting keys this manager accepts.
478 *
479 * @since 2.0.1
480 *
481 * @param string $context_type Context the save is for.
482 * @return string[]
483 */
484 public function get_known_setting_keys(string $context_type = 'site'): array {
485 $keys = array_merge(
486 array_keys($this->get_default_settings($context_type)),
487 $this->additional_setting_keys()
488 );
489
490 $keys = array_values(array_unique(array_filter($keys, 'is_string')));
491
492 /**
493 * Filters the keys a settings category accepts.
494 *
495 * Shared with Settings_Management_Endpoint so an add-on registering
496 * settings against an existing category declares them once.
497 *
498 * @since 2.0.1
499 *
500 * @param string[] $keys Accepted setting keys.
501 * @param string $category Settings category (the manager type).
502 * @param string $context_type Context the save is for.
503 */
504 return apply_filters('thinkrank_known_setting_keys', $keys, $this->manager_type, $context_type);
505 }
506
507 /**
508 * Whether this manager stores a setting under this key.
509 *
510 * Public counterpart of is_known_setting_key() for callers outside the
511 * save path — the schema upgrade that clears rows written before the
512 * allow-list existed, and tests.
513 *
514 * @since 2.0.1
515 *
516 * @param string $key Setting key.
517 * @param string $context_type Context to judge it in.
518 * @return bool
519 */
520 public function accepts_setting_key(string $key, string $context_type = 'site'): bool {
521 return $this->is_known_setting_key(sanitize_key($key), $this->get_known_setting_keys($context_type));
522 }
523
524 /**
525 * Whether a key is one this manager stores.
526 *
527 * @since 2.0.1
528 *
529 * @param string $key Sanitized setting key.
530 * @param array $known Known keys for the context.
531 * @return bool
532 */
533 protected function is_known_setting_key(string $key, array $known): bool {
534 if (in_array($key, $known, true)) {
535 return true;
536 }
537
538 foreach ($this->dynamic_setting_key_patterns() as $pattern) {
539 if (preg_match($pattern, $key)) {
540 return true;
541 }
542 }
543
544 return false;
545 }
546
547 /**
548 * Sanitize settings array
549 *
550 * @since 1.0.0
551 *
552 * @param array $settings Settings to sanitize
553 * @return array Sanitized settings
554 */
555 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
556 $sanitized = [];
557 $known = $this->get_known_setting_keys($context_type);
558
559 foreach ($settings as $key => $value) {
560 $sanitized_key = sanitize_key($key);
561
562 if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
563 continue;
564 }
565
566 // A key no manager declares is not a setting. Stored, it becomes a
567 // row that get_settings() returns forever, so it round-trips into
568 // every later response and is re-posted by the UI on the next save
569 // — which is how the REST envelope came to be stored (#452).
570 if (!$this->is_known_setting_key($sanitized_key, $known)) {
571 $this->log_unknown_setting_key($sanitized_key);
572 continue;
573 }
574
575 if (is_string($value)) {
576 // Multi-line fields must keep their newlines; sanitize_text_field
577 // would flatten them onto a single line.
578 if (in_array($sanitized_key, self::MULTILINE_STRING_KEYS, true)) {
579 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
580 } elseif (in_array($sanitized_key, self::TEMPLATE_STRING_KEYS, true)) {
581 $sanitized[$sanitized_key] = $this->sanitize_template_field($value);
582 } else {
583 $sanitized[$sanitized_key] = sanitize_text_field($value);
584 }
585 } elseif (is_array($value)) {
586 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
587 } elseif (is_numeric($value)) {
588 $sanitized[$sanitized_key] = (float) $value;
589 } elseif (is_bool($value)) {
590 $sanitized[$sanitized_key] = (bool) $value;
591 } else {
592 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
593 }
594 }
595
596 return $sanitized;
597 }
598
599 /**
600 * Record a rejected setting key.
601 *
602 * Dropping silently is the hazard this gate carries: a legitimate key
603 * missing from a manager's declarations would disappear with no trace. On
604 * a debug install it says so; in production it stays quiet, since the
605 * common source is a client posting fields that were never settings.
606 *
607 * @since 2.0.1
608 *
609 * @param string $key Key that was dropped.
610 * @return void
611 */
612 private function log_unknown_setting_key(string $key): void {
613 if (!defined('WP_DEBUG') || !WP_DEBUG) {
614 return;
615 }
616
617 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic; a dropped key is otherwise invisible.
618 error_log(sprintf('ThinkRank [%s]: dropped unknown setting key "%s"', $this->manager_type, $key));
619 }
620
621 /**
622 * Sanitize a %token% template while keeping its tokens intact.
623 *
624 * Applies the same protections as sanitize_text_field() — tag stripping,
625 * invalid-UTF8 rejection, control-character and newline removal — but
626 * deliberately omits its percent-encoding strip, which corrupts tokens like
627 * %category_title% and %date%. Templates are only ever rendered into
628 * escaped output, so no percent sequence here reaches a URL context raw.
629 *
630 * @since 1.20.1
631 *
632 * @param string $value Raw template
633 * @return string Sanitized template
634 */
635 private function sanitize_template_field(string $value): string {
636 $filtered = wp_check_invalid_utf8($value);
637
638 if (strpos($filtered, '<') !== false) {
639 $filtered = wp_pre_kses_less_than($filtered);
640 // Wrap in a paragraph so wp_strip_all_tags() sees a complete node.
641 $filtered = wp_strip_all_tags($filtered, false);
642 $filtered = str_replace("<\n", "&lt;\n", $filtered);
643 }
644
645 // Collapse newlines/tabs to spaces and drop other control characters,
646 // mirroring sanitize_text_field()'s single-line guarantee.
647 $filtered = preg_replace('/[\r\n\t ]+/', ' ', $filtered);
648 $filtered = preg_replace('/[\x00-\x1F\x7F]/u', '', (string) $filtered);
649
650 return trim((string) $filtered);
651 }
652
653 /**
654 * Recursively sanitize array values
655 *
656 * @since 1.0.0
657 *
658 * @param array $input Array to sanitize
659 * @return array Sanitized array
660 */
661 private function sanitize_array_recursive(array $input): array {
662 $sanitized = [];
663
664 foreach ($input as $key => $value) {
665 $sanitized_key = sanitize_key($key);
666
667 if (is_string($value)) {
668 $sanitized[$sanitized_key] = sanitize_text_field($value);
669 } elseif (is_array($value)) {
670 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
671 } elseif (is_numeric($value)) {
672 $sanitized[$sanitized_key] = (float) $value;
673 } elseif (is_bool($value)) {
674 $sanitized[$sanitized_key] = (bool) $value;
675 } elseif ('' === $value || null === $value) {
676 // Handle empty values - preserve as empty string for open/close times, convert to boolean for closed
677 if ($sanitized_key === 'closed') {
678 $sanitized[$sanitized_key] = false;
679 } else {
680 $sanitized[$sanitized_key] = '';
681 }
682 } else {
683 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
684 }
685 }
686
687 return $sanitized;
688 }
689
690 /**
691 * Clear cache for specific context
692 *
693 * @since 1.0.0
694 *
695 * @param string $context_type The context type
696 * @param int|null $context_id Optional. Context ID
697 */
698 protected function clear_cache(string $context_type, ?int $context_id): void {
699 $cache_key = $this->get_cache_key($context_type, $context_id);
700 wp_cache_delete($cache_key, 'thinkrank_seo');
701
702 // Clear related transients
703 delete_transient("thinkrank_seo_{$this->manager_type}_{$context_type}_{$context_id}");
704 }
705
706 /**
707 * Get cache key for context
708 *
709 * @since 1.0.0
710 *
711 * @param string $context_type The context type
712 * @param int|null $context_id Optional. Context ID
713 * @return string Cache key
714 */
715 /**
716 * Setting keys stored as booleans, so a read hands them back as booleans.
717 *
718 * The database stores them as '1' / '', and a manager whose validator
719 * demands a real boolean will then reject its own stored values — which is
720 * exactly what made every save routed through
721 * Seo_Settings_Manager::save_settings_by_category() fail after it merged
722 * the existing settings back in (#395). Subclasses override this so the
723 * read and the validator cannot drift apart.
724 *
725 * @since 2.0.1
726 *
727 * @return string[] Keys to coerce to boolean on read.
728 */
729 protected function boolean_setting_keys(): array {
730 return [
731 'enabled',
732 'auto_generate_schema',
733 'rich_snippets_optimization',
734 'performance_tracking',
735 'auto_deploy',
736 'validation_on_save',
737 'rich_snippets_testing',
738 'organization_schema',
739 'knowledge_graph',
740 'add_missing_alt',
741 'add_missing_title',
742 'save_alt_to_media',
743 'auto_fill_on_upload',
744 'media_alt_overwrite',
745 ];
746 }
747
748 protected function get_cache_key(string $context_type, ?int $context_id): string {
749 // Normalise NULL to 0 so reads (which pass NULL for site-wide) and writes
750 // (which pass 0) resolve to the SAME cache entry — otherwise a save would
751 // never invalidate the value a front-end read cached.
752 $db_context_id = $context_id === null ? 0 : $context_id;
753 return "seo_settings_{$this->manager_type}_{$context_type}_{$db_context_id}";
754 }
755
756 /**
757 * Ensure settings table exists
758 *
759 * @since 1.0.0
760 *
761 * @return bool True if table exists or was created successfully
762 */
763 /**
764 * Record why a save failed, so "Failed to update … settings" in the UI has
765 * a matching, actionable line in the PHP error log.
766 *
767 * A customer cannot act on the generic message, and neither can support
768 * without this — the missing-table case (wizard blocked after migration,
769 * every settings screen failing) looked identical to a validation problem.
770 *
771 * The reason is also kept on the instance so the REST layer can put it in
772 * the response instead of a fixed string — see get_last_save_error().
773 *
774 * @since 1.28.0
775 *
776 * @param string $reason Why the save failed.
777 * @param string $code Optional. Machine-readable failure code.
778 * @return void
779 */
780 protected function log_save_failure(string $reason, string $code = 'save_failed'): void {
781 if ('' === $this->last_save_error) {
782 $this->last_save_error = $reason;
783 $this->last_save_error_code = $code;
784 }
785
786 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic; the UI only shows a generic failure message.
787 error_log(sprintf('ThinkRank [%s]: settings save failed — %s', $this->manager_type, $reason));
788 }
789
790 /**
791 * Why the last save_settings() call returned false.
792 *
793 * @since 1.32.1
794 *
795 * @return string Failure reason, or '' if the last save succeeded.
796 */
797 public function get_last_save_error(): string {
798 return $this->last_save_error;
799 }
800
801 /**
802 * Machine-readable code for the last save failure.
803 *
804 * One of: unsupported_context, settings_table_missing, validation_failed,
805 * db_insert_failed, save_failed.
806 *
807 * @since 1.32.1
808 *
809 * @return string Failure code, or '' if the last save succeeded.
810 */
811 public function get_last_save_error_code(): string {
812 return $this->last_save_error_code;
813 }
814
815 protected function ensure_settings_table_exists(): bool {
816 // Check if table exists
817 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table existence check requires direct database access
818 $table_exists = $this->wpdb->get_var(
819 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
820 $this->wpdb->prepare(
821 "SHOW TABLES LIKE %s",
822 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- settings_table is validated class property
823 $this->settings_table
824 )
825 );
826
827 return $table_exists === $this->settings_table;
828 }
829
830 // Abstract methods that must be implemented by concrete classes
831
832 /**
833 * Validate SEO settings (must be implemented by concrete classes)
834 *
835 * @since 1.0.0
836 *
837 * @param array $settings Settings array to validate
838 * @return array Validation results
839 */
840 abstract public function validate_settings(array $settings): array;
841
842 /**
843 * Get output data for frontend rendering (must be implemented by concrete classes)
844 *
845 * @since 1.0.0
846 *
847 * @param string $context_type The context type
848 * @param int|null $context_id Optional. Context ID
849 * @return array Output data ready for frontend rendering
850 */
851 abstract public function get_output_data(string $context_type, ?int $context_id): array;
852
853 /**
854 * Get default settings for a context type (must be implemented by concrete classes)
855 *
856 * @since 1.0.0
857 *
858 * @param string $context_type The context type to get defaults for
859 * @return array Default settings array
860 */
861 abstract public function get_default_settings(string $context_type): array;
862
863 /**
864 * Get settings schema definition (must be implemented by concrete classes)
865 *
866 * @since 1.0.0
867 *
868 * @param string $context_type The context type to get schema for
869 * @return array Settings schema definition
870 */
871 abstract public function get_settings_schema(string $context_type): array;
872
873 /**
874 * Bulk update settings
875 *
876 * @since 1.0.0
877 *
878 * @param array $bulk_settings Array of settings keyed by context_type:context_id
879 * @return array Results array with success/failure status for each update
880 */
881 public function bulk_update_settings(array $bulk_settings): array {
882 $results = [];
883
884 foreach ($bulk_settings as $context_key => $settings) {
885 // Parse context key (format: "context_type:context_id" or "context_type")
886 $parts = explode(':', $context_key);
887 $context_type = $parts[0];
888 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
889
890 $success = $this->save_settings($context_type, $context_id, $settings);
891 $results[$context_key] = [
892 'success' => $success,
893 'context_type' => $context_type,
894 'context_id' => $context_id,
895 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings'
896 ];
897 }
898
899 return $results;
900 }
901
902 /**
903 * Get settings history
904 *
905 * @since 1.0.0
906 *
907 * @param string $context_type The context type
908 * @param int|null $context_id Optional. Context ID
909 * @param int $limit Optional. Number of revisions to return
910 * @return array Array of settings revisions
911 */
912 public function get_settings_history(string $context_type, ?int $context_id, int $limit = 10): array {
913 // For now, return empty array - history tracking can be implemented later
914 // This would require additional database tables for revision tracking
915 return [];
916 }
917
918 /**
919 * Export settings
920 *
921 * @since 1.0.0
922 *
923 * @param string $context_type Optional. Context type to export
924 * @param int|null $context_id Optional. Context ID to export
925 * @return array Exported settings with metadata
926 */
927 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
928 $export_data = [
929 'version' => '1.0.0',
930 'manager_type' => $this->manager_type,
931 'exported_at' => current_time('mysql'),
932 'settings' => []
933 ];
934
935 if ($context_type !== null) {
936 // Export specific context
937 $settings = $this->get_settings($context_type, $context_id);
938 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
939 } else {
940 // Export all settings for this manager type
941 // 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
942 $sql = sprintf(
943 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
944 $this->settings_table
945 );
946 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
947 $results = $this->wpdb->get_results(
948 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
949 $this->wpdb->prepare(
950 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
951 $sql,
952 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
953 $this->manager_type
954 ),
955 ARRAY_A
956 );
957
958 $grouped_settings = [];
959 foreach ($results as $row) {
960 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
961 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
962 }
963
964 $export_data['settings'] = $grouped_settings;
965 }
966
967 return $export_data;
968 }
969
970 /**
971 * Import settings
972 *
973 * @since 1.0.0
974 *
975 * @param array $import_data Exported settings data
976 * @param array $options Import options
977 * @return array Import results with success/failure details
978 */
979 public function import_settings(array $import_data, array $options = []): array {
980 $results = [
981 'success' => true,
982 'imported_count' => 0,
983 'failed_count' => 0,
984 'details' => []
985 ];
986
987 // Validate import data structure
988 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
989 $results['success'] = false;
990 $results['details'][] = 'Invalid import data structure';
991 return $results;
992 }
993
994 // Default import options
995 $options = array_merge([
996 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
997 'validate' => true
998 ], $options);
999
1000 foreach ($import_data['settings'] as $context_key => $settings) {
1001 // Parse context key
1002 $parts = explode(':', $context_key);
1003 $context_type = $parts[0];
1004 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
1005
1006 // Check if settings already exist
1007 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
1008 $results['details'][] = "Skipped existing settings for {$context_key}";
1009 continue;
1010 }
1011
1012 // Merge with existing settings if requested
1013 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
1014 $existing_settings = $this->get_settings($context_type, $context_id);
1015 $settings = array_merge($existing_settings, $settings);
1016 }
1017
1018 // Validate settings if requested
1019 if ($options['validate']) {
1020 $validation = $this->validate_settings($settings);
1021 if (!$validation['valid']) {
1022 $results['failed_count']++;
1023 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
1024 continue;
1025 }
1026 }
1027
1028 // Import settings
1029 $success = $this->save_settings($context_type, $context_id, $settings);
1030 if ($success) {
1031 $results['imported_count']++;
1032 $results['details'][] = "Successfully imported settings for {$context_key}";
1033 } else {
1034 $results['failed_count']++;
1035 $results['details'][] = "Failed to import settings for {$context_key}";
1036 }
1037 }
1038
1039 $results['success'] = $results['failed_count'] === 0;
1040 return $results;
1041 }
1042 }
1043