PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.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-rankmath-exporter.php +169 -27 1.26.0 → 2.14.0 View file →
@@ -5,9 +5,9 @@
5 5 *
6 6 * Reads Rank Math data from postmeta/termmeta/options and normalizes
7 7 * into the canonical snapshot format.
8 8 *
9 - * CRITICAL: rank_math_robots is a serialized array — use maybe_unserialize()
9 + * CRITICAL: rank_math_robots is a serialized array — use Safe_Unserializer
10 10 * then in_array() to check for 'noindex'/'nofollow'.
11 11 *
12 12 * @package ThinkRank\Admin\Importers
13 13 * @since 2.0.0
@@ -182,8 +182,17 @@
182 182 if ($log_count > 0) {
183 183 $types['404_logs'] = $log_count;
184 184 }
185 185
186 + // FAQ / HowTo blocks live in post_content, not in any meta table, so
187 + // they are invisible to every count above. Without this type they were
188 + // never migrated at all and simply broke when Rank Math went away
189 + // (#777).
190 + $block_count = Block_Converter::count_posts();
191 + if ($block_count > 0) {
192 + $types[Block_Converter::TYPE] = $block_count;
193 + }
194 +
186 195 foreach ($this->option_keys as $key) {
187 196 if (get_option($key, null) !== null) {
188 197 $types['settings'] = 1;
189 198 break;
@@ -253,10 +262,10 @@
253 262 'object_id' => $post_id,
254 263 'object_type' => 'post',
255 264 'source_plugin' => $this->plugin_slug,
256 265 'data' => [
257 - 'seo_title' => $this->convert_template_variables($meta['rank_math_title'] ?? '', $post_id),
258 - 'meta_description' => $this->convert_template_variables($meta['rank_math_description'] ?? '', $post_id),
266 + 'seo_title' => $this->convert_post_value($meta['rank_math_title'] ?? '', $post_id),
267 + 'meta_description' => $this->convert_post_value($meta['rank_math_description'] ?? '', $post_id),
259 268 'focus_keyword' => $primary_keyword,
260 269 // Full keyword list; the migrator dedupes, drops empties and
261 270 // caps at the ThinkRank maximum via Focus_Keywords.
262 271 'focus_keywords' => $focus_keywords,
@@ -268,16 +277,22 @@
268 277 'nosnippet' => isset($robots_flags['nosnippet']) ? 1 : 0,
269 278 'max_snippet' => $this->advanced_robot_int($advanced_robots, 'max-snippet'),
270 279 'max_video_preview' => $this->advanced_robot_int($advanced_robots, 'max-video-preview'),
271 280 'max_image_preview' => $this->advanced_robot_string($advanced_robots, 'max-image-preview'),
272 - 'og_title' => $this->convert_template_variables($meta['rank_math_facebook_title'] ?? '', $post_id),
273 - 'og_description' => $this->convert_template_variables($meta['rank_math_facebook_description'] ?? '', $post_id),
281 + 'og_title' => $this->convert_post_value($meta['rank_math_facebook_title'] ?? '', $post_id),
282 + 'og_description' => $this->convert_post_value($meta['rank_math_facebook_description'] ?? '', $post_id),
274 283 'og_image' => $meta['rank_math_facebook_image'] ?? '',
275 - 'twitter_title' => $this->convert_template_variables($meta['rank_math_twitter_title'] ?? '', $post_id),
276 - 'twitter_description' => $this->convert_template_variables($meta['rank_math_twitter_description'] ?? '', $post_id),
284 + 'twitter_title' => $this->convert_post_value($meta['rank_math_twitter_title'] ?? '', $post_id),
285 + 'twitter_description' => $this->convert_post_value($meta['rank_math_twitter_description'] ?? '', $post_id),
277 286 'twitter_image' => $meta['rank_math_twitter_image'] ?? '',
278 287 'primary_category' => (int) ($meta['rank_math_primary_category'] ?? 0),
279 288 'schema_type' => $this->resolve_schema_type($meta),
289 + // WooCommerce product identifier. Rank Math keeps it in its
290 + // own per-post meta; ThinkRank Pro reads the canonical field
291 + // this maps to, so a migrating store keeps the identifier
292 + // Google asks for instead of collecting a "missing
293 + // identifier" warning on every product (#715).
294 + 'product_identifier' => (string) ($meta['rank_math_gtin_code'] ?? ''),
280 295 // Rank Math pillar content maps directly to ThinkRank pillar content.
281 296 'pillar_content' => $this->normalize_pillar_content($meta['rank_math_pillar_content'] ?? ''),
282 297 ],
283 298 'extended' => [
@@ -334,16 +349,16 @@
334 349 'object_id' => $term_id,
335 350 'object_type' => 'term',
336 351 'source_plugin' => $this->plugin_slug,
337 352 'data' => [
338 - 'seo_title' => $this->convert_term_template_variables($meta['rank_math_title'] ?? '', $term_id),
339 - 'meta_description' => $this->convert_term_template_variables($meta['rank_math_description'] ?? '', $term_id),
353 + 'seo_title' => $this->convert_term_value($meta['rank_math_title'] ?? '', $term_id),
354 + 'meta_description' => $this->convert_term_value($meta['rank_math_description'] ?? '', $term_id),
340 355 'focus_keyword' => $primary_keyword,
341 356 'canonical_url' => $meta['rank_math_canonical_url'] ?? '',
342 357 'noindex' => $robots['noindex'],
343 358 'nofollow' => $robots['nofollow'],
344 - 'og_title' => $this->convert_term_template_variables($meta['rank_math_facebook_title'] ?? '', $term_id),
345 - 'og_description' => $this->convert_term_template_variables($meta['rank_math_facebook_description'] ?? '', $term_id),
359 + 'og_title' => $this->convert_term_value($meta['rank_math_facebook_title'] ?? '', $term_id),
360 + 'og_description' => $this->convert_term_value($meta['rank_math_facebook_description'] ?? '', $term_id),
346 361 ],
347 362 'extended' => [
348 363 'og_image' => $meta['rank_math_facebook_image'] ?? '',
349 364 'twitter_title' => $meta['rank_math_twitter_title'] ?? '',
@@ -681,8 +696,9 @@
681 696
682 697 $offset = ($page - 1) * $this->chunk_size;
683 698
684 699 $rows = $wpdb->get_results(
700 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
685 701 $wpdb->prepare(
686 702 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
687 703 $this->chunk_size,
688 704 $offset
@@ -688,8 +704,9 @@
688 704 $offset
689 705 ),
690 706 ARRAY_A
691 707 );
708 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
692 709
693 710 // One rule can fan out into several records, so pagination must key off
694 711 // the number of ROWS fetched, not the number of records emitted.
695 712 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
@@ -705,9 +722,9 @@
705 722 // rows — ONE rule can match many source URLs. ThinkRank's redirect
706 723 // table is one row per source, so fan each source out into its own
707 724 // record rather than keeping only the first (which silently dropped
708 725 // every additional source).
709 - $sources = maybe_unserialize($row['sources'] ?? '');
726 + $sources = Safe_Unserializer::unserialize($row['sources'] ?? '');
710 727 if (!is_array($sources) || empty($sources)) {
711 728 continue;
712 729 }
713 730
@@ -748,8 +765,40 @@
748 765
749 766 /**
750 767 * {@inheritDoc}
751 768 *
769 + * `content_blocks` records carry post ids only. The blocks themselves stay
770 + * in `post_content` and are rewritten in place at migrate time — copying
771 + * the content into the snapshot would mean holding a second copy of every
772 + * FAQ page in `wp_options`, which is where snapshots live.
773 + */
774 + protected function export_custom_type_page(string $type, int $page): array {
775 + if (Block_Converter::TYPE !== $type) {
776 + return parent::export_custom_type_page($type, $page);
777 + }
778 +
779 + $post_ids = Block_Converter::get_post_ids($page, $this->chunk_size);
780 +
781 + // export_chunk() decides whether another page follows from the number
782 + // of rows the query returned, so report it even when nothing is emitted.
783 + $this->last_page_row_count = count($post_ids);
784 +
785 + $records = [];
786 + foreach ($post_ids as $post_id) {
787 + $records[] = [
788 + 'object_id' => $post_id,
789 + 'object_type' => 'post',
790 + 'source_plugin' => $this->plugin_slug,
791 + 'data' => ['post_id' => $post_id],
792 + ];
793 + }
794 +
795 + return $records;
796 + }
797 +
798 + /**
799 + * {@inheritDoc}
800 + *
752 801 * Rank Math's 404 monitor keeps one row per URI in `rank_math_404_logs`,
753 802 * which lines up with ThinkRank Pro's `thinkrank_404_logs`.
754 803 */
755 804 protected function export_404_logs_page(int $page): array {
@@ -765,8 +814,9 @@
765 814 $offset = ($page - 1) * $this->chunk_size;
766 815
767 816 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
768 817 $rows = $wpdb->get_results(
818 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
769 819 $wpdb->prepare(
770 820 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
771 821 $this->chunk_size,
772 822 $offset
@@ -772,8 +822,9 @@
772 822 $offset
773 823 ),
774 824 ARRAY_A
775 825 );
826 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
776 827
777 828 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
778 829
779 830 if (empty($rows)) {
@@ -901,9 +952,9 @@
901 952 if (strpos($key, 'rank_math_schema_') !== 0) {
902 953 continue;
903 954 }
904 955
905 - $schema = maybe_unserialize($value);
956 + $schema = Safe_Unserializer::unserialize($value);
906 957 if (!is_array($schema)) {
907 958 continue;
908 959 }
909 960
@@ -945,18 +996,80 @@
945 996 return self::MODERN_SCHEMA_TYPE_MAP[$key] ?? '';
946 997 }
947 998
948 999 /**
1000 + * Rank Math per-post tokens ThinkRank resolves per request (#886): kept
1001 + * as tags rather than frozen into text. Same vocabulary as
1002 + * convert_template_tokens().
1003 + */
1004 + private const POST_TOKENS = [
1005 + '%title%' => '%title%',
1006 + '%sitename%' => '%sitename%',
1007 + '%sep%' => '%sep%',
1008 + '%excerpt%' => '%excerpt%',
1009 + '%date%' => '%date%',
1010 + '%modified%' => '%modified%',
1011 + '%name%' => '%author%',
1012 + '%category%' => '%category%',
1013 + '%wc_price%' => '%price%',
1014 + '%wc_sku%' => '%sku%',
1015 + '%wc_shortdesc%' => '%short_description%',
1016 + '%wc_brand%' => '%brand%',
1017 + ];
1018 +
1019 + /**
1020 + * Rank Math per-term tokens ThinkRank resolves on a term archive.
1021 + */
1022 + private const TERM_TOKENS = [
1023 + '%term%' => '%term%',
1024 + '%term_description%' => '%excerpt%',
1025 + '%sitename%' => '%sitename%',
1026 + '%sep%' => '%sep%',
1027 + ];
1028 +
1029 + /**
1030 + * A per-post Rank Math value: mapped tokens as ThinkRank tags, the rest literal.
1031 + *
1032 + * @param mixed $value Raw Rank Math value
1033 + * @param int $post_id Post ID
1034 + * @return string
1035 + */
1036 + private function convert_post_value($value, int $post_id): string {
1037 + return $this->tokenize_object_template(
1038 + $this->stringify_template_value($value),
1039 + self::literal_token_patterns(self::POST_TOKENS),
1040 + fn(string $rest): string => $this->convert_template_variables($rest, $post_id)
1041 + );
1042 + }
1043 +
1044 + /**
1045 + * A per-term Rank Math value: mapped tokens as ThinkRank tags, the rest literal.
1046 + *
1047 + * @param mixed $value Raw Rank Math value
1048 + * @param int $term_id Term ID
1049 + * @return string
1050 + */
1051 + private function convert_term_value($value, int $term_id): string {
1052 + return $this->tokenize_object_template(
1053 + $this->stringify_template_value($value),
1054 + self::literal_token_patterns(self::TERM_TOKENS),
1055 + fn(string $rest): string => $this->convert_term_template_variables($rest, $term_id)
1056 + );
1057 + }
1058 +
1059 + /**
949 1060 * Resolve a Rank Math TERM title/description value, replacing the
950 1061 * term-context tokens (%term%, %term_description%) Rank Math uses for term
951 1062 * archives before delegating to the shared variable resolver. Without this
952 1063 * the term name is stripped and titles render as "Archives - Site".
953 1064 *
954 - * @param string $value Raw Rank Math term meta value
955 - * @param int $term_id Term ID for context
1065 + * @param mixed $value Raw Rank Math term meta value
1066 + * @param int $term_id Term ID for context
956 1067 * @return string Resolved value
957 1068 */
958 - private function convert_term_template_variables(string $value, int $term_id): string {
1069 + private function convert_term_template_variables($value, int $term_id): string {
1070 + // Same foreign-data rule as convert_template_variables (see abstract).
1071 + $value = $this->stringify_template_value($value);
959 1072 if ($value === '' || strpos($value, '%') === false) {
960 1073 return $value;
961 1074 }
962 1075
@@ -972,9 +1085,13 @@
972 1085 // Collapse whitespace left where a token (e.g. %page%) resolved to ''.
973 1086 return trim((string) preg_replace('/\s{2,}/', ' ', $this->convert_template_variables($value)));
974 1087 }
975 1088
976 - protected function convert_template_variables(string $value, ?int $post_id = null): string {
1089 + protected function convert_template_variables($value, ?int $post_id = null): string {
1090 + // Foreign data first: booleans/arrays in the source plugin's options
1091 + // must degrade to '' here, not fatal the migration (see abstract).
1092 + $value = $this->stringify_template_value($value);
1093 +
977 1094 if (empty($value) || strpos($value, '%') === false) {
978 1095 return $value;
979 1096 }
980 1097
@@ -992,9 +1109,12 @@
992 1109 if ($post_id) {
993 1110 $post = get_post($post_id);
994 1111 if ($post) {
995 1112 $replacements['%title%'] = $post->post_title;
996 - $replacements['%excerpt%'] = wp_trim_words($post->post_excerpt ?: wp_trim_words(wp_strip_all_tags($post->post_content), 55), 55);
1113 + $replacements['%excerpt%'] = \ThinkRank\Core\Seo_Text::trim_words(
1114 + $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
1115 + 55
1116 + );
997 1117 $replacements['%date%'] = get_the_date('', $post);
998 1118 $replacements['%modified%'] = get_the_modified_date('', $post);
999 1119 $replacements['%id%'] = (string) $post_id;
1000 1120 $replacements['%name%'] = get_the_author_meta('display_name', (int) $post->post_author);
@@ -1032,12 +1152,16 @@
1032 1152 * a direct equivalent are renamed; tokens ThinkRank cannot resolve (e.g.
1033 1153 * %page%, %pt_single%, %currentyear%) are stripped so they never render
1034 1154 * literally on the frontend.
1035 1155 *
1036 - * @param string $template Raw Rank Math template
1156 + * @param mixed $template Raw Rank Math template
1037 1157 * @return string ThinkRank-compatible template
1038 1158 */
1039 - private function convert_template_tokens(string $template): string {
1159 + private function convert_template_tokens($template): string {
1160 + // Foreign data first: booleans/arrays in the source plugin's options
1161 + // must degrade to '' here, not fatal the migration (see abstract).
1162 + $template = $this->stringify_template_value($template);
1163 +
1040 1164 if (empty($template) || strpos($template, '%') === false) {
1041 1165 return $template;
1042 1166 }
1043 1167
@@ -1043,13 +1167,25 @@
1043 1167
1044 1168 // Rank Math token => ThinkRank Global SEO token (structure preserved).
1045 1169 $token_map = [
1046 1170 '%name%' => '%author%', // Rank Math author display name token
1171 + // WooCommerce product variables. Rank Math lets a product title or
1172 + // description carry the price, SKU and stock status; every one of
1173 + // them used to be stripped by the pass below, so "Buy %title% for
1174 + // %wc_price%" imported as "Buy %title% for" (#715).
1175 + '%wc_price%' => '%price%',
1176 + '%wc_sku%' => '%sku%',
1177 + '%wc_shortdesc%' => '%short_description%',
1178 + '%wc_brand%' => '%brand%',
1047 1179 ];
1048 1180 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1049 1181
1050 1182 // Tokens ThinkRank's Global SEO engine resolves natively — keep as-is.
1051 - $supported = ['%title%', '%sitename%', '%sep%', '%excerpt%', '%date%', '%modified%', '%author%', '%category%'];
1183 + $supported = [
1184 + '%title%', '%sitename%', '%sep%', '%excerpt%', '%date%', '%modified%', '%author%', '%category%',
1185 + // Resolved by Pattern_Resolver on a product, empty elsewhere.
1186 + '%price%', '%sale_price%', '%sku%', '%stock_status%', '%short_description%', '%brand%',
1187 + ];
1052 1188
1053 1189 // Strip any token ThinkRank cannot resolve so it does not render literally.
1054 1190 $template = preg_replace_callback(
1055 1191 '/%[a-z0-9_]+%/i',
@@ -1076,9 +1212,13 @@
1076 1212 *
1077 1213 * @param string $format Raw Rank Math image format
1078 1214 * @return string ThinkRank-compatible image format
1079 1215 */
1080 - private function convert_image_tokens(string $format): string {
1216 + private function convert_image_tokens($format): string {
1217 + // Foreign data first: booleans/arrays in the source plugin's options
1218 + // must degrade to '' here, not fatal the migration (see abstract).
1219 + $format = $this->stringify_template_value($format);
1220 +
1081 1221 if ($format === '' || strpos($format, '%') === false) {
1082 1222 return $format;
1083 1223 }
1084 1224
@@ -1111,9 +1251,11 @@
1111 1251
1112 1252 foreach ($meta as $key => $value) {
1113 1253 if (strpos($key, 'rank_math_schema_') === 0) {
1114 1254 $schema_key = str_replace('rank_math_schema_', '', $key);
1115 - $details[$schema_key] = maybe_unserialize($value);
1255 + // Schema blocks are always arrays; anything else is malformed
1256 + // source data and must not travel further into the migrator.
1257 + $details[$schema_key] = Safe_Unserializer::to_array($value);
1116 1258 }
1117 1259 }
1118 1260
1119 1261 return $details;
@@ -1169,9 +1311,9 @@
1169 1311 foreach ($meta as $key => $value) {
1170 1312 if (strpos($key, 'rank_math_schema_') !== 0) {
1171 1313 continue;
1172 1314 }
1173 - $schema = maybe_unserialize($value);
1315 + $schema = Safe_Unserializer::unserialize($value);
1174 1316 if (!is_array($schema)) {
1175 1317 continue;
1176 1318 }
1177 1319 $type = strtolower((string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_'))));
@@ -1232,9 +1374,9 @@
1232 1374 * @param mixed $robots_raw Raw (serialized) rank_math_robots value
1233 1375 * @return array Map of present flag => true (noarchive/noimageindex/nosnippet)
1234 1376 */
1235 1377 private function extract_robots_flags($robots_raw): array {
1236 - $robots = maybe_unserialize($robots_raw);
1378 + $robots = Safe_Unserializer::unserialize($robots_raw);
1237 1379 if (!is_array($robots)) {
1238 1380 return [];
1239 1381 }
1240 1382
@@ -1273,9 +1415,9 @@
1273 1415 * @param mixed $raw Raw (serialized) meta value
1274 1416 * @return array Associative directive => value map (empty when unset)
1275 1417 */
1276 1418 private function parse_advanced_robots_meta($raw): array {
1277 - $advanced = maybe_unserialize($raw);
1419 + $advanced = Safe_Unserializer::unserialize($raw);
1278 1420 return is_array($advanced) ? $advanced : [];
1279 1421 }
1280 1422
1281 1423 /**
@@ -1700,9 +1842,9 @@
1700 1842 if (isset($titles["pt_{$pt}_description"])) {
1701 1843 $pt_settings['description_template'] = $this->convert_template_tokens($titles["pt_{$pt}_description"]);
1702 1844 }
1703 1845 if (isset($titles["pt_{$pt}_robots"])) {
1704 - $pt_settings['robots'] = maybe_unserialize($titles["pt_{$pt}_robots"]);
1846 + $pt_settings['robots'] = Safe_Unserializer::to_array($titles["pt_{$pt}_robots"]);
1705 1847 }
1706 1848 // Rank Math's per-type Link Suggestions toggle maps onto ThinkRank's
1707 1849 // global-SEO `link_suggestions` (which gates the Pillar Content column
1708 1850 // and post-list filter). Only captured when Rank Math stored a value.