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

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

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