PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/admin/importers/class-snapshot-migrator.php +523 -32 2.9.0 → 2.14.1 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
@@ -96,8 +111,15 @@
96 111 'image_seo',
97 112 'sitemap_settings',
98 113 'analytics_connected',
99 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',
100 122 // Capture-all raw buckets (whole source option sets stored verbatim).
101 123 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
102 124 // a re-export recreates them, so they never block cleanup.
103 125 'raw_options',
@@ -159,8 +181,12 @@
159 181 if ($type === '404_logs') {
160 182 return $this->migrate_404_logs($plugin, $page);
161 183 }
162 184
185 + if ($type === Block_Converter::TYPE) {
186 + return $this->migrate_content_blocks($plugin, $page);
187 + }
188 +
163 189 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
164 190 if ($chunk === null || empty($chunk)) {
165 191 // An empty chunk is not the end of the type. An exporter that pages
166 192 // one shared table and then splits the rows by kind writes nothing
@@ -286,8 +312,12 @@
286 312 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
287 313 continue;
288 314 }
289 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.
290 320 if ($object_type === 'post') {
291 321 // Never overwrite existing ThinkRank data
292 322 $existing = get_post_meta($object_id, $thinkrank_key, true);
293 323 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -293,9 +323,9 @@
293 323 if ($existing !== '' && $existing !== false && $existing !== null) {
294 324 continue;
295 325 }
296 326
297 - update_post_meta($object_id, $thinkrank_key, $value);
327 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
298 328 $record_had_writes = true;
299 329 } elseif ($object_type === 'term') {
300 330 $existing = get_term_meta($object_id, $thinkrank_key, true);
301 331 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -301,9 +331,9 @@
301 331 if ($existing !== '' && $existing !== false && $existing !== null) {
302 332 continue;
303 333 }
304 334
305 - update_term_meta($object_id, $thinkrank_key, $value);
335 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
306 336 $record_had_writes = true;
307 337 } elseif ($object_type === 'user') {
308 338 $existing = get_user_meta($object_id, $thinkrank_key, true);
309 339 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -309,9 +339,9 @@
309 339 if ($existing !== '' && $existing !== false && $existing !== null) {
310 340 continue;
311 341 }
312 342
313 - update_user_meta($object_id, $thinkrank_key, $value);
343 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
314 344 $record_had_writes = true;
315 345 }
316 346 }
317 347
@@ -351,8 +381,15 @@
351 381 if ($object_type === 'post' && $this->migrate_video_schema($object_id, $data, $record)) {
352 382 $record_had_writes = true;
353 383 }
354 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 +
355 392 // Per-object redirect. SEOPress is the one source that stores a
356 393 // redirect as object meta rather than in a rules table, so its
357 394 // per-post redirects were exported into extended.redirect_* and
358 395 // then dropped for want of anywhere to put them. They have a home
@@ -629,18 +666,22 @@
629 666 // strings; but the restore's whole policy is to write the file's value
630 667 // verbatim, and a file holding one as an array would otherwise raise a
631 668 // TypeError that takes down the rest of the chunk with it. One bad key
632 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.
633 674 try {
634 675 switch ($object_type) {
635 676 case 'post':
636 - update_post_meta($object_id, $key, $value);
677 + update_post_meta($object_id, $key, wp_slash($value));
637 678 return true;
638 679 case 'term':
639 - update_term_meta($object_id, $key, $value);
680 + update_term_meta($object_id, $key, wp_slash($value));
640 681 return true;
641 682 case 'user':
642 - update_user_meta($object_id, $key, $value);
683 + update_user_meta($object_id, $key, wp_slash($value));
643 684 return true;
644 685 }
645 686 } catch (\Throwable $e) {
646 687 if (defined('WP_DEBUG') && WP_DEBUG) {
@@ -1414,9 +1455,9 @@
1414 1455 }
1415 1456
1416 1457 $robots['index'] = empty($robots['noindex']);
1417 1458
1418 - update_term_meta($term_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1459 + update_term_meta($term_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1419 1460 update_term_meta($term_id, '_thinkrank_robots_meta_enabled', 1);
1420 1461
1421 1462 return true;
1422 1463 }
@@ -1575,9 +1616,9 @@
1575 1616 if (is_string($existing) && $existing !== '') {
1576 1617 return false;
1577 1618 }
1578 1619
1579 - 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)));
1580 1621
1581 1622 return true;
1582 1623 }
1583 1624
@@ -1609,9 +1650,9 @@
1609 1650 if (is_string($existing) && $existing !== '') {
1610 1651 return false;
1611 1652 }
1612 1653
1613 - 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)));
1614 1655
1615 1656 return true;
1616 1657 }
1617 1658
@@ -1691,13 +1732,13 @@
1691 1732 }
1692 1733
1693 1734 $wrote = false;
1694 1735 if (!empty($robots)) {
1695 - 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)));
1696 1737 $wrote = true;
1697 1738 }
1698 1739 if (!empty($advanced)) {
1699 - 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)));
1700 1741 $wrote = true;
1701 1742 }
1702 1743 if ($wrote) {
1703 1744 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -1738,9 +1779,9 @@
1738 1779 $processed++;
1739 1780 }
1740 1781 }
1741 1782
1742 - 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'])) {
1743 1784 $site_identity = get_option('thinkrank_site_identity_settings', []);
1744 1785 $updated = false;
1745 1786
1746 1787 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
@@ -1746,12 +1787,8 @@
1746 1787 if (!empty($data['homepage_title']) && empty($site_identity['homepage_title'])) {
1747 1788 $site_identity['homepage_title'] = $data['homepage_title'];
1748 1789 $updated = true;
1749 1790 }
1750 - if (!empty($data['homepage_description']) && empty($site_identity['homepage_description'])) {
1751 - $site_identity['homepage_description'] = $data['homepage_description'];
1752 - $updated = true;
1753 - }
1754 1791 if (!empty($data['organization_name']) && empty($site_identity['organization_name'])) {
1755 1792 $site_identity['organization_name'] = $data['organization_name'];
1756 1793 $updated = true;
1757 1794 }
@@ -1765,21 +1802,10 @@
1765 1802 $processed++;
1766 1803 }
1767 1804 }
1768 1805
1769 - if (!empty($data['social_profiles'])) {
1770 - $social = get_option('thinkrank_social_media_settings', []);
1771 - $updated = false;
1772 -
1773 - foreach ($data['social_profiles'] as $platform => $url) {
1774 - if (!empty($url) && empty($social[$platform])) {
1775 - $social[$platform] = $url;
1776 - $updated = true;
1777 - }
1778 - }
1779 -
1780 - if ($updated) {
1781 - 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'])) {
1782 1808 $processed++;
1783 1809 }
1784 1810 }
1785 1811
@@ -1836,8 +1862,23 @@
1836 1862 $processed++;
1837 1863 }
1838 1864 }
1839 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 +
1840 1881 // Site-identity settings (homepage/org/breadcrumbs/local SEO) are served to
1841 1882 // the frontend from the wp_thinkrank_seo_settings table via the manager, not
1842 1883 // from the option written above — route them through the manager so they
1843 1884 // actually take effect.
@@ -1973,8 +2014,12 @@
1973 2014 // real template and beats the literal-resolved data.homepage_title, so it
1974 2015 // wins when the source provided one.
1975 2016 $title_formats = is_array($extended['title_formats'] ?? null) ? $extended['title_formats'] : [];
1976 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'] ?? '')));
1977 2022 $set('site_name', $data['organization_name'] ?? '');
1978 2023 $set('alternate_name', $data['alternate_name'] ?? '');
1979 2024 $set('logo_url', $data['organization_logo'] ?? '');
1980 2025
@@ -2016,8 +2061,23 @@
2016 2061 $set('breadcrumb_separator', $breadcrumbs['separator'] ?? '');
2017 2062 $set('breadcrumb_prefix', $breadcrumbs['prefix'] ?? '');
2018 2063 }
2019 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 +
2020 2080 // Local SEO (extended.local_seo) — migrate the full NAP + geo when there
2021 2081 // is any meaningful business data (name, phone, address or coordinates),
2022 2082 // and enable the feature alongside it. Each field is written only while
2023 2083 // ThinkRank's Business Info still holds its default (non-destructive).
@@ -2164,9 +2224,10 @@
2164 2224 }
2165 2225
2166 2226 $app_id = trim((string) ($defaults['facebook_app_id'] ?? ''));
2167 2227 $og_image = trim((string) ($defaults['og_default_image'] ?? ''));
2168 - if ($app_id === '' && $og_image === '') {
2228 + $twitter_image = trim((string) ($defaults['twitter_default_image'] ?? ''));
2229 + if ($app_id === '' && $og_image === '' && $twitter_image === '') {
2169 2230 return false;
2170 2231 }
2171 2232
2172 2233 $manager = new \ThinkRank\SEO\Social_Meta_Manager();
@@ -2182,8 +2243,12 @@
2182 2243 // outside the allow-list never reaches the database.
2183 2244 if ($og_image !== '' && empty($current['default_og_image'])) {
2184 2245 $updates['default_og_image'] = $og_image;
2185 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 + }
2186 2251
2187 2252 if (empty($updates)) {
2188 2253 return false;
2189 2254 }
@@ -2191,8 +2256,97 @@
2191 2256 return (bool) $manager->save_settings('site', null, $updates);
2192 2257 }
2193 2258
2194 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 + /**
2195 2349 * Migrate the source plugin's Pinterest site-verification code into
2196 2350 * ThinkRank's core `pinterest_site_verification` setting. Never overwrites
2197 2351 * a configured code.
2198 2352 *
@@ -2530,8 +2684,116 @@
2530 2684 ];
2531 2685 }
2532 2686
2533 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 + /**
2534 2796 * Build ThinkRank Pro's Redirections store, when Pro is active.
2535 2797 *
2536 2798 * Split out (and protected) so tests can substitute a fake — the real store
2537 2799 * writes to Pro's tables. Pro lazily creates them via Schema::ensure().
@@ -2931,11 +3193,39 @@
2931 3193 foreach ($extended as $key => $value) {
2932 3194 if (in_array($key, self::HANDLED_EXTENDED_SETTINGS, true) || empty($value)) {
2933 3195 continue;
2934 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 + }
2935 3225 $buckets[] = [
2936 3226 'key' => 'settings.' . $key,
2937 - 'label' => (string) $key,
3227 + 'label' => self::extended_bucket_label((string) $key) ?? ucfirst(str_replace('_', ' ', (string) $key)),
2938 3228 'count' => 1,
2939 3229 ];
2940 3230 }
2941 3231 }
@@ -2940,8 +3230,209 @@
2940 3230 }
2941 3231 }
2942 3232
2943 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;
2944 3435 }
2945 3436
2946 3437 /**
2947 3438 * Fold post IDs the source excluded from its sitemap into ThinkRank's