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

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