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

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

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