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

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