← 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 | /** |