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.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-rankmath-exporter.php +152 -24 1.27.0 → 2.14.1 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,8 +996,68 @@
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".
@@ -954,9 +1065,9 @@
954 1065 * @param mixed $value Raw Rank Math term meta value
955 1066 * @param int $term_id Term ID for context
956 1067 * @return string Resolved value
957 1068 */
958 - private function convert_term_template_variables(mixed $value, int $term_id): string {
1069 + private function convert_term_template_variables($value, int $term_id): string {
959 1070 // Same foreign-data rule as convert_template_variables (see abstract).
960 1071 $value = $this->stringify_template_value($value);
961 1072 if ($value === '' || strpos($value, '%') === false) {
962 1073 return $value;
@@ -974,9 +1085,9 @@
974 1085 // Collapse whitespace left where a token (e.g. %page%) resolved to ''.
975 1086 return trim((string) preg_replace('/\s{2,}/', ' ', $this->convert_template_variables($value)));
976 1087 }
977 1088
978 - protected function convert_template_variables(mixed $value, ?int $post_id = null): string {
1089 + protected function convert_template_variables($value, ?int $post_id = null): string {
979 1090 // Foreign data first: booleans/arrays in the source plugin's options
980 1091 // must degrade to '' here, not fatal the migration (see abstract).
981 1092 $value = $this->stringify_template_value($value);
982 1093
@@ -998,9 +1109,12 @@
998 1109 if ($post_id) {
999 1110 $post = get_post($post_id);
1000 1111 if ($post) {
1001 1112 $replacements['%title%'] = $post->post_title;
1002 - $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 + );
1003 1117 $replacements['%date%'] = get_the_date('', $post);
1004 1118 $replacements['%modified%'] = get_the_modified_date('', $post);
1005 1119 $replacements['%id%'] = (string) $post_id;
1006 1120 $replacements['%name%'] = get_the_author_meta('display_name', (int) $post->post_author);
@@ -1041,9 +1155,9 @@
1041 1155 *
1042 1156 * @param mixed $template Raw Rank Math template
1043 1157 * @return string ThinkRank-compatible template
1044 1158 */
1045 - private function convert_template_tokens(mixed $template): string {
1159 + private function convert_template_tokens($template): string {
1046 1160 // Foreign data first: booleans/arrays in the source plugin's options
1047 1161 // must degrade to '' here, not fatal the migration (see abstract).
1048 1162 $template = $this->stringify_template_value($template);
1049 1163
@@ -1053,13 +1167,25 @@
1053 1167
1054 1168 // Rank Math token => ThinkRank Global SEO token (structure preserved).
1055 1169 $token_map = [
1056 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%',
1057 1179 ];
1058 1180 $template = str_replace(array_keys($token_map), array_values($token_map), $template);
1059 1181
1060 1182 // Tokens ThinkRank's Global SEO engine resolves natively — keep as-is.
1061 - $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 + ];
1062 1188
1063 1189 // Strip any token ThinkRank cannot resolve so it does not render literally.
1064 1190 $template = preg_replace_callback(
1065 1191 '/%[a-z0-9_]+%/i',
@@ -1086,9 +1212,9 @@
1086 1212 *
1087 1213 * @param string $format Raw Rank Math image format
1088 1214 * @return string ThinkRank-compatible image format
1089 1215 */
1090 - private function convert_image_tokens(mixed $format): string {
1216 + private function convert_image_tokens($format): string {
1091 1217 // Foreign data first: booleans/arrays in the source plugin's options
1092 1218 // must degrade to '' here, not fatal the migration (see abstract).
1093 1219 $format = $this->stringify_template_value($format);
1094 1220
@@ -1125,9 +1251,11 @@
1125 1251
1126 1252 foreach ($meta as $key => $value) {
1127 1253 if (strpos($key, 'rank_math_schema_') === 0) {
1128 1254 $schema_key = str_replace('rank_math_schema_', '', $key);
1129 - $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);
1130 1258 }
1131 1259 }
1132 1260
1133 1261 return $details;
@@ -1183,9 +1311,9 @@
1183 1311 foreach ($meta as $key => $value) {
1184 1312 if (strpos($key, 'rank_math_schema_') !== 0) {
1185 1313 continue;
1186 1314 }
1187 - $schema = maybe_unserialize($value);
1315 + $schema = Safe_Unserializer::unserialize($value);
1188 1316 if (!is_array($schema)) {
1189 1317 continue;
1190 1318 }
1191 1319 $type = strtolower((string) ($schema['@type'] ?? substr($key, strlen('rank_math_schema_'))));
@@ -1246,9 +1374,9 @@
1246 1374 * @param mixed $robots_raw Raw (serialized) rank_math_robots value
1247 1375 * @return array Map of present flag => true (noarchive/noimageindex/nosnippet)
1248 1376 */
1249 1377 private function extract_robots_flags($robots_raw): array {
1250 - $robots = maybe_unserialize($robots_raw);
1378 + $robots = Safe_Unserializer::unserialize($robots_raw);
1251 1379 if (!is_array($robots)) {
1252 1380 return [];
1253 1381 }
1254 1382
@@ -1287,9 +1415,9 @@
1287 1415 * @param mixed $raw Raw (serialized) meta value
1288 1416 * @return array Associative directive => value map (empty when unset)
1289 1417 */
1290 1418 private function parse_advanced_robots_meta($raw): array {
1291 - $advanced = maybe_unserialize($raw);
1419 + $advanced = Safe_Unserializer::unserialize($raw);
1292 1420 return is_array($advanced) ? $advanced : [];
1293 1421 }
1294 1422
1295 1423 /**
@@ -1714,9 +1842,9 @@
1714 1842 if (isset($titles["pt_{$pt}_description"])) {
1715 1843 $pt_settings['description_template'] = $this->convert_template_tokens($titles["pt_{$pt}_description"]);
1716 1844 }
1717 1845 if (isset($titles["pt_{$pt}_robots"])) {
1718 - $pt_settings['robots'] = maybe_unserialize($titles["pt_{$pt}_robots"]);
1846 + $pt_settings['robots'] = Safe_Unserializer::to_array($titles["pt_{$pt}_robots"]);
1719 1847 }
1720 1848 // Rank Math's per-type Link Suggestions toggle maps onto ThinkRank's
1721 1849 // global-SEO `link_suggestions` (which gates the Pillar Content column
1722 1850 // and post-list filter). Only captured when Rank Math stored a value.