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

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

638 lines 23.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abstract SEO Manager Base Class
4 *
5 * Provides common functionality for all SEO managers including database operations,
6 * validation patterns, and utility methods. All concrete SEO managers should extend
7 * this class to ensure consistent behavior and reduce code duplication.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 use ThinkRank\SEO\Interfaces\SEO_Manager_Interface;
19
20 /**
21 * Abstract SEO Manager Base Class
22 *
23 * Implements common functionality for all SEO managers following DRY principles.
24 * Provides database operations, validation utilities, and standardized patterns.
25 *
26 * @since 1.0.0
27 */
28 abstract class Abstract_SEO_Manager implements SEO_Manager_Interface {
29
30 /**
31 * WordPress database instance
32 *
33 * @since 1.0.0
34 * @var \wpdb
35 */
36 protected \wpdb $wpdb;
37
38 /**
39 * Settings table name
40 *
41 * @since 1.0.0
42 * @var string
43 */
44 protected string $settings_table;
45
46 /**
47 * Manager type identifier
48 *
49 * @since 1.0.0
50 * @var string
51 */
52 protected string $manager_type;
53
54 /**
55 * Supported context types
56 *
57 * @since 1.0.0
58 * @var array
59 */
60 protected array $supported_contexts = ['site', 'post', 'page', 'product'];
61
62 /**
63 * Constructor
64 *
65 * @since 1.0.0
66 *
67 * @param string $manager_type The manager type identifier
68 */
69 public function __construct(string $manager_type) {
70 global $wpdb;
71
72 $this->wpdb = $wpdb;
73 $this->settings_table = $wpdb->prefix . 'thinkrank_seo_settings';
74 $this->manager_type = sanitize_key($manager_type);
75 }
76
77 /**
78 * Get SEO settings for a specific context
79 *
80 * @since 1.0.0
81 *
82 * @param string $context_type The context type
83 * @param int|null $context_id Optional. Context ID
84 * @return array SEO settings array
85 */
86 public function get_settings(string $context_type, ?int $context_id = null): array {
87 $context_type = sanitize_key($context_type);
88
89 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
90 return $this->get_default_settings($context_type);
91 }
92
93 // Convert NULL context_id to 0 for site-wide settings to match save behavior
94 $db_context_id = $context_id === null ? 0 : $context_id;
95
96 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings require direct database access for real-time data, table name is validated
97 $sql = sprintf(
98 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
99 $this->settings_table
100 );
101
102 $results = $this->wpdb->get_results(
103 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
104 $this->wpdb->prepare(
105 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
106 $sql,
107 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameters are validated and used as placeholders
108 $context_type,
109 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- context_id is validated integer
110 $db_context_id,
111 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- manager_type is validated class property
112 $this->manager_type
113 ),
114 ARRAY_A
115 );
116
117 $settings = [];
118 foreach ($results as $row) {
119 $value = maybe_unserialize($row['setting_value']);
120
121 // Ensure proper data type conversion for common boolean fields
122 if (in_array($row['setting_key'], [
123 'enabled', 'auto_generate_schema', 'rich_snippets_optimization',
124 'performance_tracking', 'auto_deploy', 'validation_on_save',
125 'rich_snippets_testing', 'organization_schema', 'knowledge_graph'
126 ], true)) {
127 // Convert string/numeric boolean representations to actual booleans
128 if (is_string($value)) {
129 $value = in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
130 } elseif (is_numeric($value)) {
131 $value = (bool) $value;
132 }
133 }
134
135 // Ensure cache_duration is an integer
136 if ($row['setting_key'] === 'cache_duration') {
137 $value = (int) $value;
138 }
139
140 $settings[$row['setting_key']] = $value;
141 }
142
143 // Merge with defaults to ensure all required keys exist
144 return array_merge($this->get_default_settings($context_type), $settings);
145 }
146
147 /**
148 * Save SEO settings for a specific context
149 *
150 * @since 1.0.0
151 *
152 * @param string $context_type The context type
153 * @param int|null $context_id Optional. Context ID
154 * @param array $settings Settings array to save
155 * @return bool True on success, false on failure
156 */
157 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
158 $context_type = sanitize_key($context_type);
159
160 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
161 // Unsupported context type - validation failed
162 return false;
163 }
164
165 // Check if settings table exists
166 if (!$this->ensure_settings_table_exists()) {
167 // Settings table creation failed
168 return false;
169 }
170
171 // Validate settings before saving
172 $validation = $this->validate_settings($settings);
173 if (!$validation['valid']) {
174 // Settings validation failed - error details available in validation response
175 return false;
176 }
177
178 // Sanitize settings
179 $sanitized_settings = $this->sanitize_settings($settings);
180
181 $success = true;
182 foreach ($sanitized_settings as $key => $value) {
183 $sanitized_key = sanitize_key($key);
184 $serialized_value = maybe_serialize($value);
185 $current_time = current_time('mysql');
186
187 // Convert NULL context_id to 0 for site-wide settings to work with UNIQUE constraint
188 // MySQL treats multiple NULL values as distinct in UNIQUE constraints
189 $db_context_id = $context_id === null ? 0 : $context_id;
190
191 // Use INSERT ... ON DUPLICATE KEY UPDATE for proper upsert behavior
192 $sql = $this->wpdb->prepare(
193 "INSERT INTO `{$this->settings_table}`
194 (`context_type`, `context_id`, `setting_category`, `setting_key`, `setting_value`, `is_active`, `created_at`, `updated_at`)
195 VALUES (%s, %d, %s, %s, %s, %d, %s, %s)
196 ON DUPLICATE KEY UPDATE
197 `setting_value` = VALUES(`setting_value`),
198 `is_active` = VALUES(`is_active`),
199 `updated_at` = VALUES(`updated_at`)",
200 $context_type,
201 $db_context_id,
202 $this->manager_type,
203 $sanitized_key,
204 $serialized_value,
205 1,
206 $current_time,
207 $current_time
208 );
209
210 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- SEO settings require direct database access, SQL is properly prepared
211 $result = $this->wpdb->query($sql);
212
213 if (false === $result) {
214 // Database operation failed - error details available in wpdb->last_error
215 $success = false;
216 }
217 }
218
219 // Clear relevant caches
220 $this->clear_cache($context_type, $context_id);
221
222 return $success;
223 }
224
225 /**
226 * Delete settings for a specific context
227 *
228 * @since 1.0.0
229 *
230 * @param string $context_type The context type
231 * @param int|null $context_id Optional. Context ID
232 * @return bool True on success, false on failure
233 */
234 public function delete_settings(string $context_type, ?int $context_id): bool {
235 $context_type = sanitize_key($context_type);
236
237 // Convert NULL context_id to 0 for site-wide settings to match save behavior
238 $db_context_id = $context_id === null ? 0 : $context_id;
239
240 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO settings deletion requires direct database access
241 $result = $this->wpdb->delete(
242 $this->settings_table,
243 [
244 'context_type' => $context_type,
245 'context_id' => $db_context_id,
246 'setting_category' => $this->manager_type
247 ],
248 ['%s', '%d', '%s']
249 );
250
251 if ($result !== false) {
252 $this->clear_cache($context_type, $context_id);
253 return true;
254 }
255
256 return false;
257 }
258
259 /**
260 * Check if settings exist for a context
261 *
262 * @since 1.0.0
263 *
264 * @param string $context_type The context type
265 * @param int|null $context_id Optional. Context ID
266 * @return bool True if settings exist, false otherwise
267 */
268 public function has_settings(string $context_type, ?int $context_id): bool {
269 $context_type = sanitize_key($context_type);
270
271 // Convert NULL context_id to 0 for site-wide settings to match save behavior
272 $db_context_id = $context_id === null ? 0 : $context_id;
273
274 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings existence check requires direct database access, table name is validated
275 $sql = sprintf(
276 'SELECT COUNT(*) FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
277 $this->settings_table
278 );
279
280 $count = $this->wpdb->get_var(
281 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
282 $this->wpdb->prepare(
283 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
284 $sql,
285 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameters are validated and used as placeholders
286 $context_type,
287 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- context_id is validated integer
288 $db_context_id,
289 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- manager_type is validated class property
290 $this->manager_type
291 )
292 );
293
294 return (int) $count > 0;
295 }
296
297 /**
298 * Get supported context types
299 *
300 * @since 1.0.0
301 *
302 * @return array Array of supported context types
303 */
304 public function get_supported_contexts(): array {
305 return $this->supported_contexts;
306 }
307
308 /**
309 * Sanitize settings array
310 *
311 * @since 1.0.0
312 *
313 * @param array $settings Settings to sanitize
314 * @return array Sanitized settings
315 */
316 protected function sanitize_settings(array $settings): array {
317 $sanitized = [];
318
319 foreach ($settings as $key => $value) {
320 $sanitized_key = sanitize_key($key);
321
322 if (is_string($value)) {
323 $sanitized[$sanitized_key] = sanitize_text_field($value);
324 } elseif (is_array($value)) {
325 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
326 } elseif (is_numeric($value)) {
327 $sanitized[$sanitized_key] = (float) $value;
328 } elseif (is_bool($value)) {
329 $sanitized[$sanitized_key] = (bool) $value;
330 } else {
331 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
332 }
333 }
334
335 return $sanitized;
336 }
337
338 /**
339 * Recursively sanitize array values
340 *
341 * @since 1.0.0
342 *
343 * @param array $array Array to sanitize
344 * @return array Sanitized array
345 */
346 private function sanitize_array_recursive(array $array): array {
347 $sanitized = [];
348
349 foreach ($array as $key => $value) {
350 $sanitized_key = sanitize_key($key);
351
352 if (is_string($value)) {
353 $sanitized[$sanitized_key] = sanitize_text_field($value);
354 } elseif (is_array($value)) {
355 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
356 } elseif (is_numeric($value)) {
357 $sanitized[$sanitized_key] = (float) $value;
358 } elseif (is_bool($value)) {
359 $sanitized[$sanitized_key] = (bool) $value;
360 } elseif ('' === $value || null === $value) {
361 // Handle empty values - preserve as empty string for open/close times, convert to boolean for closed
362 if ($sanitized_key === 'closed') {
363 $sanitized[$sanitized_key] = false;
364 } else {
365 $sanitized[$sanitized_key] = '';
366 }
367 } else {
368 $sanitized[$sanitized_key] = sanitize_text_field((string) $value);
369 }
370 }
371
372 return $sanitized;
373 }
374
375 /**
376 * Clear cache for specific context
377 *
378 * @since 1.0.0
379 *
380 * @param string $context_type The context type
381 * @param int|null $context_id Optional. Context ID
382 */
383 protected function clear_cache(string $context_type, ?int $context_id): void {
384 $cache_key = $this->get_cache_key($context_type, $context_id);
385 wp_cache_delete($cache_key, 'thinkrank_seo');
386
387 // Clear related transients
388 delete_transient("thinkrank_seo_{$this->manager_type}_{$context_type}_{$context_id}");
389 }
390
391 /**
392 * Get cache key for context
393 *
394 * @since 1.0.0
395 *
396 * @param string $context_type The context type
397 * @param int|null $context_id Optional. Context ID
398 * @return string Cache key
399 */
400 protected function get_cache_key(string $context_type, ?int $context_id): string {
401 return "seo_settings_{$this->manager_type}_{$context_type}_" . ($context_id ?? 'site');
402 }
403
404 /**
405 * Ensure settings table exists
406 *
407 * @since 1.0.0
408 *
409 * @return bool True if table exists or was created successfully
410 */
411 protected function ensure_settings_table_exists(): bool {
412 // Check if table exists
413 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table existence check requires direct database access
414 $table_exists = $this->wpdb->get_var(
415 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
416 $this->wpdb->prepare(
417 "SHOW TABLES LIKE %s",
418 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- settings_table is validated class property
419 $this->settings_table
420 )
421 );
422
423 return $table_exists === $this->settings_table;
424 }
425
426 // Abstract methods that must be implemented by concrete classes
427
428 /**
429 * Validate SEO settings (must be implemented by concrete classes)
430 *
431 * @since 1.0.0
432 *
433 * @param array $settings Settings array to validate
434 * @return array Validation results
435 */
436 abstract public function validate_settings(array $settings): array;
437
438 /**
439 * Get output data for frontend rendering (must be implemented by concrete classes)
440 *
441 * @since 1.0.0
442 *
443 * @param string $context_type The context type
444 * @param int|null $context_id Optional. Context ID
445 * @return array Output data ready for frontend rendering
446 */
447 abstract public function get_output_data(string $context_type, ?int $context_id): array;
448
449 /**
450 * Get default settings for a context type (must be implemented by concrete classes)
451 *
452 * @since 1.0.0
453 *
454 * @param string $context_type The context type to get defaults for
455 * @return array Default settings array
456 */
457 abstract public function get_default_settings(string $context_type): array;
458
459 /**
460 * Get settings schema definition (must be implemented by concrete classes)
461 *
462 * @since 1.0.0
463 *
464 * @param string $context_type The context type to get schema for
465 * @return array Settings schema definition
466 */
467 abstract public function get_settings_schema(string $context_type): array;
468
469 /**
470 * Bulk update settings
471 *
472 * @since 1.0.0
473 *
474 * @param array $bulk_settings Array of settings keyed by context_type:context_id
475 * @return array Results array with success/failure status for each update
476 */
477 public function bulk_update_settings(array $bulk_settings): array {
478 $results = [];
479
480 foreach ($bulk_settings as $context_key => $settings) {
481 // Parse context key (format: "context_type:context_id" or "context_type")
482 $parts = explode(':', $context_key);
483 $context_type = $parts[0];
484 $context_id = isset($parts[1]) ? (int) $parts[1] : null;
485
486 $success = $this->save_settings($context_type, $context_id, $settings);
487 $results[$context_key] = [
488 'success' => $success,
489 'context_type' => $context_type,
490 'context_id' => $context_id,
491 'message' => $success ? 'Settings updated successfully' : 'Failed to update settings'
492 ];
493 }
494
495 return $results;
496 }
497
498 /**
499 * Get settings history
500 *
501 * @since 1.0.0
502 *
503 * @param string $context_type The context type
504 * @param int|null $context_id Optional. Context ID
505 * @param int $limit Optional. Number of revisions to return
506 * @return array Array of settings revisions
507 */
508 public function get_settings_history(string $context_type, ?int $context_id, int $limit = 10): array {
509 // For now, return empty array - history tracking can be implemented later
510 // This would require additional database tables for revision tracking
511 return [];
512 }
513
514 /**
515 * Export settings
516 *
517 * @since 1.0.0
518 *
519 * @param string $context_type Optional. Context type to export
520 * @param int|null $context_id Optional. Context ID to export
521 * @return array Exported settings with metadata
522 */
523 public function export_settings(?string $context_type = null, ?int $context_id = null): array {
524 $export_data = [
525 'version' => '1.0.0',
526 'manager_type' => $this->manager_type,
527 'exported_at' => current_time('mysql'),
528 'settings' => []
529 ];
530
531 if ($context_type !== null) {
532 // Export specific context
533 $settings = $this->get_settings($context_type, $context_id);
534 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
535 } else {
536 // Export all settings for this manager type
537 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings export requires direct database access, table name is validated
538 $sql = sprintf(
539 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
540 $this->settings_table
541 );
542 $results = $this->wpdb->get_results(
543 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
544 $this->wpdb->prepare(
545 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
546 $sql,
547 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- manager_type is validated class property
548 $this->manager_type
549 ),
550 ARRAY_A
551 );
552
553 $grouped_settings = [];
554 foreach ($results as $row) {
555 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');
556 $grouped_settings[$key][$row['setting_key']] = maybe_unserialize($row['setting_value']);
557 }
558
559 $export_data['settings'] = $grouped_settings;
560 }
561
562 return $export_data;
563 }
564
565 /**
566 * Import settings
567 *
568 * @since 1.0.0
569 *
570 * @param array $import_data Exported settings data
571 * @param array $options Import options
572 * @return array Import results with success/failure details
573 */
574 public function import_settings(array $import_data, array $options = []): array {
575 $results = [
576 'success' => true,
577 'imported_count' => 0,
578 'failed_count' => 0,
579 'details' => []
580 ];
581
582 // Validate import data structure
583 if (!isset($import_data['settings']) || !is_array($import_data['settings'])) {
584 $results['success'] = false;
585 $results['details'][] = 'Invalid import data structure';
586 return $results;
587 }
588
589 // Default import options
590 $options = array_merge([
591 'merge_strategy' => 'replace', // 'replace', 'merge', 'skip_existing'
592 'validate' => true
593 ], $options);
594
595 foreach ($import_data['settings'] as $context_key => $settings) {
596 // Parse context key
597 $parts = explode(':', $context_key);
598 $context_type = $parts[0];
599 $context_id = isset($parts[1]) && $parts[1] !== 'site' ? (int) $parts[1] : null;
600
601 // Check if settings already exist
602 if ($options['merge_strategy'] === 'skip_existing' && $this->has_settings($context_type, $context_id)) {
603 $results['details'][] = "Skipped existing settings for {$context_key}";
604 continue;
605 }
606
607 // Merge with existing settings if requested
608 if ($options['merge_strategy'] === 'merge' && $this->has_settings($context_type, $context_id)) {
609 $existing_settings = $this->get_settings($context_type, $context_id);
610 $settings = array_merge($existing_settings, $settings);
611 }
612
613 // Validate settings if requested
614 if ($options['validate']) {
615 $validation = $this->validate_settings($settings);
616 if (!$validation['valid']) {
617 $results['failed_count']++;
618 $results['details'][] = "Validation failed for {$context_key}: " . implode(', ', $validation['errors']);
619 continue;
620 }
621 }
622
623 // Import settings
624 $success = $this->save_settings($context_type, $context_id, $settings);
625 if ($success) {
626 $results['imported_count']++;
627 $results['details'][] = "Successfully imported settings for {$context_key}";
628 } else {
629 $results['failed_count']++;
630 $results['details'][] = "Failed to import settings for {$context_key}";
631 }
632 }
633
634 $results['success'] = $results['failed_count'] === 0;
635 return $results;
636 }
637 }
638