PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.3.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.3.0
2.8.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 All 49 releases
thinkrank / includes / admin / importers / class-abstract-plugin-exporter.php

class-abstract-plugin-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.3.0, at includes/admin/importers/class-abstract-plugin-exporter.php

651 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Abstract Plugin Exporter
5 *
6 * Base class for all plugin-specific exporters. Provides shared orchestration
7 * logic for reading source data, normalizing it, and writing to wp_options
8 * via Snapshot_Store.
9 *
10 * @package ThinkRank\Admin\Importers
11 * @since 2.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Admin\Importers;
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Abstract Plugin Exporter Class
24 *
25 * @since 2.0.0
26 */
27 abstract class Abstract_Plugin_Exporter {
28
29 /**
30 * Plugin slug identifier (e.g., 'yoast')
31 *
32 * @var string
33 */
34 protected string $plugin_slug;
35
36 /**
37 * Human-readable plugin name
38 *
39 * @var string
40 */
41 protected string $plugin_name;
42
43 /**
44 * Plugin file path for detection
45 *
46 * @var string
47 */
48 protected string $plugin_file;
49
50 /**
51 * Meta key prefix used by the source plugin
52 *
53 * @var string
54 */
55 protected string $meta_key_prefix;
56
57 /**
58 * WordPress option keys used by the source plugin for settings
59 *
60 * @var array
61 */
62 protected array $option_keys = [];
63
64 /**
65 * Number of records per chunk
66 *
67 * @var int
68 */
69 protected int $chunk_size = 100;
70
71 /**
72 * Raw number of rows the current page's paginated query returned, before any
73 * per-record filtering. Page methods that skip records (e.g. users with no
74 * migratable title/description) set this via the id helpers so export_chunk()
75 * can decide has_more from the fetched-row count, not the emitted count.
76 * Null means the page method didn't report one (1:1 methods) — fall back to
77 * the emitted count.
78 *
79 * @var int|null
80 */
81 protected ?int $last_page_row_count = null;
82
83 /**
84 * Ordered list of data types to export
85 */
86 private const EXPORT_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
87
88 /**
89 * Detect whether this plugin's data exists in the database
90 *
91 * @return bool True if source data is detected
92 */
93 abstract public function detect(): bool;
94
95 /**
96 * Get available data types with their record counts
97 *
98 * @return array Associative array of type => count
99 */
100 abstract public function get_available_types(): array;
101
102 /**
103 * Export a page of post meta data
104 *
105 * @param int $page Page number (1-indexed)
106 * @return array Array of normalized records
107 */
108 abstract protected function export_postmeta_page(int $page): array;
109
110 /**
111 * Export a page of term meta data
112 *
113 * @param int $page Page number (1-indexed)
114 * @return array Array of normalized records
115 */
116 abstract protected function export_termmeta_page(int $page): array;
117
118 /**
119 * Export a page of user meta data
120 *
121 * @param int $page Page number (1-indexed)
122 * @return array Array of normalized records
123 */
124 abstract protected function export_usermeta_page(int $page): array;
125
126 /**
127 * Export global settings
128 *
129 * @return array Array with single settings record
130 */
131 abstract protected function export_settings(): array;
132
133 /**
134 * Export a page of redirections
135 *
136 * @param int $page Page number (1-indexed)
137 * @return array Array of normalized records
138 */
139 abstract protected function export_redirections_page(int $page): array;
140
141 /**
142 * Export a page of logged 404 hits.
143 *
144 * Concrete, not abstract: only source plugins that ship a 404 monitor have
145 * anything to hand over, so the default is "nothing to export" and the
146 * exporters that do (Rank Math) override it.
147 *
148 * @param int $page Page number (1-indexed)
149 * @return array Array of normalized records
150 */
151 protected function export_404_logs_page(int $page): array {
152 return [];
153 }
154
155 /**
156 * Records for a data type outside the fixed set above.
157 *
158 * Nothing to hand over by default. It exists so an exporter can gain a type
159 * (ThinkRank's own export lets Pro register its tables this way) without
160 * every subclass having to reimplement export_chunk()'s chunk writing and
161 * manifest bookkeeping.
162 *
163 * @since 2.2.0
164 *
165 * @param string $type Data type
166 * @param int $page Page number (1-indexed)
167 * @return array Records
168 */
169 protected function export_custom_type_page(string $type, int $page): array {
170 return [];
171 }
172
173 /**
174 * Capture the source plugin's Role Manager assignments: role slug => the
175 * capabilities that role holds whose name starts with $prefix.
176 *
177 * Shared by every exporter because role capabilities live on the roles
178 * themselves (`wp_user_roles`), not in any of the plugin's own options — so
179 * an exporter's raw option capture never covers them, whichever plugin it is.
180 *
181 * The administrator is skipped: ThinkRank's Capability_Manager never
182 * modifies it (it always passes via `manage_options`), so carrying its caps
183 * over would be meaningless.
184 *
185 * @param string $prefix Source capability prefix, e.g. 'rank_math_' or 'wpseo_'
186 * @return array<string,string[]> Role slug => granted capabilities
187 */
188 protected function extract_role_capabilities(string $prefix): array {
189 if (!function_exists('wp_roles') || $prefix === '') {
190 return [];
191 }
192
193 $captured = [];
194 foreach (wp_roles()->roles as $slug => $role) {
195 if ($slug === 'administrator' || empty($role['capabilities'])) {
196 continue;
197 }
198
199 $caps = [];
200 foreach ($role['capabilities'] as $cap => $granted) {
201 // Roles store revoked caps as `false`; only carry over grants.
202 if ($granted && strpos((string) $cap, $prefix) === 0) {
203 $caps[] = (string) $cap;
204 }
205 }
206
207 if (!empty($caps)) {
208 sort($caps);
209 $captured[(string) $slug] = $caps;
210 }
211 }
212
213 return $captured;
214 }
215
216 /**
217 * Convert plugin-specific template variables to literal values
218 *
219 * Accepts mixed because the input is another plugin's stored data, over
220 * which we have no schema guarantees. Rank Math in particular can hold
221 * booleans inside its options arrays where a template string is expected,
222 * and the `?? ''` at the call sites only guards against a MISSING key —
223 * a present-but-boolean value sailed straight into a string-typed
224 * parameter and fataled the whole migration at the snapshot step.
225 * Implementations MUST start with stringify_template_value().
226 *
227 * @param mixed $value Value potentially containing template variables
228 * @param int|null $post_id Post ID for context-specific variables
229 * @return string Converted string
230 */
231 abstract protected function convert_template_variables($value, ?int $post_id = null): string;
232
233 /**
234 * Coerce a foreign settings/meta value into a template string.
235 *
236 * Strings pass through; ints and floats are kept as their string form (a
237 * purely numeric title is odd but meaningful); everything else — booleans,
238 * arrays, objects, null — has no sensible reading as a template, so it
239 * becomes '', which downstream already treats as "not set" and replaces
240 * with defaults. Dropping garbage beats failing the migration over it.
241 *
242 * @param mixed $value Raw value from the source plugin's storage.
243 * @return string Usable template string, possibly ''.
244 */
245 final protected function stringify_template_value($value): string {
246 if (is_string($value)) {
247 return $value;
248 }
249
250 if (is_int($value) || is_float($value)) {
251 return (string) $value;
252 }
253
254 return '';
255 }
256
257 /**
258 * Export a chunk of data and write to snapshot
259 *
260 * This is the main orchestration method. It calls the appropriate
261 * export_*_page() method, writes the chunk via Snapshot_Store, and
262 * updates the manifest.
263 *
264 * @param string $type Data type to export
265 * @param int $page Page number (1-indexed)
266 * @return array Result with status, has_more, page, total, exported
267 */
268 public function export_chunk(string $type, int $page): array {
269 // Reset before the page method runs; it (or its id helper) records the
270 // raw fetched-row count here.
271 $this->last_page_row_count = null;
272
273 switch ($type) {
274 case 'postmeta':
275 $records = $this->export_postmeta_page($page);
276 break;
277 case 'termmeta':
278 $records = $this->export_termmeta_page($page);
279 break;
280 case 'usermeta':
281 $records = $this->export_usermeta_page($page);
282 break;
283 case 'settings':
284 $records = $this->export_settings();
285 break;
286 case 'redirections':
287 $records = $this->export_redirections_page($page);
288 break;
289 case '404_logs':
290 $records = $this->export_404_logs_page($page);
291 break;
292 default:
293 $records = $this->export_custom_type_page($type, $page);
294 }
295
296 $exported_count = count($records);
297
298 // Write chunk to snapshot store
299 if ($exported_count > 0) {
300 Snapshot_Store::write_chunk($this->plugin_slug, $type, $page, $records);
301 }
302
303 // Determine if there are more pages from the number of rows the paginated
304 // query returned, NOT the emitted count. A page method may fetch a full
305 // chunk_size of rows but emit fewer after filtering (e.g. users without a
306 // migratable title/description); keying has_more off the emitted count
307 // would halt pagination early and silently skip later pages. Fall back to
308 // the emitted count for 1:1 page methods that don't report a row count.
309 $fetched_count = $this->last_page_row_count ?? $exported_count;
310 $has_more = $fetched_count >= $this->chunk_size && $type !== 'settings';
311
312 // Get total count for this type
313 $types = $this->get_available_types();
314 $total = $types[$type] ?? 0;
315
316 // Update manifest
317 $this->update_manifest($type, $page, $exported_count, $has_more, $total);
318
319 return [
320 'status' => $has_more ? 'processing' : 'complete',
321 'message' => sprintf(
322 'Exported %d %s records (page %d)',
323 $exported_count,
324 $type,
325 $page
326 ),
327 'has_more' => $has_more,
328 'page' => $page,
329 'total' => $total,
330 'exported' => $exported_count,
331 ];
332 }
333
334 /**
335 * Update the snapshot manifest after writing a chunk
336 *
337 * @param string $type Data type
338 * @param int $page Current page
339 * @param int $count Records in this chunk
340 * @param bool $has_more Whether more pages remain
341 * @param int $total Total records for this type
342 * @return void
343 */
344 private function update_manifest(string $type, int $page, int $count, bool $has_more, int $total): void {
345 $manifest = Snapshot_Store::get_manifest($this->plugin_slug) ?? [
346 'plugin' => $this->plugin_slug,
347 'plugin_name' => $this->plugin_name,
348 'exported_at' => gmdate('c'),
349 'version' => '1.0',
350 'types' => [],
351 'status' => 'exporting',
352 'last_migrated' => null,
353 'migration_version' => null,
354 ];
355
356 // Update type info
357 if (!isset($manifest['types'][$type])) {
358 $manifest['types'][$type] = [
359 'total_records' => $total,
360 'total_chunks' => 0,
361 ];
362 }
363
364 $manifest['types'][$type]['total_chunks'] = $page;
365 $manifest['types'][$type]['total_records'] = $total;
366 $manifest['status'] = 'exporting';
367 $manifest['exported_at'] = gmdate('c');
368
369 Snapshot_Store::write_manifest($this->plugin_slug, $manifest);
370 }
371
372 /**
373 * Mark the export as complete in the manifest
374 *
375 * Called by the controller after all types have been exported.
376 *
377 * @return void
378 */
379 public function finalize_export(): void {
380 $manifest = Snapshot_Store::get_manifest($this->plugin_slug);
381 if ($manifest) {
382 $manifest['status'] = 'complete';
383 $manifest['exported_at'] = gmdate('c');
384 Snapshot_Store::write_manifest($this->plugin_slug, $manifest);
385 }
386 }
387
388 /**
389 * Normalize robots directives from any plugin format to standard 0/1 values
390 *
391 * Handles:
392 * - Integer/string 1/0 (Yoast noindex/nofollow)
393 * - Serialized array containing 'noindex'/'nofollow' strings (Rank Math)
394 * - Boolean true/false (AIOSEO)
395 * - String 'yes' for noindex (SEOPress inverted logic — caller must handle inversion)
396 *
397 * @param mixed $noindex_value Raw noindex value from source
398 * @param mixed $nofollow_value Raw nofollow value from source
399 * @return array ['noindex' => 0|1, 'nofollow' => 0|1]
400 */
401 protected function normalize_robots($noindex_value, $nofollow_value = null): array {
402 $result = ['noindex' => 0, 'nofollow' => 0];
403
404 // Handle serialized array (Rank Math stores robots as serialized array)
405 if (is_string($noindex_value) && is_serialized($noindex_value)) {
406 $noindex_value = Safe_Unserializer::unserialize($noindex_value);
407 }
408
409 if (is_array($noindex_value)) {
410 // Rank Math format: serialized array with 'noindex', 'nofollow' as values
411 $result['noindex'] = in_array('noindex', $noindex_value, true) ? 1 : 0;
412 $result['nofollow'] = in_array('nofollow', $noindex_value, true) ? 1 : 0;
413 return $result;
414 }
415
416 // Handle individual values
417 $result['noindex'] = $this->normalize_bool_value($noindex_value);
418
419 if ($nofollow_value !== null) {
420 $result['nofollow'] = $this->normalize_bool_value($nofollow_value);
421 }
422
423 return $result;
424 }
425
426 /**
427 * Normalize a value to 0 or 1
428 *
429 * @param mixed $value Value to normalize
430 * @return int 0 or 1
431 */
432 private function normalize_bool_value($value): int {
433 if ($value === null || $value === '' || $value === false) {
434 return 0;
435 }
436
437 if (is_bool($value)) {
438 return $value ? 1 : 0;
439 }
440
441 return (int) $value ? 1 : 0;
442 }
443
444 /**
445 * Get paginated post IDs that have meta keys with the plugin's prefix
446 *
447 * @param int $page Page number (1-indexed)
448 * @return array Array of post IDs
449 */
450 protected function get_post_ids_with_meta(int $page): array {
451 global $wpdb;
452
453 $offset = ($page - 1) * $this->chunk_size;
454 $post_types = $this->get_exportable_post_types();
455
456 // Defensive: a site with no viewable post types has nothing to export.
457 if (empty($post_types)) {
458 return [];
459 }
460
461 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
462
463 // Restrict to publicly-viewable post types so WordPress-internal objects
464 // (oembed_cache, revisions, nav menu items, block/template CPTs, …) never
465 // enter the snapshot — SEO meta left on them is noise. Filtering in the
466 // query (rather than per-record) keeps the chunk-size based has_more
467 // pagination in export_chunk() accurate.
468 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
469 $sql = $wpdb->prepare(
470 "SELECT DISTINCT pm.post_id
471 FROM {$wpdb->postmeta} pm
472 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
473 WHERE pm.meta_key LIKE %s
474 AND p.post_type IN ({$placeholders})
475 ORDER BY pm.post_id ASC
476 LIMIT %d OFFSET %d",
477 array_merge(
478 [$wpdb->esc_like($this->meta_key_prefix) . '%'],
479 $post_types,
480 [$this->chunk_size, $offset]
481 )
482 );
483 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
484
485 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
486 $ids = $wpdb->get_col($sql);
487 $this->last_page_row_count = count($ids);
488
489 return $ids;
490 }
491
492 /**
493 * Publicly-viewable post types whose meta is worth exporting.
494 *
495 * Excludes WordPress-internal types (oembed_cache, revision, nav_menu_item,
496 * wp_* block/template CPTs) that are never served to visitors and therefore
497 * carry no meaningful SEO data.
498 *
499 * @return string[] Post type slugs
500 */
501 protected function get_exportable_post_types(): array {
502 return array_values(
503 array_filter(get_post_types([], 'names'), 'is_post_type_viewable')
504 );
505 }
506
507 /**
508 * Get paginated term IDs that have meta keys with the plugin's prefix
509 *
510 * @param int $page Page number (1-indexed)
511 * @return array Array of term IDs
512 */
513 protected function get_term_ids_with_meta(int $page): array {
514 global $wpdb;
515
516 $offset = ($page - 1) * $this->chunk_size;
517
518 $ids = $wpdb->get_col(
519 $wpdb->prepare(
520 "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key LIKE %s ORDER BY term_id ASC LIMIT %d OFFSET %d",
521 $wpdb->esc_like($this->meta_key_prefix) . '%',
522 $this->chunk_size,
523 $offset
524 )
525 );
526 $this->last_page_row_count = count($ids);
527
528 return $ids;
529 }
530
531 /**
532 * Get paginated user IDs that have meta keys with the plugin's prefix
533 *
534 * @param int $page Page number (1-indexed)
535 * @return array Array of user IDs
536 */
537 protected function get_user_ids_with_meta(int $page): array {
538 global $wpdb;
539
540 $offset = ($page - 1) * $this->chunk_size;
541
542 $ids = $wpdb->get_col(
543 $wpdb->prepare(
544 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id ASC LIMIT %d OFFSET %d",
545 $wpdb->esc_like($this->meta_key_prefix) . '%',
546 $this->chunk_size,
547 $offset
548 )
549 );
550 $this->last_page_row_count = count($ids);
551
552 return $ids;
553 }
554
555 /**
556 * Get all meta values for a user with the plugin's prefix
557 *
558 * @param int $user_id User ID
559 * @return array Associative array of meta_key => meta_value
560 */
561 protected function get_all_plugin_user_meta(int $user_id): array {
562 global $wpdb;
563
564 $results = $wpdb->get_results(
565 $wpdb->prepare(
566 "SELECT meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key LIKE %s",
567 $user_id,
568 $wpdb->esc_like($this->meta_key_prefix) . '%'
569 ),
570 ARRAY_A
571 );
572
573 $meta = [];
574 foreach ($results as $row) {
575 $meta[$row['meta_key']] = $row['meta_value'];
576 }
577
578 return $meta;
579 }
580
581 /**
582 * Get all meta values for a post with the plugin's prefix
583 *
584 * @param int $post_id Post ID
585 * @return array Associative array of meta_key => meta_value
586 */
587 protected function get_all_plugin_meta(int $post_id): array {
588 global $wpdb;
589
590 $results = $wpdb->get_results(
591 $wpdb->prepare(
592 "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s",
593 $post_id,
594 $wpdb->esc_like($this->meta_key_prefix) . '%'
595 ),
596 ARRAY_A
597 );
598
599 $meta = [];
600 foreach ($results as $row) {
601 $meta[$row['meta_key']] = $row['meta_value'];
602 }
603
604 return $meta;
605 }
606
607 /**
608 * Get all meta values for a term with the plugin's prefix
609 *
610 * @param int $term_id Term ID
611 * @return array Associative array of meta_key => meta_value
612 */
613 protected function get_all_plugin_term_meta(int $term_id): array {
614 global $wpdb;
615
616 $results = $wpdb->get_results(
617 $wpdb->prepare(
618 "SELECT meta_key, meta_value FROM {$wpdb->termmeta} WHERE term_id = %d AND meta_key LIKE %s",
619 $term_id,
620 $wpdb->esc_like($this->meta_key_prefix) . '%'
621 ),
622 ARRAY_A
623 );
624
625 $meta = [];
626 foreach ($results as $row) {
627 $meta[$row['meta_key']] = $row['meta_value'];
628 }
629
630 return $meta;
631 }
632
633 /**
634 * Get the plugin slug
635 *
636 * @return string
637 */
638 public function get_plugin_slug(): string {
639 return $this->plugin_slug;
640 }
641
642 /**
643 * Get the plugin name
644 *
645 * @return string
646 */
647 public function get_plugin_name(): string {
648 return $this->plugin_name;
649 }
650 }
651