PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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.2.0, at includes/admin/importers/class-snapshot-migrator.php

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