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

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