PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
2.14.3 2.14.2 2.14.1 2.14.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 All 58 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.0.0, at includes/admin/importers/class-snapshot-migrator.php

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