PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.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 / 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.0.2, at includes/admin/importers/class-abstract-plugin-exporter.php

633 lines 20.3 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 * Capture the source plugin's Role Manager assignments: role slug => the
157 * capabilities that role holds whose name starts with $prefix.
158 *
159 * Shared by every exporter because role capabilities live on the roles
160 * themselves (`wp_user_roles`), not in any of the plugin's own options — so
161 * an exporter's raw option capture never covers them, whichever plugin it is.
162 *
163 * The administrator is skipped: ThinkRank's Capability_Manager never
164 * modifies it (it always passes via `manage_options`), so carrying its caps
165 * over would be meaningless.
166 *
167 * @param string $prefix Source capability prefix, e.g. 'rank_math_' or 'wpseo_'
168 * @return array<string,string[]> Role slug => granted capabilities
169 */
170 protected function extract_role_capabilities(string $prefix): array {
171 if (!function_exists('wp_roles') || $prefix === '') {
172 return [];
173 }
174
175 $captured = [];
176 foreach (wp_roles()->roles as $slug => $role) {
177 if ($slug === 'administrator' || empty($role['capabilities'])) {
178 continue;
179 }
180
181 $caps = [];
182 foreach ($role['capabilities'] as $cap => $granted) {
183 // Roles store revoked caps as `false`; only carry over grants.
184 if ($granted && strpos((string) $cap, $prefix) === 0) {
185 $caps[] = (string) $cap;
186 }
187 }
188
189 if (!empty($caps)) {
190 sort($caps);
191 $captured[(string) $slug] = $caps;
192 }
193 }
194
195 return $captured;
196 }
197
198 /**
199 * Convert plugin-specific template variables to literal values
200 *
201 * Accepts mixed because the input is another plugin's stored data, over
202 * which we have no schema guarantees. Rank Math in particular can hold
203 * booleans inside its options arrays where a template string is expected,
204 * and the `?? ''` at the call sites only guards against a MISSING key —
205 * a present-but-boolean value sailed straight into a string-typed
206 * parameter and fataled the whole migration at the snapshot step.
207 * Implementations MUST start with stringify_template_value().
208 *
209 * @param mixed $value Value potentially containing template variables
210 * @param int|null $post_id Post ID for context-specific variables
211 * @return string Converted string
212 */
213 abstract protected function convert_template_variables($value, ?int $post_id = null): string;
214
215 /**
216 * Coerce a foreign settings/meta value into a template string.
217 *
218 * Strings pass through; ints and floats are kept as their string form (a
219 * purely numeric title is odd but meaningful); everything else — booleans,
220 * arrays, objects, null — has no sensible reading as a template, so it
221 * becomes '', which downstream already treats as "not set" and replaces
222 * with defaults. Dropping garbage beats failing the migration over it.
223 *
224 * @param mixed $value Raw value from the source plugin's storage.
225 * @return string Usable template string, possibly ''.
226 */
227 final protected function stringify_template_value($value): string {
228 if (is_string($value)) {
229 return $value;
230 }
231
232 if (is_int($value) || is_float($value)) {
233 return (string) $value;
234 }
235
236 return '';
237 }
238
239 /**
240 * Export a chunk of data and write to snapshot
241 *
242 * This is the main orchestration method. It calls the appropriate
243 * export_*_page() method, writes the chunk via Snapshot_Store, and
244 * updates the manifest.
245 *
246 * @param string $type Data type to export
247 * @param int $page Page number (1-indexed)
248 * @return array Result with status, has_more, page, total, exported
249 */
250 public function export_chunk(string $type, int $page): array {
251 // Reset before the page method runs; it (or its id helper) records the
252 // raw fetched-row count here.
253 $this->last_page_row_count = null;
254
255 switch ($type) {
256 case 'postmeta':
257 $records = $this->export_postmeta_page($page);
258 break;
259 case 'termmeta':
260 $records = $this->export_termmeta_page($page);
261 break;
262 case 'usermeta':
263 $records = $this->export_usermeta_page($page);
264 break;
265 case 'settings':
266 $records = $this->export_settings();
267 break;
268 case 'redirections':
269 $records = $this->export_redirections_page($page);
270 break;
271 case '404_logs':
272 $records = $this->export_404_logs_page($page);
273 break;
274 default:
275 $records = [];
276 }
277
278 $exported_count = count($records);
279
280 // Write chunk to snapshot store
281 if ($exported_count > 0) {
282 Snapshot_Store::write_chunk($this->plugin_slug, $type, $page, $records);
283 }
284
285 // Determine if there are more pages from the number of rows the paginated
286 // query returned, NOT the emitted count. A page method may fetch a full
287 // chunk_size of rows but emit fewer after filtering (e.g. users without a
288 // migratable title/description); keying has_more off the emitted count
289 // would halt pagination early and silently skip later pages. Fall back to
290 // the emitted count for 1:1 page methods that don't report a row count.
291 $fetched_count = $this->last_page_row_count ?? $exported_count;
292 $has_more = $fetched_count >= $this->chunk_size && $type !== 'settings';
293
294 // Get total count for this type
295 $types = $this->get_available_types();
296 $total = $types[$type] ?? 0;
297
298 // Update manifest
299 $this->update_manifest($type, $page, $exported_count, $has_more, $total);
300
301 return [
302 'status' => $has_more ? 'processing' : 'complete',
303 'message' => sprintf(
304 'Exported %d %s records (page %d)',
305 $exported_count,
306 $type,
307 $page
308 ),
309 'has_more' => $has_more,
310 'page' => $page,
311 'total' => $total,
312 'exported' => $exported_count,
313 ];
314 }
315
316 /**
317 * Update the snapshot manifest after writing a chunk
318 *
319 * @param string $type Data type
320 * @param int $page Current page
321 * @param int $count Records in this chunk
322 * @param bool $has_more Whether more pages remain
323 * @param int $total Total records for this type
324 * @return void
325 */
326 private function update_manifest(string $type, int $page, int $count, bool $has_more, int $total): void {
327 $manifest = Snapshot_Store::get_manifest($this->plugin_slug) ?? [
328 'plugin' => $this->plugin_slug,
329 'plugin_name' => $this->plugin_name,
330 'exported_at' => gmdate('c'),
331 'version' => '1.0',
332 'types' => [],
333 'status' => 'exporting',
334 'last_migrated' => null,
335 'migration_version' => null,
336 ];
337
338 // Update type info
339 if (!isset($manifest['types'][$type])) {
340 $manifest['types'][$type] = [
341 'total_records' => $total,
342 'total_chunks' => 0,
343 ];
344 }
345
346 $manifest['types'][$type]['total_chunks'] = $page;
347 $manifest['types'][$type]['total_records'] = $total;
348 $manifest['status'] = 'exporting';
349 $manifest['exported_at'] = gmdate('c');
350
351 Snapshot_Store::write_manifest($this->plugin_slug, $manifest);
352 }
353
354 /**
355 * Mark the export as complete in the manifest
356 *
357 * Called by the controller after all types have been exported.
358 *
359 * @return void
360 */
361 public function finalize_export(): void {
362 $manifest = Snapshot_Store::get_manifest($this->plugin_slug);
363 if ($manifest) {
364 $manifest['status'] = 'complete';
365 $manifest['exported_at'] = gmdate('c');
366 Snapshot_Store::write_manifest($this->plugin_slug, $manifest);
367 }
368 }
369
370 /**
371 * Normalize robots directives from any plugin format to standard 0/1 values
372 *
373 * Handles:
374 * - Integer/string 1/0 (Yoast noindex/nofollow)
375 * - Serialized array containing 'noindex'/'nofollow' strings (Rank Math)
376 * - Boolean true/false (AIOSEO)
377 * - String 'yes' for noindex (SEOPress inverted logic — caller must handle inversion)
378 *
379 * @param mixed $noindex_value Raw noindex value from source
380 * @param mixed $nofollow_value Raw nofollow value from source
381 * @return array ['noindex' => 0|1, 'nofollow' => 0|1]
382 */
383 protected function normalize_robots($noindex_value, $nofollow_value = null): array {
384 $result = ['noindex' => 0, 'nofollow' => 0];
385
386 // Handle serialized array (Rank Math stores robots as serialized array)
387 if (is_string($noindex_value) && is_serialized($noindex_value)) {
388 $noindex_value = Safe_Unserializer::unserialize($noindex_value);
389 }
390
391 if (is_array($noindex_value)) {
392 // Rank Math format: serialized array with 'noindex', 'nofollow' as values
393 $result['noindex'] = in_array('noindex', $noindex_value, true) ? 1 : 0;
394 $result['nofollow'] = in_array('nofollow', $noindex_value, true) ? 1 : 0;
395 return $result;
396 }
397
398 // Handle individual values
399 $result['noindex'] = $this->normalize_bool_value($noindex_value);
400
401 if ($nofollow_value !== null) {
402 $result['nofollow'] = $this->normalize_bool_value($nofollow_value);
403 }
404
405 return $result;
406 }
407
408 /**
409 * Normalize a value to 0 or 1
410 *
411 * @param mixed $value Value to normalize
412 * @return int 0 or 1
413 */
414 private function normalize_bool_value($value): int {
415 if ($value === null || $value === '' || $value === false) {
416 return 0;
417 }
418
419 if (is_bool($value)) {
420 return $value ? 1 : 0;
421 }
422
423 return (int) $value ? 1 : 0;
424 }
425
426 /**
427 * Get paginated post IDs that have meta keys with the plugin's prefix
428 *
429 * @param int $page Page number (1-indexed)
430 * @return array Array of post IDs
431 */
432 protected function get_post_ids_with_meta(int $page): array {
433 global $wpdb;
434
435 $offset = ($page - 1) * $this->chunk_size;
436 $post_types = $this->get_exportable_post_types();
437
438 // Defensive: a site with no viewable post types has nothing to export.
439 if (empty($post_types)) {
440 return [];
441 }
442
443 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
444
445 // Restrict to publicly-viewable post types so WordPress-internal objects
446 // (oembed_cache, revisions, nav menu items, block/template CPTs, …) never
447 // enter the snapshot — SEO meta left on them is noise. Filtering in the
448 // query (rather than per-record) keeps the chunk-size based has_more
449 // pagination in export_chunk() accurate.
450 // 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.
451 $sql = $wpdb->prepare(
452 "SELECT DISTINCT pm.post_id
453 FROM {$wpdb->postmeta} pm
454 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
455 WHERE pm.meta_key LIKE %s
456 AND p.post_type IN ({$placeholders})
457 ORDER BY pm.post_id ASC
458 LIMIT %d OFFSET %d",
459 array_merge(
460 [$wpdb->esc_like($this->meta_key_prefix) . '%'],
461 $post_types,
462 [$this->chunk_size, $offset]
463 )
464 );
465 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
466
467 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
468 $ids = $wpdb->get_col($sql);
469 $this->last_page_row_count = count($ids);
470
471 return $ids;
472 }
473
474 /**
475 * Publicly-viewable post types whose meta is worth exporting.
476 *
477 * Excludes WordPress-internal types (oembed_cache, revision, nav_menu_item,
478 * wp_* block/template CPTs) that are never served to visitors and therefore
479 * carry no meaningful SEO data.
480 *
481 * @return string[] Post type slugs
482 */
483 protected function get_exportable_post_types(): array {
484 return array_values(
485 array_filter(get_post_types([], 'names'), 'is_post_type_viewable')
486 );
487 }
488
489 /**
490 * Get paginated term IDs that have meta keys with the plugin's prefix
491 *
492 * @param int $page Page number (1-indexed)
493 * @return array Array of term IDs
494 */
495 protected function get_term_ids_with_meta(int $page): array {
496 global $wpdb;
497
498 $offset = ($page - 1) * $this->chunk_size;
499
500 $ids = $wpdb->get_col(
501 $wpdb->prepare(
502 "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key LIKE %s ORDER BY term_id ASC LIMIT %d OFFSET %d",
503 $wpdb->esc_like($this->meta_key_prefix) . '%',
504 $this->chunk_size,
505 $offset
506 )
507 );
508 $this->last_page_row_count = count($ids);
509
510 return $ids;
511 }
512
513 /**
514 * Get paginated user IDs that have meta keys with the plugin's prefix
515 *
516 * @param int $page Page number (1-indexed)
517 * @return array Array of user IDs
518 */
519 protected function get_user_ids_with_meta(int $page): array {
520 global $wpdb;
521
522 $offset = ($page - 1) * $this->chunk_size;
523
524 $ids = $wpdb->get_col(
525 $wpdb->prepare(
526 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id ASC LIMIT %d OFFSET %d",
527 $wpdb->esc_like($this->meta_key_prefix) . '%',
528 $this->chunk_size,
529 $offset
530 )
531 );
532 $this->last_page_row_count = count($ids);
533
534 return $ids;
535 }
536
537 /**
538 * Get all meta values for a user with the plugin's prefix
539 *
540 * @param int $user_id User ID
541 * @return array Associative array of meta_key => meta_value
542 */
543 protected function get_all_plugin_user_meta(int $user_id): array {
544 global $wpdb;
545
546 $results = $wpdb->get_results(
547 $wpdb->prepare(
548 "SELECT meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key LIKE %s",
549 $user_id,
550 $wpdb->esc_like($this->meta_key_prefix) . '%'
551 ),
552 ARRAY_A
553 );
554
555 $meta = [];
556 foreach ($results as $row) {
557 $meta[$row['meta_key']] = $row['meta_value'];
558 }
559
560 return $meta;
561 }
562
563 /**
564 * Get all meta values for a post with the plugin's prefix
565 *
566 * @param int $post_id Post ID
567 * @return array Associative array of meta_key => meta_value
568 */
569 protected function get_all_plugin_meta(int $post_id): array {
570 global $wpdb;
571
572 $results = $wpdb->get_results(
573 $wpdb->prepare(
574 "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s",
575 $post_id,
576 $wpdb->esc_like($this->meta_key_prefix) . '%'
577 ),
578 ARRAY_A
579 );
580
581 $meta = [];
582 foreach ($results as $row) {
583 $meta[$row['meta_key']] = $row['meta_value'];
584 }
585
586 return $meta;
587 }
588
589 /**
590 * Get all meta values for a term with the plugin's prefix
591 *
592 * @param int $term_id Term ID
593 * @return array Associative array of meta_key => meta_value
594 */
595 protected function get_all_plugin_term_meta(int $term_id): array {
596 global $wpdb;
597
598 $results = $wpdb->get_results(
599 $wpdb->prepare(
600 "SELECT meta_key, meta_value FROM {$wpdb->termmeta} WHERE term_id = %d AND meta_key LIKE %s",
601 $term_id,
602 $wpdb->esc_like($this->meta_key_prefix) . '%'
603 ),
604 ARRAY_A
605 );
606
607 $meta = [];
608 foreach ($results as $row) {
609 $meta[$row['meta_key']] = $row['meta_value'];
610 }
611
612 return $meta;
613 }
614
615 /**
616 * Get the plugin slug
617 *
618 * @return string
619 */
620 public function get_plugin_slug(): string {
621 return $this->plugin_slug;
622 }
623
624 /**
625 * Get the plugin name
626 *
627 * @return string
628 */
629 public function get_plugin_name(): string {
630 return $this->plugin_name;
631 }
632 }
633