PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.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 1.28.0 1.27.0 1.26.0 All 55 releases
← All changes | includes/admin/importers/class-snapshot-migrator.php +1368 -83 2.0.2 → 2.13.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,16 @@
94 110 'role_capabilities',
95 111 'image_seo',
96 112 'sitemap_settings',
97 113 'analytics_connected',
114 + 'focus_pages',
115 + // Applied by migrate_site_identity() (#886).
116 + 'robots_txt',
117 + 'feed',
118 + // Applied by migrate_markdown_for_ai() into ThinkRank Pro (#886).
119 + // Without Pro it has no home, but neither is anything lost: cleanup
120 + // removes a feature toggle, not content.
121 + 'markdown_for_ai',
98 122 // Capture-all raw buckets (whole source option sets stored verbatim).
99 123 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
100 124 // a re-export recreates them, so they never block cleanup.
101 125 'raw_options',
@@ -105,16 +129,29 @@
105 129 'sitemap_settings_raw',
106 130 ];
107 131
108 132 /**
133 + * Conflict strategies for a chunk that targets data ThinkRank already holds.
134 + *
135 + * SKIP is right for an import: another plugin's value must never clobber
136 + * something the user has already set here. OVERWRITE is right for a
137 + * restore: the whole point of restoring a backup is to get the saved values
138 + * back, and a "successful" restore that silently kept the current values
139 + * would be the opposite of what was asked for.
140 + */
141 + public const CONFLICT_SKIP = 'skip';
142 + public const CONFLICT_OVERWRITE = 'overwrite';
143 +
144 + /**
109 145 * Migrate one chunk of snapshot data to ThinkRank meta
110 146 *
111 147 * @param string $plugin Plugin slug
112 148 * @param string $type Data type (postmeta, termmeta, usermeta, settings)
113 149 * @param int $page Chunk/page number
150 + * @param string $conflict How to treat data ThinkRank already holds
114 151 * @return array Result with status, has_more, processed, skipped
115 152 */
116 - public function migrate_chunk(string $plugin, string $type, int $page): array {
153 + public function migrate_chunk(string $plugin, string $type, int $page, string $conflict = self::CONFLICT_SKIP): array {
117 154 // Validate manifest status
118 155 $manifest = Snapshot_Store::get_manifest($plugin);
119 156 if (!$manifest || ($manifest['status'] ?? '') !== 'complete') {
120 157 return [
@@ -125,8 +162,15 @@
125 162 'skipped' => 0,
126 163 ];
127 164 }
128 165
166 + // ThinkRank's own export is not normalized into the canonical fields
167 + // META_MAP translates; it carries raw _thinkrank_* meta, so it takes a
168 + // restore path that writes those back untouched.
169 + if ($plugin === Thinkrank_Exporter::SLUG) {
170 + return $this->restore_native_chunk($manifest, $type, $page, $conflict);
171 + }
172 +
129 173 if ($type === 'settings') {
130 174 return $this->migrate_settings($plugin);
131 175 }
132 176
@@ -137,16 +181,42 @@
137 181 if ($type === '404_logs') {
138 182 return $this->migrate_404_logs($plugin, $page);
139 183 }
140 184
185 + if ($type === Block_Converter::TYPE) {
186 + return $this->migrate_content_blocks($plugin, $page);
187 + }
188 +
141 189 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
142 190 if ($chunk === null || empty($chunk)) {
191 + // An empty chunk is not the end of the type. An exporter that pages
192 + // one shared table and then splits the rows by kind writes nothing
193 + // for a page whose rows all belonged to another kind — Squirrly
194 + // reads the whole `qss` table that way, so a site whose terms and
195 + // authors sit past the first page of posts has an empty chunk 1 and
196 + // its real records in chunk 2. Ending the loop here dropped them
197 + // silently, under a `complete` status, and cleanup then removed the
198 + // source copy. Keep asking while the manifest says there are more
199 + // chunks, exactly as the non-empty path below does.
200 + $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
201 + $has_more = $page < $total_chunks;
202 +
203 + // The last chunk of a sparse export can legitimately be the empty
204 + // one — Squirrly's tail page holds only term rows — and it still
205 + // ends the migration, so release the editors that mark_bulk() set
206 + // polling. Without this they poll until the marker's own expiry.
207 + if ($type === 'postmeta' && !$has_more) {
208 + Metadata_Pending::clear_bulk();
209 + }
210 +
143 211 return [
144 - 'status' => 'complete',
145 - 'message' => 'No data in chunk',
146 - 'has_more' => false,
147 - 'processed' => 0,
148 - 'skipped' => 0,
212 + 'status' => $has_more ? 'processing' : 'complete',
213 + 'message' => sprintf('No data in chunk %d', $page),
214 + 'has_more' => $has_more,
215 + 'page' => $page,
216 + 'total_chunks' => $total_chunks,
217 + 'processed' => 0,
218 + 'skipped' => 0,
149 219 ];
150 220 }
151 221
152 222 // Tell an open editor that SEO meta is being written right now, so its
@@ -176,8 +246,18 @@
176 246 $skipped++;
177 247 continue;
178 248 }
179 249
250 + // The object has to still exist. A source that keys its SEO by URL
251 + // rather than by a foreign key keeps rows for content that was
252 + // deleted years ago — Squirrly's `qss` table is keyed on a URL hash
253 + // — and writing their meta creates orphan rows no screen can reach
254 + // and no uninstall sweeps, while reporting them as migrated.
255 + if (!$this->object_exists($object_type, $object_id)) {
256 + $skipped++;
257 + continue;
258 + }
259 +
180 260 // Track migrated posts so their SEO score can be computed once the
181 261 // chunk's meta has landed (terms are not scored).
182 262 if ($object_type === 'post') {
183 263 $post_ids[$object_id] = true;
@@ -188,8 +268,25 @@
188 268 // Collect focus keywords (primary + secondary) to seed the Pro
189 269 // Rank Tracker watch-list once the chunk is processed.
190 270 $this->collect_keywords($record, $data, $keywords);
191 271
272 + // Term social fields travel in `extended`, not `data`: only the
273 + // AIOSEO exporter puts them in the canonical bucket, the other four
274 + // put the identical values one level out. The loop below walks
275 + // `data`, so every term OG image and Twitter title/description was
276 + // exported and then dropped. Fold them in for terms, without
277 + // letting them win over a value the record already carries.
278 + if ($object_type === 'term') {
279 + $extended = is_array($record['extended'] ?? null) ? $record['extended'] : [];
280 + foreach (['og_title', 'og_description', 'og_image', 'twitter_title', 'twitter_description', 'twitter_image'] as $social_key) {
281 + if (!isset($data[$social_key]) || $data[$social_key] === '') {
282 + if (isset($extended[$social_key]) && $extended[$social_key] !== '') {
283 + $data[$social_key] = $extended[$social_key];
284 + }
285 + }
286 + }
287 + }
288 +
192 289 foreach ($data as $canonical_key => $value) {
193 290 if (!isset(self::META_MAP[$canonical_key])) {
194 291 continue;
195 292 }
@@ -195,10 +292,13 @@
195 292 }
196 293
197 294 // Focus keywords are migrated as an array via the dedicated
198 295 // 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') {
296 + // single-value meta in sync), so skip the scalar write here —
297 + // but that writer only runs for posts, so skipping it for a
298 + // term meant nobody wrote the term's focus keyword at all,
299 + // even though get-term-seo reads `_thinkrank_focus_keyword`.
300 + if ($canonical_key === 'focus_keyword' && $object_type === 'post') {
201 301 continue;
202 302 }
203 303
204 304 $thinkrank_key = self::META_MAP[$canonical_key];
@@ -212,8 +312,12 @@
212 312 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
213 313 continue;
214 314 }
215 315
316 + // The meta writers unslash their value, so every write below
317 + // slashes first. Unslashed, a title or description with a
318 + // backslash in it lost it on the way in, and a JSON value lost
319 + // the backslash of every `\"` and `\uXXXX` escape.
216 320 if ($object_type === 'post') {
217 321 // Never overwrite existing ThinkRank data
218 322 $existing = get_post_meta($object_id, $thinkrank_key, true);
219 323 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -219,9 +323,9 @@
219 323 if ($existing !== '' && $existing !== false && $existing !== null) {
220 324 continue;
221 325 }
222 326
223 - update_post_meta($object_id, $thinkrank_key, $value);
327 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
224 328 $record_had_writes = true;
225 329 } elseif ($object_type === 'term') {
226 330 $existing = get_term_meta($object_id, $thinkrank_key, true);
227 331 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -227,9 +331,9 @@
227 331 if ($existing !== '' && $existing !== false && $existing !== null) {
228 332 continue;
229 333 }
230 334
231 - update_term_meta($object_id, $thinkrank_key, $value);
335 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
232 336 $record_had_writes = true;
233 337 } elseif ($object_type === 'user') {
234 338 $existing = get_user_meta($object_id, $thinkrank_key, true);
235 339 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -235,9 +339,9 @@
235 339 if ($existing !== '' && $existing !== false && $existing !== null) {
236 340 continue;
237 341 }
238 342
239 - update_user_meta($object_id, $thinkrank_key, $value);
343 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
240 344 $record_had_writes = true;
241 345 }
242 346 }
243 347
@@ -251,8 +355,17 @@
251 355 if ($object_type === 'post' && $this->migrate_robots_payload($object_id, $data)) {
252 356 $record_had_writes = true;
253 357 }
254 358
359 + // The same directives for a term. Every exporter emits term
360 + // noindex/nofollow and ThinkRank stores them, but nothing wrote
361 + // them — so a category the owner had deliberately kept out of the
362 + // index came back indexable after the switch, which is the worst
363 + // way for an import to be wrong.
364 + if ($object_type === 'term' && $this->migrate_term_robots_payload($object_id, $data)) {
365 + $record_had_writes = true;
366 + }
367 +
255 368 // Pillar / cornerstone content flag (post meta only).
256 369 if ($object_type === 'post' && $this->migrate_pillar_content($object_id, $data)) {
257 370 $record_had_writes = true;
258 371 }
@@ -268,13 +381,41 @@
268 381 if ($object_type === 'post' && $this->migrate_video_schema($object_id, $data, $record)) {
269 382 $record_had_writes = true;
270 383 }
271 384
385 + // The post's own schemas as ThinkRank Pro Custom Schema entries
386 + // scoped to it (extended.custom_schemas, #886).
387 + if ($object_type === 'post' && !empty($record['extended']['custom_schemas']) && is_array($record['extended']['custom_schemas'])
388 + && $this->migrate_custom_schemas($source_plugin, $record['extended']['custom_schemas']) > 0) {
389 + $record_had_writes = true;
390 + }
391 +
392 + // Per-object redirect. SEOPress is the one source that stores a
393 + // redirect as object meta rather than in a rules table, so its
394 + // per-post redirects were exported into extended.redirect_* and
395 + // then dropped for want of anywhere to put them. They have a home
396 + // now: Object_Redirect writes through to Pro's rules table, and
397 + // returns a WP_Error (which we skip) when Pro is inactive, leaving
398 + // the value in the snapshot for a later run.
399 + if (in_array($object_type, ['post', 'term'], true)
400 + && $this->migrate_object_redirect($object_type, $object_id, $record)) {
401 + $record_had_writes = true;
402 + }
403 +
272 404 // Per-post "exclude from sitemap" flags. ThinkRank models sitemap
273 405 // exclusion as one comma-separated ID list on the sitemap settings
274 406 // rather than per-post meta, so collect the IDs and apply them once
275 407 // after the chunk (a settings write per post would be wasteful).
276 - if ($object_type === 'post' && !empty($record['extended']['exclude_sitemap'])) {
408 + // Two spellings reach here: Rank Math's exporter emits
409 + // `exclude_sitemap`, Squirrly's `exclude_from_sitemap`. Only the
410 + // first was read, so every Squirrly `nositemap` flag was dropped
411 + // and posts the owner had hidden reappeared in the sitemap. Accept
412 + // both rather than renaming one, because snapshots already exported
413 + // carry whichever spelling their exporter used at the time.
414 + $excluded_from_sitemap = !empty($record['extended']['exclude_sitemap'])
415 + || !empty($record['extended']['exclude_from_sitemap']);
416 +
417 + if ($object_type === 'post' && $excluded_from_sitemap) {
277 418 $sitemap_excluded[] = $object_id;
278 419 }
279 420
280 421 if ($record_had_writes) {
@@ -334,9 +475,502 @@
334 475 'keywords_truncated_sample' => array_slice($truncations, 0, 10),
335 476 ];
336 477 }
337 478
479 +
338 480 /**
481 + * Restore one chunk of ThinkRank's own export.
482 + *
483 + * Deliberately does NOT reuse the canonical loop above. That loop maps
484 + * through META_MAP, rebuilds the robots payload from canonical flags and
485 + * drops every key it does not know — correct when translating another
486 + * plugin's data, lossy when the data is already ours. Here the record holds
487 + * raw `_thinkrank_*` meta and the job is to put it back exactly as it was.
488 + *
489 + * @param array $manifest Snapshot manifest
490 + * @param string $type Data type
491 + * @param int $page Chunk page
492 + * @param string $conflict CONFLICT_SKIP | CONFLICT_OVERWRITE
493 + * @return array Result
494 + */
495 + private function restore_native_chunk(array $manifest, string $type, int $page, string $conflict): array {
496 + if ($type === 'settings') {
497 + return $this->restore_native_settings($conflict);
498 + }
499 +
500 + // Pro's own tables (redirections, 404 logs, rank tracker, Brand
501 + // Visibility) are exported through a filter and come back through one:
502 + // the free plugin holds the records but has nowhere to put them.
503 + if (!in_array($type, ['postmeta', 'termmeta', 'usermeta'], true)) {
504 + return $this->restore_extension_chunk($manifest, $type, $page, $conflict);
505 + }
506 +
507 + $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, $type, $page);
508 + if (empty($chunk)) {
509 + return [
510 + 'status' => 'complete',
511 + 'message' => 'No data in chunk',
512 + 'has_more' => false,
513 + 'processed' => 0,
514 + 'skipped' => 0,
515 + 'missing' => 0,
516 + ];
517 + }
518 +
519 + // Hold open the editor's "SEO meta is being written" window for as long
520 + // as the restore runs, exactly as the import path does.
521 + if ($type === 'postmeta') {
522 + Metadata_Pending::mark_bulk();
523 + }
524 +
525 + $processed = 0;
526 + $skipped = 0;
527 + $missing = 0;
528 +
529 + foreach ($chunk as $record) {
530 + $object_id = (int) ($record['object_id'] ?? 0);
531 + $object_type = (string) ($record['object_type'] ?? '');
532 + $data = $record['data'] ?? [];
533 +
534 + if (!$object_id || !is_array($data) || empty($data)) {
535 + $skipped++;
536 + continue;
537 + }
538 +
539 + // A file from another site (or one taken before a post was deleted)
540 + // references IDs that are not here. Counted separately from
541 + // `skipped` so the UI can say "12 posts no longer exist" rather
542 + // than reporting a silent no-op.
543 + if (!$this->object_exists($object_type, $object_id)) {
544 + $missing++;
545 + continue;
546 + }
547 +
548 + $wrote = false;
549 + foreach ($data as $meta_key => $value) {
550 + // Only ThinkRank's own meta, whatever the file claims: a
551 + // hand-edited export must not become a way to write arbitrary
552 + // meta onto any post.
553 + if (strpos((string) $meta_key, Thinkrank_Exporter::META_PREFIX) !== 0) {
554 + continue;
555 + }
556 +
557 + if ($conflict === self::CONFLICT_SKIP) {
558 + $existing = $this->get_object_meta($object_type, $object_id, (string) $meta_key);
559 + if ($existing !== '' && $existing !== false && $existing !== null) {
560 + continue;
561 + }
562 + }
563 +
564 + // No skip-empty rule here, unlike the import path. An empty
565 + // string is a real stored value for some fields (the author
566 + // archive templates, where "" means render no template), and
567 + // dropping it would restore the default instead.
568 + if ($this->write_object_meta($object_type, $object_id, (string) $meta_key, $value)) {
569 + $wrote = true;
570 + }
571 + }
572 +
573 + if ($wrote) {
574 + $processed++;
575 + } else {
576 + $skipped++;
577 + }
578 + }
579 +
580 + $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
581 + $has_more = $page < $total_chunks;
582 +
583 + if ($type === 'postmeta' && !$has_more) {
584 + Metadata_Pending::clear_bulk();
585 + }
586 +
587 + return [
588 + 'status' => $has_more ? 'processing' : 'complete',
589 + 'message' => sprintf(
590 + 'Restored %d records, skipped %d, %d no longer exist (page %d)',
591 + $processed,
592 + $skipped,
593 + $missing,
594 + $page
595 + ),
596 + 'has_more' => $has_more,
597 + 'page' => $page,
598 + 'total_chunks' => $total_chunks,
599 + 'processed' => $processed,
600 + 'skipped' => $skipped,
601 + 'missing' => $missing,
602 + ];
603 + }
604 +
605 + /**
606 + * Hand a non-core type's records to whoever registered it.
607 + *
608 + * With no handler the records stay in the snapshot rather than being
609 + * dropped: reporting "0 restored" is honest, and a later Pro activation can
610 + * still drain the same snapshot.
611 + *
612 + * @param array $manifest Snapshot manifest
613 + * @param string $type Data type
614 + * @param int $page Chunk page
615 + * @param string $conflict CONFLICT_SKIP | CONFLICT_OVERWRITE
616 + * @return array Result
617 + */
618 + private function restore_extension_chunk(array $manifest, string $type, int $page, string $conflict): array {
619 + $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, $type, $page) ?? [];
620 + $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
621 + $has_more = $page < $total_chunks;
622 +
623 + /**
624 + * Filters the number of records a non-core restore type applied.
625 + *
626 + * Handlers should write the records and return how many they wrote.
627 + * Anything not written stays in the snapshot.
628 + *
629 + * @since 2.2.0
630 + *
631 + * @param int $processed Records applied (0 by default).
632 + * @param array $records Records from this chunk.
633 + * @param string $type Data type being restored.
634 + * @param string $conflict 'skip' or 'overwrite'.
635 + */
636 + $processed = (int) apply_filters('thinkrank_restore_records', 0, $chunk, $type, $conflict);
637 + $skipped = max(0, count($chunk) - $processed);
638 +
639 + return [
640 + 'status' => $has_more ? 'processing' : 'complete',
641 + 'message' => sprintf('Restored %d %s records, skipped %d (page %d)', $processed, $type, $skipped, $page),
642 + 'has_more' => $has_more,
643 + 'page' => $page,
644 + 'total_chunks' => $total_chunks,
645 + 'processed' => $processed,
646 + 'skipped' => $skipped,
647 + 'missing' => 0,
648 + ];
649 + }
650 +
651 + /**
652 + * Write a single meta value for post|term|user.
653 + *
654 + * @param string $object_type One of post|term|user
655 + * @param int $object_id Object id
656 + * @param string $key Meta key
657 + * @param mixed $value Meta value
658 + * @return bool Whether the value was written
659 + */
660 + private function write_object_meta(string $object_type, int $object_id, string $key, $value): bool {
661 + // Registered meta can carry a typed sanitize_callback, and some of ours
662 + // declare `string` — `_thinkrank_robots_meta` and
663 + // `_thinkrank_advanced_robots_meta` both run through
664 + // Metabox_Manager::sanitize_json_meta_field(string $value). Everything
665 + // writing those today stores JSON, so an export carries them back as
666 + // strings; but the restore's whole policy is to write the file's value
667 + // verbatim, and a file holding one as an array would otherwise raise a
668 + // TypeError that takes down the rest of the chunk with it. One bad key
669 + // is worth skipping, not the records behind it.
670 + //
671 + // wp_slash() because the meta writers unslash: a restored JSON value
672 + // (schema form data, robots) would otherwise lose the backslash of
673 + // every escaped quote and come back as invalid JSON.
674 + try {
675 + switch ($object_type) {
676 + case 'post':
677 + update_post_meta($object_id, $key, wp_slash($value));
678 + return true;
679 + case 'term':
680 + update_term_meta($object_id, $key, wp_slash($value));
681 + return true;
682 + case 'user':
683 + update_user_meta($object_id, $key, wp_slash($value));
684 + return true;
685 + }
686 + } catch (\Throwable $e) {
687 + if (defined('WP_DEBUG') && WP_DEBUG) {
688 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic; a skipped key is otherwise invisible.
689 + error_log(sprintf('ThinkRank restore: skipped %s meta "%s" on %d — %s', $object_type, $key, $object_id, $e->getMessage()));
690 + }
691 + }
692 +
693 + return false;
694 + }
695 +
696 + /**
697 + * Restore ThinkRank's own settings from a native snapshot.
698 + *
699 + * Bypasses migrate_settings() entirely: that method is Yoast/Rank Math
700 + * shaped — separator code maps, knowledge graph assembly, webmaster tools —
701 + * and none of it applies to data already in our own format.
702 + *
703 + * @param string $conflict CONFLICT_SKIP | CONFLICT_OVERWRITE
704 + * @return array Result
705 + */
706 + private function restore_native_settings(string $conflict): array {
707 + $chunk = Snapshot_Store::read_chunk(Thinkrank_Exporter::SLUG, 'settings', 1);
708 + $data = $chunk[0]['data'] ?? [];
709 +
710 + if (!is_array($data) || empty($data)) {
711 + return [
712 + 'status' => 'complete',
713 + 'message' => 'No settings in snapshot',
714 + 'has_more' => false,
715 + 'processed' => 0,
716 + 'skipped' => 0,
717 + ];
718 + }
719 +
720 + $overwrite = $conflict === self::CONFLICT_OVERWRITE;
721 +
722 + $processed = $this->restore_settings_options((array) ($data['options'] ?? []), $overwrite);
723 + $processed += $this->restore_settings_table((array) ($data['seo_table'] ?? []), $overwrite);
724 + $processed += $this->restore_aggregate_options((array) ($data['aggregate'] ?? []), $overwrite);
725 +
726 + return [
727 + 'status' => 'complete',
728 + 'message' => sprintf('Restored %d settings', $processed),
729 + 'has_more' => false,
730 + 'page' => 1,
731 + 'processed' => $processed,
732 + 'skipped' => 0,
733 + ];
734 + }
735 +
736 + /**
737 + * Restore the `thinkrank_{key}` options behind Settings.
738 + *
739 + * Written through Settings::set() rather than update_option() so the class's
740 + * own key validation, encryption and cache invalidation all run.
741 + *
742 + * @param array $options Setting key => value
743 + * @param bool $overwrite Whether to replace values already stored here
744 + * @return int Number of settings written
745 + */
746 + private function restore_settings_options(array $options, bool $overwrite): int {
747 + if (empty($options) || !class_exists('ThinkRank\\Core\\Settings')) {
748 + return 0;
749 + }
750 +
751 + $settings = \ThinkRank\Core\Settings::instance();
752 + $written = 0;
753 +
754 + foreach ($options as $key => $value) {
755 + $key = (string) $key;
756 +
757 + if (!$overwrite) {
758 + // A distinctive sentinel, because `false` and `''` are both
759 + // legitimate stored values here.
760 + if (get_option('thinkrank_' . $key, '__tr_not_set__') !== '__tr_not_set__') {
761 + continue;
762 + }
763 + }
764 +
765 + if ($settings->set($key, $value)) {
766 + $written++;
767 + }
768 + }
769 +
770 + return $written;
771 + }
772 +
773 + /**
774 + * Restore the thinkrank_seo_settings table, one category/context at a time.
775 + *
776 + * @param array $categories Category => context type => context id => key => row
777 + * @param bool $overwrite Whether to replace rows already stored here
778 + * @return int Number of settings written
779 + */
780 + private function restore_settings_table(array $categories, bool $overwrite): int {
781 + $written = 0;
782 +
783 + foreach ($categories as $category => $contexts) {
784 + $manager = $this->create_settings_restorer((string) $category);
785 + if ($manager === null) {
786 + continue;
787 + }
788 +
789 + foreach ((array) $contexts as $context_type => $context_ids) {
790 + foreach ((array) $context_ids as $context_id => $rows) {
791 + $existing = $overwrite ? [] : $manager->get_settings((string) $context_type, (int) $context_id);
792 + $payload = [];
793 +
794 + foreach ((array) $rows as $key => $row) {
795 + if (!$overwrite && array_key_exists($key, $existing)) {
796 + continue;
797 + }
798 +
799 + // Rows are exported as ['value' => …, 'type' => …,
800 + // 'priority' => …]; older files may carry the bare value.
801 + $payload[$key] = is_array($row) && array_key_exists('value', $row)
802 + ? $row['value']
803 + : $row;
804 + }
805 +
806 + if (empty($payload)) {
807 + continue;
808 + }
809 +
810 + // Declare the keys before saving. sanitize_settings() drops
811 + // any key the manager does not claim, and save_settings()
812 + // still returns true when it dropped every one of them — so
813 + // without this the restore reports success and writes
814 + // nothing. The rows came out of this table to begin with,
815 + // which is the strongest claim to being real settings that
816 + // exists.
817 + $manager->set_restorable_keys(array_keys($payload));
818 +
819 + if ($manager->save_settings((string) $context_type, (int) $context_id, $payload)) {
820 + $written += count($payload);
821 + }
822 + }
823 + }
824 + }
825 +
826 + return $written;
827 + }
828 +
829 + /**
830 + * A minimal Abstract_SEO_Manager for one settings category.
831 + *
832 + * Going through a manager (rather than writing rows directly) buys the
833 + * upsert, the shared sanitizer that knows which keys are multiline or
834 + * template strings, the cache invalidation, and the
835 + * `thinkrank_seo_settings_saved` action other managers listen for.
836 + *
837 + * Validation is deliberately permissive: this is the site's own data coming
838 + * back, and a validator that has tightened since the export was taken would
839 + * silently drop rows mid-restore. Reaching here already requires
840 + * manage_options, so the file is not a privilege boundary.
841 + *
842 + * @param string $category Settings category (the manager_type column)
843 + * @return \ThinkRank\SEO\Abstract_SEO_Manager|null
844 + */
845 + protected function create_settings_restorer(string $category) {
846 + if ($category === '' || !class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
847 + return null;
848 + }
849 +
850 + return new class($category) extends \ThinkRank\SEO\Abstract_SEO_Manager {
851 +
852 + /** @var string[] Keys this restore pass is allowed to write. */
853 + private array $restorable_keys = [];
854 +
855 + /**
856 + * @param string[] $keys Setting keys about to be restored.
857 + * @return void
858 + */
859 + public function set_restorable_keys(array $keys): void {
860 + $this->restorable_keys = array_values(array_filter($keys, 'is_string'));
861 + }
862 +
863 + public function validate_settings(array $settings): array {
864 + return ['valid' => true, 'errors' => []];
865 + }
866 +
867 + public function get_output_data(string $context_type, ?int $context_id): array {
868 + return [];
869 + }
870 +
871 + /**
872 + * Backs the allow-list sanitize_settings() checks against. Empty
873 + * until set_restorable_keys() names the keys of the batch being
874 + * written, so the restorer can never write a key that was not in
875 + * the file.
876 + */
877 + public function get_default_settings(string $context_type): array {
878 + return array_fill_keys($this->restorable_keys, '');
879 + }
880 +
881 + public function get_settings_schema(string $context_type): array {
882 + return [];
883 + }
884 + };
885 + }
886 +
887 + /**
888 + * Restore the standalone aggregate settings options.
889 + *
890 + * @param array $options Option name => value
891 + * @param bool $overwrite Whether to replace options already stored here
892 + * @return int Number of options written
893 + */
894 + private function restore_aggregate_options(array $options, bool $overwrite): int {
895 + $written = 0;
896 +
897 + foreach ($options as $option_name => $value) {
898 + $option_name = (string) $option_name;
899 +
900 + // Only the options the exporter actually emits, whatever the file
901 + // claims. A plain `thinkrank_` prefix check would not be enough:
902 + // the snapshot chunks themselves live under that prefix, so a
903 + // hand-edited export could rewrite the snapshot it is restoring from.
904 + if (!in_array($option_name, Thinkrank_Exporter::AGGREGATE_OPTIONS, true)) {
905 + continue;
906 + }
907 +
908 + $existing = get_option($option_name, '__tr_not_set__');
909 +
910 + if (!$overwrite && $existing !== '__tr_not_set__') {
911 + continue;
912 + }
913 +
914 + // An aggregate option is written whole, so a key the exporter
915 + // stripped would be DELETED here rather than just left alone — an
916 + // overwrite-restore would wipe this site's Google OAuth tokens and
917 + // platform verification codes on the way to restoring everything
918 + // around them. Carry the local values forward for exactly the keys
919 + // export redacts, matching that redaction key for key and depth for
920 + // depth.
921 + if (is_array($value) && is_array($existing)) {
922 + $value = $this->carry_forward_redacted(
923 + $value,
924 + $existing,
925 + array_merge(
926 + Thinkrank_Exporter::secret_setting_keys(),
927 + Thinkrank_Exporter::SECRET_OPTION_KEYS[$option_name] ?? []
928 + )
929 + );
930 + }
931 +
932 + update_option($option_name, $value);
933 + $written++;
934 + }
935 +
936 + return $written;
937 + }
938 +
939 + /**
940 + * Put back the secrets the export stripped, from what this site already has.
941 + *
942 + * The mirror image of Thinkrank_Exporter::strip_secret_keys(): that walks
943 + * the payload to any depth removing keys named as secrets, so this walks it
944 + * to the same depth restoring them. A key the export DID carry is left
945 + * alone — the carry-forward only fills a hole, so a deliberate change still
946 + * lands.
947 + *
948 + * @since 2.3.1
949 + *
950 + * @param array $incoming The option value from the snapshot.
951 + * @param array $existing The option value this site already holds.
952 + * @param string[] $secret_keys Key names redaction removes.
953 + * @return array
954 + */
955 + private function carry_forward_redacted(array $incoming, array $existing, array $secret_keys): array {
956 + foreach ($existing as $key => $existing_value) {
957 + if (is_string($key) && in_array($key, $secret_keys, true)) {
958 + if (!array_key_exists($key, $incoming)) {
959 + $incoming[$key] = $existing_value;
960 + }
961 + continue;
962 + }
963 +
964 + if (is_array($existing_value) && isset($incoming[$key]) && is_array($incoming[$key])) {
965 + $incoming[$key] = $this->carry_forward_redacted($incoming[$key], $existing_value, $secret_keys);
966 + }
967 + }
968 +
969 + return $incoming;
970 + }
971 +
972 + /**
339 973 * Dry-run a snapshot chunk: classify what a migrate WOULD do without
340 974 * writing anything. Mirrors migrate_chunk()'s per-field decision (skip
341 975 * empty values, never overwrite existing ThinkRank data) so the counts
342 976 * match what a real migrate would produce.
@@ -729,8 +1363,160 @@
729 1363 return $seeded;
730 1364 }
731 1365
732 1366 /**
1367 + * Migrate a source plugin's per-object redirect into ThinkRank.
1368 + *
1369 + * The destination is Pro's redirections table, not object meta, so this
1370 + * goes through Object_Redirect rather than writing a key: that keeps the
1371 + * imported rule subject to the same guards as one typed into the edit
1372 + * screen (no self-referential rule, no query-string source) and puts it in
1373 + * the Redirections manager where the user can see and edit it.
1374 + *
1375 + * An existing redirect on the object is left alone — the import rule is
1376 + * SKIP on conflict, and a redirect the user already set here outranks one
1377 + * carried over from the plugin being replaced.
1378 + *
1379 + * @param string $object_type 'post' or 'term'.
1380 + * @param int $object_id Object ID.
1381 + * @param array $record Full snapshot record.
1382 + * @return bool Whether a redirect was written.
1383 + */
1384 + private function migrate_object_redirect(string $object_type, int $object_id, array $record): bool {
1385 + $extended = $record['extended'] ?? [];
1386 +
1387 + if (!is_array($extended) || empty($extended['redirect_url'])) {
1388 + return false;
1389 + }
1390 +
1391 + // A source that models the redirect as a toggle plus a URL can carry a
1392 + // URL the site is not actually serving. Honour the toggle when present.
1393 + if (array_key_exists('redirect_enabled', $extended) && empty($extended['redirect_enabled'])) {
1394 + return false;
1395 + }
1396 +
1397 + if ('' !== Object_Redirect::get($object_type, $object_id)['url']) {
1398 + return false;
1399 + }
1400 +
1401 + $result = Object_Redirect::save(
1402 + $object_type,
1403 + $object_id,
1404 + (string) $extended['redirect_url'],
1405 + $extended['redirect_type'] ?? Object_Redirect::DEFAULT_TYPE
1406 + );
1407 +
1408 + return !is_wp_error($result);
1409 + }
1410 +
1411 + /**
1412 + * The robots directives for a term.
1413 + *
1414 + * Deliberately narrower than migrate_robots_payload(): update-term-seo
1415 + * writes `_thinkrank_robots_meta` and `_thinkrank_robots_meta_enabled` and
1416 + * nothing else for a term, so the advanced directives a post supports have
1417 + * nowhere to go here and are left in the snapshot rather than written to a
1418 + * key no reader looks at.
1419 + *
1420 + * Same two rules as the post path — never overwrite an existing payload,
1421 + * and never turn the override on for an all-false set, which is just
1422 + * ThinkRank's default index/follow spelled out.
1423 + *
1424 + * @param int $term_id Target term ID.
1425 + * @param array $data Canonical record data.
1426 + * @return bool True when a payload was written.
1427 + */
1428 + private function migrate_term_robots_payload(int $term_id, array $data): bool {
1429 + $existing = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1430 + if (is_string($existing) && $existing !== '') {
1431 + return false;
1432 + }
1433 +
1434 + $robots = [];
1435 + $has_active_directive = false;
1436 +
1437 + foreach (self::ROBOTS_FIELDS as $field) {
1438 + if (!array_key_exists($field, $data)) {
1439 + continue;
1440 + }
1441 +
1442 + $value = $data[$field];
1443 + if ($value === '' || $value === null) {
1444 + continue;
1445 + }
1446 +
1447 + $robots[$field] = (bool) (int) $value;
1448 + if ($robots[$field]) {
1449 + $has_active_directive = true;
1450 + }
1451 + }
1452 +
1453 + if (!$has_active_directive) {
1454 + return false;
1455 + }
1456 +
1457 + $robots['index'] = empty($robots['noindex']);
1458 +
1459 + update_term_meta($term_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1460 + update_term_meta($term_id, '_thinkrank_robots_meta_enabled', 1);
1461 +
1462 + return true;
1463 + }
1464 +
1465 + /**
1466 + * Carry the source's watched pages into ThinkRank Pro's Focus Pages.
1467 + *
1468 + * Squirrly keeps this list on its own servers, so the exporter reads it
1469 + * live while the source plugin is still installed and connected — after
1470 + * the switch there is nowhere left to read it from. See
1471 + * Squirrly_Exporter::fetch_focus_pages().
1472 + *
1473 + * Focus Pages is a Pro feature and a deliberately small, hand-picked list
1474 + * (Settings::MAX_PAGES). Two rules follow from that: never touch a
1475 + * selection the user has already made here, and never import more than
1476 + * the cap. Without Pro the ids stay in the snapshot for a later run, the
1477 + * same way per-object redirects wait for Pro's rules table.
1478 + *
1479 + * @param array $extended Extended settings payload.
1480 + * @return bool True when at least one page was added.
1481 + */
1482 + private function migrate_focus_pages(array $extended): bool {
1483 + $ids = $extended['focus_pages'] ?? [];
1484 + if (!is_array($ids) || $ids === []) {
1485 + return false;
1486 + }
1487 +
1488 + if (!class_exists('ThinkRank\\Pro\\Focus_Pages\\Settings')) {
1489 + return false;
1490 + }
1491 +
1492 + $settings = new \ThinkRank\Pro\Focus_Pages\Settings();
1493 +
1494 + // A choice already made here outranks one carried over, exactly as
1495 + // every other field in this class treats an existing value.
1496 + if ($settings->get() !== []) {
1497 + return false;
1498 + }
1499 +
1500 + $added = false;
1501 + foreach ($ids as $id) {
1502 + $post_id = (int) $id;
1503 + if ($post_id <= 0 || get_post($post_id) === null) {
1504 + continue;
1505 + }
1506 +
1507 + if (method_exists($settings, 'is_full') && $settings->is_full()) {
1508 + break;
1509 + }
1510 +
1511 + $settings->add($post_id);
1512 + $added = true;
1513 + }
1514 +
1515 + return $added;
1516 + }
1517 +
1518 + /**
733 1519 * Migrate the pillar / cornerstone content flag to ThinkRank post meta.
734 1520 *
735 1521 * ThinkRank stores an enabled flag as the string '1'; the reader
736 1522 * (Pillar_Content endpoint) matches meta_value = '1'. Never overwrites an
@@ -758,19 +1544,17 @@
758 1544 /**
759 1545 * Migrate the post's focus keywords.
760 1546 *
761 1547 * 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.
1548 + * the single `focus_keyword`) and persists via Focus_Keywords::save(). Never
1549 + * overwrites existing ThinkRank focus keywords.
766 1550 *
767 - * Posts whose source exceeded the free limit are recorded in `$truncations`
768 - * so the import summary can surface them as a Pro upsell.
1551 + * Posts whose source had more keywords than were stored are recorded in
1552 + * `$truncations` so the import summary can report them.
769 1553 *
770 1554 * @param int $post_id Target post ID.
771 1555 * @param array $data Canonical record data.
772 - * @param array|null $truncations Accumulator: appended with overflow info.
1556 + * @param array|null $truncations Accumulator: appended with what was dropped.
773 1557 * @return bool True when keywords were written.
774 1558 */
775 1559 private function migrate_focus_keywords(int $post_id, array $data, ?array &$truncations = null): bool {
776 1560 $keywords = [];
@@ -779,9 +1563,10 @@
779 1563 } elseif (!empty($data['focus_keyword'])) {
780 1564 $keywords = [$data['focus_keyword']];
781 1565 }
782 1566
783 - if (empty(Focus_Keywords::normalize($keywords, 0))) {
1567 + $all = Focus_Keywords::normalize($keywords, 0);
1568 + if (empty($all)) {
784 1569 return false;
785 1570 }
786 1571
787 1572 // Never overwrite existing ThinkRank focus keywords.
@@ -788,19 +1573,19 @@
788 1573 if (!empty(Focus_Keywords::get($post_id))) {
789 1574 return false;
790 1575 }
791 1576
792 - $result = Focus_Keywords::save_with_overflow($post_id, $keywords);
1577 + $saved = Focus_Keywords::save($post_id, $all);
793 1578
794 - if (!empty($result['overflow']) && is_array($truncations)) {
1579 + if (count($saved) < count($all) && is_array($truncations)) {
795 1580 $truncations[] = [
796 1581 'post_id' => $post_id,
797 - 'kept' => count($result['kept']),
798 - 'gated' => $result['overflow'],
1582 + 'kept' => count($saved),
1583 + 'dropped' => array_slice($all, count($saved)),
799 1584 ];
800 1585 }
801 1586
802 - return !empty($result['kept']);
1587 + return !empty($saved);
803 1588 }
804 1589
805 1590 /**
806 1591 * Seed the metabox Review schema form data for an imported review post.
@@ -831,9 +1616,9 @@
831 1616 if (is_string($existing) && $existing !== '') {
832 1617 return false;
833 1618 }
834 1619
835 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($review));
1620 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($review)));
836 1621
837 1622 return true;
838 1623 }
839 1624
@@ -865,9 +1650,9 @@
865 1650 if (is_string($existing) && $existing !== '') {
866 1651 return false;
867 1652 }
868 1653
869 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($video));
1654 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($video)));
870 1655
871 1656 return true;
872 1657 }
873 1658
@@ -947,13 +1732,13 @@
947 1732 }
948 1733
949 1734 $wrote = false;
950 1735 if (!empty($robots)) {
951 - update_post_meta($post_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1736 + update_post_meta($post_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
952 1737 $wrote = true;
953 1738 }
954 1739 if (!empty($advanced)) {
955 - update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_json_encode($advanced));
1740 + update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_slash(wp_json_encode($advanced)));
956 1741 $wrote = true;
957 1742 }
958 1743 if ($wrote) {
959 1744 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -994,9 +1779,9 @@
994 1779 $processed++;
995 1780 }
996 1781 }
997 1782
998 - if (!empty($data['homepage_title']) || !empty($data['homepage_description']) || !empty($data['organization_name']) || !empty($data['organization_logo'])) {
1783 + if (!empty($data['homepage_title']) || !empty($data['organization_name']) || !empty($data['organization_logo'])) {
999 1784 $site_identity = get_option('thinkrank_site_identity_settings', []);
1000 1785 $updated = false;
1001 1786
1002 1787 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
@@ -1002,12 +1787,8 @@
1002 1787 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
1003 1788 $site_identity['homepage_title'] = $data['homepage_title'];
1004 1789 $updated = true;
1005 1790 }
1006 - if (!empty($data['homepage_description']) && empty($site_identity['homepage_description'])) {
1007 - $site_identity['homepage_description'] = $data['homepage_description'];
1008 - $updated = true;
1009 - }
1010 1791 if (!empty($data['organization_name']) && empty($site_identity['organization_name'])) {
1011 1792 $site_identity['organization_name'] = $data['organization_name'];
1012 1793 $updated = true;
1013 1794 }
@@ -1021,21 +1802,10 @@
1021 1802 $processed++;
1022 1803 }
1023 1804 }
1024 1805
1025 - if (!empty($data['social_profiles'])) {
1026 - $social = get_option('thinkrank_social_media_settings', []);
1027 - $updated = false;
1028 -
1029 - foreach ($data['social_profiles'] as $platform => $url) {
1030 - if (!empty($url) && empty($social[$platform])) {
1031 - $social[$platform] = $url;
1032 - $updated = true;
1033 - }
1034 - }
1035 -
1036 - if ($updated) {
1037 - update_option('thinkrank_social_media_settings', $social);
1806 + if (!empty($data['social_profiles']) && is_array($data['social_profiles'])) {
1807 + if ($this->migrate_social_profiles($data['social_profiles'])) {
1038 1808 $processed++;
1039 1809 }
1040 1810 }
1041 1811
@@ -1092,8 +1862,23 @@
1092 1862 $processed++;
1093 1863 }
1094 1864 }
1095 1865
1866 + // Per-taxonomy and blog-index noindex, into the Content Type Matrix.
1867 + // The templates beside them have no ThinkRank store and stay preserved.
1868 + if ($this->migrate_archive_noindex($extended)) {
1869 + $processed++;
1870 + }
1871 +
1872 + // Site-wide custom schemas and Markdown for AI, both ThinkRank Pro.
1873 + $global_schemas = $extended['custom_schemas']['entries'] ?? [];
1874 + if (is_array($global_schemas) && $this->migrate_custom_schemas($plugin, $global_schemas) > 0) {
1875 + $processed++;
1876 + }
1877 + if ($this->migrate_markdown_for_ai($extended)) {
1878 + $processed++;
1879 + }
1880 +
1096 1881 // Site-identity settings (homepage/org/breadcrumbs/local SEO) are served to
1097 1882 // the frontend from the wp_thinkrank_seo_settings table via the manager, not
1098 1883 // from the option written above — route them through the manager so they
1099 1884 // actually take effect.
@@ -1105,8 +1890,13 @@
1105 1890 if ($this->migrate_image_seo($extended)) {
1106 1891 $processed++;
1107 1892 }
1108 1893
1894 + // The pages the source had under active watch.
1895 + if ($this->migrate_focus_pages($extended)) {
1896 + $processed++;
1897 + }
1898 +
1109 1899 // Sitemap inclusion settings.
1110 1900 if ($this->migrate_sitemap($extended)) {
1111 1901 $processed++;
1112 1902 }
@@ -1174,18 +1964,33 @@
1174 1964 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
1175 1965 $current = $manager->get_settings('site');
1176 1966
1177 1967 // 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 - ];
1968 + //
1969 + // The title formats come from Site_Identity_Manager rather than being
1970 + // restated here. They were restated once, drifted (the homepage seed
1971 + // still used a literal '|' after the shipped default moved to %sep%),
1972 + // and the six per-context formats below were never listed at all — so
1973 + // every shipped default read as "the user chose this" and no imported
1974 + // title format was ever written.
1975 + $seeds = array_merge(
1976 + \ThinkRank\SEO\Site_Identity_Manager::TITLE_FORMAT_DEFAULTS,
1977 + [
1978 + 'site_name' => get_bloginfo('name'),
1979 + 'logo_url' => '',
1980 + 'breadcrumb_home_text' => 'Home',
1981 + // Two shipped values, both untouched. get_default_settings()
1982 + // says '>' and the admin screen seeds '›' (as does the
1983 + // breadcrumb renderer's own fallback), so which one a site
1984 + // holds depends only on whether that screen has ever been
1985 + // saved. Recognising one and not the other would skip the
1986 + // imported separator on half of all installs.
1987 + 'breadcrumb_separator' => ['>', '›'],
1988 + 'business_type' => '',
1989 + 'business_name' => '',
1990 + 'business_phone' => '',
1991 + ]
1992 + );
1188 1993
1189 1994 $updates = [];
1190 1995 $set = static function (string $key, $value) use (&$updates, $current, $seeds): void {
1191 1996 if ($value === '' || $value === null) {
@@ -1191,9 +1996,14 @@
1191 1996 if ($value === '' || $value === null) {
1192 1997 return;
1193 1998 }
1194 1999 $cur = $current[$key] ?? null;
1195 - $is_default = !array_key_exists($key, $current) || $cur === '' || $cur === ($seeds[$key] ?? null);
2000 + // A seed may list several values when more than one shipped default
2001 + // is in circulation for the same field.
2002 + $shipped = array_key_exists($key, $seeds) ? (array) $seeds[$key] : [];
2003 + $is_default = !array_key_exists($key, $current)
2004 + || $cur === ''
2005 + || in_array($cur, $shipped, true);
1196 2006 if ($is_default) {
1197 2007 $updates[$key] = $value;
1198 2008 }
1199 2009 };
@@ -1204,8 +2014,12 @@
1204 2014 // real template and beats the literal-resolved data.homepage_title, so it
1205 2015 // wins when the source provided one.
1206 2016 $title_formats = is_array($extended['title_formats'] ?? null) ? $extended['title_formats'] : [];
1207 2017 $set('homepage_title', $title_formats['homepage_title'] ?? ($data['homepage_title'] ?? ''));
2018 + // The homepage meta description. It used to go to the legacy
2019 + // thinkrank_site_identity_settings option, which nothing reads, so the
2020 + // homepage fell back to the tagline (#897).
2021 + $set('homepage_description', trim((string) ($data['homepage_description'] ?? '')));
1208 2022 $set('site_name', $data['organization_name'] ?? '');
1209 2023 $set('alternate_name', $data['alternate_name'] ?? '');
1210 2024 $set('logo_url', $data['organization_logo'] ?? '');
1211 2025
@@ -1247,8 +2061,23 @@
1247 2061 $set('breadcrumb_separator', $breadcrumbs['separator'] ?? '');
1248 2062 $set('breadcrumb_prefix', $breadcrumbs['prefix'] ?? '');
1249 2063 }
1250 2064
2065 + // A hand-written robots.txt (extended.robots_txt.content) goes into
2066 + // ThinkRank's Robots.txt textarea only while that is still empty:
2067 + // a file the user already wrote here is never replaced (#886).
2068 + $robots_txt = $extended['robots_txt'] ?? [];
2069 + if (is_array($robots_txt)) {
2070 + $set('robots_txt_content', trim((string) ($robots_txt['content'] ?? '')));
2071 + }
2072 +
2073 + // The "appeared first on" feed backlink (extended.feed.source_link).
2074 + // Only switches it on: a source that had it off is no reason to turn
2075 + // off a signature ThinkRank seeds for new installs.
2076 + if (!empty($extended['feed']['source_link']) && empty($current['feed_source_link'])) {
2077 + $updates['feed_source_link'] = true;
2078 + }
2079 +
1251 2080 // Local SEO (extended.local_seo) — migrate the full NAP + geo when there
1252 2081 // is any meaningful business data (name, phone, address or coordinates),
1253 2082 // and enable the feature alongside it. Each field is written only while
1254 2083 // ThinkRank's Business Info still holds its default (non-destructive).
@@ -1395,9 +2224,10 @@
1395 2224 }
1396 2225
1397 2226 $app_id = trim((string) ($defaults['facebook_app_id'] ?? ''));
1398 2227 $og_image = trim((string) ($defaults['og_default_image'] ?? ''));
1399 - if ($app_id === '' && $og_image === '') {
2228 + $twitter_image = trim((string) ($defaults['twitter_default_image'] ?? ''));
2229 + if ($app_id === '' && $og_image === '' && $twitter_image === '') {
1400 2230 return false;
1401 2231 }
1402 2232
1403 2233 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
@@ -1406,11 +2236,19 @@
1406 2236 $updates = [];
1407 2237 if ($app_id !== '' && empty($current['facebook_app_id'])) {
1408 2238 $updates['facebook_app_id'] = $app_id;
1409 2239 }
1410 - if ($og_image !== '' && empty($current['default_image'])) {
1411 - $updates['default_image'] = $og_image;
2240 + // `default_og_image` is the key Social_Meta_Manager declares for the
2241 + // site context. `default_image` is only a legacy alias the front-end
2242 + // readers still honour — saving under it is discarded, because a key
2243 + // outside the allow-list never reaches the database.
2244 + if ($og_image !== '' && empty($current['default_og_image'])) {
2245 + $updates['default_og_image'] = $og_image;
1412 2246 }
2247 + // The Twitter card fallback image (Slim SEO's default_twitter_image).
2248 + if ($twitter_image !== '' && empty($current['default_twitter_image'])) {
2249 + $updates['default_twitter_image'] = $twitter_image;
2250 + }
1413 2251
1414 2252 if (empty($updates)) {
1415 2253 return false;
1416 2254 }
@@ -1418,8 +2256,97 @@
1418 2256 return (bool) $manager->save_settings('site', null, $updates);
1419 2257 }
1420 2258
1421 2259 /**
2260 + * Social profile URLs (data.social_profiles, platform => URL) into the
2261 + * stores that are read (#898).
2262 + *
2263 + * They used to be written as `thinkrank_social_media_settings[<platform>]`,
2264 + * a key nothing reads: the consumer looks for `<platform>_url`, the
2265 + * Organization graph reads the Schema Manager's
2266 + * `organization_social_<platform>`, and `twitter:site` comes from the
2267 + * Social Meta `twitter_username`. So the profiles never reached `sameAs`
2268 + * and the X handle stopped printing once the source plugin was off. Each
2269 + * store is filled only where it is still empty.
2270 + *
2271 + * @param array $profiles platform => profile URL
2272 + * @return bool True if anything was written
2273 + */
2274 + private function migrate_social_profiles(array $profiles): bool {
2275 + $profiles = array_filter(array_map(
2276 + static fn($url): string => is_scalar($url) ? trim((string) $url) : '',
2277 + $profiles
2278 + ));
2279 + if (empty($profiles)) {
2280 + return false;
2281 + }
2282 +
2283 + $written = false;
2284 +
2285 + $social = get_option('thinkrank_social_media_settings', []);
2286 + $social = is_array($social) ? $social : [];
2287 + $social_updated = false;
2288 + foreach ($profiles as $platform => $url) {
2289 + $key = sanitize_key((string) $platform) . '_url';
2290 + if (empty($social[$key])) {
2291 + $social[$key] = esc_url_raw($url);
2292 + $social_updated = true;
2293 + }
2294 + }
2295 + if ($social_updated) {
2296 + update_option('thinkrank_social_media_settings', $social);
2297 + $written = true;
2298 + }
2299 +
2300 + // The Organization node's sameAs.
2301 + $schema_platforms = ['facebook', 'twitter', 'linkedin', 'instagram', 'youtube', 'pinterest', 'whatsapp', 'telegram'];
2302 + $manager = $this->create_schema_manager();
2303 + if ($manager !== null) {
2304 + $current = $manager->get_settings('site');
2305 + $updates = [];
2306 + foreach ($profiles as $platform => $url) {
2307 + $field = 'organization_social_' . $platform;
2308 + if (in_array($platform, $schema_platforms, true) && filter_var($url, FILTER_VALIDATE_URL) && empty($current[$field])) {
2309 + $updates[$field] = esc_url_raw($url);
2310 + }
2311 + }
2312 + if (!empty($updates) && $manager->save_settings('site', null, $updates)) {
2313 + $written = true;
2314 + }
2315 + }
2316 +
2317 + // twitter:site, from an X/Twitter profile URL.
2318 + $handle = self::twitter_handle_from_url((string) ($profiles['twitter'] ?? ''));
2319 + if ($handle !== '' && class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) {
2320 + $social_meta = new \ThinkRank\SEO\Social_Meta_Manager();
2321 + $current = $social_meta->get_settings('site');
2322 + if (empty($current['twitter_username']) && $social_meta->save_settings('site', null, ['twitter_username' => $handle])) {
2323 + $written = true;
2324 + }
2325 + }
2326 +
2327 + return $written;
2328 + }
2329 +
2330 + /**
2331 + * The handle in an x.com / twitter.com profile URL, or '' when the URL is
2332 + * not one (`https://x.com/arsenal` → `arsenal`). Handles are 1-15 letters,
2333 + * digits or underscores, which is also what Social Meta accepts.
2334 + *
2335 + * @param string $url Profile URL
2336 + */
2337 + public static function twitter_handle_from_url(string $url): string {
2338 + $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
2339 + if (!in_array(preg_replace('/^(www\.|mobile\.)/', '', $host), ['x.com', 'twitter.com'], true)) {
2340 + return '';
2341 + }
2342 + $segment = explode('/', trim((string) wp_parse_url($url, PHP_URL_PATH), '/'))[0] ?? '';
2343 + $segment = ltrim(rawurldecode($segment), '@');
2344 +
2345 + return preg_match('/^[A-Za-z0-9_]{1,15}$/', $segment) ? $segment : '';
2346 + }
2347 +
2348 + /**
1422 2349 * Migrate the source plugin's Pinterest site-verification code into
1423 2350 * ThinkRank's core `pinterest_site_verification` setting. Never overwrites
1424 2351 * a configured code.
1425 2352 *
@@ -1660,18 +2587,40 @@
1660 2587 $skipped++;
1661 2588 }
1662 2589 }
1663 2590
2591 + // Report the same has_more every other type does. Hardcoding false
2592 + // here was invisible in the admin, which iterates 1..total_chunks, and
2593 + // silently truncated the MCP/ability import, which loops on has_more
2594 + // alone: a site with more than one chunk of rules got its first
2595 + // hundred and a clean `complete`.
2596 + $has_more = $page < (int) ($this->chunk_total($plugin, 'redirections'));
2597 +
1664 2598 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,
2599 + 'status' => $has_more ? 'processing' : 'complete',
2600 + 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
2601 + 'has_more' => $has_more,
2602 + 'page' => $page,
2603 + 'total_chunks' => $this->chunk_total($plugin, 'redirections'),
2604 + 'processed' => $processed,
2605 + 'skipped' => $skipped,
1670 2606 ];
1671 2607 }
1672 2608
1673 2609 /**
2610 + * How many chunks the manifest declares for a type, or 0 when unknown.
2611 + *
2612 + * @param string $plugin Source slug.
2613 + * @param string $type Snapshot type.
2614 + * @return int
2615 + */
2616 + private function chunk_total(string $plugin, string $type): int {
2617 + $manifest = Snapshot_Store::get_manifest($plugin);
2618 +
2619 + return (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
2620 + }
2621 +
2622 + /**
1674 2623 * Migrate a chunk of logged 404 hits into ThinkRank Pro's 404 Monitor.
1675 2624 * Pro-gated exactly like migrate_redirections().
1676 2625 *
1677 2626 * @param string $plugin Plugin slug
@@ -1721,18 +2670,130 @@
1721 2670 $skipped++;
1722 2671 }
1723 2672 }
1724 2673
2674 + $has_more = $page < $this->chunk_total($plugin, '404_logs');
2675 +
1725 2676 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,
2677 + 'status' => $has_more ? 'processing' : 'complete',
2678 + 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
2679 + 'has_more' => $has_more,
2680 + 'page' => $page,
2681 + 'total_chunks' => $this->chunk_total($plugin, '404_logs'),
2682 + 'processed' => $processed,
2683 + 'skipped' => $skipped,
1731 2684 ];
1732 2685 }
1733 2686
1734 2687 /**
2688 + * Rewrite a chunk of posts' Rank Math FAQ / HowTo blocks into ThinkRank's
2689 + * own blocks.
2690 + *
2691 + * Unlike every other type here this does not write meta — it edits
2692 + * `post_content` in place, because that is where the blocks live. The
2693 + * snapshot chunk carries post ids only, so the conversion always runs
2694 + * against the post as it stands now rather than a stale copy.
2695 + *
2696 + * The conflict strategy is deliberately ignored. A Rank Math block and a
2697 + * ThinkRank block are not two values competing for one field: the Rank Math
2698 + * one is broken markup that needs replacing, and any ThinkRank block
2699 + * already in the post is simply left alone by the converter.
2700 + *
2701 + * @param string $plugin Plugin slug
2702 + * @param int $page Chunk number
2703 + * @return array Migration result
2704 + */
2705 + private function migrate_content_blocks(string $plugin, int $page): array {
2706 + $chunk = Snapshot_Store::read_chunk($plugin, Block_Converter::TYPE, $page);
2707 + $total_chunks = $this->chunk_total($plugin, Block_Converter::TYPE);
2708 +
2709 + if ($chunk === null || empty($chunk)) {
2710 + $has_more = $page < $total_chunks;
2711 +
2712 + return [
2713 + 'status' => $has_more ? 'processing' : 'complete',
2714 + 'message' => 'No content blocks in chunk',
2715 + 'has_more' => $has_more,
2716 + 'page' => $page,
2717 + 'processed' => 0,
2718 + 'skipped' => 0,
2719 + 'failed' => 0,
2720 + 'failures' => [],
2721 + ];
2722 + }
2723 +
2724 + // Rewriting a few hundred posts is well past the default execution
2725 + // window on shared hosting, and a timeout mid-chunk would leave the
2726 + // migration looking stalled.
2727 + if (function_exists('set_time_limit')) {
2728 + @set_time_limit(300); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2729 + }
2730 +
2731 + $processed = 0;
2732 + $skipped = 0;
2733 + $failed = 0;
2734 + $failures = [];
2735 + $blocks = 0;
2736 +
2737 + foreach ($chunk as $record) {
2738 + $post_id = (int) ($record['object_id'] ?? ($record['data']['post_id'] ?? 0));
2739 + if ($post_id < 1) {
2740 + $skipped++;
2741 + continue;
2742 + }
2743 +
2744 + $result = Block_Converter::convert_post($post_id);
2745 +
2746 + if ('converted' === $result['status']) {
2747 + $processed++;
2748 + $blocks += $result['converted'];
2749 + continue;
2750 + }
2751 +
2752 + // A post the converter refused (broken block markup, a PCRE
2753 + // failure) was left untouched and still holds its Rank Math
2754 + // blocks. Folding it into `skipped` made it indistinguishable from
2755 + // a post that was simply already converted, so it is counted and
2756 + // named on its own.
2757 + if ('error' === $result['status']) {
2758 + $failed++;
2759 + $failures[] = ['post_id' => $post_id, 'message' => $result['message']];
2760 + continue;
2761 + }
2762 +
2763 + // `unchanged` is the normal outcome of a re-run, not a failure.
2764 + $skipped++;
2765 + }
2766 +
2767 + $has_more = $page < $total_chunks;
2768 +
2769 + if (!$has_more) {
2770 + // The detector caches its scan for an hour; without clearing it the
2771 + // Migration screen keeps offering blocks that are no longer there.
2772 + (new Import_Detector())->clear_cache();
2773 + }
2774 +
2775 + return [
2776 + 'status' => $has_more ? 'processing' : 'complete',
2777 + 'message' => sprintf(
2778 + 'Converted %d FAQ/HowTo blocks in %d posts, skipped %d, failed %d (page %d)',
2779 + $blocks,
2780 + $processed,
2781 + $skipped,
2782 + $failed,
2783 + $page
2784 + ),
2785 + 'has_more' => $has_more,
2786 + 'page' => $page,
2787 + 'total_chunks' => $total_chunks,
2788 + 'processed' => $processed,
2789 + 'skipped' => $skipped,
2790 + 'failed' => $failed,
2791 + 'failures' => $failures,
2792 + ];
2793 + }
2794 +
2795 + /**
1735 2796 * Build ThinkRank Pro's Redirections store, when Pro is active.
1736 2797 *
1737 2798 * Split out (and protected) so tests can substitute a fake — the real store
1738 2799 * writes to Pro's tables. Pro lazily creates them via Schema::ensure().
@@ -2042,14 +3103,15 @@
2042 3103 return true;
2043 3104 }
2044 3105
2045 3106 /**
2046 - * Migrate the source plugin's scheduled SEO email report cadence into
2047 - * ThinkRank's Email Reporting config.
3107 + * Carry the source plugin's scheduled SEO email report over as ThinkRank's
3108 + * Email Reporting switch.
2048 3109 *
2049 3110 * Only touches a config the user has not enabled yet, and never turns
2050 3111 * reports ON unless the source had them on — an unexpected recurring email
2051 - * after an import would be worse than a missing one.
3112 + * after an import would be worse than a missing one. The source cadence is
3113 + * not carried: the report's schedule is not a setting this plugin stores.
2052 3114 *
2053 3115 * @param array $extended Canonical settings `extended` payload
2054 3116 * @return bool True if the config was written
2055 3117 */
@@ -2067,16 +3129,10 @@
2067 3129 if (!empty($current['enabled'])) {
2068 3130 return false;
2069 3131 }
2070 3132
2071 - $frequency = (int) ($reports['frequency_days'] ?? 0);
2072 - $update = ['enabled' => true];
2073 - if ($frequency > 0) {
2074 - $update['frequency_days'] = $frequency;
2075 - }
3133 + $config_manager->save(['enabled' => true]);
2076 3134
2077 - $config_manager->save(array_merge($current, $update));
2078 -
2079 3135 return true;
2080 3136 }
2081 3137
2082 3138 /**
@@ -2137,11 +3193,39 @@
2137 3193 foreach ($extended as $key => $value) {
2138 3194 if (in_array($key, self::HANDLED_EXTENDED_SETTINGS, true) || empty($value)) {
2139 3195 continue;
2140 3196 }
3197 + // A bucket the migrator applies part of blocks cleanup only for
3198 + // the part it does not.
3199 + if (is_array($value) && empty($this->unapplied_remainder((string) $key, $value))) {
3200 + continue;
3201 + }
3202 + // Custom schemas have a home only in ThinkRank Pro. Without it
3203 + // they are preserved, and cleanup would delete the source's
3204 + // copy, including every post's own schemas.
3205 + if ($key === 'custom_schemas') {
3206 + if ($this->create_custom_schema_repository() !== null) {
3207 + continue;
3208 + }
3209 + $count = count(is_array($value['entries'] ?? null) ? $value['entries'] : []) + (int) ($value['post_count'] ?? 0);
3210 + $buckets[] = [
3211 + 'key' => 'settings.custom_schemas',
3212 + 'label' => (string) self::extended_bucket_label('custom_schemas'),
3213 + 'count' => max(1, $count),
3214 + ];
3215 + continue;
3216 + }
3217 + if ($key === 'schema_templates' && is_array($value)) {
3218 + $buckets[] = [
3219 + 'key' => 'settings.schema_templates',
3220 + 'label' => (string) self::extended_bucket_label('schema_templates'),
3221 + 'count' => count($value),
3222 + ];
3223 + continue;
3224 + }
2141 3225 $buckets[] = [
2142 3226 'key' => 'settings.' . $key,
2143 - 'label' => (string) $key,
3227 + 'label' => self::extended_bucket_label((string) $key) ?? ucfirst(str_replace('_', ' ', (string) $key)),
2144 3228 'count' => 1,
2145 3229 ];
2146 3230 }
2147 3231 }
@@ -2146,8 +3230,209 @@
2146 3230 }
2147 3231 }
2148 3232
2149 3233 return $buckets;
3234 + }
3235 +
3236 + /**
3237 + * Save canonical custom schema entries into ThinkRank Pro's Custom Schema.
3238 + *
3239 + * Each entry's id is derived from the source and its `key`, so running
3240 + * the migration again finds the entry it wrote and leaves it — including
3241 + * any edit made since. An entry the Repository refuses (an invalid
3242 + * condition, an oversized document) is skipped; the source still holds
3243 + * it, and the snapshot keeps the rendered JSON.
3244 + *
3245 + * @param string $plugin Source plugin slug
3246 + * @param array $entries Canonical entries {key, title, enabled, json, conditions}
3247 + * @return int Entries written
3248 + */
3249 + private function migrate_custom_schemas(string $plugin, array $entries): int {
3250 + $repository = $this->create_custom_schema_repository();
3251 + if ($repository === null) {
3252 + return 0;
3253 + }
3254 +
3255 + $written = 0;
3256 + foreach ($entries as $entry) {
3257 + if (!is_array($entry) || trim((string) ($entry['json'] ?? '')) === '') {
3258 + continue;
3259 + }
3260 + $id = 'cs_' . substr(md5($plugin . '|' . (string) ($entry['key'] ?? wp_json_encode($entry))), 0, 12);
3261 + if ($repository->get($id) !== null) {
3262 + continue;
3263 + }
3264 +
3265 + try {
3266 + $repository->save([
3267 + 'id' => $id,
3268 + 'title' => (string) ($entry['title'] ?? ''),
3269 + 'enabled' => !empty($entry['enabled']),
3270 + 'json' => (string) $entry['json'],
3271 + 'conditions' => is_array($entry['conditions'] ?? null) ? $entry['conditions'] : ['include' => [], 'exclude' => []],
3272 + ]);
3273 + $written++;
3274 + } catch (\Throwable $e) {
3275 + continue;
3276 + }
3277 + }
3278 +
3279 + return $written;
3280 + }
3281 +
3282 + /**
3283 + * ThinkRank Pro's Custom Schema repository, when Pro is active.
3284 + *
3285 + * @return object|null
3286 + */
3287 + protected function create_custom_schema_repository(): ?object {
3288 + return class_exists('ThinkRank\\Pro\\Schema\\Repository') ? new \ThinkRank\Pro\Schema\Repository() : null;
3289 + }
3290 +
3291 + /**
3292 + * Turn on ThinkRank Pro's Markdown for AI with the source's post types
3293 + * (extended.markdown_for_ai), while its settings were never saved.
3294 + *
3295 + * @param array $extended Canonical settings `extended` payload
3296 + * @return bool True if written
3297 + */
3298 + private function migrate_markdown_for_ai(array $extended): bool {
3299 + $markdown = $extended['markdown_for_ai'] ?? [];
3300 + if (!is_array($markdown) || empty($markdown['enabled']) || !class_exists('ThinkRank\\Pro\\Markdown_For_AI\\Settings')) {
3301 + return false;
3302 + }
3303 + if (get_option('thinkrank_pro_markdown_for_ai', null) !== null) {
3304 + return false;
3305 + }
3306 +
3307 + $types = array_values(array_filter(
3308 + array_map('strval', is_array($markdown['post_types'] ?? null) ? $markdown['post_types'] : ['post']),
3309 + 'post_type_exists'
3310 + ));
3311 +
3312 + return (bool) (new \ThinkRank\Pro\Markdown_For_AI\Settings())->save([
3313 + 'enabled' => true,
3314 + 'post_types' => !empty($types) ? $types : ['post'],
3315 + ]);
3316 + }
3317 +
3318 + /**
3319 + * What a preserved settings bucket holds, as the cleanup warning names it.
3320 + *
3321 + * The warning exists so someone can decide whether to delete their old
3322 + * plugin's data; printing `code_injection` does not help them decide
3323 + * (#900). Every bucket an exporter can leave unapplied has an entry here,
3324 + * and a test fails when one is added without.
3325 + *
3326 + * @param string $key Extended bucket key
3327 + * @return string|null Label, or null when the bucket has none
3328 + */
3329 + public static function extended_bucket_label(string $key): ?string {
3330 + $labels = [
3331 + 'code_injection' => __('Header, body and footer code', 'thinkrank'),
3332 + 'redirect_settings' => __('Redirect behaviour settings', 'thinkrank'),
3333 + 'taxonomy_settings' => __('Taxonomy templates and images', 'thinkrank'),
3334 + 'post_type_archive_settings' => __('Post type archive templates and images', 'thinkrank'),
3335 + 'no_category_base' => __('Removing /category/ from category URLs', 'thinkrank'),
3336 + 'webmaster_tools' => __('Webmaster tools verification codes', 'thinkrank'),
3337 + 'access_control' => __('Role access settings', 'thinkrank'),
3338 + 'og_frontpage_title' => __('Homepage social title', 'thinkrank'),
3339 + 'og_frontpage_desc' => __('Homepage social description', 'thinkrank'),
3340 + 'og_frontpage_image' => __('Homepage social image', 'thinkrank'),
3341 + 'custom_schemas' => __('Custom schemas (need ThinkRank Pro)', 'thinkrank'),
3342 + 'schema_templates' => __('Schema templates with no ThinkRank equivalent', 'thinkrank'),
3343 + ];
3344 +
3345 + return $labels[$key] ?? null;
3346 + }
3347 +
3348 + /**
3349 + * What is left of a partially-applied extended bucket once the keys
3350 + * migrate_archive_noindex() consumes are removed.
3351 + *
3352 + * `taxonomy_settings` and `post_type_archive_settings` carry a `noindex`
3353 + * per context that is applied, beside templates and images that are not.
3354 + * Any other bucket is returned whole.
3355 + *
3356 + * @param string $key Extended bucket key
3357 + * @param array $value Bucket contents
3358 + * @return array The unapplied part
3359 + */
3360 + private function unapplied_remainder(string $key, array $value): array {
3361 + if ($key !== 'taxonomy_settings' && $key !== 'post_type_archive_settings') {
3362 + return $value;
3363 + }
3364 +
3365 + $remainder = [];
3366 + foreach ($value as $context => $settings) {
3367 + if (!is_array($settings)) {
3368 + continue;
3369 + }
3370 + // Only the blog index has an archive entity to receive a noindex.
3371 + $applied = $key === 'taxonomy_settings' || (string) $context === 'post';
3372 + $rest = $applied ? array_diff_key($settings, ['noindex' => true]) : $settings;
3373 + if (!empty($rest)) {
3374 + $remainder[$context] = $rest;
3375 + }
3376 + }
3377 +
3378 + return $remainder;
3379 + }
3380 +
3381 + /**
3382 + * Apply per-taxonomy and blog-index noindex through the Content Type
3383 + * Matrix (`taxonomy:<slug>` / `archive:blog`), the entities the front end
3384 + * reads robots directives for on those archives.
3385 + *
3386 + * Reads `extended.taxonomy_settings[<taxonomy>].noindex` (Yoast's and
3387 + * Slim SEO's shape) and `extended.post_type_archive_settings.post.noindex`.
3388 + * An entity whose robots were already customised in ThinkRank is left
3389 + * alone, and a source that did NOT noindex an archive writes nothing —
3390 + * "indexed" is the default and restating it would pin the entity.
3391 + *
3392 + * @param array $extended Canonical settings `extended` payload
3393 + * @return bool True if any entity was written
3394 + */
3395 + private function migrate_archive_noindex(array $extended): bool {
3396 + if (!class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
3397 + return false;
3398 + }
3399 +
3400 + // Only public taxonomies are matrix entities (Content_Type_Settings::get_entities()).
3401 + $entities = [];
3402 + $taxonomies = is_array($extended['taxonomy_settings'] ?? null) ? $extended['taxonomy_settings'] : [];
3403 + foreach ($taxonomies as $taxonomy => $settings) {
3404 + if (!is_array($settings) || empty($settings['noindex']) || !taxonomy_exists((string) $taxonomy)) {
3405 + continue;
3406 + }
3407 + $object = get_taxonomy((string) $taxonomy);
3408 + if (is_object($object) && !empty($object->public)) {
3409 + $entities[] = \ThinkRank\SEO\Content_Type_Settings::PREFIX_TAXONOMY . $taxonomy;
3410 + }
3411 + }
3412 + $archives = is_array($extended['post_type_archive_settings'] ?? null) ? $extended['post_type_archive_settings'] : [];
3413 + if (!empty($archives['post']['noindex'])) {
3414 + $entities[] = \ThinkRank\SEO\Content_Type_Settings::ENTITY_BLOG_INDEX;
3415 + }
3416 +
3417 + $written = false;
3418 + foreach ($entities as $entity_key) {
3419 + $existing = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
3420 + if (!empty($existing['robots_meta_enabled'])) {
3421 + continue;
3422 + }
3423 + $robots = \ThinkRank\SEO\Content_Type_Settings::default_robots_meta($entity_key);
3424 + $robots['index'] = false;
3425 + $robots['noindex'] = true;
3426 + if (\ThinkRank\SEO\Content_Type_Settings::update_entity_settings($entity_key, [
3427 + 'robots_meta_enabled' => true,
3428 + 'robots_meta' => $robots,
3429 + ])) {
3430 + $written = true;
3431 + }
3432 + }
3433 +
3434 + return $written;
2150 3435 }
2151 3436
2152 3437 /**
2153 3438 * Fold post IDs the source excluded from its sitemap into ThinkRank's