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

620 lines 20.0 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(mixed $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(mixed $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 $records = match ($type) {
256 'postmeta' => $this->export_postmeta_page($page),
257 'termmeta' => $this->export_termmeta_page($page),
258 'usermeta' => $this->export_usermeta_page($page),
259 'settings' => $this->export_settings(),
260 'redirections' => $this->export_redirections_page($page),
261 '404_logs' => $this->export_404_logs_page($page),
262 default => [],
263 };
264
265 $exported_count = count($records);
266
267 // Write chunk to snapshot store
268 if ($exported_count > 0) {
269 Snapshot_Store::write_chunk($this->plugin_slug, $type, $page, $records);
270 }
271
272 // Determine if there are more pages from the number of rows the paginated
273 // query returned, NOT the emitted count. A page method may fetch a full
274 // chunk_size of rows but emit fewer after filtering (e.g. users without a
275 // migratable title/description); keying has_more off the emitted count
276 // would halt pagination early and silently skip later pages. Fall back to
277 // the emitted count for 1:1 page methods that don't report a row count.
278 $fetched_count = $this->last_page_row_count ?? $exported_count;
279 $has_more = $fetched_count >= $this->chunk_size && $type !== 'settings';
280
281 // Get total count for this type
282 $types = $this->get_available_types();
283 $total = $types[$type] ?? 0;
284
285 // Update manifest
286 $this->update_manifest($type, $page, $exported_count, $has_more, $total);
287
288 return [
289 'status' => $has_more ? 'processing' : 'complete',
290 'message' => sprintf(
291 'Exported %d %s records (page %d)',
292 $exported_count,
293 $type,
294 $page
295 ),
296 'has_more' => $has_more,
297 'page' => $page,
298 'total' => $total,
299 'exported' => $exported_count,
300 ];
301 }
302
303 /**
304 * Update the snapshot manifest after writing a chunk
305 *
306 * @param string $type Data type
307 * @param int $page Current page
308 * @param int $count Records in this chunk
309 * @param bool $has_more Whether more pages remain
310 * @param int $total Total records for this type
311 * @return void
312 */
313 private function update_manifest(string $type, int $page, int $count, bool $has_more, int $total): void {
314 $manifest = Snapshot_Store::get_manifest($this->plugin_slug) ?? [
315 'plugin' => $this->plugin_slug,
316 'plugin_name' => $this->plugin_name,
317 'exported_at' => gmdate('c'),
318 'version' => '1.0',
319 'types' => [],
320 'status' => 'exporting',
321 'last_migrated' => null,
322 'migration_version' => null,
323 ];
324
325 // Update type info
326 if (!isset($manifest['types'][$type])) {
327 $manifest['types'][$type] = [
328 'total_records' => $total,
329 'total_chunks' => 0,
330 ];
331 }
332
333 $manifest['types'][$type]['total_chunks'] = $page;
334 $manifest['types'][$type]['total_records'] = $total;
335 $manifest['status'] = 'exporting';
336 $manifest['exported_at'] = gmdate('c');
337
338 Snapshot_Store::write_manifest($this->plugin_slug, $manifest);
339 }
340
341 /**
342 * Mark the export as complete in the manifest
343 *
344 * Called by the controller after all types have been exported.
345 *
346 * @return void
347 */
348 public function finalize_export(): void {
349 $manifest = Snapshot_Store::get_manifest($this->plugin_slug);
350 if ($manifest) {
351 $manifest['status'] = 'complete';
352 $manifest['exported_at'] = gmdate('c');
353 Snapshot_Store::write_manifest($this->plugin_slug, $manifest);
354 }
355 }
356
357 /**
358 * Normalize robots directives from any plugin format to standard 0/1 values
359 *
360 * Handles:
361 * - Integer/string 1/0 (Yoast noindex/nofollow)
362 * - Serialized array containing 'noindex'/'nofollow' strings (Rank Math)
363 * - Boolean true/false (AIOSEO)
364 * - String 'yes' for noindex (SEOPress inverted logic — caller must handle inversion)
365 *
366 * @param mixed $noindex_value Raw noindex value from source
367 * @param mixed $nofollow_value Raw nofollow value from source
368 * @return array ['noindex' => 0|1, 'nofollow' => 0|1]
369 */
370 protected function normalize_robots($noindex_value, $nofollow_value = null): array {
371 $result = ['noindex' => 0, 'nofollow' => 0];
372
373 // Handle serialized array (Rank Math stores robots as serialized array)
374 if (is_string($noindex_value) && is_serialized($noindex_value)) {
375 $noindex_value = Safe_Unserializer::unserialize($noindex_value);
376 }
377
378 if (is_array($noindex_value)) {
379 // Rank Math format: serialized array with 'noindex', 'nofollow' as values
380 $result['noindex'] = in_array('noindex', $noindex_value, true) ? 1 : 0;
381 $result['nofollow'] = in_array('nofollow', $noindex_value, true) ? 1 : 0;
382 return $result;
383 }
384
385 // Handle individual values
386 $result['noindex'] = $this->normalize_bool_value($noindex_value);
387
388 if ($nofollow_value !== null) {
389 $result['nofollow'] = $this->normalize_bool_value($nofollow_value);
390 }
391
392 return $result;
393 }
394
395 /**
396 * Normalize a value to 0 or 1
397 *
398 * @param mixed $value Value to normalize
399 * @return int 0 or 1
400 */
401 private function normalize_bool_value($value): int {
402 if ($value === null || $value === '' || $value === false) {
403 return 0;
404 }
405
406 if (is_bool($value)) {
407 return $value ? 1 : 0;
408 }
409
410 return (int) $value ? 1 : 0;
411 }
412
413 /**
414 * Get paginated post IDs that have meta keys with the plugin's prefix
415 *
416 * @param int $page Page number (1-indexed)
417 * @return array Array of post IDs
418 */
419 protected function get_post_ids_with_meta(int $page): array {
420 global $wpdb;
421
422 $offset = ($page - 1) * $this->chunk_size;
423 $post_types = $this->get_exportable_post_types();
424
425 // Defensive: a site with no viewable post types has nothing to export.
426 if (empty($post_types)) {
427 return [];
428 }
429
430 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
431
432 // Restrict to publicly-viewable post types so WordPress-internal objects
433 // (oembed_cache, revisions, nav menu items, block/template CPTs, …) never
434 // enter the snapshot — SEO meta left on them is noise. Filtering in the
435 // query (rather than per-record) keeps the chunk-size based has_more
436 // pagination in export_chunk() accurate.
437 // 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.
438 $sql = $wpdb->prepare(
439 "SELECT DISTINCT pm.post_id
440 FROM {$wpdb->postmeta} pm
441 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
442 WHERE pm.meta_key LIKE %s
443 AND p.post_type IN ({$placeholders})
444 ORDER BY pm.post_id ASC
445 LIMIT %d OFFSET %d",
446 array_merge(
447 [$wpdb->esc_like($this->meta_key_prefix) . '%'],
448 $post_types,
449 [$this->chunk_size, $offset]
450 )
451 );
452 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
453
454 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
455 $ids = $wpdb->get_col($sql);
456 $this->last_page_row_count = count($ids);
457
458 return $ids;
459 }
460
461 /**
462 * Publicly-viewable post types whose meta is worth exporting.
463 *
464 * Excludes WordPress-internal types (oembed_cache, revision, nav_menu_item,
465 * wp_* block/template CPTs) that are never served to visitors and therefore
466 * carry no meaningful SEO data.
467 *
468 * @return string[] Post type slugs
469 */
470 protected function get_exportable_post_types(): array {
471 return array_values(
472 array_filter(get_post_types([], 'names'), 'is_post_type_viewable')
473 );
474 }
475
476 /**
477 * Get paginated term IDs that have meta keys with the plugin's prefix
478 *
479 * @param int $page Page number (1-indexed)
480 * @return array Array of term IDs
481 */
482 protected function get_term_ids_with_meta(int $page): array {
483 global $wpdb;
484
485 $offset = ($page - 1) * $this->chunk_size;
486
487 $ids = $wpdb->get_col(
488 $wpdb->prepare(
489 "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key LIKE %s ORDER BY term_id ASC LIMIT %d OFFSET %d",
490 $wpdb->esc_like($this->meta_key_prefix) . '%',
491 $this->chunk_size,
492 $offset
493 )
494 );
495 $this->last_page_row_count = count($ids);
496
497 return $ids;
498 }
499
500 /**
501 * Get paginated user IDs that have meta keys with the plugin's prefix
502 *
503 * @param int $page Page number (1-indexed)
504 * @return array Array of user IDs
505 */
506 protected function get_user_ids_with_meta(int $page): array {
507 global $wpdb;
508
509 $offset = ($page - 1) * $this->chunk_size;
510
511 $ids = $wpdb->get_col(
512 $wpdb->prepare(
513 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id ASC LIMIT %d OFFSET %d",
514 $wpdb->esc_like($this->meta_key_prefix) . '%',
515 $this->chunk_size,
516 $offset
517 )
518 );
519 $this->last_page_row_count = count($ids);
520
521 return $ids;
522 }
523
524 /**
525 * Get all meta values for a user with the plugin's prefix
526 *
527 * @param int $user_id User ID
528 * @return array Associative array of meta_key => meta_value
529 */
530 protected function get_all_plugin_user_meta(int $user_id): array {
531 global $wpdb;
532
533 $results = $wpdb->get_results(
534 $wpdb->prepare(
535 "SELECT meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key LIKE %s",
536 $user_id,
537 $wpdb->esc_like($this->meta_key_prefix) . '%'
538 ),
539 ARRAY_A
540 );
541
542 $meta = [];
543 foreach ($results as $row) {
544 $meta[$row['meta_key']] = $row['meta_value'];
545 }
546
547 return $meta;
548 }
549
550 /**
551 * Get all meta values for a post with the plugin's prefix
552 *
553 * @param int $post_id Post ID
554 * @return array Associative array of meta_key => meta_value
555 */
556 protected function get_all_plugin_meta(int $post_id): array {
557 global $wpdb;
558
559 $results = $wpdb->get_results(
560 $wpdb->prepare(
561 "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE %s",
562 $post_id,
563 $wpdb->esc_like($this->meta_key_prefix) . '%'
564 ),
565 ARRAY_A
566 );
567
568 $meta = [];
569 foreach ($results as $row) {
570 $meta[$row['meta_key']] = $row['meta_value'];
571 }
572
573 return $meta;
574 }
575
576 /**
577 * Get all meta values for a term with the plugin's prefix
578 *
579 * @param int $term_id Term ID
580 * @return array Associative array of meta_key => meta_value
581 */
582 protected function get_all_plugin_term_meta(int $term_id): array {
583 global $wpdb;
584
585 $results = $wpdb->get_results(
586 $wpdb->prepare(
587 "SELECT meta_key, meta_value FROM {$wpdb->termmeta} WHERE term_id = %d AND meta_key LIKE %s",
588 $term_id,
589 $wpdb->esc_like($this->meta_key_prefix) . '%'
590 ),
591 ARRAY_A
592 );
593
594 $meta = [];
595 foreach ($results as $row) {
596 $meta[$row['meta_key']] = $row['meta_value'];
597 }
598
599 return $meta;
600 }
601
602 /**
603 * Get the plugin slug
604 *
605 * @return string
606 */
607 public function get_plugin_slug(): string {
608 return $this->plugin_slug;
609 }
610
611 /**
612 * Get the plugin name
613 *
614 * @return string
615 */
616 public function get_plugin_name(): string {
617 return $this->plugin_name;
618 }
619 }
620