PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / admin / importers / class-snapshot-migrator.php

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

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