PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/admin/importers/class-snapshot-migrator.php +147 -12 2.8.0 → 2.12.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
@@ -159,8 +174,12 @@
159 174 if ($type === '404_logs') {
160 175 return $this->migrate_404_logs($plugin, $page);
161 176 }
162 177
178 + if ($type === Block_Converter::TYPE) {
179 + return $this->migrate_content_blocks($plugin, $page);
180 + }
181 +
163 182 $chunk = Snapshot_Store::read_chunk($plugin, $type, $page);
164 183 if ($chunk === null || empty($chunk)) {
165 184 // An empty chunk is not the end of the type. An exporter that pages
166 185 // one shared table and then splits the rows by kind writes nothing
@@ -286,8 +305,12 @@
286 305 if ($value === 0 && in_array($canonical_key, ['primary_category'], true)) {
287 306 continue;
288 307 }
289 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.
290 313 if ($object_type === 'post') {
291 314 // Never overwrite existing ThinkRank data
292 315 $existing = get_post_meta($object_id, $thinkrank_key, true);
293 316 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -293,9 +316,9 @@
293 316 if ($existing !== '' && $existing !== false && $existing !== null) {
294 317 continue;
295 318 }
296 319
297 - update_post_meta($object_id, $thinkrank_key, $value);
320 + update_post_meta($object_id, $thinkrank_key, wp_slash($value));
298 321 $record_had_writes = true;
299 322 } elseif ($object_type === 'term') {
300 323 $existing = get_term_meta($object_id, $thinkrank_key, true);
301 324 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -301,9 +324,9 @@
301 324 if ($existing !== '' && $existing !== false && $existing !== null) {
302 325 continue;
303 326 }
304 327
305 - update_term_meta($object_id, $thinkrank_key, $value);
328 + update_term_meta($object_id, $thinkrank_key, wp_slash($value));
306 329 $record_had_writes = true;
307 330 } elseif ($object_type === 'user') {
308 331 $existing = get_user_meta($object_id, $thinkrank_key, true);
309 332 if ($existing !== '' && $existing !== false && $existing !== null) {
@@ -309,9 +332,9 @@
309 332 if ($existing !== '' && $existing !== false && $existing !== null) {
310 333 continue;
311 334 }
312 335
313 - update_user_meta($object_id, $thinkrank_key, $value);
336 + update_user_meta($object_id, $thinkrank_key, wp_slash($value));
314 337 $record_had_writes = true;
315 338 }
316 339 }
317 340
@@ -629,18 +652,22 @@
629 652 // strings; but the restore's whole policy is to write the file's value
630 653 // verbatim, and a file holding one as an array would otherwise raise a
631 654 // TypeError that takes down the rest of the chunk with it. One bad key
632 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.
633 660 try {
634 661 switch ($object_type) {
635 662 case 'post':
636 - update_post_meta($object_id, $key, $value);
663 + update_post_meta($object_id, $key, wp_slash($value));
637 664 return true;
638 665 case 'term':
639 - update_term_meta($object_id, $key, $value);
666 + update_term_meta($object_id, $key, wp_slash($value));
640 667 return true;
641 668 case 'user':
642 - update_user_meta($object_id, $key, $value);
669 + update_user_meta($object_id, $key, wp_slash($value));
643 670 return true;
644 671 }
645 672 } catch (\Throwable $e) {
646 673 if (defined('WP_DEBUG') && WP_DEBUG) {
@@ -1414,9 +1441,9 @@
1414 1441 }
1415 1442
1416 1443 $robots['index'] = empty($robots['noindex']);
1417 1444
1418 - update_term_meta($term_id, '_thinkrank_robots_meta', wp_json_encode($robots));
1445 + update_term_meta($term_id, '_thinkrank_robots_meta', wp_slash(wp_json_encode($robots)));
1419 1446 update_term_meta($term_id, '_thinkrank_robots_meta_enabled', 1);
1420 1447
1421 1448 return true;
1422 1449 }
@@ -1575,9 +1602,9 @@
1575 1602 if (is_string($existing) && $existing !== '') {
1576 1603 return false;
1577 1604 }
1578 1605
1579 - 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)));
1580 1607
1581 1608 return true;
1582 1609 }
1583 1610
@@ -1609,9 +1636,9 @@
1609 1636 if (is_string($existing) && $existing !== '') {
1610 1637 return false;
1611 1638 }
1612 1639
1613 - 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)));
1614 1641
1615 1642 return true;
1616 1643 }
1617 1644
@@ -1691,13 +1718,13 @@
1691 1718 }
1692 1719
1693 1720 $wrote = false;
1694 1721 if (!empty($robots)) {
1695 - 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)));
1696 1723 $wrote = true;
1697 1724 }
1698 1725 if (!empty($advanced)) {
1699 - 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)));
1700 1727 $wrote = true;
1701 1728 }
1702 1729 if ($wrote) {
1703 1730 update_post_meta($post_id, '_thinkrank_robots_meta_enabled', 1);
@@ -2526,8 +2553,116 @@
2526 2553 'page' => $page,
2527 2554 'total_chunks' => $this->chunk_total($plugin, '404_logs'),
2528 2555 'processed' => $processed,
2529 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,
2530 2665 ];
2531 2666 }
2532 2667
2533 2668 /**