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 +786 -62 2.7.0 → 2.13.0 View file →
@@ -54,8 +54,15 @@
54 54 'twitter_description' => '_thinkrank_twitter_description',
55 55 'twitter_image' => '_thinkrank_twitter_image',
56 56 'primary_category' => '_thinkrank_primary_category',
57 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',
58 65 ];
59 66
60 67 /**
61 68 * Canonical robots meta fields. Composed into JSON-encoded
@@ -72,9 +79,17 @@
72 79
73 80 /**
74 81 * Data types that are migratable (have post/term/user meta mappings)
75 82 */
76 - 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 + ];
77 92
78 93 /**
79 94 * Settings-record `extended` keys that either migrate today or are safe to
80 95 * discard on cleanup (raw_options is pure capture-all insurance; a fresh
@@ -95,8 +110,16 @@
95 110 'role_capabilities',
96 111 'image_seo',
97 112 'sitemap_settings',
98 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',
99 122 // Capture-all raw buckets (whole source option sets stored verbatim).
100 123 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
101 124 // a re-export recreates them, so they never block cleanup.
102 125 'raw_options',
@@ -158,16 +181,42 @@
158 181 if ($type === '404_logs') {
159 182 return $this->migrate_404_logs($plugin, $page);
160 183 }
161 184
185 + if ($type === Block_Converter::TYPE) {
186 + return $this->migrate_content_blocks($plugin, $page);
187 + }
188 +
162 189 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
163 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 +
164 211 return [
165 - 'status' => 'complete',
166 - 'message' => 'No data in chunk',
167 - 'has_more' => false,
168 - 'processed' => 0,
169 - '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,
170 219 ];
171 220 }
172 221
173 222 // Tell an open editor that SEO meta is being written right now, so its
@@ -197,8 +246,18 @@
197 246 $skipped++;
198 247 continue;
199 248 }
200 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 +
201 260 // Track migrated posts so their SEO score can be computed once the
202 261 // chunk's meta has landed (terms are not scored).
203 262 if ($object_type === 'post') {
204 263 $post_ids[$object_id] = true;
@@ -209,8 +268,25 @@
209 268 // Collect focus keywords (primary + secondary) to seed the Pro
210 269 // Rank Tracker watch-list once the chunk is processed.
211 270 $this->collect_keywords($record, $data, $keywords);
212 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 +
213 289 foreach ($data as $canonical_key => $value) {
214 290 if (!isset(self::META_MAP[$canonical_key])) {
215 291 continue;
216 292 }
@@ -216,10 +292,13 @@
216 292 }
217 293
218 294 // Focus keywords are migrated as an array via the dedicated
219 295 // migrate_focus_keywords() below (which also keeps the legacy
220 - // single-value meta in sync), so skip the scalar write here.
221 - 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') {
222 301 continue;
223 302 }
224 303
225 304 $thinkrank_key = self::META_MAP[$canonical_key];
@@ -233,8 +312,12 @@
233 312 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
234 313 continue;
235 314 }
236 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.
237 320 if ($object_type === 'post') {
238 321 // Never overwrite existing ThinkRank data
239 322 $existing = get_post_meta($object_id, $thinkrank_key, true);
240 323 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -240,9 +323,9 @@
240 323 if ($existing !== '' && $existing !== false && $existing !== null) {
241 324 continue;
242 325 }
243 326
244 - update_post_meta($object_id, $thinkrank_key, $value);
327 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
245 328 $record_had_writes = true;
246 329 } elseif ($object_type === 'term') {
247 330 $existing = get_term_meta($object_id, $thinkrank_key, true);
248 331 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -248,9 +331,9 @@
248 331 if ($existing !== '' && $existing !== false && $existing !== null) {
249 332 continue;
250 333 }
251 334
252 - update_term_meta($object_id, $thinkrank_key, $value);
335 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
253 336 $record_had_writes = true;
254 337 } elseif ($object_type === 'user') {
255 338 $existing = get_user_meta($object_id, $thinkrank_key, true);
256 339 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -256,9 +339,9 @@
256 339 if ($existing !== '' && $existing !== false && $existing !== null) {
257 340 continue;
258 341 }
259 342
260 - update_user_meta($object_id, $thinkrank_key, $value);
343 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
261 344 $record_had_writes = true;
262 345 }
263 346 }
264 347
@@ -272,8 +355,17 @@
272 355 if ($object_type === 'post' && $this->migrate_robots_payload($object_id, $data)) {
273 356 $record_had_writes = true;
274 357 }
275 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 +
276 368 // Pillar / cornerstone content flag (post meta only).
277 369 if ($object_type === 'post' && $this->migrate_pillar_content($object_id, $data)) {
278 370 $record_had_writes = true;
279 371 }
@@ -289,8 +381,15 @@
289 381 if ($object_type === 'post' && $this->migrate_video_schema($object_id, $data, $record)) {
290 382 $record_had_writes = true;
291 383 }
292 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 +
293 392 // Per-object redirect. SEOPress is the one source that stores a
294 393 // redirect as object meta rather than in a rules table, so its
295 394 // per-post redirects were exported into extended.redirect_* and
296 395 // then dropped for want of anywhere to put them. They have a home
@@ -305,9 +404,18 @@
305 404 // Per-post "exclude from sitemap" flags. ThinkRank models sitemap
306 405 // exclusion as one comma-separated ID list on the sitemap settings
307 406 // rather than per-post meta, so collect the IDs and apply them once
308 407 // after the chunk (a settings write per post would be wasteful).
309 - 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) {
310 418 $sitemap_excluded[] = $object_id;
311 419 }
312 420
313 421 if ($record_had_writes) {
@@ -558,18 +666,22 @@
558 666 // strings; but the restore's whole policy is to write the file's value
559 667 // verbatim, and a file holding one as an array would otherwise raise a
560 668 // TypeError that takes down the rest of the chunk with it. One bad key
561 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.
562 674 try {
563 675 switch ($object_type) {
564 676 case 'post':
565 - update_post_meta($object_id, $key, $value);
677 + update_post_meta($object_id, $key, wp_slash($value));
566 678 return true;
567 679 case 'term':
568 - update_term_meta($object_id, $key, $value);
680 + update_term_meta($object_id, $key, wp_slash($value));
569 681 return true;
570 682 case 'user':
571 - update_user_meta($object_id, $key, $value);
683 + update_user_meta($object_id, $key, wp_slash($value));
572 684 return true;
573 685 }
574 686 } catch (\Throwable $e) {
575 687 if (defined('WP_DEBUG') && WP_DEBUG) {
@@ -1296,8 +1408,115 @@
1296 1408 return !is_wp_error($result);
1297 1409 }
1298 1410
1299 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 + /**
1300 1519 * Migrate the pillar / cornerstone content flag to ThinkRank post meta.
1301 1520 *
1302 1521 * ThinkRank stores an enabled flag as the string '1'; the reader
1303 1522 * (Pillar_Content endpoint) matches meta_value = '1'. Never overwrites an
@@ -1397,9 +1616,9 @@
1397 1616 if (is_string($existing) && $existing !== '') {
1398 1617 return false;
1399 1618 }
1400 1619
1401 - 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)));
1402 1621
1403 1622 return true;
1404 1623 }
1405 1624
@@ -1431,9 +1650,9 @@
1431 1650 if (is_string($existing) && $existing !== '') {
1432 1651 return false;
1433 1652 }
1434 1653
1435 - 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)));
1436 1655
1437 1656 return true;
1438 1657 }
1439 1658
@@ -1513,13 +1732,13 @@
1513 1732 }
1514 1733
1515 1734 $wrote = false;
1516 1735 if (!empty($robots)) {
1517 - 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)));
1518 1737 $wrote = true;
1519 1738 }
1520 1739 if (!empty($advanced)) {
1521 - 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)));
1522 1741 $wrote = true;
1523 1742 }
1524 1743 if ($wrote) {
1525 1744 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -1560,9 +1779,9 @@
1560 1779 $processed++;
1561 1780 }
1562 1781 }
1563 1782
1564 - 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'])) {
1565 1784 $site_identity = get_option('thinkrank_site_identity_settings', []);
1566 1785 $updated = false;
1567 1786
1568 1787 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
@@ -1568,12 +1787,8 @@
1568 1787 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
1569 1788 $site_identity['homepage_title'] = $data['homepage_title'];
1570 1789 $updated = true;
1571 1790 }
1572 - if (!empty($data['homepage_description']) && empty($site_identity['homepage_description'])) {
1573 - $site_identity['homepage_description'] = $data['homepage_description'];
1574 - $updated = true;
1575 - }
1576 1791 if (!empty($data['organization_name']) && empty($site_identity['organization_name'])) {
1577 1792 $site_identity['organization_name'] = $data['organization_name'];
1578 1793 $updated = true;
1579 1794 }
@@ -1587,21 +1802,10 @@
1587 1802 $processed++;
1588 1803 }
1589 1804 }
1590 1805
1591 - if (!empty($data['social_profiles'])) {
1592 - $social = get_option('thinkrank_social_media_settings', []);
1593 - $updated = false;
1594 -
1595 - foreach ($data['social_profiles'] as $platform => $url) {
1596 - if (!empty($url) && empty($social[$platform])) {
1597 - $social[$platform] = $url;
1598 - $updated = true;
1599 - }
1600 - }
1601 -
1602 - if ($updated) {
1603 - 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'])) {
1604 1808 $processed++;
1605 1809 }
1606 1810 }
1607 1811
@@ -1658,8 +1862,23 @@
1658 1862 $processed++;
1659 1863 }
1660 1864 }
1661 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 +
1662 1881 // Site-identity settings (homepage/org/breadcrumbs/local SEO) are served to
1663 1882 // the frontend from the wp_thinkrank_seo_settings table via the manager, not
1664 1883 // from the option written above — route them through the manager so they
1665 1884 // actually take effect.
@@ -1671,8 +1890,13 @@
1671 1890 if ($this->migrate_image_seo($extended)) {
1672 1891 $processed++;
1673 1892 }
1674 1893
1894 + // The pages the source had under active watch.
1895 + if ($this->migrate_focus_pages($extended)) {
1896 + $processed++;
1897 + }
1898 +
1675 1899 // Sitemap inclusion settings.
1676 1900 if ($this->migrate_sitemap($extended)) {
1677 1901 $processed++;
1678 1902 }
@@ -1740,18 +1964,33 @@
1740 1964 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
1741 1965 $current = $manager->get_settings('site');
1742 1966
1743 1967 // ThinkRank default seeds — only overwrite a value the user has not changed.
1744 - $seeds = [
1745 - 'homepage_title' => '%site_title% | %site_description%',
1746 - 'site_name' => get_bloginfo('name'),
1747 - 'logo_url' => '',
1748 - 'breadcrumb_home_text' => 'Home',
1749 - 'breadcrumb_separator' => '>',
1750 - 'business_type' => '',
1751 - 'business_name' => '',
1752 - 'business_phone' => '',
1753 - ];
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 + );
1754 1993
1755 1994 $updates = [];
1756 1995 $set = static function (string $key, $value) use (&$updates, $current, $seeds): void {
1757 1996 if ($value === '' || $value === null) {
@@ -1757,9 +1996,14 @@
1757 1996 if ($value === '' || $value === null) {
1758 1997 return;
1759 1998 }
1760 1999 $cur = $current[$key] ?? null;
1761 - $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);
1762 2006 if ($is_default) {
1763 2007 $updates[$key] = $value;
1764 2008 }
1765 2009 };
@@ -1770,8 +2014,12 @@
1770 2014 // real template and beats the literal-resolved data.homepage_title, so it
1771 2015 // wins when the source provided one.
1772 2016 $title_formats = is_array($extended['title_formats'] ?? null) ? $extended['title_formats'] : [];
1773 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'] ?? '')));
1774 2022 $set('site_name', $data['organization_name'] ?? '');
1775 2023 $set('alternate_name', $data['alternate_name'] ?? '');
1776 2024 $set('logo_url', $data['organization_logo'] ?? '');
1777 2025
@@ -1813,8 +2061,23 @@
1813 2061 $set('breadcrumb_separator', $breadcrumbs['separator'] ?? '');
1814 2062 $set('breadcrumb_prefix', $breadcrumbs['prefix'] ?? '');
1815 2063 }
1816 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 +
1817 2080 // Local SEO (extended.local_seo) — migrate the full NAP + geo when there
1818 2081 // is any meaningful business data (name, phone, address or coordinates),
1819 2082 // and enable the feature alongside it. Each field is written only while
1820 2083 // ThinkRank's Business Info still holds its default (non-destructive).
@@ -1961,9 +2224,10 @@
1961 2224 }
1962 2225
1963 2226 $app_id = trim((string) ($defaults['facebook_app_id'] ?? ''));
1964 2227 $og_image = trim((string) ($defaults['og_default_image'] ?? ''));
1965 - if ($app_id === '' && $og_image === '') {
2228 + $twitter_image = trim((string) ($defaults['twitter_default_image'] ?? ''));
2229 + if ($app_id === '' && $og_image === '' && $twitter_image === '') {
1966 2230 return false;
1967 2231 }
1968 2232
1969 2233 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
@@ -1972,11 +2236,19 @@
1972 2236 $updates = [];
1973 2237 if ($app_id !== '' && empty($current['facebook_app_id'])) {
1974 2238 $updates['facebook_app_id'] = $app_id;
1975 2239 }
1976 - if ($og_image !== '' && empty($current['default_image'])) {
1977 - $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;
1978 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 + }
1979 2251
1980 2252 if (empty($updates)) {
1981 2253 return false;
1982 2254 }
@@ -1984,8 +2256,97 @@
1984 2256 return (bool) $manager->save_settings('site', null, $updates);
1985 2257 }
1986 2258
1987 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 + /**
1988 2349 * Migrate the source plugin's Pinterest site-verification code into
1989 2350 * ThinkRank's core `pinterest_site_verification` setting. Never overwrites
1990 2351 * a configured code.
1991 2352 *
@@ -2226,18 +2587,40 @@
2226 2587 $skipped++;
2227 2588 }
2228 2589 }
2229 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 +
2230 2598 return [
2231 - 'status' => 'complete',
2232 - 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
2233 - 'has_more' => false,
2234 - 'processed' => $processed,
2235 - '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,
2236 2606 ];
2237 2607 }
2238 2608
2239 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 + /**
2240 2623 * Migrate a chunk of logged 404 hits into ThinkRank Pro's 404 Monitor.
2241 2624 * Pro-gated exactly like migrate_redirections().
2242 2625 *
2243 2626 * @param string $plugin Plugin slug
@@ -2287,18 +2670,130 @@
2287 2670 $skipped++;
2288 2671 }
2289 2672 }
2290 2673
2674 + $has_more = $page < $this->chunk_total($plugin, '404_logs');
2675 +
2291 2676 return [
2292 - 'status' => 'complete',
2293 - 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
2294 - 'has_more' => false,
2295 - 'processed' => $processed,
2296 - '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,
2297 2684 ];
2298 2685 }
2299 2686
2300 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 + /**
2301 2796 * Build ThinkRank Pro's Redirections store, when Pro is active.
2302 2797 *
2303 2798 * Split out (and protected) so tests can substitute a fake — the real store
2304 2799 * writes to Pro's tables. Pro lazily creates them via Schema::ensure().
@@ -2698,11 +3193,39 @@
2698 3193 foreach ($extended as $key => $value) {
2699 3194 if (in_array($key, self::HANDLED_EXTENDED_SETTINGS, true) || empty($value)) {
2700 3195 continue;
2701 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 + }
2702 3225 $buckets[] = [
2703 3226 'key' => 'settings.' . $key,
2704 - 'label' => (string) $key,
3227 + 'label' => self::extended_bucket_label((string) $key) ?? ucfirst(str_replace('_', ' ', (string) $key)),
2705 3228 'count' => 1,
2706 3229 ];
2707 3230 }
2708 3231 }
@@ -2707,8 +3230,209 @@
2707 3230 }
2708 3231 }
2709 3232
2710 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;
2711 3435 }
2712 3436
2713 3437 /**
2714 3438 * Fold post IDs the source excluded from its sitemap into ThinkRank's