PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/admin/importers/class-snapshot-migrator.php +410 -42 2.7.0 → 2.11.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,9 @@
95 110 'role_capabilities',
96 111 'image_seo',
97 112 'sitemap_settings',
98 113 'analytics_connected',
114 + 'focus_pages',
99 115 // Capture-all raw buckets (whole source option sets stored verbatim).
100 116 // They live in the SNAPSHOT — cleanup never touches the snapshot — and
101 117 // a re-export recreates them, so they never block cleanup.
102 118 'raw_options',
@@ -158,16 +174,42 @@
158 174 if ($type === '404_logs') {
159 175 return $this->migrate_404_logs($plugin, $page);
160 176 }
161 177
178 + if ($type === Block_Converter::TYPE) {
179 + return $this->migrate_content_blocks($plugin, $page);
180 + }
181 +
162 182 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
163 183 if ($chunk === null || empty($chunk)) {
184 + // An empty chunk is not the end of the type. An exporter that pages
185 + // one shared table and then splits the rows by kind writes nothing
186 + // for a page whose rows all belonged to another kind — Squirrly
187 + // reads the whole `qss` table that way, so a site whose terms and
188 + // authors sit past the first page of posts has an empty chunk 1 and
189 + // its real records in chunk 2. Ending the loop here dropped them
190 + // silently, under a `complete` status, and cleanup then removed the
191 + // source copy. Keep asking while the manifest says there are more
192 + // chunks, exactly as the non-empty path below does.
193 + $total_chunks = (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
194 + $has_more = $page < $total_chunks;
195 +
196 + // The last chunk of a sparse export can legitimately be the empty
197 + // one — Squirrly's tail page holds only term rows — and it still
198 + // ends the migration, so release the editors that mark_bulk() set
199 + // polling. Without this they poll until the marker's own expiry.
200 + if ($type === 'postmeta' && !$has_more) {
201 + Metadata_Pending::clear_bulk();
202 + }
203 +
164 204 return [
165 - 'status' => 'complete',
166 - 'message' => 'No data in chunk',
167 - 'has_more' => false,
168 - 'processed' => 0,
169 - 'skipped' => 0,
205 + 'status' => $has_more ? 'processing' : 'complete',
206 + 'message' => sprintf('No data in chunk %d', $page),
207 + 'has_more' => $has_more,
208 + 'page' => $page,
209 + 'total_chunks' => $total_chunks,
210 + 'processed' => 0,
211 + 'skipped' => 0,
170 212 ];
171 213 }
172 214
173 215 // Tell an open editor that SEO meta is being written right now, so its
@@ -197,8 +239,18 @@
197 239 $skipped++;
198 240 continue;
199 241 }
200 242
243 + // The object has to still exist. A source that keys its SEO by URL
244 + // rather than by a foreign key keeps rows for content that was
245 + // deleted years ago — Squirrly's `qss` table is keyed on a URL hash
246 + // — and writing their meta creates orphan rows no screen can reach
247 + // and no uninstall sweeps, while reporting them as migrated.
248 + if (!$this->object_exists($object_type, $object_id)) {
249 + $skipped++;
250 + continue;
251 + }
252 +
201 253 // Track migrated posts so their SEO score can be computed once the
202 254 // chunk's meta has landed (terms are not scored).
203 255 if ($object_type === 'post') {
204 256 $post_ids[$object_id] = true;
@@ -209,8 +261,25 @@
209 261 // Collect focus keywords (primary + secondary) to seed the Pro
210 262 // Rank Tracker watch-list once the chunk is processed.
211 263 $this->collect_keywords($record, $data, $keywords);
212 264
265 + // Term social fields travel in `extended`, not `data`: only the
266 + // AIOSEO exporter puts them in the canonical bucket, the other four
267 + // put the identical values one level out. The loop below walks
268 + // `data`, so every term OG image and Twitter title/description was
269 + // exported and then dropped. Fold them in for terms, without
270 + // letting them win over a value the record already carries.
271 + if ($object_type === 'term') {
272 + $extended = is_array($record['extended'] ?? null) ? $record['extended'] : [];
273 + foreach (['og_title', 'og_description', 'og_image', 'twitter_title', 'twitter_description', 'twitter_image'] as $social_key) {
274 + if (!isset($data[$social_key]) || $data[$social_key] === '') {
275 + if (isset($extended[$social_key]) && $extended[$social_key] !== '') {
276 + $data[$social_key] = $extended[$social_key];
277 + }
278 + }
279 + }
280 + }
281 +
213 282 foreach ($data as $canonical_key => $value) {
214 283 if (!isset(self::META_MAP[$canonical_key])) {
215 284 continue;
216 285 }
@@ -216,10 +285,13 @@
216 285 }
217 286
218 287 // Focus keywords are migrated as an array via the dedicated
219 288 // 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') {
289 + // single-value meta in sync), so skip the scalar write here —
290 + // but that writer only runs for posts, so skipping it for a
291 + // term meant nobody wrote the term's focus keyword at all,
292 + // even though get-term-seo reads `_thinkrank_focus_keyword`.
293 + if ($canonical_key === 'focus_keyword' && $object_type === 'post') {
222 294 continue;
223 295 }
224 296
225 297 $thinkrank_key = self::META_MAP[$canonical_key];
@@ -233,8 +305,12 @@
233 305 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
234 306 continue;
235 307 }
236 308
309 + // The meta writers unslash their value, so every write below
310 + // slashes first. Unslashed, a title or description with a
311 + // backslash in it lost it on the way in, and a JSON value lost
312 + // the backslash of every `\"` and `\uXXXX` escape.
237 313 if ($object_type === 'post') {
238 314 // Never overwrite existing ThinkRank data
239 315 $existing = get_post_meta($object_id, $thinkrank_key, true);
240 316 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -240,9 +316,9 @@
240 316 if ($existing !== '' && $existing !== false && $existing !== null) {
241 317 continue;
242 318 }
243 319
244 - update_post_meta($object_id, $thinkrank_key, $value);
320 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
245 321 $record_had_writes = true;
246 322 } elseif ($object_type === 'term') {
247 323 $existing = get_term_meta($object_id, $thinkrank_key, true);
248 324 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -248,9 +324,9 @@
248 324 if ($existing !== '' && $existing !== false && $existing !== null) {
249 325 continue;
250 326 }
251 327
252 - update_term_meta($object_id, $thinkrank_key, $value);
328 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
253 329 $record_had_writes = true;
254 330 } elseif ($object_type === 'user') {
255 331 $existing = get_user_meta($object_id, $thinkrank_key, true);
256 332 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -256,9 +332,9 @@
256 332 if ($existing !== '' && $existing !== false && $existing !== null) {
257 333 continue;
258 334 }
259 335
260 - update_user_meta($object_id, $thinkrank_key, $value);
336 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
261 337 $record_had_writes = true;
262 338 }
263 339 }
264 340
@@ -272,8 +348,17 @@
272 348 if ($object_type === 'post' && $this->migrate_robots_payload($object_id, $data)) {
273 349 $record_had_writes = true;
274 350 }
275 351
352 + // The same directives for a term. Every exporter emits term
353 + // noindex/nofollow and ThinkRank stores them, but nothing wrote
354 + // them — so a category the owner had deliberately kept out of the
355 + // index came back indexable after the switch, which is the worst
356 + // way for an import to be wrong.
357 + if ($object_type === 'term' && $this->migrate_term_robots_payload($object_id, $data)) {
358 + $record_had_writes = true;
359 + }
360 +
276 361 // Pillar / cornerstone content flag (post meta only).
277 362 if ($object_type === 'post' && $this->migrate_pillar_content($object_id, $data)) {
278 363 $record_had_writes = true;
279 364 }
@@ -305,9 +390,18 @@
305 390 // Per-post "exclude from sitemap" flags. ThinkRank models sitemap
306 391 // exclusion as one comma-separated ID list on the sitemap settings
307 392 // rather than per-post meta, so collect the IDs and apply them once
308 393 // after the chunk (a settings write per post would be wasteful).
309 - if ($object_type === 'post' && !empty($record['extended']['exclude_sitemap'])) {
394 + // Two spellings reach here: Rank Math's exporter emits
395 + // `exclude_sitemap`, Squirrly's `exclude_from_sitemap`. Only the
396 + // first was read, so every Squirrly `nositemap` flag was dropped
397 + // and posts the owner had hidden reappeared in the sitemap. Accept
398 + // both rather than renaming one, because snapshots already exported
399 + // carry whichever spelling their exporter used at the time.
400 + $excluded_from_sitemap = !empty($record['extended']['exclude_sitemap'])
401 + || !empty($record['extended']['exclude_from_sitemap']);
402 +
403 + if ($object_type === 'post' && $excluded_from_sitemap) {
310 404 $sitemap_excluded[] = $object_id;
311 405 }
312 406
313 407 if ($record_had_writes) {
@@ -558,18 +652,22 @@
558 652 // strings; but the restore's whole policy is to write the file's value
559 653 // verbatim, and a file holding one as an array would otherwise raise a
560 654 // TypeError that takes down the rest of the chunk with it. One bad key
561 655 // is worth skipping, not the records behind it.
656 + //
657 + // wp_slash() because the meta writers unslash: a restored JSON value
658 + // (schema form data, robots) would otherwise lose the backslash of
659 + // every escaped quote and come back as invalid JSON.
562 660 try {
563 661 switch ($object_type) {
564 662 case 'post':
565 - update_post_meta($object_id, $key, $value);
663 + update_post_meta($object_id, $key, wp_slash($value));
566 664 return true;
567 665 case 'term':
568 - update_term_meta($object_id, $key, $value);
666 + update_term_meta($object_id, $key, wp_slash($value));
569 667 return true;
570 668 case 'user':
571 - update_user_meta($object_id, $key, $value);
669 + update_user_meta($object_id, $key, wp_slash($value));
572 670 return true;
573 671 }
574 672 } catch (\Throwable $e) {
575 673 if (defined('WP_DEBUG') && WP_DEBUG) {
@@ -1296,8 +1394,115 @@
1296 1394 return !is_wp_error($result);
1297 1395 }
1298 1396
1299 1397 /**
1398 + * The robots directives for a term.
1399 + *
1400 + * Deliberately narrower than migrate_robots_payload(): update-term-seo
1401 + * writes `_thinkrank_robots_meta` and `_thinkrank_robots_meta_enabled` and
1402 + * nothing else for a term, so the advanced directives a post supports have
1403 + * nowhere to go here and are left in the snapshot rather than written to a
1404 + * key no reader looks at.
1405 + *
1406 + * Same two rules as the post path — never overwrite an existing payload,
1407 + * and never turn the override on for an all-false set, which is just
1408 + * ThinkRank's default index/follow spelled out.
1409 + *
1410 + * @param int $term_id Target term ID.
1411 + * @param array $data Canonical record data.
1412 + * @return bool True when a payload was written.
1413 + */
1414 + private function migrate_term_robots_payload(int $term_id, array $data): bool {
1415 + $existing = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1416 + if (is_string($existing) && $existing !== '') {
1417 + return false;
1418 + }
1419 +
1420 + $robots = [];
1421 + $has_active_directive = false;
1422 +
1423 + foreach (self::ROBOTS_FIELDS as $field) {
1424 + if (!array_key_exists($field, $data)) {
1425 + continue;
1426 + }
1427 +
1428 + $value = $data[$field];
1429 + if ($value === '' || $value === null) {
1430 + continue;
1431 + }
1432 +
1433 + $robots[$field] = (bool) (int) $value;
1434 + if ($robots[$field]) {
1435 + $has_active_directive = true;
1436 + }
1437 + }
1438 +
1439 + if (!$has_active_directive) {
1440 + return false;
1441 + }
1442 +
1443 + $robots['index'] = empty($robots['noindex']);
1444 +
1445 + update_term_meta($term_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1446 + update_term_meta($term_id, '_thinkrank_robots_meta_enabled', 1);
1447 +
1448 + return true;
1449 + }
1450 +
1451 + /**
1452 + * Carry the source's watched pages into ThinkRank Pro's Focus Pages.
1453 + *
1454 + * Squirrly keeps this list on its own servers, so the exporter reads it
1455 + * live while the source plugin is still installed and connected — after
1456 + * the switch there is nowhere left to read it from. See
1457 + * Squirrly_Exporter::fetch_focus_pages().
1458 + *
1459 + * Focus Pages is a Pro feature and a deliberately small, hand-picked list
1460 + * (Settings::MAX_PAGES). Two rules follow from that: never touch a
1461 + * selection the user has already made here, and never import more than
1462 + * the cap. Without Pro the ids stay in the snapshot for a later run, the
1463 + * same way per-object redirects wait for Pro's rules table.
1464 + *
1465 + * @param array $extended Extended settings payload.
1466 + * @return bool True when at least one page was added.
1467 + */
1468 + private function migrate_focus_pages(array $extended): bool {
1469 + $ids = $extended['focus_pages'] ?? [];
1470 + if (!is_array($ids) || $ids === []) {
1471 + return false;
1472 + }
1473 +
1474 + if (!class_exists('ThinkRank\\Pro\\Focus_Pages\\Settings')) {
1475 + return false;
1476 + }
1477 +
1478 + $settings = new \ThinkRank\Pro\Focus_Pages\Settings();
1479 +
1480 + // A choice already made here outranks one carried over, exactly as
1481 + // every other field in this class treats an existing value.
1482 + if ($settings->get() !== []) {
1483 + return false;
1484 + }
1485 +
1486 + $added = false;
1487 + foreach ($ids as $id) {
1488 + $post_id = (int) $id;
1489 + if ($post_id <= 0 || get_post($post_id) === null) {
1490 + continue;
1491 + }
1492 +
1493 + if (method_exists($settings, 'is_full') && $settings->is_full()) {
1494 + break;
1495 + }
1496 +
1497 + $settings->add($post_id);
1498 + $added = true;
1499 + }
1500 +
1501 + return $added;
1502 + }
1503 +
1504 + /**
1300 1505 * Migrate the pillar / cornerstone content flag to ThinkRank post meta.
1301 1506 *
1302 1507 * ThinkRank stores an enabled flag as the string '1'; the reader
1303 1508 * (Pillar_Content endpoint) matches meta_value = '1'. Never overwrites an
@@ -1397,9 +1602,9 @@
1397 1602 if (is_string($existing) && $existing !== '') {
1398 1603 return false;
1399 1604 }
1400 1605
1401 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($review));
1606 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($review)));
1402 1607
1403 1608 return true;
1404 1609 }
1405 1610
@@ -1431,9 +1636,9 @@
1431 1636 if (is_string($existing) && $existing !== '') {
1432 1637 return false;
1433 1638 }
1434 1639
1435 - update_post_meta($post_id, '_thinkrank_schema_form_data', wp_json_encode($video));
1640 + update_post_meta($post_id, '_thinkrank_schema_form_data', wp_slash(wp_json_encode($video)));
1436 1641
1437 1642 return true;
1438 1643 }
1439 1644
@@ -1513,13 +1718,13 @@
1513 1718 }
1514 1719
1515 1720 $wrote = false;
1516 1721 if (!empty($robots)) {
1517 - update_post_meta($post_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1722 + update_post_meta($post_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1518 1723 $wrote = true;
1519 1724 }
1520 1725 if (!empty($advanced)) {
1521 - update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_json_encode($advanced));
1726 + update_post_meta($post_id, '_thinkrank_advanced_robots_meta', wp_slash(wp_json_encode($advanced)));
1522 1727 $wrote = true;
1523 1728 }
1524 1729 if ($wrote) {
1525 1730 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -1671,8 +1876,13 @@
1671 1876 if ($this->migrate_image_seo($extended)) {
1672 1877 $processed++;
1673 1878 }
1674 1879
1880 + // The pages the source had under active watch.
1881 + if ($this->migrate_focus_pages($extended)) {
1882 + $processed++;
1883 + }
1884 +
1675 1885 // Sitemap inclusion settings.
1676 1886 if ($this->migrate_sitemap($extended)) {
1677 1887 $processed++;
1678 1888 }
@@ -1740,18 +1950,33 @@
1740 1950 $manager = new \ThinkRank\SEO\Site_Identity_Manager();
1741 1951 $current = $manager->get_settings('site');
1742 1952
1743 1953 // 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 - ];
1954 + //
1955 + // The title formats come from Site_Identity_Manager rather than being
1956 + // restated here. They were restated once, drifted (the homepage seed
1957 + // still used a literal '|' after the shipped default moved to %sep%),
1958 + // and the six per-context formats below were never listed at all — so
1959 + // every shipped default read as "the user chose this" and no imported
1960 + // title format was ever written.
1961 + $seeds = array_merge(
1962 + \ThinkRank\SEO\Site_Identity_Manager::TITLE_FORMAT_DEFAULTS,
1963 + [
1964 + 'site_name' => get_bloginfo('name'),
1965 + 'logo_url' => '',
1966 + 'breadcrumb_home_text' => 'Home',
1967 + // Two shipped values, both untouched. get_default_settings()
1968 + // says '>' and the admin screen seeds '›' (as does the
1969 + // breadcrumb renderer's own fallback), so which one a site
1970 + // holds depends only on whether that screen has ever been
1971 + // saved. Recognising one and not the other would skip the
1972 + // imported separator on half of all installs.
1973 + 'breadcrumb_separator' => ['>', '›'],
1974 + 'business_type' => '',
1975 + 'business_name' => '',
1976 + 'business_phone' => '',
1977 + ]
1978 + );
1754 1979
1755 1980 $updates = [];
1756 1981 $set = static function (string $key, $value) use (&$updates, $current, $seeds): void {
1757 1982 if ($value === '' || $value === null) {
@@ -1757,9 +1982,14 @@
1757 1982 if ($value === '' || $value === null) {
1758 1983 return;
1759 1984 }
1760 1985 $cur = $current[$key] ?? null;
1761 - $is_default = !array_key_exists($key, $current) || $cur === '' || $cur === ($seeds[$key] ?? null);
1986 + // A seed may list several values when more than one shipped default
1987 + // is in circulation for the same field.
1988 + $shipped = array_key_exists($key, $seeds) ? (array) $seeds[$key] : [];
1989 + $is_default = !array_key_exists($key, $current)
1990 + || $cur === ''
1991 + || in_array($cur, $shipped, true);
1762 1992 if ($is_default) {
1763 1993 $updates[$key] = $value;
1764 1994 }
1765 1995 };
@@ -1972,10 +2202,14 @@
1972 2202 $updates = [];
1973 2203 if ($app_id !== '' && empty($current['facebook_app_id'])) {
1974 2204 $updates['facebook_app_id'] = $app_id;
1975 2205 }
1976 - if ($og_image !== '' && empty($current['default_image'])) {
1977 - $updates['default_image'] = $og_image;
2206 + // `default_og_image` is the key Social_Meta_Manager declares for the
2207 + // site context. `default_image` is only a legacy alias the front-end
2208 + // readers still honour — saving under it is discarded, because a key
2209 + // outside the allow-list never reaches the database.
2210 + if ($og_image !== '' && empty($current['default_og_image'])) {
2211 + $updates['default_og_image'] = $og_image;
1978 2212 }
1979 2213
1980 2214 if (empty($updates)) {
1981 2215 return false;
@@ -2226,18 +2460,40 @@
2226 2460 $skipped++;
2227 2461 }
2228 2462 }
2229 2463
2464 + // Report the same has_more every other type does. Hardcoding false
2465 + // here was invisible in the admin, which iterates 1..total_chunks, and
2466 + // silently truncated the MCP/ability import, which loops on has_more
2467 + // alone: a site with more than one chunk of rules got its first
2468 + // hundred and a clean `complete`.
2469 + $has_more = $page < (int) ($this->chunk_total($plugin, 'redirections'));
2470 +
2230 2471 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,
2472 + 'status' => $has_more ? 'processing' : 'complete',
2473 + 'message' => sprintf('Migrated %d redirections, skipped %d (page %d)', $processed, $skipped, $page),
2474 + 'has_more' => $has_more,
2475 + 'page' => $page,
2476 + 'total_chunks' => $this->chunk_total($plugin, 'redirections'),
2477 + 'processed' => $processed,
2478 + 'skipped' => $skipped,
2236 2479 ];
2237 2480 }
2238 2481
2239 2482 /**
2483 + * How many chunks the manifest declares for a type, or 0 when unknown.
2484 + *
2485 + * @param string $plugin Source slug.
2486 + * @param string $type Snapshot type.
2487 + * @return int
2488 + */
2489 + private function chunk_total(string $plugin, string $type): int {
2490 + $manifest = Snapshot_Store::get_manifest($plugin);
2491 +
2492 + return (int) ($manifest['types'][$type]['total_chunks'] ?? 0);
2493 + }
2494 +
2495 + /**
2240 2496 * Migrate a chunk of logged 404 hits into ThinkRank Pro's 404 Monitor.
2241 2497 * Pro-gated exactly like migrate_redirections().
2242 2498 *
2243 2499 * @param string $plugin Plugin slug
@@ -2287,14 +2543,126 @@
2287 2543 $skipped++;
2288 2544 }
2289 2545 }
2290 2546
2547 + $has_more = $page < $this->chunk_total($plugin, '404_logs');
2548 +
2291 2549 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,
2550 + 'status' => $has_more ? 'processing' : 'complete',
2551 + 'message' => sprintf('Migrated %d 404 logs, skipped %d (page %d)', $processed, $skipped, $page),
2552 + 'has_more' => $has_more,
2553 + 'page' => $page,
2554 + 'total_chunks' => $this->chunk_total($plugin, '404_logs'),
2555 + 'processed' => $processed,
2556 + 'skipped' => $skipped,
2557 + ];
2558 + }
2559 +
2560 + /**
2561 + * Rewrite a chunk of posts' Rank Math FAQ / HowTo blocks into ThinkRank's
2562 + * own blocks.
2563 + *
2564 + * Unlike every other type here this does not write meta — it edits
2565 + * `post_content` in place, because that is where the blocks live. The
2566 + * snapshot chunk carries post ids only, so the conversion always runs
2567 + * against the post as it stands now rather than a stale copy.
2568 + *
2569 + * The conflict strategy is deliberately ignored. A Rank Math block and a
2570 + * ThinkRank block are not two values competing for one field: the Rank Math
2571 + * one is broken markup that needs replacing, and any ThinkRank block
2572 + * already in the post is simply left alone by the converter.
2573 + *
2574 + * @param string $plugin Plugin slug
2575 + * @param int $page Chunk number
2576 + * @return array Migration result
2577 + */
2578 + private function migrate_content_blocks(string $plugin, int $page): array {
2579 + $chunk = Snapshot_Store::read_chunk($plugin, Block_Converter::TYPE, $page);
2580 + $total_chunks = $this->chunk_total($plugin, Block_Converter::TYPE);
2581 +
2582 + if ($chunk === null || empty($chunk)) {
2583 + $has_more = $page < $total_chunks;
2584 +
2585 + return [
2586 + 'status' => $has_more ? 'processing' : 'complete',
2587 + 'message' => 'No content blocks in chunk',
2588 + 'has_more' => $has_more,
2589 + 'page' => $page,
2590 + 'processed' => 0,
2591 + 'skipped' => 0,
2592 + 'failed' => 0,
2593 + 'failures' => [],
2594 + ];
2595 + }
2596 +
2597 + // Rewriting a few hundred posts is well past the default execution
2598 + // window on shared hosting, and a timeout mid-chunk would leave the
2599 + // migration looking stalled.
2600 + if (function_exists('set_time_limit')) {
2601 + @set_time_limit(300); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
2602 + }
2603 +
2604 + $processed = 0;
2605 + $skipped = 0;
2606 + $failed = 0;
2607 + $failures = [];
2608 + $blocks = 0;
2609 +
2610 + foreach ($chunk as $record) {
2611 + $post_id = (int) ($record['object_id'] ?? ($record['data']['post_id'] ?? 0));
2612 + if ($post_id < 1) {
2613 + $skipped++;
2614 + continue;
2615 + }
2616 +
2617 + $result = Block_Converter::convert_post($post_id);
2618 +
2619 + if ('converted' === $result['status']) {
2620 + $processed++;
2621 + $blocks += $result['converted'];
2622 + continue;
2623 + }
2624 +
2625 + // A post the converter refused (broken block markup, a PCRE
2626 + // failure) was left untouched and still holds its Rank Math
2627 + // blocks. Folding it into `skipped` made it indistinguishable from
2628 + // a post that was simply already converted, so it is counted and
2629 + // named on its own.
2630 + if ('error' === $result['status']) {
2631 + $failed++;
2632 + $failures[] = ['post_id' => $post_id, 'message' => $result['message']];
2633 + continue;
2634 + }
2635 +
2636 + // `unchanged` is the normal outcome of a re-run, not a failure.
2637 + $skipped++;
2638 + }
2639 +
2640 + $has_more = $page < $total_chunks;
2641 +
2642 + if (!$has_more) {
2643 + // The detector caches its scan for an hour; without clearing it the
2644 + // Migration screen keeps offering blocks that are no longer there.
2645 + (new Import_Detector())->clear_cache();
2646 + }
2647 +
2648 + return [
2649 + 'status' => $has_more ? 'processing' : 'complete',
2650 + 'message' => sprintf(
2651 + 'Converted %d FAQ/HowTo blocks in %d posts, skipped %d, failed %d (page %d)',
2652 + $blocks,
2653 + $processed,
2654 + $skipped,
2655 + $failed,
2656 + $page
2657 + ),
2658 + 'has_more' => $has_more,
2659 + 'page' => $page,
2660 + 'total_chunks' => $total_chunks,
2661 + 'processed' => $processed,
2662 + 'skipped' => $skipped,
2663 + 'failed' => $failed,
2664 + 'failures' => $failures,
2297 2665 ];
2298 2666 }
2299 2667
2300 2668 /**