PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.6.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.6.0
2.10.0 2.9.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 All 51 releases
thinkrank / includes / admin / importers / class-snapshot-migrator.php

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

2,910 lines 115.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Snapshot Migrator
5 *
6 * Plugin-agnostic migrator that reads normalized snapshot data from wp_options
7 * and writes _thinkrank_* post/term/user meta. Has no knowledge of source plugin
8 * formats.
9 *
10 * Rules:
11 * - Never overwrite existing ThinkRank data
12 * - Skip empty string values
13 * - Write _thinkrank_imported_from audit trail
14 * - Refuse to start if manifest status != 'complete'
15 * - Ignore record['extended'] (preserved in snapshot for future migration)
16 *
17 * @package ThinkRank\Admin\Importers
18 * @since 2.0.0
19 */
20
21 declare(strict_types=1);
22
23 namespace ThinkRank\Admin\Importers;
24
25 use ThinkRank\SEO\Focus_Keywords;
26 use ThinkRank\SEO\Metadata_Pending;
27 use ThinkRank\SEO\Pattern_Resolver;
28
29 if (!defined('ABSPATH')) {
30 exit;
31 }
32
33 /**
34 * Snapshot Migrator Class
35 *
36 * @since 2.0.0
37 */
38 class Snapshot_Migrator {
39
40 /**
41 * Canonical field → ThinkRank meta key mapping.
42 * This map grows as ThinkRank adds features.
43 */
44 private const META_MAP = [
45 'seo_title' => '_thinkrank_seo_title',
46 'meta_description' => '_thinkrank_meta_description',
47 'focus_keyword' => '_thinkrank_focus_keyword',
48 'canonical_url' => '_thinkrank_canonical_url',
49 'og_title' => '_thinkrank_og_title',
50 'og_description' => '_thinkrank_og_description',
51 'og_image' => '_thinkrank_og_image',
52 'twitter_title' => '_thinkrank_twitter_title',
53 'twitter_description' => '_thinkrank_twitter_description',
54 'twitter_image' => '_thinkrank_twitter_image',
55 'primary_category' => '_thinkrank_primary_category',
56 'schema_type' => '_thinkrank_selected_schema_type',
57 ];
58
59 /**
60 * Canonical robots meta fields. Composed into JSON-encoded
61 * `_thinkrank_robots_meta` / `_thinkrank_advanced_robots_meta`
62 * post meta by build_robots_payload().
63 */
64 private const ROBOTS_FIELDS = [
65 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet',
66 ];
67
68 private const ADVANCED_ROBOTS_FIELDS = [
69 'max_snippet', 'max_video_preview', 'max_image_preview',
70 ];
71
72 /**
73 * Data types that are migratable (have post/term/user meta mappings)
74 */
75 private const MIGRATABLE_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
76
77 /**
78 * Settings-record `extended` keys that either migrate today or are safe to
79 * discard on cleanup (raw_options is pure capture-all insurance; a fresh
80 * export recreates it, and analytics_connected is informational only).
81 * Anything OUTSIDE this list is treated as preserved-but-unapplied data by
82 * get_unmigrated_extended_buckets(), which gates /import/cleanup.
83 */
84 private const HANDLED_EXTENDED_SETTINGS = [
85 'breadcrumb_settings',
86 'local_seo',
87 'post_type_settings',
88 'title_formats',
89 'author_archives',
90 'instant_indexing_post_types',
91 'instant_indexing_log',
92 'publisher_sitemaps',
93 'email_reports',
94 'role_capabilities',
95 'image_seo',
96 'sitemap_settings',
97 'analytics_connected',
98 // Capture-all raw buckets (whole source option sets stored verbatim).
99 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
100 // a re-export recreates them, so they never block cleanup.
101 'raw_options',
102 'search_appearance',
103 'social_settings',
104 'advanced',
105 'sitemap_settings_raw',
106 ];
107
108 /**
109 * Conflict strategies for a chunk that targets data ThinkRank already holds.
110 *
111 * SKIP is right for an import: another plugin's value must never clobber
112 * something the user has already set here. OVERWRITE is right for a
113 * restore: the whole point of restoring a backup is to get the saved values
114 * back, and a "successful" restore that silently kept the current values
115 * would be the opposite of what was asked for.
116 */
117 public const CONFLICT_SKIP = 'skip';
118 public const CONFLICT_OVERWRITE = 'overwrite';
119
120 /**
121 * Migrate one chunk of snapshot data to ThinkRank meta
122 *
123 * @param string $plugin Plugin slug
124 * @param string $type Data type (postmeta, termmeta, usermeta, settings)
125 * @param int $page Chunk/page number
126 * @param string $conflict How to treat data ThinkRank already holds
127 * @return array Result with status, has_more, processed, skipped
128 */
129 public function migrate_chunk(string $plugin, string $type, int $page, string $conflict = self::CONFLICT_SKIP): array {
130 // Validate manifest status
131 $manifest = Snapshot_Store::get_manifest($plugin);
132 if (!$manifest || ($manifest['status'] ?? '') !== 'complete') {
133 return [
134 'status' => 'error',
135 'message' => 'Snapshot is not complete. Run export first.',
136 'has_more' => false,
137 'processed' => 0,
138 'skipped' => 0,
139 ];
140 }
141
142 // ThinkRank's own export is not normalized into the canonical fields
143 // META_MAP translates; it carries raw _thinkrank_* meta, so it takes a
144 // restore path that writes those back untouched.
145 if ($plugin === Thinkrank_Exporter::SLUG) {
146 return $this->restore_native_chunk($manifest, $type, $page, $conflict);
147 }
148
149 if ($type === 'settings') {
150 return $this->migrate_settings($plugin);
151 }
152
153 if ($type === 'redirections') {
154 return $this->migrate_redirections($plugin, $page);
155 }
156
157 if ($type === '404_logs') {
158 return $this->migrate_404_logs($plugin, $page);
159 }
160
161 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
162 if ($chunk === null || empty($chunk)) {
163 return [
164 'status' => 'complete',
165 'message' => 'No data in chunk',
166 'has_more' => false,
167 'processed' => 0,
168 'skipped' => 0,
169 ];
170 }
171
172 // Tell an open editor that SEO meta is being written right now, so its
173 // panel adopts the imported title / description instead of showing the
174 // pre-import values until a reload. Re-marked on every chunk, which is
175 // what holds the window open for a long migration (#329).
176 if ($type === 'postmeta') {
177 Metadata_Pending::mark_bulk();
178 }
179
180 $processed = 0;
181 $skipped = 0;
182 $keywords = [];
183 $post_ids = [];
184 // Post IDs the source excluded from its sitemap.
185 $sitemap_excluded = [];
186 // Focus keyword overflow: posts whose source had more than MAX keywords.
187 $truncations = [];
188
189 foreach ($chunk as $record) {
190 $object_id = (int) ($record['object_id'] ?? 0);
191 $object_type = $record['object_type'] ?? '';
192 $source_plugin = $record['source_plugin'] ?? $plugin;
193 $data = $record['data'] ?? [];
194
195 if (!$object_id || empty($data)) {
196 $skipped++;
197 continue;
198 }
199
200 // Track migrated posts so their SEO score can be computed once the
201 // chunk's meta has landed (terms are not scored).
202 if ($object_type === 'post') {
203 $post_ids[$object_id] = true;
204 }
205
206 $record_had_writes = false;
207
208 // Collect focus keywords (primary + secondary) to seed the Pro
209 // Rank Tracker watch-list once the chunk is processed.
210 $this->collect_keywords($record, $data, $keywords);
211
212 foreach ($data as $canonical_key => $value) {
213 if (!isset(self::META_MAP[$canonical_key])) {
214 continue;
215 }
216
217 // Focus keywords are migrated as an array via the dedicated
218 // migrate_focus_keywords() below (which also keeps the legacy
219 // single-value meta in sync), so skip the scalar write here.
220 if ($canonical_key === 'focus_keyword') {
221 continue;
222 }
223
224 $thinkrank_key = self::META_MAP[$canonical_key];
225
226 // Skip empty string values
227 if ($value === '' || $value === null) {
228 continue;
229 }
230
231 // Skip zero values for integer fields that are "not set"
232 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
233 continue;
234 }
235
236 if ($object_type === 'post') {
237 // Never overwrite existing ThinkRank data
238 $existing = get_post_meta($object_id, $thinkrank_key, true);
239 if ($existing !== '' && $existing !== false && $existing !== null) {
240 continue;
241 }
242
243 update_post_meta($object_id, $thinkrank_key, $value);
244 $record_had_writes = true;
245 } elseif ($object_type === 'term') {
246 $existing = get_term_meta($object_id, $thinkrank_key, true);
247 if ($existing !== '' && $existing !== false && $existing !== null) {
248 continue;
249 }
250
251 update_term_meta($object_id, $thinkrank_key, $value);
252 $record_had_writes = true;
253 } elseif ($object_type === 'user') {
254 $existing = get_user_meta($object_id, $thinkrank_key, true);
255 if ($existing !== '' && $existing !== false && $existing !== null) {
256 continue;
257 }
258
259 update_user_meta($object_id, $thinkrank_key, $value);
260 $record_had_writes = true;
261 }
262 }
263
264 // Focus keywords (post meta only). Migrates the full deduped,
265 // capped keyword array and keeps the legacy single value in sync.
266 if ($object_type === 'post' && $this->migrate_focus_keywords($object_id, $data, $truncations)) {
267 $record_had_writes = true;
268 }
269
270 // Compose the per-post robots JSON payload (post meta only).
271 if ($object_type === 'post' && $this->migrate_robots_payload($object_id, $data)) {
272 $record_had_writes = true;
273 }
274
275 // Pillar / cornerstone content flag (post meta only).
276 if ($object_type === 'post' && $this->migrate_pillar_content($object_id, $data)) {
277 $record_had_writes = true;
278 }
279
280 // Review schema form data (post meta only). Seeds the metabox Review
281 // form so an imported review renders once deployed.
282 if ($object_type === 'post' && $this->migrate_review_schema($object_id, $data, $record)) {
283 $record_had_writes = true;
284 }
285
286 // VideoObject schema form data (post meta only). Seeds the metabox
287 // Video form so an imported video schema renders once deployed.
288 if ($object_type === 'post' && $this->migrate_video_schema($object_id, $data, $record)) {
289 $record_had_writes = true;
290 }
291
292 // Per-post "exclude from sitemap" flags. ThinkRank models sitemap
293 // exclusion as one comma-separated ID list on the sitemap settings
294 // rather than per-post meta, so collect the IDs and apply them once
295 // after the chunk (a settings write per post would be wasteful).
296 if ($object_type === 'post' && !empty($record['extended']['exclude_sitemap'])) {
297 $sitemap_excluded[] = $object_id;
298 }
299
300 if ($record_had_writes) {
301 // Write audit trail
302 if ($object_type === 'post') {
303 update_post_meta($object_id, '_thinkrank_imported_from', $source_plugin);
304 } elseif ($object_type === 'term') {
305 update_term_meta($object_id, '_thinkrank_imported_from', $source_plugin);
306 } elseif ($object_type === 'user') {
307 update_user_meta($object_id, '_thinkrank_imported_from', $source_plugin);
308 }
309 $processed++;
310 } else {
311 $skipped++;
312 }
313 }
314
315 // Seed the Pro Rank Tracker watch-list from the collected keywords.
316 // No-op when Pro is inactive.
317 $keywords_seeded = $this->seed_rank_tracker($keywords);
318
319 // Fold this chunk's sitemap-excluded posts into the sitemap settings.
320 $sitemap_excluded_count = $this->migrate_sitemap_exclusions($sitemap_excluded);
321
322 // Compute + persist SEO scores for the migrated posts so the SEO
323 // Overview reflects accurate data without a manual re-analyze. The
324 // snapshot's chunk pagination bounds this to <=100 posts per request,
325 // which keeps each pass well within PHP execution limits.
326 $analyzed = $this->analyze_posts(array_keys($post_ids));
327
328 // Check if there are more chunks
329 $type_info = $manifest['types'][$type] ?? [];
330 $total_chunks = $type_info['total_chunks'] ?? 0;
331 $has_more = $page < $total_chunks;
332
333 // Last chunk: no more writes are coming, so stop every open editor
334 // polling for one. The marker's own expiry covers a migration that is
335 // abandoned part-way and never reaches this line.
336 if ($type === 'postmeta' && !$has_more) {
337 Metadata_Pending::clear_bulk();
338 }
339
340 return [
341 'status' => $has_more ? 'processing' : 'complete',
342 'message' => sprintf('Migrated %d records, skipped %d (page %d)', $processed, $skipped, $page),
343 'has_more' => $has_more,
344 'page' => $page,
345 'total_chunks' => $total_chunks,
346 'processed' => $processed,
347 'skipped' => $skipped,
348 'keywords_seeded' => $keywords_seeded,
349 'sitemap_excluded' => $sitemap_excluded_count,
350 'analyzed' => $analyzed,
351 // Posts whose source had more than the max focus keywords; the
352 // excess was capped but preserved in the overflow meta.
353 'keywords_truncated' => count($truncations),
354 'keywords_truncated_sample' => array_slice($truncations, 0, 10),
355 ];
356 }
357
358
359 /**
360 * Restore one chunk of ThinkRank's own export.
361 *
362 * Deliberately does NOT reuse the canonical loop above. That loop maps
363 * through META_MAP, rebuilds the robots payload from canonical flags and
364 * drops every key it does not know — correct when translating another
365 * plugin's data, lossy when the data is already ours. Here the record holds
366 * raw `_thinkrank_*` meta and the job is to put it back exactly as it was.
367 *
368 * @param array $manifest Snapshot manifest
369 * @param string $type Data type
370 * @param int $page Chunk page
371 * @param string $conflict CONFLICT_SKIP | CONFLICT_OVERWRITE
372 * @return array Result
373 */
374 private function restore_native_chunk(array $manifest, string $type, int $page, string $conflict): array {
375 if ($type === 'settings') {
376 return $this->restore_native_settings($conflict);
377 }
378
379 // Pro's own tables (redirections, 404 logs, rank tracker, Brand
380 // Visibility) are exported through a filter and come back through one:
381 // the free plugin holds the records but has nowhere to put them.
382 if (!in_array($type, ['postmeta', 'termmeta', 'usermeta'], true)) {
383 return $this->restore_extension_chunk($manifest, $type, $page, $conflict);
384 }
385
386 $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, $type, $page);
387 if (empty($chunk)) {
388 return [
389 'status' => 'complete',
390 'message' => 'No data in chunk',
391 'has_more' => false,
392 'processed' => 0,
393 'skipped' => 0,
394 'missing' => 0,
395 ];
396 }
397
398 // Hold open the editor's "SEO meta is being written" window for as long
399 // as the restore runs, exactly as the import path does.
400 if ($type === 'postmeta') {
401 Metadata_Pending::mark_bulk();
402 }
403
404 $processed = 0;
405 $skipped = 0;
406 $missing = 0;
407
408 foreach ($chunk as $record) {
409 $object_id = (int) ($record['object_id'] ?? 0);
410 $object_type = (string) ($record['object_type'] ?? '');
411 $data = $record['data'] ?? [];
412
413 if (!$object_id || !is_array($data) || empty($data)) {
414 $skipped++;
415 continue;
416 }
417
418 // A file from another site (or one taken before a post was deleted)
419 // references IDs that are not here. Counted separately from
420 // `skipped` so the UI can say "12 posts no longer exist" rather
421 // than reporting a silent no-op.
422 if (!$this->object_exists($object_type, $object_id)) {
423 $missing++;
424 continue;
425 }
426
427 $wrote = false;
428 foreach ($data as $meta_key => $value) {
429 // Only ThinkRank's own meta, whatever the file claims: a
430 // hand-edited export must not become a way to write arbitrary
431 // meta onto any post.
432 if (strpos((string) $meta_key, Thinkrank_Exporter::META_PREFIX) !== 0) {
433 continue;
434 }
435
436 if ($conflict === self::CONFLICT_SKIP) {
437 $existing = $this->get_object_meta($object_type, $object_id, (string) $meta_key);
438 if ($existing !== '' && $existing !== false && $existing !== null) {
439 continue;
440 }
441 }
442
443 // No skip-empty rule here, unlike the import path. An empty
444 // string is a real stored value for some fields (the author
445 // archive templates, where "" means render no template), and
446 // dropping it would restore the default instead.
447 if ($this->write_object_meta($object_type, $object_id, (string) $meta_key, $value)) {
448 $wrote = true;
449 }
450 }
451
452 if ($wrote) {
453 $processed++;
454 } else {
455 $skipped++;
456 }
457 }
458
459 $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
460 $has_more = $page < $total_chunks;
461
462 if ($type === 'postmeta' && !$has_more) {
463 Metadata_Pending::clear_bulk();
464 }
465
466 return [
467 'status' => $has_more ? 'processing' : 'complete',
468 'message' => sprintf(
469 'Restored %d records, skipped %d, %d no longer exist (page %d)',
470 $processed,
471 $skipped,
472 $missing,
473 $page
474 ),
475 'has_more' => $has_more,
476 'page' => $page,
477 'total_chunks' => $total_chunks,
478 'processed' => $processed,
479 'skipped' => $skipped,
480 'missing' => $missing,
481 ];
482 }
483
484 /**
485 * Hand a non-core type's records to whoever registered it.
486 *
487 * With no handler the records stay in the snapshot rather than being
488 * dropped: reporting "0 restored" is honest, and a later Pro activation can
489 * still drain the same snapshot.
490 *
491 * @param array $manifest Snapshot manifest
492 * @param string $type Data type
493 * @param int $page Chunk page
494 * @param string $conflict CONFLICT_SKIP | CONFLICT_OVERWRITE
495 * @return array Result
496 */
497 private function restore_extension_chunk(array $manifest, string $type, int $page, string $conflict): array {
498 $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, $type, $page) ?? [];
499 $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
500 $has_more = $page < $total_chunks;
501
502 /**
503 * Filters the number of records a non-core restore type applied.
504 *
505 * Handlers should write the records and return how many they wrote.
506 * Anything not written stays in the snapshot.
507 *
508 * @since 2.2.0
509 *
510 * @param int $processed Records applied (0 by default).
511 * @param array $records Records from this chunk.
512 * @param string $type Data type being restored.
513 * @param string $conflict 'skip' or 'overwrite'.
514 */
515 $processed = (int) apply_filters('thinkrank_restore_records', 0, $chunk, $type, $conflict);
516 $skipped = max(0, count($chunk) - $processed);
517
518 return [
519 'status' => $has_more ? 'processing' : 'complete',
520 'message' => sprintf('Restored %d %s records, skipped %d (page %d)', $processed, $type, $skipped, $page),
521 'has_more' => $has_more,
522 'page' => $page,
523 'total_chunks' => $total_chunks,
524 'processed' => $processed,
525 'skipped' => $skipped,
526 'missing' => 0,
527 ];
528 }
529
530 /**
531 * Write a single meta value for post|term|user.
532 *
533 * @param string $object_type One of post|term|user
534 * @param int $object_id Object id
535 * @param string $key Meta key
536 * @param mixed $value Meta value
537 * @return bool Whether the value was written
538 */
539 private function write_object_meta(string $object_type, int $object_id, string $key, $value): bool {
540 // Registered meta can carry a typed sanitize_callback, and some of ours
541 // declare `string` — `_thinkrank_robots_meta` and
542 // `_thinkrank_advanced_robots_meta` both run through
543 // Metabox_Manager::sanitize_json_meta_field(string $value). Everything
544 // writing those today stores JSON, so an export carries them back as
545 // strings; but the restore's whole policy is to write the file's value
546 // verbatim, and a file holding one as an array would otherwise raise a
547 // TypeError that takes down the rest of the chunk with it. One bad key
548 // is worth skipping, not the records behind it.
549 try {
550 switch ($object_type) {
551 case 'post':
552 update_post_meta($object_id, $key, $value);
553 return true;
554 case 'term':
555 update_term_meta($object_id, $key, $value);
556 return true;
557 case 'user':
558 update_user_meta($object_id, $key, $value);
559 return true;
560 }
561 } catch (\Throwable $e) {
562 if (defined('WP_DEBUG') && WP_DEBUG) {
563 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic; a skipped key is otherwise invisible.
564 error_log(sprintf('ThinkRank restore: skipped %s meta "%s" on %d — %s', $object_type, $key, $object_id, $e->getMessage()));
565 }
566 }
567
568 return false;
569 }
570
571 /**
572 * Restore ThinkRank's own settings from a native snapshot.
573 *
574 * Bypasses migrate_settings() entirely: that method is Yoast/Rank Math
575 * shaped — separator code maps, knowledge graph assembly, webmaster tools —
576 * and none of it applies to data already in our own format.
577 *
578 * @param string $conflict CONFLICT_SKIP | CONFLICT_OVERWRITE
579 * @return array Result
580 */
581 private function restore_native_settings(string $conflict): array {
582 $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, 'settings', 1);
583 $data = $chunk[0]['data'] ?? [];
584
585 if (!is_array($data) || empty($data)) {
586 return [
587 'status' => 'complete',
588 'message' => 'No settings in snapshot',
589 'has_more' => false,
590 'processed' => 0,
591 'skipped' => 0,
592 ];
593 }
594
595 $overwrite = $conflict === self::CONFLICT_OVERWRITE;
596
597 $processed = $this->restore_settings_options((array) ($data['options'] ?? []), $overwrite);
598 $processed += $this->restore_settings_table((array) ($data['seo_table'] ?? []), $overwrite);
599 $processed += $this->restore_aggregate_options((array) ($data['aggregate'] ?? []), $overwrite);
600
601 return [
602 'status' => 'complete',
603 'message' => sprintf('Restored %d settings', $processed),
604 'has_more' => false,
605 'page' => 1,
606 'processed' => $processed,
607 'skipped' => 0,
608 ];
609 }
610
611 /**
612 * Restore the `thinkrank_{key}` options behind Settings.
613 *
614 * Written through Settings::set() rather than update_option() so the class's
615 * own key validation, encryption and cache invalidation all run.
616 *
617 * @param array $options Setting key => value
618 * @param bool $overwrite Whether to replace values already stored here
619 * @return int Number of settings written
620 */
621 private function restore_settings_options(array $options, bool $overwrite): int {
622 if (empty($options) || !class_exists('ThinkRank\\Core\\Settings')) {
623 return 0;
624 }
625
626 $settings = \ThinkRank\Core\Settings::instance();
627 $written = 0;
628
629 foreach ($options as $key => $value) {
630 $key = (string) $key;
631
632 if (!$overwrite) {
633 // A distinctive sentinel, because `false` and `''` are both
634 // legitimate stored values here.
635 if (get_option('thinkrank_' . $key, '__tr_not_set__') !== '__tr_not_set__') {
636 continue;
637 }
638 }
639
640 if ($settings->set($key, $value)) {
641 $written++;
642 }
643 }
644
645 return $written;
646 }
647
648 /**
649 * Restore the thinkrank_seo_settings table, one category/context at a time.
650 *
651 * @param array $categories Category => context type => context id => key => row
652 * @param bool $overwrite Whether to replace rows already stored here
653 * @return int Number of settings written
654 */
655 private function restore_settings_table(array $categories, bool $overwrite): int {
656 $written = 0;
657
658 foreach ($categories as $category => $contexts) {
659 $manager = $this->create_settings_restorer((string) $category);
660 if ($manager === null) {
661 continue;
662 }
663
664 foreach ((array) $contexts as $context_type => $context_ids) {
665 foreach ((array) $context_ids as $context_id => $rows) {
666 $existing = $overwrite ? [] : $manager->get_settings((string) $context_type, (int) $context_id);
667 $payload = [];
668
669 foreach ((array) $rows as $key => $row) {
670 if (!$overwrite && array_key_exists($key, $existing)) {
671 continue;
672 }
673
674 // Rows are exported as ['value' => …, 'type' => …,
675 // 'priority' => …]; older files may carry the bare value.
676 $payload[$key] = is_array($row) && array_key_exists('value', $row)
677 ? $row['value']
678 : $row;
679 }
680
681 if (empty($payload)) {
682 continue;
683 }
684
685 // Declare the keys before saving. sanitize_settings() drops
686 // any key the manager does not claim, and save_settings()
687 // still returns true when it dropped every one of them — so
688 // without this the restore reports success and writes
689 // nothing. The rows came out of this table to begin with,
690 // which is the strongest claim to being real settings that
691 // exists.
692 $manager->set_restorable_keys(array_keys($payload));
693
694 if ($manager->save_settings((string) $context_type, (int) $context_id, $payload)) {
695 $written += count($payload);
696 }
697 }
698 }
699 }
700
701 return $written;
702 }
703
704 /**
705 * A minimal Abstract_SEO_Manager for one settings category.
706 *
707 * Going through a manager (rather than writing rows directly) buys the
708 * upsert, the shared sanitizer that knows which keys are multiline or
709 * template strings, the cache invalidation, and the
710 * `thinkrank_seo_settings_saved` action other managers listen for.
711 *
712 * Validation is deliberately permissive: this is the site's own data coming
713 * back, and a validator that has tightened since the export was taken would
714 * silently drop rows mid-restore. Reaching here already requires
715 * manage_options, so the file is not a privilege boundary.
716 *
717 * @param string $category Settings category (the manager_type column)
718 * @return \ThinkRank\SEO\Abstract_SEO_Manager|null
719 */
720 protected function create_settings_restorer(string $category) {
721 if ($category === '' || !class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
722 return null;
723 }
724
725 return new class($category) extends \ThinkRank\SEO\Abstract_SEO_Manager {
726
727 /** @var string[] Keys this restore pass is allowed to write. */
728 private array $restorable_keys = [];
729
730 /**
731 * @param string[] $keys Setting keys about to be restored.
732 * @return void
733 */
734 public function set_restorable_keys(array $keys): void {
735 $this->restorable_keys = array_values(array_filter($keys, 'is_string'));
736 }
737
738 public function validate_settings(array $settings): array {
739 return ['valid' => true, 'errors' => []];
740 }
741
742 public function get_output_data(string $context_type, ?int $context_id): array {
743 return [];
744 }
745
746 /**
747 * Backs the allow-list sanitize_settings() checks against. Empty
748 * until set_restorable_keys() names the keys of the batch being
749 * written, so the restorer can never write a key that was not in
750 * the file.
751 */
752 public function get_default_settings(string $context_type): array {
753 return array_fill_keys($this->restorable_keys, '');
754 }
755
756 public function get_settings_schema(string $context_type): array {
757 return [];
758 }
759 };
760 }
761
762 /**
763 * Restore the standalone aggregate settings options.
764 *
765 * @param array $options Option name => value
766 * @param bool $overwrite Whether to replace options already stored here
767 * @return int Number of options written
768 */
769 private function restore_aggregate_options(array $options, bool $overwrite): int {
770 $written = 0;
771
772 foreach ($options as $option_name => $value) {
773 $option_name = (string) $option_name;
774
775 // Only the options the exporter actually emits, whatever the file
776 // claims. A plain `thinkrank_` prefix check would not be enough:
777 // the snapshot chunks themselves live under that prefix, so a
778 // hand-edited export could rewrite the snapshot it is restoring from.
779 if (!in_array($option_name, Thinkrank_Exporter::AGGREGATE_OPTIONS, true)) {
780 continue;
781 }
782
783 $existing = get_option($option_name, '__tr_not_set__');
784
785 if (!$overwrite && $existing !== '__tr_not_set__') {
786 continue;
787 }
788
789 // An aggregate option is written whole, so a key the exporter
790 // stripped would be DELETED here rather than just left alone — an
791 // overwrite-restore would wipe this site's Google OAuth tokens and
792 // platform verification codes on the way to restoring everything
793 // around them. Carry the local values forward for exactly the keys
794 // export redacts, matching that redaction key for key and depth for
795 // depth.
796 if (is_array($value) && is_array($existing)) {
797 $value = $this->carry_forward_redacted(
798 $value,
799 $existing,
800 array_merge(
801 Thinkrank_Exporter::secret_setting_keys(),
802 Thinkrank_Exporter::SECRET_OPTION_KEYS[$option_name] ?? []
803 )
804 );
805 }
806
807 update_option($option_name, $value);
808 $written++;
809 }
810
811 return $written;
812 }
813
814 /**
815 * Put back the secrets the export stripped, from what this site already has.
816 *
817 * The mirror image of Thinkrank_Exporter::strip_secret_keys(): that walks
818 * the payload to any depth removing keys named as secrets, so this walks it
819 * to the same depth restoring them. A key the export DID carry is left
820 * alone — the carry-forward only fills a hole, so a deliberate change still
821 * lands.
822 *
823 * @since 2.3.1
824 *
825 * @param array $incoming The option value from the snapshot.
826 * @param array $existing The option value this site already holds.
827 * @param string[] $secret_keys Key names redaction removes.
828 * @return array
829 */
830 private function carry_forward_redacted(array $incoming, array $existing, array $secret_keys): array {
831 foreach ($existing as $key => $existing_value) {
832 if (is_string($key) && in_array($key, $secret_keys, true)) {
833 if (!array_key_exists($key, $incoming)) {
834 $incoming[$key] = $existing_value;
835 }
836 continue;
837 }
838
839 if (is_array($existing_value) && isset($incoming[$key]) && is_array($incoming[$key])) {
840 $incoming[$key] = $this->carry_forward_redacted($incoming[$key], $existing_value, $secret_keys);
841 }
842 }
843
844 return $incoming;
845 }
846
847 /**
848 * Dry-run a snapshot chunk: classify what a migrate WOULD do without
849 * writing anything. Mirrors migrate_chunk()'s per-field decision (skip
850 * empty values, never overwrite existing ThinkRank data) so the counts
851 * match what a real migrate would produce.
852 *
853 * Each object-meta record lands in exactly one bucket:
854 * - `unmatched` — the referenced post/term/user no longer exists here.
855 * - `would_write` — at least one field would be written (target empty).
856 * - `conflicts` — no writes, but the source differs from an existing
857 * ThinkRank value that migrate would NOT overwrite.
858 * - `skipped` — matched, but nothing to write and nothing conflicting
859 * (empty data, or values already identical).
860 *
861 * Only object-meta types (postmeta/termmeta/usermeta) are previewed;
862 * settings/redirections/404 logs are migrated wholesale and return zeros.
863 *
864 * @param string $plugin Source plugin slug.
865 * @param string $type Snapshot data type.
866 * @param int $page 1-based chunk page.
867 * @return array<string,mixed>
868 */
869 public function preview_chunk(string $plugin, string $type, int $page): array {
870 $summary = [
871 'type' => $type,
872 'page' => $page,
873 'records' => 0,
874 'unmatched' => 0,
875 'would_write' => 0,
876 'conflicts' => 0,
877 'skipped' => 0,
878 'has_more' => false,
879 'samples' => ['unmatched' => [], 'would_write' => [], 'conflicts' => []],
880 ];
881
882 $manifest = Snapshot_Store::get_manifest($plugin);
883 if (!$manifest || ($manifest['status'] ?? '') !== 'complete') {
884 $summary['error'] = 'Snapshot is not complete. Run export first.';
885 return $summary;
886 }
887
888 if (!in_array($type, ['postmeta', 'termmeta', 'usermeta'], true)) {
889 return $summary;
890 }
891
892 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
893 if ($chunk === null || empty($chunk)) {
894 return $summary;
895 }
896
897 foreach ($chunk as $record) {
898 $summary['records']++;
899
900 $object_id = (int) ($record['object_id'] ?? 0);
901 $object_type = $record['object_type'] ?? '';
902 $data = $record['data'] ?? [];
903
904 if (!$object_id || empty($data)) {
905 $summary['skipped']++;
906 continue;
907 }
908
909 if (!$this->object_exists($object_type, $object_id)) {
910 $summary['unmatched']++;
911 if (count($summary['samples']['unmatched']) < 10) {
912 $summary['samples']['unmatched'][] = ['object_id' => $object_id, 'object_type' => $object_type];
913 }
914 continue;
915 }
916
917 $writes = [];
918 $conflicts = [];
919
920 foreach ($data as $canonical_key => $value) {
921 if (!isset(self::META_MAP[$canonical_key])) {
922 continue;
923 }
924 if ($value === '' || $value === null) {
925 continue;
926 }
927 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
928 continue;
929 }
930
931 $existing = $this->get_object_meta($object_type, $object_id, self::META_MAP[$canonical_key]);
932 if ($existing === '' || $existing === false || $existing === null) {
933 $writes[] = $canonical_key;
934 } else {
935 $source = is_scalar($value) ? (string) $value : (string) wp_json_encode($value);
936 if ((string) $existing !== $source) {
937 $conflicts[] = $canonical_key;
938 }
939 }
940 }
941
942 if (!empty($writes)) {
943 $summary['would_write']++;
944 if (count($summary['samples']['would_write']) < 10) {
945 $summary['samples']['would_write'][] = ['object_id' => $object_id, 'fields' => $writes];
946 }
947 } elseif (!empty($conflicts)) {
948 $summary['conflicts']++;
949 if (count($summary['samples']['conflicts']) < 10) {
950 $summary['samples']['conflicts'][] = ['object_id' => $object_id, 'fields' => $conflicts];
951 }
952 } else {
953 $summary['skipped']++;
954 }
955 }
956
957 $type_info = $manifest['types'][$type] ?? [];
958 $total_chunks = $type_info['total_chunks'] ?? 0;
959 $summary['has_more'] = $page < $total_chunks;
960
961 return $summary;
962 }
963
964 /**
965 * Whether the referenced object still exists on this site.
966 *
967 * @param string $object_type One of post|term|user.
968 * @param int $object_id Object id.
969 * @return bool
970 */
971 private function object_exists(string $object_type, int $object_id): bool {
972 switch ($object_type) {
973 case 'post':
974 return (bool) get_post($object_id);
975 case 'term':
976 return (bool) get_term($object_id);
977 case 'user':
978 return (bool) get_userdata($object_id);
979 }
980 return false;
981 }
982
983 /**
984 * Read a single meta value for post|term|user.
985 *
986 * @param string $object_type One of post|term|user.
987 * @param int $object_id Object id.
988 * @param string $key Meta key.
989 * @return mixed
990 */
991 private function get_object_meta(string $object_type, int $object_id, string $key) {
992 switch ($object_type) {
993 case 'post':
994 return get_post_meta($object_id, $key, true);
995 case 'term':
996 return get_term_meta($object_id, $key, true);
997 case 'user':
998 return get_user_meta($object_id, $key, true);
999 }
1000 return '';
1001 }
1002
1003 /**
1004 * Lazily instantiated SEO score calculator.
1005 *
1006 * @var \ThinkRank\AI\SEOScoreCalculator|null
1007 */
1008 private ?\ThinkRank\AI\SEOScoreCalculator $score_calculator = null;
1009
1010 /**
1011 * Calculate and store SEO scores for freshly migrated posts.
1012 *
1013 * The scorer is purely local/algorithmic (no external AI calls), so it is
1014 * safe to run synchronously in bulk. Posts that already carry a score are
1015 * skipped, keeping the pass idempotent across re-runs. A per-post failure
1016 * is swallowed so one bad post never aborts the whole chunk.
1017 *
1018 * Fires `thinkrank_seo_score_updated` once when any score was written so the
1019 * cached SEO Overview / usage-analytics responses are invalidated.
1020 *
1021 * @param int[] $post_ids Migrated post IDs (de-duplicated)
1022 * @return int Number of posts scored this pass
1023 */
1024 private function analyze_posts(array $post_ids): int {
1025 if (empty($post_ids)) {
1026 return 0;
1027 }
1028
1029 // Delegate to the shared scoring loop (also used by the
1030 // bulk-analyze-and-save ability); migration only needs the count.
1031 $summary = $this->score_posts($post_ids);
1032
1033 return $summary['scored'];
1034 }
1035
1036 /**
1037 * Score and persist SEO scores for a set of posts, returning per-post
1038 * results plus totals. This is the shared bulk-scoring loop used both by
1039 * migration (via analyze_posts()) and the bulk-analyze-and-save ability.
1040 *
1041 * The scorer is purely local/algorithmic (no external AI calls), so it is
1042 * safe to run synchronously in bulk. A per-post failure is captured, never
1043 * thrown, so one bad post cannot abort the batch. Fires
1044 * `thinkrank_seo_score_updated` once when any score was written so cached
1045 * SEO Overview / usage-analytics responses are invalidated.
1046 *
1047 * @param int[] $post_ids Post IDs to score (de-duplicated internally).
1048 * @param bool $rescore When false (default), posts that already carry a
1049 * stored score are left untouched (idempotent). When
1050 * true, every post is re-scored and re-saved.
1051 * @return array{results:array<int,array<string,mixed>>,scored:int,skipped:int,failed:int,total:int}
1052 */
1053 public function score_posts(array $post_ids, bool $rescore = false): array {
1054 $post_ids = array_values(array_unique(array_map('intval', $post_ids)));
1055
1056 $calculator = $this->get_score_calculator();
1057 $user_id = get_current_user_id();
1058
1059 $results = [];
1060 $scored = 0;
1061 $skipped = 0;
1062 $failed = 0;
1063
1064 foreach ($post_ids as $post_id) {
1065 $result = $this->score_single_post($calculator, $user_id, $post_id, $rescore);
1066 $results[] = $result;
1067
1068 if ($result['status'] === 'scored') {
1069 $scored++;
1070 } elseif ($result['status'] === 'error') {
1071 $failed++;
1072 } else {
1073 $skipped++;
1074 }
1075 }
1076
1077 if ($scored > 0) {
1078 // Invalidate cached analytics / SEO Overview responses.
1079 do_action('thinkrank_seo_score_updated');
1080 }
1081
1082 return [
1083 'results' => $results,
1084 'scored' => $scored,
1085 'skipped' => $skipped,
1086 'failed' => $failed,
1087 'total' => count($results),
1088 ];
1089 }
1090
1091 /**
1092 * Score and persist a single post. Returns a structured per-post result:
1093 * status is one of `scored`, `skipped_existing`, `not_found`,
1094 * `no_content`, or `error`.
1095 *
1096 * @param \ThinkRank\AI\SEOScoreCalculator $calculator Shared calculator.
1097 * @param int $user_id Acting user id.
1098 * @param int $post_id Post to score.
1099 * @param bool $rescore Re-score even if scored.
1100 * @return array<string,mixed>
1101 */
1102 private function score_single_post(\ThinkRank\AI\SEOScoreCalculator $calculator, int $user_id, int $post_id, bool $rescore): array {
1103 $base = ['post_id' => $post_id, 'status' => '', 'score' => null, 'score_id' => null];
1104
1105 if (!$rescore && $calculator->get_latest_score($post_id) !== null) {
1106 return array_merge($base, ['status' => 'skipped_existing']);
1107 }
1108
1109 if (!get_post($post_id)) {
1110 return array_merge($base, ['status' => 'not_found']);
1111 }
1112
1113 try {
1114 $content_data = $calculator->analyze_post_content($post_id);
1115 if (empty($content_data)) {
1116 return array_merge($base, ['status' => 'no_content']);
1117 }
1118
1119 // Score against the effective title/description (custom value, else
1120 // the resolved Global pattern) so posts that inherit their title or
1121 // description from a global pattern are scored the same as in the
1122 // editor and on the frontend, instead of as if those fields were
1123 // empty. Pattern_Resolver::title() already falls back through the
1124 // WordPress post title, so the previous post_title fallback is covered.
1125 $metadata = [
1126 'title' => Pattern_Resolver::effective_title($post_id),
1127 'description' => Pattern_Resolver::effective_description($post_id),
1128 ];
1129
1130 // Score against all focus keywords; the calculator uses the
1131 // highest-scoring keyword as the final score.
1132 $target_keywords = Focus_Keywords::get($post_id);
1133
1134 $score_data = $calculator->calculate_score(
1135 $content_data,
1136 $metadata,
1137 ['target_keywords' => $target_keywords]
1138 );
1139
1140 $score_id = $calculator->save_score($post_id, $user_id, $score_data);
1141 if ($score_id === false) {
1142 return array_merge($base, ['status' => 'error']);
1143 }
1144
1145 return [
1146 'post_id' => $post_id,
1147 'status' => 'scored',
1148 'score' => isset($score_data['overall_score']) ? (int) $score_data['overall_score'] : null,
1149 'score_id' => (int) $score_id,
1150 ];
1151 } catch (\Throwable $e) {
1152 // Never let a single post abort the batch.
1153 return array_merge($base, ['status' => 'error']);
1154 }
1155 }
1156
1157 /**
1158 * Get (and lazily build) the shared SEO score calculator instance.
1159 *
1160 * @return \ThinkRank\AI\SEOScoreCalculator
1161 */
1162 private function get_score_calculator(): \ThinkRank\AI\SEOScoreCalculator {
1163 if ($this->score_calculator === null) {
1164 $this->score_calculator = new \ThinkRank\AI\SEOScoreCalculator(new \ThinkRank\Core\Database());
1165 }
1166
1167 return $this->score_calculator;
1168 }
1169
1170 /**
1171 * Collect a record's focus keywords (primary + secondary) into an
1172 * accumulator keyed by a normalized form to avoid duplicate inserts.
1173 *
1174 * Primary lives in the canonical `data['focus_keyword']`; secondary
1175 * keyphrases are preserved in `extended['focus_keywords_additional']`.
1176 *
1177 * @param array $record Full snapshot record
1178 * @param array $data Canonical record data
1179 * @param array $keywords Accumulator (passed by reference): normalized => raw
1180 * @return void
1181 */
1182 private function collect_keywords(array $record, array $data, array &$keywords): void {
1183 $candidates = [];
1184
1185 $primary = (string) ($data['focus_keyword'] ?? '');
1186 if ($primary !== '') {
1187 $candidates[] = $primary;
1188 }
1189
1190 $additional = $record['extended']['focus_keywords_additional'] ?? [];
1191 if (is_array($additional)) {
1192 foreach ($additional as $keyword) {
1193 $candidates[] = (string) $keyword;
1194 }
1195 }
1196
1197 foreach ($candidates as $keyword) {
1198 $key = strtolower(trim($keyword));
1199 if ($key !== '') {
1200 $keywords[$key] = $keyword;
1201 }
1202 }
1203 }
1204
1205 /**
1206 * Seed the Pro Rank Tracker watch-list with the collected keywords.
1207 *
1208 * Gated on Pro being active (classes present). The Free plugin never
1209 * hard-depends on Pro — the fully-qualified references only resolve when
1210 * Pro's autoloader is registered. Pro lazily creates its tables via
1211 * Schema::ensure() and add_keyword() is idempotent (INSERT IGNORE).
1212 *
1213 * @param array $keywords Map of normalized => raw keyword
1214 * @return int Number of keywords handed to the watch-list
1215 */
1216 private function seed_rank_tracker(array $keywords): int {
1217 if (empty($keywords)) {
1218 return 0;
1219 }
1220
1221 if (
1222 !class_exists('ThinkRank\\Pro\\Rank_Tracker\\Schema')
1223 || !class_exists('ThinkRank\\Pro\\Rank_Tracker\\Repository')
1224 ) {
1225 return 0;
1226 }
1227
1228 \ThinkRank\Pro\Rank_Tracker\Schema::ensure();
1229 $repository = new \ThinkRank\Pro\Rank_Tracker\Repository();
1230
1231 $seeded = 0;
1232 foreach ($keywords as $keyword) {
1233 if ($repository->add_keyword($keyword)) {
1234 $seeded++;
1235 }
1236 }
1237
1238 return $seeded;
1239 }
1240
1241 /**
1242 * Migrate the pillar / cornerstone content flag to ThinkRank post meta.
1243 *
1244 * ThinkRank stores an enabled flag as the string '1'; the reader
1245 * (Pillar_Content endpoint) matches meta_value = '1'. Never overwrites an
1246 * existing ThinkRank value.
1247 *
1248 * @param int $post_id Target post ID
1249 * @param array $data Canonical record data
1250 * @return bool True when the flag was written
1251 */
1252 private function migrate_pillar_content(int $post_id, array $data): bool {
1253 if (empty($data['pillar_content'])) {
1254 return false;
1255 }
1256
1257 $existing = get_post_meta($post_id, '_thinkrank_pillar_content', true);
1258 if ($existing !== '' && $existing !== false && $existing !== null) {
1259 return false;
1260 }
1261
1262 update_post_meta($post_id, '_thinkrank_pillar_content', '1');
1263
1264 return true;
1265 }
1266
1267 /**
1268 * Migrate the post's focus keywords.
1269 *
1270 * Reads the full list from the snapshot's `focus_keywords` (falling back to
1271 * the single `focus_keyword`) and persists via Focus_Keywords::save(). Never
1272 * overwrites existing ThinkRank focus keywords.
1273 *
1274 * Posts whose source had more keywords than were stored are recorded in
1275 * `$truncations` so the import summary can report them.
1276 *
1277 * @param int $post_id Target post ID.
1278 * @param array $data Canonical record data.
1279 * @param array|null $truncations Accumulator: appended with what was dropped.
1280 * @return bool True when keywords were written.
1281 */
1282 private function migrate_focus_keywords(int $post_id, array $data, ?array &$truncations = null): bool {
1283 $keywords = [];
1284 if (!empty($data['focus_keywords']) && is_array($data['focus_keywords'])) {
1285 $keywords = $data['focus_keywords'];
1286 } elseif (!empty($data['focus_keyword'])) {
1287 $keywords = [$data['focus_keyword']];
1288 }
1289
1290 $all = Focus_Keywords::normalize($keywords, 0);
1291 if (empty($all)) {
1292 return false;
1293 }
1294
1295 // Never overwrite existing ThinkRank focus keywords.
1296 if (!empty(Focus_Keywords::get($post_id))) {
1297 return false;
1298 }
1299
1300 $saved = Focus_Keywords::save($post_id, $all);
1301
1302 if (count($saved) < count($all) && is_array($truncations)) {
1303 $truncations[] = [
1304 'post_id' => $post_id,
1305 'kept' => count($saved),
1306 'dropped' => array_slice($all, count($saved)),
1307 ];
1308 }
1309
1310 return !empty($saved);
1311 }
1312
1313 /**
1314 * Seed the metabox Review schema form data for an imported review post.
1315 *
1316 * Only runs when the record's schema type resolved to 'Review'. Writes the
1317 * carried `review_*` fields (from the snapshot's extended.review_schema) as
1318 * the JSON `_thinkrank_schema_form_data` the metabox Review form reads, so
1319 * the rating survives the import and renders once the user deploys it.
1320 * Never overwrites existing ThinkRank schema form data.
1321 *
1322 * @param int $post_id Target post ID
1323 * @param array $data Canonical record data
1324 * @param array $record Full snapshot record (for the extended payload)
1325 * @return bool True when form data was written
1326 */
1327 private function migrate_review_schema(int $post_id, array $data, array $record): bool {
1328 if (($data['schema_type'] ?? '') !== 'Review') {
1329 return false;
1330 }
1331
1332 $review = $record['extended']['review_schema'] ?? [];
1333 if (empty($review) || !is_array($review)) {
1334 return false;
1335 }
1336
1337 // Never overwrite existing ThinkRank schema form data.
1338 $existing = get_post_meta($post_id, '_thinkrank_schema_form_data', true);
1339 if (is_string($existing) && $existing !== '') {
1340 return false;
1341 }
1342
1343 update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($review));
1344
1345 return true;
1346 }
1347
1348 /**
1349 * Seed the metabox Video schema form data for an imported VideoObject post.
1350 *
1351 * Only runs when the record's schema type resolved to 'VideoObject'. Writes
1352 * the carried `video_*` fields (from the snapshot's extended.video_schema) as
1353 * the JSON `_thinkrank_schema_form_data` the metabox Video form reads, so the
1354 * video details survive the import. Never overwrites existing schema form data.
1355 *
1356 * @param int $post_id Target post ID
1357 * @param array $data Canonical record data
1358 * @param array $record Full snapshot record (for the extended payload)
1359 * @return bool True when form data was written
1360 */
1361 private function migrate_video_schema(int $post_id, array $data, array $record): bool {
1362 if (($data['schema_type'] ?? '') !== 'VideoObject') {
1363 return false;
1364 }
1365
1366 $video = $record['extended']['video_schema'] ?? [];
1367 if (empty($video) || !is_array($video)) {
1368 return false;
1369 }
1370
1371 // Never overwrite existing ThinkRank schema form data.
1372 $existing = get_post_meta($post_id, '_thinkrank_schema_form_data', true);
1373 if (is_string($existing) && $existing !== '') {
1374 return false;
1375 }
1376
1377 update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($video));
1378
1379 return true;
1380 }
1381
1382 /**
1383 * Compose and persist the per-post robots payload.
1384 *
1385 * Folds the canonical robots flags into JSON-encoded
1386 * `_thinkrank_robots_meta` and `_thinkrank_advanced_robots_meta`
1387 * post meta and flips the override toggle when at least one
1388 * directive is present. Never overwrites existing ThinkRank data.
1389 *
1390 * @param int $post_id Target post ID
1391 * @param array $data Canonical record data
1392 * @return bool True when at least one robots field was written
1393 */
1394 private function migrate_robots_payload(int $post_id, array $data): bool {
1395 $existing_payload = get_post_meta($post_id, '_thinkrank_robots_meta', true);
1396 if (is_string($existing_payload) && $existing_payload !== '') {
1397 return false;
1398 }
1399
1400 $robots = [];
1401 foreach (self::ROBOTS_FIELDS as $field) {
1402 if (!array_key_exists($field, $data)) {
1403 continue;
1404 }
1405 $value = $data[$field];
1406 if ($value === '' || $value === null) {
1407 continue;
1408 }
1409 $robots[$field] = (bool) (int) $value;
1410 }
1411
1412 $advanced = [];
1413 foreach (self::ADVANCED_ROBOTS_FIELDS as $field) {
1414 if (!array_key_exists($field, $data)) {
1415 continue;
1416 }
1417 $value = $data[$field];
1418 if ($value === '' || $value === null) {
1419 continue;
1420 }
1421 if ($field === 'max_image_preview') {
1422 $allowed = ['none', 'standard', 'large'];
1423 $value = in_array($value, $allowed, true) ? $value : 'large';
1424 $advanced[$field] = $value;
1425 $advanced['image_preview_enabled'] = $value !== 'none';
1426 continue;
1427 }
1428 $advanced[$field] = (int) $value;
1429 if ($field === 'max_snippet') {
1430 $advanced['snippet_enabled'] = (int) $value !== 0;
1431 } elseif ($field === 'max_video_preview') {
1432 $advanced['video_preview_enabled'] = (int) $value !== 0;
1433 }
1434 }
1435
1436 // The source exporter emits every robots flag (0/1) for every post, so
1437 // $robots is rarely empty. Only persist a robots override when at least
1438 // one directive is actually active (or an advanced directive exists);
1439 // an all-false array equals ThinkRank's default index/follow and must
1440 // not flip robots_meta_enabled on for posts that had no directive.
1441 $has_active_directive = false;
1442 foreach ($robots as $flag) {
1443 if ($flag) {
1444 $has_active_directive = true;
1445 break;
1446 }
1447 }
1448 if (!$has_active_directive && empty($advanced)) {
1449 return false;
1450 }
1451
1452 // Default index=true unless noindex was explicitly imported.
1453 if (!isset($robots['index'])) {
1454 $robots['index'] = empty($robots['noindex']);
1455 }
1456
1457 $wrote = false;
1458 if (!empty($robots)) {
1459 update_post_meta($post_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1460 $wrote = true;
1461 }
1462 if (!empty($advanced)) {
1463 update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_json_encode($advanced));
1464 $wrote = true;
1465 }
1466 if ($wrote) {
1467 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
1468 }
1469
1470 return $wrote;
1471 }
1472
1473 /**
1474 * Migrate settings from snapshot
1475 *
1476 * @param string $plugin Plugin slug
1477 * @return array Result
1478 */
1479 private function migrate_settings(string $plugin): array {
1480 $chunk = Snapshot_Store::read_chunk($plugin, 'settings', 1);
1481 if ($chunk === null || empty($chunk)) {
1482 return [
1483 'status' => 'complete',
1484 'message' => 'No settings to migrate',
1485 'has_more' => false,
1486 'processed' => 0,
1487 'skipped' => 0,
1488 ];
1489 }
1490
1491 $settings_record = $chunk[0] ?? [];
1492 $data = $settings_record['data'] ?? [];
1493 $extended = $settings_record['extended'] ?? [];
1494 $processed = 0;
1495
1496 // Map settings to ThinkRank options
1497 if (!empty($data['separator'])) {
1498 $global_seo = get_option('thinkrank_global_seo_settings', []);
1499 if (empty($global_seo['separator'])) {
1500 $global_seo['separator'] = $data['separator'];
1501 update_option('thinkrank_global_seo_settings', $global_seo);
1502 $processed++;
1503 }
1504 }
1505
1506 if (!empty($data['homepage_title']) || !empty($data['homepage_description']) || !empty($data['organization_name']) || !empty($data['organization_logo'])) {
1507 $site_identity = get_option('thinkrank_site_identity_settings', []);
1508 $updated = false;
1509
1510 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
1511 $site_identity['homepage_title'] = $data['homepage_title'];
1512 $updated = true;
1513 }
1514 if (!empty($data['homepage_description']) && empty($site_identity['homepage_description'])) {
1515 $site_identity['homepage_description'] = $data['homepage_description'];
1516 $updated = true;
1517 }
1518 if (!empty($data['organization_name']) && empty($site_identity['organization_name'])) {
1519 $site_identity['organization_name'] = $data['organization_name'];
1520 $updated = true;
1521 }
1522 if (!empty($data['organization_logo']) && empty($site_identity['organization_logo'])) {
1523 $site_identity['organization_logo'] = $data['organization_logo'];
1524 $updated = true;
1525 }
1526
1527 if ($updated) {
1528 update_option('thinkrank_site_identity_settings', $site_identity);
1529 $processed++;
1530 }
1531 }
1532
1533 if (!empty($data['social_profiles'])) {
1534 $social = get_option('thinkrank_social_media_settings', []);
1535 $updated = false;
1536
1537 foreach ($data['social_profiles'] as $platform => $url) {
1538 if (!empty($url) && empty($social[$platform])) {
1539 $social[$platform] = $url;
1540 $updated = true;
1541 }
1542 }
1543
1544 if ($updated) {
1545 update_option('thinkrank_social_media_settings', $social);
1546 $processed++;
1547 }
1548 }
1549
1550 if (!empty($data['noindex_archives'])) {
1551 $robot_meta = get_option('thinkrank_global_robot_meta_settings', []);
1552 $updated = false;
1553
1554 if (!empty($data['noindex_archives']['date']) && empty($robot_meta['noindex_date_archives'])) {
1555 $robot_meta['noindex_date_archives'] = true;
1556 $updated = true;
1557 }
1558 if (!empty($data['noindex_archives']['author']) && empty($robot_meta['noindex_author_archives'])) {
1559 $robot_meta['noindex_author_archives'] = true;
1560 $updated = true;
1561 }
1562
1563 if ($updated) {
1564 update_option('thinkrank_global_robot_meta_settings', $robot_meta);
1565 $processed++;
1566 }
1567
1568 // Author-archive noindex has an effective home in ThinkRank: the core
1569 // author_archives_index setting the Author Archives feature consults
1570 // (the global_robot_meta keys above are not read for archives).
1571 if (!empty($data['noindex_archives']['author']) && class_exists('ThinkRank\\Core\\Settings')) {
1572 $settings = \ThinkRank\Core\Settings::instance();
1573 if ($settings->get('author_archives_index', true)) {
1574 $settings->set('author_archives_index', false);
1575 $processed++;
1576 }
1577 }
1578 }
1579
1580 // Twitter card default.
1581 if ($this->migrate_twitter_card($data)) {
1582 $processed++;
1583 }
1584
1585 // Site-wide social defaults (Facebook App ID, default OG image).
1586 if ($this->migrate_social_defaults($data)) {
1587 $processed++;
1588 }
1589
1590 // Pinterest site verification — the only webmaster-tools code
1591 // ThinkRank renders today. The rest of extended.webmaster_tools stays
1592 // preserved in the snapshot (and gates cleanup).
1593 if ($this->migrate_pinterest_verification($extended)) {
1594 $processed++;
1595 }
1596
1597 // Per-post-type title/description templates and (active) robots defaults.
1598 if (!empty($extended['post_type_settings']) && is_array($extended['post_type_settings'])) {
1599 if ($this->migrate_post_type_settings($extended['post_type_settings'])) {
1600 $processed++;
1601 }
1602 }
1603
1604 // Site-identity settings (homepage/org/breadcrumbs/local SEO) are served to
1605 // the frontend from the wp_thinkrank_seo_settings table via the manager, not
1606 // from the option written above — route them through the manager so they
1607 // actually take effect.
1608 if ($this->migrate_site_identity($data, $extended)) {
1609 $processed++;
1610 }
1611
1612 // Image SEO auto alt/title generation settings.
1613 if ($this->migrate_image_seo($extended)) {
1614 $processed++;
1615 }
1616
1617 // Sitemap inclusion settings.
1618 if ($this->migrate_sitemap($extended)) {
1619 $processed++;
1620 }
1621
1622 // Knowledge Graph entity (organization/person name) into schema settings.
1623 if ($this->migrate_knowledge_graph($data)) {
1624 $processed++;
1625 }
1626
1627 // IndexNow API key + auto-submit post types into Instant Indexing.
1628 if ($this->migrate_instant_indexing($data, $extended)) {
1629 $processed++;
1630 }
1631
1632 // Author archive behaviour (enabled / title / meta description).
1633 if ($this->migrate_author_archives($extended)) {
1634 $processed++;
1635 }
1636
1637 // Scheduled SEO email report cadence.
1638 if ($this->migrate_email_reports($extended)) {
1639 $processed++;
1640 }
1641
1642 // Role Manager: per-role access to ThinkRank's admin areas.
1643 if ($this->migrate_role_capabilities($extended)) {
1644 $processed++;
1645 }
1646
1647 // Past IndexNow submissions into the Instant Indexing history table.
1648 if ($this->migrate_instant_indexing_log($extended) > 0) {
1649 $processed++;
1650 }
1651
1652 // News/Video sitemap post types into Pro's Publisher Sitemaps.
1653 if ($this->migrate_publisher_sitemaps($extended)) {
1654 $processed++;
1655 }
1656
1657 return [
1658 'status' => 'complete',
1659 'message' => sprintf('Migrated %d settings groups', $processed),
1660 'has_more' => false,
1661 'processed' => $processed,
1662 'skipped' => 0,
1663 ];
1664 }
1665
1666 /**
1667 * Migrate site-identity settings (homepage title, organization, breadcrumbs,
1668 * local SEO) into the wp_thinkrank_seo_settings table via Site_Identity_Manager,
1669 * which is what the frontend actually reads. Non-destructive: a value is only
1670 * written when ThinkRank still holds its default seed (or is empty), so user
1671 * customizations are preserved.
1672 *
1673 * @param array $data Canonical settings `data` payload
1674 * @param array $extended Canonical settings `extended` payload
1675 * @return bool True if any value was written
1676 */
1677 private function migrate_site_identity(array $data, array $extended): bool {
1678 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
1679 return false;
1680 }
1681
1682 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
1683 $current = $manager->get_settings('site');
1684
1685 // ThinkRank default seeds — only overwrite a value the user has not changed.
1686 $seeds = [
1687 'homepage_title' => '%site_title% | %site_description%',
1688 'site_name' => get_bloginfo('name'),
1689 'logo_url' => '',
1690 'breadcrumb_home_text' => 'Home',
1691 'breadcrumb_separator' => '>',
1692 'business_type' => '',
1693 'business_name' => '',
1694 'business_phone' => '',
1695 ];
1696
1697 $updates = [];
1698 $set = static function (string $key, $value) use (&$updates, $current, $seeds): void {
1699 if ($value === '' || $value === null) {
1700 return;
1701 }
1702 $cur = $current[$key] ?? null;
1703 $is_default = !array_key_exists($key, $current) || $cur === '' || $cur === ($seeds[$key] ?? null);
1704 if ($is_default) {
1705 $updates[$key] = $value;
1706 }
1707 };
1708
1709 // Homepage title + organization (organization maps onto site identity's
1710 // site_name / logo_url, which schema output uses as its fallback source).
1711 // A per-context title format (extended.title_formats.homepage_title) is a
1712 // real template and beats the literal-resolved data.homepage_title, so it
1713 // wins when the source provided one.
1714 $title_formats = is_array($extended['title_formats'] ?? null) ? $extended['title_formats'] : [];
1715 $set('homepage_title', $title_formats['homepage_title'] ?? ($data['homepage_title'] ?? ''));
1716 $set('site_name', $data['organization_name'] ?? '');
1717 $set('alternate_name', $data['alternate_name'] ?? '');
1718 $set('logo_url', $data['organization_logo'] ?? '');
1719
1720 // Title separator. ThinkRank stores a KEY ('dash'), not the symbol Rank
1721 // Math stores ('-'), and every migrated %sep% template renders through it
1722 // — so an unmapped separator silently changes every title.
1723 $separator_key = $this->map_separator_symbol((string) ($data['separator'] ?? ''));
1724 if ($separator_key !== '' && ($current['title_separator'] ?? 'pipe') === 'pipe') {
1725 $updates['title_separator'] = $separator_key;
1726 }
1727
1728 // Knowledge Graph entity → what this site "represents" (wizard field).
1729 $kg_type = (string) ($data['knowledge_graph']['type'] ?? '');
1730 if ($kg_type !== '' && empty($current['represents'])) {
1731 $updates['represents'] = $kg_type === 'person' ? 'person' : 'organization';
1732 }
1733
1734 // Per-context title formats (Post/Page/Category/Tag/Search/Archive).
1735 foreach (['post_title', 'page_title', 'category_title', 'tag_title', 'search_title', 'archive_title'] as $key) {
1736 $set($key, $title_formats[$key] ?? '');
1737 }
1738
1739 // The author-archive title has two readers: site identity's `author_title`
1740 // (the front-end title renderer's 'author' context) and the Author Archives
1741 // feature's own `author_archives_title`, written by migrate_author_archives().
1742 $set('author_title', $extended['author_archives']['title'] ?? '');
1743
1744 // Breadcrumbs (extended.breadcrumb_settings) — replicate Rank Math's
1745 // enabled state when ThinkRank breadcrumbs are still at their default.
1746 $breadcrumbs = $extended['breadcrumb_settings'] ?? [];
1747 if (!empty($breadcrumbs)) {
1748 // Replicate Rank Math's on/off state while ThinkRank breadcrumbs are
1749 // still at their default (enabled). Cast loosely — the stored value may
1750 // be '1'/'' rather than a real boolean.
1751 if (filter_var($current['breadcrumbs_enabled'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
1752 $updates['breadcrumbs_enabled'] = !empty($breadcrumbs['enabled']);
1753 }
1754 $set('breadcrumb_home_text', $breadcrumbs['home_label'] ?? '');
1755 $set('breadcrumb_separator', $breadcrumbs['separator'] ?? '');
1756 $set('breadcrumb_prefix', $breadcrumbs['prefix'] ?? '');
1757 }
1758
1759 // Local SEO (extended.local_seo) — migrate the full NAP + geo when there
1760 // is any meaningful business data (name, phone, address or coordinates),
1761 // and enable the feature alongside it. Each field is written only while
1762 // ThinkRank's Business Info still holds its default (non-destructive).
1763 $local = $extended['local_seo'] ?? [];
1764 $address = is_array($local['address'] ?? null) ? $local['address'] : [];
1765 $geo = is_array($local['geo'] ?? null) ? $local['geo'] : [];
1766 $hours = is_array($local['opening_hours'] ?? null) ? $local['opening_hours'] : [];
1767 $has_local = !empty($local['business_name']) || !empty($local['phone'])
1768 || !empty($address) || !empty($geo) || !empty($hours)
1769 || !empty($local['price_range']);
1770
1771 // Local SEO lands in its own $local_updates batch, saved separately from
1772 // the identity batch. Site_Identity_Manager::save_settings() validates the
1773 // whole payload and aborts ALL writes when any field is invalid — and
1774 // `local_seo_enabled` makes `business_name` mandatory. Mixing the two
1775 // batches meant a source with opening hours but no business name (Rank
1776 // Math's default Local SEO state) failed validation and silently
1777 // discarded the homepage title, logo, breadcrumbs and separator too.
1778 $local_updates = [];
1779 if ($has_local) {
1780 $set_local = static function (string $key, $value) use (&$local_updates, $current, $seeds): void {
1781 if ($value === '' || $value === null) {
1782 return;
1783 }
1784 $cur = $current[$key] ?? null;
1785 $is_default = !array_key_exists($key, $current) || $cur === '' || $cur === ($seeds[$key] ?? null);
1786 if ($is_default) {
1787 $local_updates[$key] = $value;
1788 }
1789 };
1790
1791 $set_local('business_type', $local['business_type'] ?? '');
1792 $set_local('business_name', $local['business_name'] ?? '');
1793 $set_local('business_phone', $local['phone'] ?? '');
1794
1795 // Postal address (schema.org PostalAddress → ThinkRank Business Info).
1796 $set_local('business_address', $address['street'] ?? '');
1797 $set_local('business_city', $address['city'] ?? '');
1798 $set_local('business_state', $address['state'] ?? '');
1799 $set_local('business_postal_code', $address['postal_code'] ?? '');
1800 $set_local('business_country', $address['country'] ?? '');
1801
1802 // Geo coordinates.
1803 $set_local('business_latitude', $geo['latitude'] ?? '');
1804 $set_local('business_longitude', $geo['longitude'] ?? '');
1805
1806 // Price range (scalar, e.g. "$$").
1807 $set_local('business_price_range', $local['price_range'] ?? '');
1808
1809 // Opening hours are a per-day array, so the scalar-tuned helper
1810 // doesn't apply — write directly while ThinkRank still holds no hours.
1811 if (!empty($hours) && empty($current['business_hours'])) {
1812 $local_updates['business_hours'] = $hours;
1813 }
1814
1815 // Only turn the feature ON when the business name it requires is
1816 // actually present (either carried over now or already stored).
1817 $has_name = !empty($local_updates['business_name']) || !empty($current['business_name']);
1818 if ($has_name && empty($current['local_seo_enabled'])) {
1819 $local_updates['local_seo_enabled'] = true;
1820 }
1821 }
1822
1823 $wrote = false;
1824
1825 if (!empty($updates) && $manager->save_settings('site', null, $updates)) {
1826 $wrote = true;
1827 }
1828
1829 if (!empty($local_updates) && $manager->save_settings('site', null, $local_updates)) {
1830 $wrote = true;
1831 }
1832
1833 return $wrote;
1834 }
1835
1836 /**
1837 * Map a title-separator SYMBOL (what source plugins store) to ThinkRank's
1838 * separator KEY (what Site_Identity_Manager stores and renders %sep% from).
1839 *
1840 * @param string $symbol Raw separator symbol, e.g. '-'
1841 * @return string ThinkRank separator key, or '' when unmapped
1842 */
1843 private function map_separator_symbol(string $symbol): string {
1844 $symbol = trim(html_entity_decode($symbol, ENT_QUOTES, 'UTF-8'));
1845 if ($symbol === '') {
1846 return '';
1847 }
1848
1849 $map = [
1850 '|' => 'pipe',
1851 '-' => 'dash',
1852 '–' => 'dash',
1853 '—' => 'dash',
1854 '•' => 'bullet',
1855 ':' => 'colon',
1856 '>' => 'greater',
1857 '~' => 'tilde',
1858 ];
1859
1860 return $map[$symbol] ?? '';
1861 }
1862
1863 /**
1864 * Migrate Rank Math's global Twitter card type into ThinkRank's Social Meta
1865 * settings (wp_thinkrank_seo_settings via Social_Meta_Manager). Non-destructive:
1866 * only written while ThinkRank still holds its default card type.
1867 *
1868 * @param array $data Canonical settings `data` payload
1869 * @return bool True if written
1870 */
1871 private function migrate_twitter_card(array $data): bool {
1872 $card_type = $data['twitter_card_type'] ?? '';
1873 if ($card_type === '' || !class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) {
1874 return false;
1875 }
1876
1877 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
1878 $current = $manager->get_settings('site');
1879
1880 // ThinkRank default card type — only overwrite while unchanged.
1881 if (($current['twitter_card_type'] ?? 'summary_large_image') !== 'summary_large_image') {
1882 return false;
1883 }
1884 if ($card_type === 'summary_large_image') {
1885 return false; // Identical to ThinkRank's default — nothing to change.
1886 }
1887
1888 return $manager->save_settings('site', null, ['twitter_card_type' => $card_type]);
1889 }
1890
1891 /**
1892 * Migrate site-wide social defaults (Facebook App ID, default OG image)
1893 * into ThinkRank's Social Meta settings. Non-destructive: each value is
1894 * written only while ThinkRank still holds none.
1895 *
1896 * @param array $data Canonical settings `data` payload
1897 * @return bool True if anything was written
1898 */
1899 private function migrate_social_defaults(array $data): bool {
1900 $defaults = $data['social_defaults'] ?? [];
1901 if (!is_array($defaults) || !class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) {
1902 return false;
1903 }
1904
1905 $app_id = trim((string) ($defaults['facebook_app_id'] ?? ''));
1906 $og_image = trim((string) ($defaults['og_default_image'] ?? ''));
1907 if ($app_id === '' && $og_image === '') {
1908 return false;
1909 }
1910
1911 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
1912 $current = $manager->get_settings('site');
1913
1914 $updates = [];
1915 if ($app_id !== '' && empty($current['facebook_app_id'])) {
1916 $updates['facebook_app_id'] = $app_id;
1917 }
1918 if ($og_image !== '' && empty($current['default_image'])) {
1919 $updates['default_image'] = $og_image;
1920 }
1921
1922 if (empty($updates)) {
1923 return false;
1924 }
1925
1926 return (bool) $manager->save_settings('site', null, $updates);
1927 }
1928
1929 /**
1930 * Migrate the source plugin's Pinterest site-verification code into
1931 * ThinkRank's core `pinterest_site_verification` setting. Never overwrites
1932 * a configured code.
1933 *
1934 * @param array $extended Canonical settings `extended` payload
1935 * @return bool True if written
1936 */
1937 private function migrate_pinterest_verification(array $extended): bool {
1938 $code = trim((string) ($extended['webmaster_tools']['pinterest'] ?? ''));
1939 if ($code === '' || !class_exists('ThinkRank\\Core\\Settings')) {
1940 return false;
1941 }
1942
1943 $settings = \ThinkRank\Core\Settings::instance();
1944 if ((string) $settings->get('pinterest_site_verification', '') !== '') {
1945 return false;
1946 }
1947
1948 $settings->set('pinterest_site_verification', $code);
1949
1950 return true;
1951 }
1952
1953 /**
1954 * Build the schema settings manager, when available.
1955 *
1956 * Split out (and protected) so the shim-based unit tests can substitute a
1957 * fake manager — the real one persists to the wp_thinkrank_seo_settings
1958 * table, which needs a live database.
1959 *
1960 * @return object|null Schema_Management_System instance, or null when unavailable
1961 */
1962 protected function create_schema_manager(): ?object {
1963 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
1964 return null;
1965 }
1966
1967 return new \ThinkRank\SEO\Schema_Management_System();
1968 }
1969
1970 /**
1971 * Migrate the source plugin's Knowledge Graph entity into ThinkRank's schema
1972 * settings (wp_thinkrank_seo_settings via Schema_Management_System).
1973 *
1974 * Rank Math's knowledgegraph_type is 'company' or 'person' (normalized to
1975 * 'organization'/'person' by the exporter). ThinkRank's schema settings model
1976 * the same split: organization_* fields (organization_type already defaults
1977 * to 'Organization', matching 'company') and person_* fields. The entity
1978 * name lands in organization_name or person_name accordingly. Non-destructive:
1979 * a value is only written while ThinkRank still holds no value for it.
1980 *
1981 * @param array $data Canonical settings `data` payload
1982 * @return bool True if any value was written
1983 */
1984 private function migrate_knowledge_graph(array $data): bool {
1985 $kg = $data['knowledge_graph'] ?? [];
1986 if (!is_array($kg)) {
1987 return false;
1988 }
1989
1990 $type = (string) ($kg['type'] ?? '');
1991 $name = trim((string) ($kg['name'] ?? ''));
1992 if ($type === '' || $name === '') {
1993 return false;
1994 }
1995
1996 $manager = $this->create_schema_manager();
1997 if ($manager === null) {
1998 return false;
1999 }
2000
2001 $current = $manager->get_settings('site');
2002 $updates = [];
2003
2004 if ($type === 'person') {
2005 if (empty($current['person_name'])) {
2006 $updates['person_name'] = $name;
2007 }
2008 } else {
2009 // 'organization' — organization_type's default ('Organization')
2010 // already matches Rank Math's 'company', so only the name needs
2011 // a home. Fill it while ThinkRank still holds none.
2012 if (empty($current['organization_name'])) {
2013 $updates['organization_name'] = $name;
2014 }
2015 }
2016
2017 if (empty($updates)) {
2018 return false;
2019 }
2020
2021 return (bool) $manager->save_settings('site', null, $updates);
2022 }
2023
2024 /**
2025 * Migrate the source plugin's IndexNow API key into ThinkRank's Instant
2026 * Indexing settings (thinkrank_instant_indexing_settings['api_key'], read by
2027 * Instant_Indexing_Manager). Carrying the key over avoids re-verifying the
2028 * site with IndexNow ({key}.txt is already served for it).
2029 *
2030 * Never clobbers a configured key: ThinkRank generates its own key on
2031 * activation, so this only fills the slot when it is genuinely empty/unset.
2032 *
2033 * Also carries the source's auto-submit post types
2034 * (extended.instant_indexing_post_types) so publishing keeps pinging the
2035 * same content types it did before the switch.
2036 *
2037 * @param array $data Canonical settings `data` payload
2038 * @param array $extended Canonical settings `extended` payload
2039 * @return bool True if anything was written
2040 */
2041 private function migrate_instant_indexing(array $data, array $extended = []): bool {
2042 $api_key = trim((string) ($data['instant_indexing']['api_key'] ?? ''));
2043 $source_types = $extended['instant_indexing_post_types'] ?? [];
2044 $source_types = is_array($source_types) ? $source_types : [];
2045
2046 if ($api_key === '' && empty($source_types)) {
2047 return false;
2048 }
2049
2050 $settings = get_option('thinkrank_instant_indexing_settings', []);
2051 if (!is_array($settings)) {
2052 $settings = [];
2053 }
2054
2055 $wrote = false;
2056
2057 // Auto-submit post types. ThinkRank seeds ['post','page'] on activation,
2058 // so only replace that untouched seed — never a user's own selection.
2059 if (!empty($source_types)) {
2060 $types = [];
2061 foreach ($source_types as $type) {
2062 $type = sanitize_key((string) $type);
2063 if ($type !== '' && post_type_exists($type)) {
2064 $types[] = $type;
2065 }
2066 }
2067 $types = array_values(array_unique($types));
2068
2069 $current_types = $settings['auto_submit_post_types'] ?? null;
2070 $is_seed = $current_types === null
2071 || (is_array($current_types) && array_diff($current_types, ['post', 'page']) === []
2072 && array_diff(['post', 'page'], $current_types) === []);
2073
2074 if (!empty($types) && $is_seed && $types !== $current_types) {
2075 $settings['auto_submit_post_types'] = $types;
2076 $wrote = true;
2077 }
2078 }
2079
2080 if ($api_key === '') {
2081 if ($wrote) {
2082 update_option('thinkrank_instant_indexing_settings', $settings);
2083 }
2084
2085 return $wrote;
2086 }
2087
2088 // Only migrate the key when the target is empty/unset — never overwrite.
2089 if (empty($settings['api_key'])) {
2090 $settings['api_key'] = $api_key;
2091 $wrote = true;
2092 }
2093
2094 if ($wrote) {
2095 update_option('thinkrank_instant_indexing_settings', $settings);
2096 }
2097
2098 return $wrote;
2099 }
2100
2101 /**
2102 * Migrate a chunk of redirection rules into ThinkRank Pro's Redirections.
2103 *
2104 * Pro-gated: the redirect table belongs to ThinkRank Pro, so this is a no-op
2105 * (reported as skipped, never as an error) when Pro is inactive. The snapshot
2106 * keeps the records either way, so activating Pro and re-running the
2107 * migration picks them up. Referenced only through string class names so the
2108 * free plugin never hard-depends on Pro.
2109 *
2110 * @param string $plugin Plugin slug
2111 * @param int $page Chunk number
2112 * @return array Migration result
2113 */
2114 private function migrate_redirections(string $plugin, int $page): array {
2115 $chunk = Snapshot_Store::read_chunk($plugin, 'redirections', $page);
2116 if ($chunk === null || empty($chunk)) {
2117 return [
2118 'status' => 'complete',
2119 'message' => 'No redirections in chunk',
2120 'has_more' => false,
2121 'processed' => 0,
2122 'skipped' => 0,
2123 ];
2124 }
2125
2126 $store = $this->create_redirections_store();
2127 if ($store === null || !method_exists($store, 'import_redirect')) {
2128 return [
2129 'status' => 'complete',
2130 'message' => sprintf(
2131 'Skipped %d redirections — ThinkRank Pro (Redirections) is not active. They stay in the snapshot.',
2132 count($chunk)
2133 ),
2134 'has_more' => false,
2135 'processed' => 0,
2136 'skipped' => count($chunk),
2137 ];
2138 }
2139
2140 $processed = 0;
2141 $skipped = 0;
2142
2143 foreach ($chunk as $record) {
2144 $r = $record['extended'] ?? [];
2145 $source = trim((string) ($r['source_url'] ?? ''));
2146 if ($source === '') {
2147 $skipped++;
2148 continue;
2149 }
2150
2151 // Pre-`match_type` snapshots only carried the `is_regex` boolean.
2152 $match_type = (string) ($r['match_type'] ?? (!empty($r['is_regex']) ? 'regex' : 'exact'));
2153
2154 $id = $store->import_redirect([
2155 'source_url' => $source,
2156 'match_type' => $match_type,
2157 'target_url' => (string) ($r['target_url'] ?? ''),
2158 'http_code' => (int) ($r['http_code'] ?? 301),
2159 'status' => !empty($r['enabled']) ? 'active' : 'inactive',
2160 'hits' => (int) ($r['hits'] ?? 0),
2161 'created_at' => (string) ($r['created_at'] ?? ''),
2162 'last_accessed' => (string) ($r['last_accessed'] ?? ''),
2163 ]);
2164
2165 if ($id > 0) {
2166 $processed++;
2167 } else {
2168 $skipped++;
2169 }
2170 }
2171
2172 return [
2173 'status' => 'complete',
2174 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
2175 'has_more' => false,
2176 'processed' => $processed,
2177 'skipped' => $skipped,
2178 ];
2179 }
2180
2181 /**
2182 * Migrate a chunk of logged 404 hits into ThinkRank Pro's 404 Monitor.
2183 * Pro-gated exactly like migrate_redirections().
2184 *
2185 * @param string $plugin Plugin slug
2186 * @param int $page Chunk number
2187 * @return array Migration result
2188 */
2189 private function migrate_404_logs(string $plugin, int $page): array {
2190 $chunk = Snapshot_Store::read_chunk($plugin, '404_logs', $page);
2191 if ($chunk === null || empty($chunk)) {
2192 return [
2193 'status' => 'complete',
2194 'message' => 'No 404 logs in chunk',
2195 'has_more' => false,
2196 'processed' => 0,
2197 'skipped' => 0,
2198 ];
2199 }
2200
2201 $store = $this->create_redirections_store();
2202 if ($store === null || !method_exists($store, 'import_404_log')) {
2203 return [
2204 'status' => 'complete',
2205 'message' => sprintf(
2206 'Skipped %d 404 logs — ThinkRank Pro (404 Monitor) is not active. They stay in the snapshot.',
2207 count($chunk)
2208 ),
2209 'has_more' => false,
2210 'processed' => 0,
2211 'skipped' => count($chunk),
2212 ];
2213 }
2214
2215 $processed = 0;
2216 $skipped = 0;
2217
2218 foreach ($chunk as $record) {
2219 $log = $record['extended'] ?? [];
2220 if ($store->import_404_log([
2221 'uri' => (string) ($log['uri'] ?? ''),
2222 'times_accessed' => (int) ($log['times_accessed'] ?? 1),
2223 'referer' => (string) ($log['referer'] ?? ''),
2224 'user_agent' => (string) ($log['user_agent'] ?? ''),
2225 'last_accessed' => (string) ($log['last_accessed'] ?? ''),
2226 ])) {
2227 $processed++;
2228 } else {
2229 $skipped++;
2230 }
2231 }
2232
2233 return [
2234 'status' => 'complete',
2235 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
2236 'has_more' => false,
2237 'processed' => $processed,
2238 'skipped' => $skipped,
2239 ];
2240 }
2241
2242 /**
2243 * Build ThinkRank Pro's Redirections store, when Pro is active.
2244 *
2245 * Split out (and protected) so tests can substitute a fake — the real store
2246 * writes to Pro's tables. Pro lazily creates them via Schema::ensure().
2247 *
2248 * @return object|null Store instance, or null when Pro is unavailable
2249 */
2250 protected function create_redirections_store(): ?object {
2251 if (
2252 !class_exists('ThinkRank\\Pro\\Redirections\\Schema')
2253 || !class_exists('ThinkRank\\Pro\\Redirections\\Store')
2254 ) {
2255 return null;
2256 }
2257
2258 \ThinkRank\Pro\Redirections\Schema::ensure();
2259
2260 return new \ThinkRank\Pro\Redirections\Store();
2261 }
2262
2263 /**
2264 * Migrate the source plugin's author-archive behaviour into ThinkRank's
2265 * Author Archives settings (core Settings keys read by
2266 * Author_Archives_Manager).
2267 *
2268 * The noindex flag is handled separately in migrate_settings() via
2269 * `data.noindex_archives.author`; this covers whether archives exist at all
2270 * and the title / meta-description templates they render with.
2271 * Non-destructive: each key is written only while ThinkRank still holds its
2272 * default.
2273 *
2274 * @param array $extended Canonical settings `extended` payload
2275 * @return bool True if any value was written
2276 */
2277 private function migrate_author_archives(array $extended): bool {
2278 $author = $extended['author_archives'] ?? [];
2279 if (!is_array($author) || empty($author) || !class_exists('ThinkRank\\Core\\Settings')) {
2280 return false;
2281 }
2282
2283 $settings = \ThinkRank\Core\Settings::instance();
2284 $wrote = false;
2285
2286 // Rank Math's "disable author archives" → ThinkRank's positive `enabled`.
2287 // Only act on a disable; leaving them on is already ThinkRank's default.
2288 if (array_key_exists('enabled', $author) && !$author['enabled']
2289 && $settings->get('author_archives_enabled', true)) {
2290 $settings->set('author_archives_enabled', false);
2291 $wrote = true;
2292 }
2293
2294 $title = trim((string) ($author['title'] ?? ''));
2295 if ($title !== '' && $settings->get('author_archives_title', '') === '') {
2296 $settings->set('author_archives_title', $title);
2297 $wrote = true;
2298 }
2299
2300 $description = trim((string) ($author['description'] ?? ''));
2301 if ($description !== '' && $settings->get('author_archives_meta_desc', '') === '') {
2302 $settings->set('author_archives_meta_desc', $description);
2303 $wrote = true;
2304 }
2305
2306 return $wrote;
2307 }
2308
2309 /**
2310 * Migrate the source plugin's IndexNow submission history into ThinkRank's
2311 * `thinkrank_instant_indexing_logs` table, so the Instant Indexing history
2312 * screen is not blank after switching.
2313 *
2314 * Idempotent: an entry is skipped when a row with the same URL and
2315 * timestamp already exists, so re-running never double-counts.
2316 *
2317 * @param array $extended Canonical settings `extended` payload
2318 * @return int Number of entries written
2319 */
2320 private function migrate_instant_indexing_log(array $extended): int {
2321 global $wpdb;
2322
2323 $log = $extended['instant_indexing_log'] ?? [];
2324 $entries = is_array($log['entries'] ?? null) ? $log['entries'] : [];
2325 if (empty($entries)) {
2326 return 0;
2327 }
2328
2329 $table = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
2330 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
2331 if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) {
2332 return 0;
2333 }
2334
2335 $written = 0;
2336 foreach ($entries as $entry) {
2337 $url = trim((string) ($entry['url'] ?? ''));
2338 if ($url === '') {
2339 continue;
2340 }
2341
2342 $created_at = (string) ($entry['submitted_at'] ?? '');
2343 if ($created_at === '') {
2344 $created_at = current_time('mysql');
2345 }
2346
2347 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2348 $exists = $wpdb->get_var(
2349 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
2350 $wpdb->prepare("SELECT id FROM {$table} WHERE url = %s AND created_at = %s LIMIT 1", $url, $created_at)
2351 );
2352 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2353 if ($exists) {
2354 continue;
2355 }
2356
2357 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
2358 $inserted = $wpdb->insert(
2359 $table,
2360 [
2361 'url' => $url,
2362 'status' => (string) ($entry['status'] ?? 'failed'),
2363 'response_code' => (int) ($entry['response_code'] ?? 0),
2364 'response_message' => (string) ($entry['response_message'] ?? ''),
2365 'created_at' => $created_at,
2366 ],
2367 ['%s', '%s', '%d', '%s', '%s']
2368 );
2369
2370 if ($inserted) {
2371 $written++;
2372 }
2373 }
2374
2375 return $written;
2376 }
2377
2378 /**
2379 * Migrate the source plugin's News/Video sitemap post types into ThinkRank
2380 * Pro's Publisher Sitemaps settings.
2381 *
2382 * Pro-gated via string class names so the free plugin never hard-depends on
2383 * Pro, and non-destructive: a list is written only while Pro still holds its
2384 * default for it.
2385 *
2386 * @param array $extended Canonical settings `extended` payload
2387 * @return bool True if any list was written
2388 */
2389 private function migrate_publisher_sitemaps(array $extended): bool {
2390 $source = $extended['publisher_sitemaps'] ?? [];
2391 if (!is_array($source) || empty($source)
2392 || !class_exists('ThinkRank\\Pro\\Sitemaps\\Settings')) {
2393 return false;
2394 }
2395
2396 $settings = new \ThinkRank\Pro\Sitemaps\Settings();
2397 $current = $settings->get();
2398 $defaults = \ThinkRank\Pro\Sitemaps\Settings::defaults();
2399
2400 $updates = [];
2401 foreach (['video_post_types', 'news_post_types'] as $key) {
2402 if (empty($source[$key]) || !is_array($source[$key])) {
2403 continue;
2404 }
2405
2406 // Only replace Pro's untouched default — never a user's selection.
2407 if (($current[$key] ?? null) !== ($defaults[$key] ?? null)) {
2408 continue;
2409 }
2410
2411 $types = [];
2412 foreach ($source[$key] as $type) {
2413 $type = sanitize_key((string) $type);
2414 if ($type !== '' && post_type_exists($type)) {
2415 $types[] = $type;
2416 }
2417 }
2418 $types = array_values(array_unique($types));
2419
2420 if (!empty($types) && $types !== ($current[$key] ?? null)) {
2421 $updates[$key] = $types;
2422 }
2423 }
2424
2425 if (empty($updates)) {
2426 return false;
2427 }
2428
2429 $settings->save($updates);
2430
2431 return true;
2432 }
2433
2434 /**
2435 * Source-plugin capability => the ThinkRank capabilities it corresponds to.
2436 *
2437 * Deliberately conservative: a role only gains an area when the source
2438 * plainly granted the equivalent one. Over-granting here is a privilege
2439 * escalation, while under-granting is a re-tick in the Role Manager UI, so
2440 * ambiguous cases are left out and reported instead.
2441 *
2442 * Notably absent:
2443 * - `thinkrank_settings` (Settings & API Keys) — it exposes AI provider keys
2444 * and the Google connection, a class of secret neither source plugin ever
2445 * held. Rank Math's nearest cap (`rank_math_general`) is a grab-bag and
2446 * Yoast's (`wpseo_manage_options`) is plugin-wide, so neither is specific
2447 * enough to justify handing over credentials: this stays administrator-only
2448 * after an import and must be granted by hand.
2449 * - Redirections / 404 Monitor — ThinkRank models no capability for them, so
2450 * `rank_math_redirections`, `rank_math_404_monitor` and Yoast Premium's
2451 * `wpseo_manage_redirects` have nowhere to land.
2452 * - `rank_math_admin_bar`, `rank_math_edit_htaccess` — no equivalent.
2453 */
2454 private const ROLE_CAPABILITY_MAP = [
2455 // Titles & Meta / Search Appearance.
2456 'rank_math_titles' => ['thinkrank_site_identity', 'thinkrank_global_seo', 'thinkrank_author_archives'],
2457 // General Settings holds Rank Math's Images and Instant Indexing panels.
2458 'rank_math_general' => ['thinkrank_image_seo', 'thinkrank_instant_indexing'],
2459 'rank_math_sitemap' => ['thinkrank_crawling'],
2460 'rank_math_analytics' => ['thinkrank_analytics', 'thinkrank_performance'],
2461 'rank_math_site_analysis' => ['thinkrank_analytics'],
2462 'rank_math_content_ai' => ['thinkrank_content_tools'],
2463 'rank_math_link_builder' => ['thinkrank_internal_links'],
2464 // Per-post metabox tabs.
2465 'rank_math_onpage_analysis' => ['thinkrank_content_tools'],
2466 'rank_math_onpage_snippet' => ['thinkrank_schema'],
2467 'rank_math_onpage_social' => ['thinkrank_social_media'],
2468 'rank_math_onpage_advanced' => ['thinkrank_crawling'],
2469 'rank_math_role_manager' => ['thinkrank_manage_roles'],
2470
2471 // Yoast. Its capability set is much coarser — three caps cover the whole
2472 // plugin — so `wpseo_manage_options` fans out to the areas it genuinely
2473 // controlled in Yoast (titles, social, schema, crawl, sitemaps). It does
2474 // NOT imply `thinkrank_manage_roles`: Yoast has no role-manager screen,
2475 // so nothing in the source says that role was trusted to grant access to
2476 // others.
2477 'wpseo_manage_options' => [
2478 'thinkrank_site_identity', 'thinkrank_global_seo', 'thinkrank_social_media',
2479 'thinkrank_schema', 'thinkrank_crawling', 'thinkrank_analytics',
2480 'thinkrank_image_seo', 'thinkrank_instant_indexing', 'thinkrank_author_archives',
2481 ],
2482 // Yoast's bulk title/description editor.
2483 'wpseo_bulk_edit' => ['thinkrank_global_seo'],
2484 // The metabox "Advanced" tab: robots directives, canonical, breadcrumb title.
2485 'wpseo_edit_advanced_metadata' => ['thinkrank_crawling'],
2486 ];
2487
2488 /**
2489 * Migrate the source plugin's Role Manager assignments into ThinkRank's
2490 * per-role capabilities (Capability_Manager).
2491 *
2492 * Without this, every non-administrator role loses its SEO access the
2493 * moment the source plugin is deactivated: ThinkRank grants its caps to the
2494 * administrator only, so an editor who could edit titles and social meta
2495 * simply stops seeing ThinkRank.
2496 *
2497 * Non-destructive: a role is only filled while it holds NO ThinkRank
2498 * capability yet, so a matrix the user has already configured is never
2499 * rewritten. `save_matrix()` grants the base ACCESS cap implicitly.
2500 *
2501 * @param array $extended Canonical settings `extended` payload
2502 * @return bool True if any role was granted capabilities
2503 */
2504 private function migrate_role_capabilities(array $extended): bool {
2505 $source_roles = $extended['role_capabilities'] ?? [];
2506 if (!is_array($source_roles) || empty($source_roles)
2507 || !class_exists('ThinkRank\\Core\\Capability_Manager')) {
2508 return false;
2509 }
2510
2511 $manager = '\\ThinkRank\\Core\\Capability_Manager';
2512 $matrix = $manager::get_matrix();
2513 $editable = $manager::editable_roles();
2514 $updated = false;
2515
2516 foreach ($source_roles as $role_slug => $source_caps) {
2517 $role_slug = sanitize_key((string) $role_slug);
2518
2519 // editable_roles() already excludes the administrator.
2520 if (!isset($editable[$role_slug]) || !is_array($source_caps)) {
2521 continue;
2522 }
2523
2524 // Never rewrite a role the user has already given ThinkRank access.
2525 if (!empty($matrix[$role_slug])) {
2526 continue;
2527 }
2528
2529 $granted = [];
2530 foreach ($source_caps as $source_cap) {
2531 foreach (self::ROLE_CAPABILITY_MAP[(string) $source_cap] ?? [] as $thinkrank_cap) {
2532 $granted[$thinkrank_cap] = true;
2533 }
2534 }
2535
2536 if (empty($granted)) {
2537 continue;
2538 }
2539
2540 $matrix[$role_slug] = array_keys($granted);
2541 $updated = true;
2542 }
2543
2544 if (!$updated) {
2545 return false;
2546 }
2547
2548 $manager::save_matrix($matrix);
2549
2550 return true;
2551 }
2552
2553 /**
2554 * Carry the source plugin's scheduled SEO email report over as ThinkRank's
2555 * Email Reporting switch.
2556 *
2557 * Only touches a config the user has not enabled yet, and never turns
2558 * reports ON unless the source had them on — an unexpected recurring email
2559 * after an import would be worse than a missing one. The source cadence is
2560 * not carried: the report's schedule is not a setting this plugin stores.
2561 *
2562 * @param array $extended Canonical settings `extended` payload
2563 * @return bool True if the config was written
2564 */
2565 private function migrate_email_reports(array $extended): bool {
2566 $reports = $extended['email_reports'] ?? [];
2567 if (!is_array($reports) || empty($reports['enabled'])
2568 || !class_exists('ThinkRank\\SEO\\Email_Report_Config')) {
2569 return false;
2570 }
2571
2572 $config_manager = new \ThinkRank\SEO\Email_Report_Config();
2573 $current = $config_manager->get();
2574
2575 // Never re-enable over a deliberate opt-out or clobber a live schedule.
2576 if (!empty($current['enabled'])) {
2577 return false;
2578 }
2579
2580 $config_manager->save(['enabled' => true]);
2581
2582 return true;
2583 }
2584
2585 /**
2586 * Inspect a plugin's snapshot for extended data that has NO migration path
2587 * yet — data that is preserved in the snapshot but would become the only
2588 * copy once /import/cleanup deletes the source plugin's rows.
2589 *
2590 * Covers: redirection records (exported, but the migrator has no redirect
2591 * target yet — owed to the Pro Redirections feature) and any settings
2592 * `extended` bucket outside HANDLED_EXTENDED_SETTINGS. The raw_options
2593 * capture-all bucket is deliberately NOT counted (a fresh export recreates
2594 * it; it exists precisely to survive cleanup inside the snapshot).
2595 *
2596 * Used by Import_Controller::cleanup() to require force=true before
2597 * deleting source data while such buckets exist.
2598 *
2599 * @param string $plugin Plugin slug
2600 * @return array List of ['key' => ..., 'label' => ..., 'count' => ...]
2601 */
2602 public function get_unmigrated_extended_buckets(string $plugin): array {
2603 $buckets = [];
2604
2605 $manifest = Snapshot_Store::get_manifest($plugin);
2606 if (!$manifest) {
2607 return $buckets;
2608 }
2609
2610 // Redirections and 404 logs migrate into ThinkRank Pro. With Pro active
2611 // they have a real home and never block cleanup; without it they are
2612 // preserved-but-unapplied, so cleanup must warn before the source rows
2613 // (the only other copy) go away.
2614 $store = $this->create_redirections_store();
2615 $pro_can_take_redirects = $store !== null && method_exists($store, 'import_redirect');
2616
2617 if (!$pro_can_take_redirects) {
2618 $redirection_count = (int) ($manifest['types']['redirections']['total_records'] ?? 0);
2619 if ($redirection_count > 0) {
2620 $buckets[] = [
2621 'key' => 'redirections',
2622 'label' => __('Redirections', 'thinkrank'),
2623 'count' => $redirection_count,
2624 ];
2625 }
2626
2627 $log_count = (int) ($manifest['types']['404_logs']['total_records'] ?? 0);
2628 if ($log_count > 0) {
2629 $buckets[] = [
2630 'key' => '404_logs',
2631 'label' => __('404 Logs', 'thinkrank'),
2632 'count' => $log_count,
2633 ];
2634 }
2635 }
2636
2637 $chunk = Snapshot_Store::read_chunk($plugin, 'settings', 1);
2638 $extended = $chunk[0]['extended'] ?? [];
2639 if (is_array($extended)) {
2640 foreach ($extended as $key => $value) {
2641 if (in_array($key, self::HANDLED_EXTENDED_SETTINGS, true) || empty($value)) {
2642 continue;
2643 }
2644 $buckets[] = [
2645 'key' => 'settings.' . $key,
2646 'label' => (string) $key,
2647 'count' => 1,
2648 ];
2649 }
2650 }
2651
2652 return $buckets;
2653 }
2654
2655 /**
2656 * Fold post IDs the source excluded from its sitemap into ThinkRank's
2657 * sitemap `exclude_posts` list (a comma-separated ID string — ThinkRank has
2658 * no per-post exclusion meta).
2659 *
2660 * Additive and idempotent: IDs already listed are left in place and never
2661 * duplicated, so re-running a migration converges.
2662 *
2663 * @param int[] $post_ids Post IDs to exclude
2664 * @return int Number of IDs newly added
2665 */
2666 private function migrate_sitemap_exclusions(array $post_ids): int {
2667 $post_ids = array_values(array_unique(array_filter(array_map('intval', $post_ids))));
2668 if (empty($post_ids) || !class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
2669 return 0;
2670 }
2671
2672 $manager = new \ThinkRank\SEO\Sitemap_Generator();
2673 $current = $manager->get_settings('site');
2674
2675 $existing = array_filter(array_map(
2676 'intval',
2677 array_map('trim', explode(',', (string) ($current['exclude_posts'] ?? '')))
2678 ));
2679
2680 $merged = array_values(array_unique(array_merge($existing, $post_ids)));
2681 $added = count($merged) - count($existing);
2682 if ($added <= 0) {
2683 return 0;
2684 }
2685
2686 sort($merged);
2687 $manager->save_settings('site', null, ['exclude_posts' => implode(',', $merged)]);
2688
2689 return $added;
2690 }
2691
2692 /**
2693 * Migrate a source plugin's sitemap inclusion settings into ThinkRank's
2694 * sitemap settings (wp_thinkrank_seo_settings via Sitemap_Generator). ThinkRank only
2695 * models the global enable toggle, posts/pages/categories/tags inclusion,
2696 * images, links-per-file, the ping-search-engines toggle and the sitemap-index
2697 * toggle (Rank Math is always index-based; AIOSEO exposes it explicitly);
2698 * source plugins' per-CPT / per-taxonomy toggles beyond these are not
2699 * represented. Shared by all source exporters (Rank Math, AIOSEO, SEOPress,
2700 * Yoast), which each emit this canonical shape.
2701 * Non-destructive: a value is written only while ThinkRank still holds its
2702 * default for that key.
2703 *
2704 * @param array $extended Canonical settings `extended` payload
2705 * @return bool True if any value was written
2706 */
2707 private function migrate_sitemap(array $extended): bool {
2708 $sitemap = $extended['sitemap_settings'] ?? [];
2709 if (empty($sitemap['has_data']) || !class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
2710 return false;
2711 }
2712
2713 $manager = new \ThinkRank\SEO\Sitemap_Generator();
2714 $current = $manager->get_settings('site');
2715 $defaults = $manager->get_default_settings('site');
2716
2717 $keys = ['enabled', 'include_posts', 'include_pages', 'include_categories', 'include_tags', 'include_images', 'include_featured_images', 'links_per_sitemap', 'ping_search_engines', 'exclude_posts', 'exclude_terms'];
2718 $updates = [];
2719 foreach ($keys as $key) {
2720 if (!array_key_exists($key, $sitemap)) {
2721 continue;
2722 }
2723 // Only write while ThinkRank still holds its default for this key.
2724 $current_val = $current[$key] ?? null;
2725 $default_val = $defaults[$key] ?? null;
2726 if ($current_val === $default_val && $sitemap[$key] !== $default_val) {
2727 $updates[$key] = $sitemap[$key];
2728 }
2729 }
2730
2731 // Sitemap index toggle is COUPLED to sitemap_urls in ThinkRank: the UI
2732 // rewrites the URL list when the toggle flips, and generation keys off
2733 // sitemap_urls[].type ('index' vs 'general'). So the flag and its matching
2734 // URL entry must be written together — mirror the frontend's rewrite
2735 // (SitemapGeneration.js). Only AIOSEO exposes a source equivalent. Guard on
2736 // ThinkRank still being at its default (index off) so a user's customized
2737 // URL list is never clobbered.
2738 if (!empty($sitemap['use_sitemap_index']) && empty($current['use_sitemap_index'])) {
2739 $updates['use_sitemap_index'] = true;
2740 // Populate the full segmented list (index + one child per enabled type
2741 // and per public CPT) so the index has real children — not just the
2742 // bare index entry, which would generate an empty index.
2743 $updates['sitemap_urls'] = $manager->build_segmented_sitemap_urls(array_merge($current, $sitemap));
2744 }
2745
2746 $saved = empty($updates) ? false : $manager->save_settings('site', null, $updates);
2747
2748 // Generate the physical sitemap files now so the sitemap is live
2749 // immediately after import. ThinkRank serves static files that otherwise
2750 // only appear on the next content change or a manual "Generate", whereas
2751 // Rank Math served a ready sitemap — this closes that gap. Runs off the
2752 // migrated settings whatever they are (an import that matched ThinkRank's
2753 // defaults writes no updates but still needs its files), and never fails
2754 // the migration.
2755 try {
2756 $fresh = $manager->get_settings('site');
2757 if (!empty($fresh['enabled'])) {
2758 $manager->generate_and_save($fresh);
2759 }
2760 } catch (\Throwable $e) {
2761 // Non-fatal: settings persisted; files will be built on next trigger.
2762 }
2763
2764 return $saved;
2765 }
2766
2767 /**
2768 * Migrate Rank Math image auto alt/title settings into ThinkRank's Image SEO
2769 * settings (wp_thinkrank_seo_settings table via Image_SEO_Manager). Enables
2770 * auto-generation only when Rank Math had it on and ThinkRank is still at its
2771 * default; formats are filled only when ThinkRank still holds its default.
2772 *
2773 * @param array $extended Canonical settings `extended` payload
2774 * @return bool True if any value was written
2775 */
2776 private function migrate_image_seo(array $extended): bool {
2777 if (empty($extended['image_seo']) || !class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
2778 return false;
2779 }
2780
2781 $img = $extended['image_seo'];
2782 $manager = new \ThinkRank\SEO\Image_SEO_Manager();
2783 $current = $manager->get_settings('site');
2784
2785 // ThinkRank Image SEO default seeds.
2786 $default_alt_format = '%filename%';
2787 $default_title_format = '%title% %separator% %sitename%';
2788
2789 $updates = [];
2790 if (!empty($img['add_missing_alt']) && empty($current['add_missing_alt'])) {
2791 $updates['add_missing_alt'] = true;
2792 }
2793 if (!empty($img['add_missing_title']) && empty($current['add_missing_title'])) {
2794 $updates['add_missing_title'] = true;
2795 }
2796 if (!empty($img['alt_format']) && ($current['alt_format'] ?? '') === $default_alt_format) {
2797 $updates['alt_format'] = $img['alt_format'];
2798 }
2799 if (!empty($img['title_format']) && ($current['title_format'] ?? '') === $default_title_format) {
2800 $updates['title_format'] = $img['title_format'];
2801 }
2802
2803 if (empty($updates)) {
2804 return false;
2805 }
2806
2807 return $manager->save_settings('site', null, $updates);
2808 }
2809
2810 /**
2811 * Migrate Rank Math per-post-type title/description templates and robots
2812 * defaults into ThinkRank's Global SEO option (thinkrank_global_seo_settings),
2813 * which is keyed by post type. Non-destructive: only fills values ThinkRank
2814 * has not already customized.
2815 *
2816 * @param array $post_type_settings Map of post_type => {title_template, description_template, robots, custom_robots}
2817 * @return bool True if any value was written
2818 */
2819 private function migrate_post_type_settings(array $post_type_settings): bool {
2820 $global_seo = get_option('thinkrank_global_seo_settings', []);
2821 $updated = false;
2822
2823 foreach ($post_type_settings as $post_type => $pt) {
2824 if (!post_type_exists((string) $post_type)) {
2825 continue;
2826 }
2827
2828 $existing = $global_seo[$post_type] ?? [];
2829
2830 if (!empty($pt['title_template']) && empty($existing['title'])) {
2831 $existing['title'] = $pt['title_template'];
2832 $updated = true;
2833 }
2834 if (!empty($pt['description_template']) && empty($existing['description'])) {
2835 $existing['description'] = $pt['description_template'];
2836 $updated = true;
2837 }
2838
2839 // Link Suggestions. ThinkRank's default is ON, so only a source that
2840 // turned it OFF carries information — writing an "on" would just
2841 // restate the default. Never overrides an explicit ThinkRank value.
2842 if (array_key_exists('link_suggestions', $pt) && !$pt['link_suggestions']
2843 && !array_key_exists('link_suggestions', $existing)) {
2844 $existing['link_suggestions'] = false;
2845 $updated = true;
2846 }
2847
2848 // Only migrate robots when Rank Math actually applied custom robots for
2849 // this type; otherwise the array is Rank Math's inert default.
2850 if (!empty($pt['custom_robots']) && !empty($pt['robots']) && is_array($pt['robots'])
2851 && empty($existing['robots_meta_enabled'])) {
2852 $robots = $pt['robots'];
2853 $existing['robots_meta'] = [
2854 'index' => !in_array('noindex', $robots, true),
2855 'noindex' => in_array('noindex', $robots, true),
2856 'nofollow' => in_array('nofollow', $robots, true),
2857 'noarchive' => in_array('noarchive', $robots, true),
2858 'noimageindex' => in_array('noimageindex', $robots, true),
2859 'nosnippet' => in_array('nosnippet', $robots, true),
2860 ];
2861 $existing['robots_meta_enabled'] = true;
2862 $updated = true;
2863 }
2864
2865 if (!empty($existing)) {
2866 $global_seo[$post_type] = $existing;
2867 }
2868 }
2869
2870 if ($updated) {
2871 update_option('thinkrank_global_seo_settings', $global_seo);
2872 }
2873
2874 return $updated;
2875 }
2876
2877 /**
2878 * Update manifest with migration info after all chunks are migrated
2879 *
2880 * @param string $plugin Plugin slug
2881 * @return void
2882 */
2883 public function update_manifest_migration_info(string $plugin): void {
2884 $manifest = Snapshot_Store::get_manifest($plugin);
2885 if ($manifest) {
2886 $manifest['last_migrated'] = gmdate('c');
2887 $manifest['migration_version'] = defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '2.0.0';
2888 Snapshot_Store::write_manifest($plugin, $manifest);
2889 }
2890 }
2891
2892 /**
2893 * Get migratable types from a manifest
2894 *
2895 * @param array $manifest Snapshot manifest
2896 * @return array Types that can be migrated
2897 */
2898 public function get_migratable_types(array $manifest): array {
2899 $types = [];
2900
2901 foreach ($manifest['types'] ?? [] as $type => $info) {
2902 if (in_array($type, self::MIGRATABLE_TYPES, true)) {
2903 $types[$type] = $info;
2904 }
2905 }
2906
2907 return $types;
2908 }
2909 }
2910