PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 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.9.0, at includes/admin/importers/class-snapshot-migrator.php

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