PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/admin/importers/class-snapshot-migrator.php +992 -63 2.1.0 → 2.12.0 View file →
@@ -23,8 +23,9 @@
23 23 namespace ThinkRank\Admin\Importers;
24 24
25 25 use ThinkRank\SEO\Focus_Keywords;
26 26 use ThinkRank\SEO\Metadata_Pending;
27 +use ThinkRank\SEO\Object_Redirect;
27 28 use ThinkRank\SEO\Pattern_Resolver;
28 29
29 30 if (!defined('ABSPATH')) {
30 31 exit;
@@ -53,8 +54,15 @@
53 54 'twitter_description' => '_thinkrank_twitter_description',
54 55 'twitter_image' => '_thinkrank_twitter_image',
55 56 'primary_category' => '_thinkrank_primary_category',
56 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',
57 65 ];
58 66
59 67 /**
60 68 * Canonical robots meta fields. Composed into JSON-encoded
@@ -71,9 +79,17 @@
71 79
72 80 /**
73 81 * Data types that are migratable (have post/term/user meta mappings)
74 82 */
75 - private const MIGRATABLE_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
83 + private const MIGRATABLE_TYPES = [
84 + 'postmeta',
85 + 'termmeta',
86 + 'usermeta',
87 + 'redirections',
88 + '404_logs',
89 + 'settings',
90 + Block_Converter::TYPE,
91 + ];
76 92
77 93 /**
78 94 * Settings-record `extended` keys that either migrate today or are safe to
79 95 * discard on cleanup (raw_options is pure capture-all insurance; a fresh
@@ -94,8 +110,9 @@
94 110 'role_capabilities',
95 111 'image_seo',
96 112 'sitemap_settings',
97 113 'analytics_connected',
114 + 'focus_pages',
98 115 // Capture-all raw buckets (whole source option sets stored verbatim).
99 116 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
100 117 // a re-export recreates them, so they never block cleanup.
101 118 'raw_options',
@@ -105,16 +122,29 @@
105 122 'sitemap_settings_raw',
106 123 ];
107 124
108 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 + /**
109 138 * Migrate one chunk of snapshot data to ThinkRank meta
110 139 *
111 140 * @param string $plugin Plugin slug
112 141 * @param string $type Data type (postmeta, termmeta, usermeta, settings)
113 142 * @param int $page Chunk/page number
143 + * @param string $conflict How to treat data ThinkRank already holds
114 144 * @return array Result with status, has_more, processed, skipped
115 145 */
116 - public function migrate_chunk(string $plugin, string $type, int $page): array {
146 + public function migrate_chunk(string $plugin, string $type, int $page, string $conflict = self::CONFLICT_SKIP): array {
117 147 // Validate manifest status
118 148 $manifest = Snapshot_Store::get_manifest($plugin);
119 149 if (!$manifest || ($manifest['status'] ?? '') !== 'complete') {
120 150 return [
@@ -125,8 +155,15 @@
125 155 'skipped' => 0,
126 156 ];
127 157 }
128 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 +
129 166 if ($type === 'settings') {
130 167 return $this->migrate_settings($plugin);
131 168 }
132 169
@@ -137,16 +174,42 @@
137 174 if ($type === '404_logs') {
138 175 return $this->migrate_404_logs($plugin, $page);
139 176 }
140 177
178 + if ($type === Block_Converter::TYPE) {
179 + return $this->migrate_content_blocks($plugin, $page);
180 + }
181 +
141 182 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
142 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 +
143 204 return [
144 - 'status' => 'complete',
145 - 'message' => 'No data in chunk',
146 - 'has_more' => false,
147 - 'processed' => 0,
148 - 'skipped' => 0,
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,
149 212 ];
150 213 }
151 214
152 215 // Tell an open editor that SEO meta is being written right now, so its
@@ -176,8 +239,18 @@
176 239 $skipped++;
177 240 continue;
178 241 }
179 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 +
180 253 // Track migrated posts so their SEO score can be computed once the
181 254 // chunk's meta has landed (terms are not scored).
182 255 if ($object_type === 'post') {
183 256 $post_ids[$object_id] = true;
@@ -188,8 +261,25 @@
188 261 // Collect focus keywords (primary + secondary) to seed the Pro
189 262 // Rank Tracker watch-list once the chunk is processed.
190 263 $this->collect_keywords($record, $data, $keywords);
191 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 +
192 282 foreach ($data as $canonical_key => $value) {
193 283 if (!isset(self::META_MAP[$canonical_key])) {
194 284 continue;
195 285 }
@@ -195,10 +285,13 @@
195 285 }
196 286
197 287 // Focus keywords are migrated as an array via the dedicated
198 288 // migrate_focus_keywords() below (which also keeps the legacy
199 - // single-value meta in sync), so skip the scalar write here.
200 - if ($canonical_key === 'focus_keyword') {
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') {
201 294 continue;
202 295 }
203 296
204 297 $thinkrank_key = self::META_MAP[$canonical_key];
@@ -212,8 +305,12 @@
212 305 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
213 306 continue;
214 307 }
215 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.
216 313 if ($object_type === 'post') {
217 314 // Never overwrite existing ThinkRank data
218 315 $existing = get_post_meta($object_id, $thinkrank_key, true);
219 316 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -219,9 +316,9 @@
219 316 if ($existing !== '' && $existing !== false && $existing !== null) {
220 317 continue;
221 318 }
222 319
223 - update_post_meta($object_id, $thinkrank_key, $value);
320 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
224 321 $record_had_writes = true;
225 322 } elseif ($object_type === 'term') {
226 323 $existing = get_term_meta($object_id, $thinkrank_key, true);
227 324 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -227,9 +324,9 @@
227 324 if ($existing !== '' && $existing !== false && $existing !== null) {
228 325 continue;
229 326 }
230 327
231 - update_term_meta($object_id, $thinkrank_key, $value);
328 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
232 329 $record_had_writes = true;
233 330 } elseif ($object_type === 'user') {
234 331 $existing = get_user_meta($object_id, $thinkrank_key, true);
235 332 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -235,9 +332,9 @@
235 332 if ($existing !== '' && $existing !== false && $existing !== null) {
236 333 continue;
237 334 }
238 335
239 - update_user_meta($object_id, $thinkrank_key, $value);
336 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
240 337 $record_had_writes = true;
241 338 }
242 339 }
243 340
@@ -251,8 +348,17 @@
251 348 if ($object_type === 'post' && $this->migrate_robots_payload($object_id, $data)) {
252 349 $record_had_writes = true;
253 350 }
254 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 +
255 361 // Pillar / cornerstone content flag (post meta only).
256 362 if ($object_type === 'post' && $this->migrate_pillar_content($object_id, $data)) {
257 363 $record_had_writes = true;
258 364 }
@@ -268,13 +374,34 @@
268 374 if ($object_type === 'post' && $this->migrate_video_schema($object_id, $data, $record)) {
269 375 $record_had_writes = true;
270 376 }
271 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 +
272 390 // Per-post "exclude from sitemap" flags. ThinkRank models sitemap
273 391 // exclusion as one comma-separated ID list on the sitemap settings
274 392 // rather than per-post meta, so collect the IDs and apply them once
275 393 // after the chunk (a settings write per post would be wasteful).
276 - if ($object_type === 'post' && !empty($record['extended']['exclude_sitemap'])) {
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) {
277 404 $sitemap_excluded[] = $object_id;
278 405 }
279 406
280 407 if ($record_had_writes) {
@@ -334,9 +461,502 @@
334 461 'keywords_truncated_sample' => array_slice($truncations, 0, 10),
335 462 ];
336 463 }
337 464
465 +
338 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 + /**
339 959 * Dry-run a snapshot chunk: classify what a migrate WOULD do without
340 960 * writing anything. Mirrors migrate_chunk()'s per-field decision (skip
341 961 * empty values, never overwrite existing ThinkRank data) so the counts
342 962 * match what a real migrate would produce.
@@ -729,8 +1349,160 @@
729 1349 return $seeded;
730 1350 }
731 1351
732 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 + /**
733 1505 * Migrate the pillar / cornerstone content flag to ThinkRank post meta.
734 1506 *
735 1507 * ThinkRank stores an enabled flag as the string '1'; the reader
736 1508 * (Pillar_Content endpoint) matches meta_value = '1'. Never overwrites an
@@ -758,19 +1530,17 @@
758 1530 /**
759 1531 * Migrate the post's focus keywords.
760 1532 *
761 1533 * Reads the full list from the snapshot's `focus_keywords` (falling back to
762 - * the single `focus_keyword`) and persists via Focus_Keywords::save_with_
763 - * overflow(): the first MAX keywords are the base, the rest are stored as
764 - * gated overflow (free) that Pro unlocks automatically. Never overwrites
765 - * existing ThinkRank focus keywords.
1534 + * the single `focus_keyword`) and persists via Focus_Keywords::save(). Never
1535 + * overwrites existing ThinkRank focus keywords.
766 1536 *
767 - * Posts whose source exceeded the free limit are recorded in `$truncations`
768 - * so the import summary can surface them as a Pro upsell.
1537 + * Posts whose source had more keywords than were stored are recorded in
1538 + * `$truncations` so the import summary can report them.
769 1539 *
770 1540 * @param int $post_id Target post ID.
771 1541 * @param array $data Canonical record data.
772 - * @param array|null $truncations Accumulator: appended with overflow info.
1542 + * @param array|null $truncations Accumulator: appended with what was dropped.
773 1543 * @return bool True when keywords were written.
774 1544 */
775 1545 private function migrate_focus_keywords(int $post_id, array $data, ?array &$truncations = null): bool {
776 1546 $keywords = [];
@@ -779,9 +1549,10 @@
779 1549 } elseif (!empty($data['focus_keyword'])) {
780 1550 $keywords = [$data['focus_keyword']];
781 1551 }
782 1552
783 - if (empty(Focus_Keywords::normalize($keywords, 0))) {
1553 + $all = Focus_Keywords::normalize($keywords, 0);
1554 + if (empty($all)) {
784 1555 return false;
785 1556 }
786 1557
787 1558 // Never overwrite existing ThinkRank focus keywords.
@@ -788,19 +1559,19 @@
788 1559 if (!empty(Focus_Keywords::get($post_id))) {
789 1560 return false;
790 1561 }
791 1562
792 - $result = Focus_Keywords::save_with_overflow($post_id, $keywords);
1563 + $saved = Focus_Keywords::save($post_id, $all);
793 1564
794 - if (!empty($result['overflow']) && is_array($truncations)) {
1565 + if (count($saved) < count($all) && is_array($truncations)) {
795 1566 $truncations[] = [
796 1567 'post_id' => $post_id,
797 - 'kept' => count($result['kept']),
798 - 'gated' => $result['overflow'],
1568 + 'kept' => count($saved),
1569 + 'dropped' => array_slice($all, count($saved)),
799 1570 ];
800 1571 }
801 1572
802 - return !empty($result['kept']);
1573 + return !empty($saved);
803 1574 }
804 1575
805 1576 /**
806 1577 * Seed the metabox Review schema form data for an imported review post.
@@ -831,9 +1602,9 @@
831 1602 if (is_string($existing) && $existing !== '') {
832 1603 return false;
833 1604 }
834 1605
835 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($review));
1606 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($review)));
836 1607
837 1608 return true;
838 1609 }
839 1610
@@ -865,9 +1636,9 @@
865 1636 if (is_string($existing) && $existing !== '') {
866 1637 return false;
867 1638 }
868 1639
869 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($video));
1640 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($video)));
870 1641
871 1642 return true;
872 1643 }
873 1644
@@ -947,13 +1718,13 @@
947 1718 }
948 1719
949 1720 $wrote = false;
950 1721 if (!empty($robots)) {
951 - update_post_meta($post_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1722 + update_post_meta($post_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
952 1723 $wrote = true;
953 1724 }
954 1725 if (!empty($advanced)) {
955 - update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_json_encode($advanced));
1726 + update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_slash(wp_json_encode($advanced)));
956 1727 $wrote = true;
957 1728 }
958 1729 if ($wrote) {
959 1730 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -1105,8 +1876,13 @@
1105 1876 if ($this->migrate_image_seo($extended)) {
1106 1877 $processed++;
1107 1878 }
1108 1879
1880 + // The pages the source had under active watch.
1881 + if ($this->migrate_focus_pages($extended)) {
1882 + $processed++;
1883 + }
1884 +
1109 1885 // Sitemap inclusion settings.
1110 1886 if ($this->migrate_sitemap($extended)) {
1111 1887 $processed++;
1112 1888 }
@@ -1174,18 +1950,33 @@
1174 1950 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
1175 1951 $current = $manager->get_settings('site');
1176 1952
1177 1953 // ThinkRank default seeds — only overwrite a value the user has not changed.
1178 - $seeds = [
1179 - 'homepage_title' => '%site_title% | %site_description%',
1180 - 'site_name' => get_bloginfo('name'),
1181 - 'logo_url' => '',
1182 - 'breadcrumb_home_text' => 'Home',
1183 - 'breadcrumb_separator' => '>',
1184 - 'business_type' => '',
1185 - 'business_name' => '',
1186 - 'business_phone' => '',
1187 - ];
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 + );
1188 1979
1189 1980 $updates = [];
1190 1981 $set = static function (string $key, $value) use (&$updates, $current, $seeds): void {
1191 1982 if ($value === '' || $value === null) {
@@ -1191,9 +1982,14 @@
1191 1982 if ($value === '' || $value === null) {
1192 1983 return;
1193 1984 }
1194 1985 $cur = $current[$key] ?? null;
1195 - $is_default = !array_key_exists($key, $current) || $cur === '' || $cur === ($seeds[$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);
1196 1992 if ($is_default) {
1197 1993 $updates[$key] = $value;
1198 1994 }
1199 1995 };
@@ -1406,10 +2202,14 @@
1406 2202 $updates = [];
1407 2203 if ($app_id !== '' && empty($current['facebook_app_id'])) {
1408 2204 $updates['facebook_app_id'] = $app_id;
1409 2205 }
1410 - if ($og_image !== '' && empty($current['default_image'])) {
1411 - $updates['default_image'] = $og_image;
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;
1412 2212 }
1413 2213
1414 2214 if (empty($updates)) {
1415 2215 return false;
@@ -1660,18 +2460,40 @@
1660 2460 $skipped++;
1661 2461 }
1662 2462 }
1663 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 +
1664 2471 return [
1665 - 'status' => 'complete',
1666 - 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
1667 - 'has_more' => false,
1668 - 'processed' => $processed,
1669 - 'skipped' => $skipped,
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,
1670 2479 ];
1671 2480 }
1672 2481
1673 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 + /**
1674 2496 * Migrate a chunk of logged 404 hits into ThinkRank Pro's 404 Monitor.
1675 2497 * Pro-gated exactly like migrate_redirections().
1676 2498 *
1677 2499 * @param string $plugin Plugin slug
@@ -1721,18 +2543,130 @@
1721 2543 $skipped++;
1722 2544 }
1723 2545 }
1724 2546
2547 + $has_more = $page < $this->chunk_total($plugin, '404_logs');
2548 +
1725 2549 return [
1726 - 'status' => 'complete',
1727 - 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
1728 - 'has_more' => false,
1729 - 'processed' => $processed,
1730 - 'skipped' => $skipped,
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,
1731 2557 ];
1732 2558 }
1733 2559
1734 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 + /**
1735 2669 * Build ThinkRank Pro's Redirections store, when Pro is active.
1736 2670 *
1737 2671 * Split out (and protected) so tests can substitute a fake — the real store
1738 2672 * writes to Pro's tables. Pro lazily creates them via Schema::ensure().
@@ -2042,14 +2976,15 @@
2042 2976 return true;
2043 2977 }
2044 2978
2045 2979 /**
2046 - * Migrate the source plugin's scheduled SEO email report cadence into
2047 - * ThinkRank's Email Reporting config.
2980 + * Carry the source plugin's scheduled SEO email report over as ThinkRank's
2981 + * Email Reporting switch.
2048 2982 *
2049 2983 * Only touches a config the user has not enabled yet, and never turns
2050 2984 * reports ON unless the source had them on — an unexpected recurring email
2051 - * after an import would be worse than a missing one.
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.
2052 2987 *
2053 2988 * @param array $extended Canonical settings `extended` payload
2054 2989 * @return bool True if the config was written
2055 2990 */
@@ -2067,15 +3002,9 @@
2067 3002 if (!empty($current['enabled'])) {
2068 3003 return false;
2069 3004 }
2070 3005
2071 - $frequency = (int) ($reports['frequency_days'] ?? 0);
2072 - $update = ['enabled' => true];
2073 - if ($frequency > 0) {
2074 - $update['frequency_days'] = $frequency;
2075 - }
2076 -
2077 - $config_manager->save(array_merge($current, $update));
3006 + $config_manager->save(['enabled' => true]);
2078 3007
2079 3008 return true;
2080 3009 }
2081 3010