PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-external-importer.php +1716 -299 2.6.13 → 2.7.1 View file →
@@ -176,8 +176,9 @@
176 176 $count = 0;
177 177
178 178 switch ($plugin) {
179 179 case 'yoast':
180 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
180 181 $count = (int) $wpdb->get_var("
181 182 SELECT COUNT(DISTINCT post_id)
182 183 FROM {$wpdb->postmeta}
183 184 WHERE meta_key LIKE '_yoast_wpseo_%'
@@ -185,22 +186,23 @@
185 186 ");
186 187 break;
187 188
188 189 case 'rankmath':
189 - $count = (int) $wpdb->get_var("
190 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
191 + $count = (int) $wpdb->get_var($wpdb->prepare("
190 192 SELECT COUNT(DISTINCT post_id)
191 193 FROM {$wpdb->postmeta}
192 - WHERE meta_key LIKE 'rank_math_%'
194 + WHERE meta_key LIKE %s
193 195 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
194 - ");
196 + ", 'rank_math_%'));
195 197 break;
196 198
197 199 case 'aioseo':
198 200 $table = $wpdb->prefix . 'aioseo_posts';
199 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
201 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
200 202 $count = (int) $wpdb->get_var("
201 203 SELECT COUNT(post_id)
202 - FROM {$table}
204 + FROM {$wpdb->prefix}aioseo_posts
203 205 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
204 206 ");
205 207 }
206 208 break;
@@ -222,8 +224,9 @@
222 224 case 'yoast':
223 225 // Check for Yoast schema meta
224 226 // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
225 227 // Free version may use _yoast_wpseo_schema (JSON)
228 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
226 229 $count = (int) $wpdb->get_var("
227 230 SELECT COUNT(DISTINCT post_id)
228 231 FROM {$wpdb->postmeta}
229 232 WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
@@ -234,8 +237,9 @@
234 237 case 'rankmath':
235 238 // Check for Rank Math schema meta (any schema type)
236 239 // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
237 240 // Exclude shortcode schemas (rank_math_shortcode_schema_*)
241 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
238 242 $count = (int) $wpdb->get_var("
239 243 SELECT COUNT(DISTINCT post_id)
240 244 FROM {$wpdb->postmeta}
241 245 WHERE meta_key LIKE 'rank_math_schema_%'
@@ -246,12 +250,12 @@
246 250
247 251 case 'aioseo':
248 252 // Check for AIOSEO schema in table
249 253 $table = $wpdb->prefix . 'aioseo_posts';
250 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
254 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
251 255 $count = (int) $wpdb->get_var("
252 256 SELECT COUNT(post_id)
253 - FROM {$table}
257 + FROM {$wpdb->prefix}aioseo_posts
254 258 WHERE (schema_type IS NOT NULL AND schema_type != '')
255 259 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
256 260 ");
257 261 }
@@ -272,8 +276,9 @@
272 276
273 277 switch ($plugin) {
274 278 case 'yoast':
275 279 // Check for Yoast SEO title or description
280 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
276 281 $count = (int) $wpdb->get_var("
277 282 SELECT COUNT(DISTINCT post_id)
278 283 FROM {$wpdb->postmeta}
279 284 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
@@ -283,8 +288,9 @@
283 288 break;
284 289
285 290 case 'rankmath':
286 291 // Check for Rank Math title or description
292 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
287 293 $count = (int) $wpdb->get_var("
288 294 SELECT COUNT(DISTINCT post_id)
289 295 FROM {$wpdb->postmeta}
290 296 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
@@ -295,15 +301,42 @@
295 301
296 302 case 'aioseo':
297 303 // Check for AIOSEO title or description in their custom table
298 304 $table = $wpdb->prefix . 'aioseo_posts';
299 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
305 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
300 306 $count = (int) $wpdb->get_var("
301 307 SELECT COUNT(post_id)
302 - FROM {$table}
308 + FROM {$wpdb->prefix}aioseo_posts
303 309 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
304 310 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
305 311 ");
312 +
313 + // Per-post overrides are the exception. AIOSEO also serves a
314 + // title and description for every post from its site-wide
315 + // templates, and those are what the import mainly picks up —
316 + // so a site that never overrode an individual post still has
317 + // plenty to migrate. Counting overrides alone reported zero
318 + // there, which rendered the card "Unavailable" with a disabled
319 + // button and made the import impossible to start.
320 + $templates = $this->get_aioseo_post_type_templates();
321 + if (!empty($templates)) {
322 + $post_types = array_keys($templates);
323 + $type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
324 +
325 + $template_count = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
326 + "SELECT COUNT(*)
327 + FROM {$wpdb->posts}
328 + WHERE post_status = 'publish'
329 + AND post_type IN ({$type_placeholders})",
330 + $post_types
331 + ));
332 +
333 + // Take the larger of the two. Templates configured for a
334 + // post type with no published posts would otherwise report
335 + // zero and disable the card, hiding override rows that do
336 + // exist — the same dead end from the other direction.
337 + $count = max($count, $template_count);
338 + }
306 339 }
307 340 break;
308 341 }
309 342
@@ -320,8 +353,9 @@
320 353 $count = 0;
321 354
322 355 switch ($plugin) {
323 356 case 'yoast':
357 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
324 358 $count = (int) $wpdb->get_var("
325 359 SELECT COUNT(DISTINCT post_id)
326 360 FROM {$wpdb->postmeta}
327 361 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -330,8 +364,9 @@
330 364 ");
331 365 break;
332 366
333 367 case 'rankmath':
368 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
334 369 $count = (int) $wpdb->get_var("
335 370 SELECT COUNT(DISTINCT post_id)
336 371 FROM {$wpdb->postmeta}
337 372 WHERE meta_key = 'rank_math_primary_category'
@@ -340,8 +375,9 @@
340 375 ");
341 376 break;
342 377
343 378 case 'aioseo':
379 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
344 380 $count = (int) $wpdb->get_var("
345 381 SELECT COUNT(DISTINCT post_id)
346 382 FROM {$wpdb->postmeta}
347 383 WHERE meta_key = '_aioseo_primary_category'
@@ -402,8 +438,9 @@
402 438 $batch_size = intval($options['batch_size']);
403 439 $offset = intval($options['offset']);
404 440 $overwrite = (bool) $options['overwrite_existing'];
405 441
442 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
406 443 $total_posts = (int) $wpdb->get_var("
407 444 SELECT COUNT(DISTINCT post_id)
408 445 FROM {$wpdb->postmeta}
409 446 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -410,8 +447,9 @@
410 447 AND meta_value != ''
411 448 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
412 449 ");
413 450
451 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
414 452 $posts = $wpdb->get_results($wpdb->prepare("
415 453 SELECT DISTINCT post_id
416 454 FROM {$wpdb->postmeta}
417 455 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -465,8 +503,9 @@
465 503 $batch_size = intval($options['batch_size']);
466 504 $offset = intval($options['offset']);
467 505 $overwrite = (bool) $options['overwrite_existing'];
468 506
507 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
469 508 $total_posts = (int) $wpdb->get_var("
470 509 SELECT COUNT(DISTINCT post_id)
471 510 FROM {$wpdb->postmeta}
472 511 WHERE meta_key = 'rank_math_primary_category'
@@ -473,8 +512,9 @@
473 512 AND meta_value != ''
474 513 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
475 514 ");
476 515
516 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
477 517 $posts = $wpdb->get_results($wpdb->prepare("
478 518 SELECT DISTINCT post_id
479 519 FROM {$wpdb->postmeta}
480 520 WHERE meta_key = 'rank_math_primary_category'
@@ -528,8 +568,9 @@
528 568 $batch_size = intval($options['batch_size']);
529 569 $offset = intval($options['offset']);
530 570 $overwrite = (bool) $options['overwrite_existing'];
531 571
572 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
532 573 $total_posts = (int) $wpdb->get_var("
533 574 SELECT COUNT(DISTINCT post_id)
534 575 FROM {$wpdb->postmeta}
535 576 WHERE meta_key = '_aioseo_primary_category'
@@ -536,8 +577,9 @@
536 577 AND meta_value != ''
537 578 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
538 579 ");
539 580
581 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
540 582 $posts = $wpdb->get_results($wpdb->prepare("
541 583 SELECT DISTINCT post_id
542 584 FROM {$wpdb->postmeta}
543 585 WHERE meta_key = '_aioseo_primary_category'
@@ -735,24 +777,26 @@
735 777 $batch_size = intval($options['batch_size']);
736 778 $offset = intval($options['offset']);
737 779
738 780 // Get total count (for progress tracking)
739 - $total = (int) $wpdb->get_var("
781 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
782 + $total = (int) $wpdb->get_var($wpdb->prepare("
740 783 SELECT COUNT(DISTINCT post_id)
741 784 FROM {$wpdb->postmeta}
742 - WHERE meta_key LIKE '_yoast_wpseo_%'
785 + WHERE meta_key LIKE %s
743 786 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
744 - ");
787 + ", '_yoast_wpseo_%'));
745 788
746 789 // Get batch of posts with Yoast robots meta
790 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
747 791 $posts = $wpdb->get_results($wpdb->prepare("
748 792 SELECT DISTINCT post_id
749 793 FROM {$wpdb->postmeta}
750 - WHERE meta_key LIKE '_yoast_wpseo_%'
794 + WHERE meta_key LIKE %s
751 795 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
752 796 ORDER BY post_id ASC
753 797 LIMIT %d OFFSET %d
754 - ", $batch_size, $offset));
798 + ", '_yoast_wpseo_%', $batch_size, $offset));
755 799
756 800 foreach ($posts as $post_obj) {
757 801 $post_id = $post_obj->post_id;
758 802 $has_changes = false;
@@ -767,11 +811,12 @@
767 811 $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
768 812 if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
769 813 $metasync_robots['noindex'] = 'noindex';
770 814 $has_changes = true;
771 - } elseif ($yoast_noindex === '2' && !isset($metasync_robots['index'])) {
772 - // '2' means 'index' in Yoast
773 - $metasync_robots['index'] = 'index';
815 + } elseif ($yoast_noindex === '2' && isset($metasync_robots['noindex'])) {
816 + // '2' means 'index' in Yoast. Index is the implicit default, so
817 + // clear any stored noindex rather than recording a redundant flag.
818 + unset($metasync_robots['noindex']);
774 819 $has_changes = true;
775 820 }
776 821
777 822 // Import nofollow
@@ -782,8 +827,16 @@
782 827 }
783 828
784 829 // Import advanced robots (noarchive, nosnippet, noimageindex)
785 830 $yoast_adv = get_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', true);
831 + // Normalise the meta value to a string before calling explode().
832 + // Some sites store malformed/legacy data where this meta value is
833 + // an array, which would otherwise throw a TypeError on explode().
834 + if (is_array($yoast_adv)) {
835 + $yoast_adv = implode(',', $yoast_adv);
836 + } elseif (!is_string($yoast_adv)) {
837 + $yoast_adv = '';
838 + }
786 839 if (!empty($yoast_adv)) {
787 840 $adv_directives = explode(',', $yoast_adv);
788 841 foreach ($adv_directives as $directive) {
789 842 $directive = trim($directive);
@@ -793,9 +846,9 @@
793 846 }
794 847 }
795 848 }
796 849
797 - // WP-197: Also write to _metasync_robots_advanced JSON
850 + // Also write to _metasync_robots_advanced JSON
798 851 $robots_advanced = [];
799 852 if (!empty($yoast_adv)) {
800 853 $adv_directives = array_map('trim', explode(',', $yoast_adv));
801 854 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
@@ -816,10 +869,15 @@
816 869 $yoast_canonical = get_post_meta($post_id, '_yoast_wpseo_canonical', true);
817 870 if (!empty($yoast_canonical)) {
818 871 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
819 872 if (empty($existing_canonical)) {
820 - update_post_meta($post_id, 'meta_canonical', sanitize_url($yoast_canonical));
821 - $has_changes = true;
873 + // Validate: never import a corrupted value such as
874 + // "http://Array" from third-party storage.
875 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($yoast_canonical);
876 + if ($clean_canonical !== '') {
877 + update_post_meta($post_id, 'meta_canonical', $clean_canonical);
878 + $has_changes = true;
879 + }
822 880 }
823 881 }
824 882
825 883 // Save Metasync robots meta if changes were made
@@ -862,24 +920,26 @@
862 920 $batch_size = intval($options['batch_size']);
863 921 $offset = intval($options['offset']);
864 922
865 923 // Get total count (for progress tracking)
866 - $total = (int) $wpdb->get_var("
924 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
925 + $total = (int) $wpdb->get_var($wpdb->prepare("
867 926 SELECT COUNT(DISTINCT post_id)
868 927 FROM {$wpdb->postmeta}
869 - WHERE meta_key LIKE 'rank_math_%'
928 + WHERE meta_key LIKE %s
870 929 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
871 - ");
930 + ", 'rank_math_%'));
872 931
873 932 // Get batch of posts with Rank Math robots meta
933 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
874 934 $posts = $wpdb->get_results($wpdb->prepare("
875 935 SELECT DISTINCT post_id
876 936 FROM {$wpdb->postmeta}
877 - WHERE meta_key LIKE 'rank_math_%'
937 + WHERE meta_key LIKE %s
878 938 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
879 939 ORDER BY post_id ASC
880 940 LIMIT %d OFFSET %d
881 - ", $batch_size, $offset));
941 + ", 'rank_math_%', $batch_size, $offset));
882 942
883 943 foreach ($posts as $post_obj) {
884 944 $post_id = $post_obj->post_id;
885 945 $has_changes = false;
@@ -914,9 +974,9 @@
914 974 }
915 975 }
916 976 }
917 977
918 - // WP-197: Parse max-* values and write _metasync_robots_advanced JSON
978 + // Parse max-* values and write _metasync_robots_advanced JSON
919 979 $robots_advanced = [];
920 980 // Boolean directives from rank_math_robots
921 981 if (is_array($rm_robots)) {
922 982 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
@@ -948,10 +1008,15 @@
948 1008 $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
949 1009 if (!empty($rm_canonical)) {
950 1010 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
951 1011 if (empty($existing_canonical)) {
952 - update_post_meta($post_id, 'meta_canonical', sanitize_url($rm_canonical));
953 - $has_changes = true;
1012 + // Validate: never import a corrupted value such as
1013 + // "http://Array" from third-party storage.
1014 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($rm_canonical);
1015 + if ($clean_canonical !== '') {
1016 + update_post_meta($post_id, 'meta_canonical', $clean_canonical);
1017 + $has_changes = true;
1018 + }
954 1019 }
955 1020 }
956 1021
957 1022 // Save Metasync robots meta if changes were made
@@ -997,9 +1062,9 @@
997 1062 // AIOSEO stores data in a custom table
998 1063 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
999 1064
1000 1065 // Check if table exists
1001 - $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$aioseo_table'") === $aioseo_table;
1066 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($aioseo_table))) === $aioseo_table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1002 1067
1003 1068 if (!$table_exists) {
1004 1069 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1005 1070 }
@@ -1004,21 +1069,23 @@
1004 1069 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1005 1070 }
1006 1071
1007 1072 // Get total count (for progress tracking)
1073 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1008 1074 $total = (int) $wpdb->get_var("
1009 1075 SELECT COUNT(*)
1010 - FROM {$aioseo_table}
1076 + FROM {$wpdb->prefix}aioseo_posts
1011 1077 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1012 1078 ");
1013 1079
1014 1080 // Get batch of posts with AIOSEO settings
1081 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1015 1082 $posts = $wpdb->get_results($wpdb->prepare("
1016 1083 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
1017 1084 robots_noarchive, robots_nosnippet, robots_noimageindex,
1018 1085 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
1019 1086 canonical_url
1020 - FROM {$aioseo_table}
1087 + FROM {$wpdb->prefix}aioseo_posts
1021 1088 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1022 1089 ORDER BY post_id ASC
1023 1090 LIMIT %d OFFSET %d
1024 1091 ", $batch_size, $offset));
@@ -1065,9 +1132,9 @@
1065 1132 $has_changes = true;
1066 1133 }
1067 1134 }
1068 1135
1069 - // WP-197: Write _metasync_robots_advanced JSON
1136 + // Write _metasync_robots_advanced JSON
1070 1137 $robots_advanced = [];
1071 1138 if ($aioseo_data->robots_default == 0) {
1072 1139 if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
1073 1140 if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
@@ -1091,10 +1158,15 @@
1091 1158 // Import canonical URL
1092 1159 if (!empty($aioseo_data->canonical_url)) {
1093 1160 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1094 1161 if (empty($existing_canonical)) {
1095 - update_post_meta($post_id, 'meta_canonical', sanitize_url($aioseo_data->canonical_url));
1096 - $has_changes = true;
1162 + // Validate: never import a corrupted value such as
1163 + // "http://Array" from third-party storage.
1164 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($aioseo_data->canonical_url);
1165 + if ($clean_canonical !== '') {
1166 + update_post_meta($post_id, 'meta_canonical', $clean_canonical);
1167 + $has_changes = true;
1168 + }
1097 1169 }
1098 1170 }
1099 1171
1100 1172 // Save Metasync robots meta if changes were made
@@ -1165,8 +1237,9 @@
1165 1237 global $wpdb;
1166 1238 $imported_count = 0;
1167 1239
1168 1240 // First, try to import from full schema JSON (free version or old approach)
1241 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1169 1242 $posts = $wpdb->get_results("
1170 1243 SELECT post_id, meta_value
1171 1244 FROM {$wpdb->postmeta}
1172 1245 WHERE meta_key = '_yoast_wpseo_schema'
@@ -1191,14 +1264,15 @@
1191 1264 // Convert Yoast schema to Metasync format
1192 1265 $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1193 1266
1194 1267 if (!empty($metasync_schema['types'])) {
1195 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1268 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1196 1269 $imported_count++;
1197 1270 }
1198 1271 }
1199 1272
1200 1273 // Second, try to import from schema type (Premium version)
1274 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1201 1275 $posts = $wpdb->get_results("
1202 1276 SELECT post_id, meta_value
1203 1277 FROM {$wpdb->postmeta}
1204 1278 WHERE meta_key = '_yoast_wpseo_schema_article_type'
@@ -1234,9 +1308,9 @@
1234 1308 ]
1235 1309 ]
1236 1310 ];
1237 1311
1238 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1312 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1239 1313 $imported_count++;
1240 1314 }
1241 1315 }
1242 1316
@@ -1252,8 +1326,9 @@
1252 1326 $imported_count = 0;
1253 1327
1254 1328 // Get all posts with any Rank Math schema (dynamically detect schema types)
1255 1329 // Exclude shortcode schemas
1330 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1256 1331 $posts = $wpdb->get_results("
1257 1332 SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1258 1333 FROM {$wpdb->postmeta} pm
1259 1334 WHERE pm.meta_key LIKE 'rank_math_schema_%'
@@ -1290,9 +1365,9 @@
1290 1365 // Convert Rank Math schema to Metasync format
1291 1366 $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1292 1367
1293 1368 if (!empty($metasync_schema['types'])) {
1294 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1369 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1295 1370 $imported_count++;
1296 1371 $processed_posts[] = $post_id; // Mark post as processed
1297 1372 }
1298 1373 }
@@ -1309,16 +1384,17 @@
1309 1384 $imported_count = 0;
1310 1385
1311 1386 // Check if AIOSEO table exists
1312 1387 $table = $wpdb->prefix . 'aioseo_posts';
1313 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
1388 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1314 1389 return 0;
1315 1390 }
1316 1391
1317 1392 // Get all posts with AIOSEO schema
1393 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1318 1394 $posts = $wpdb->get_results("
1319 1395 SELECT post_id, schema_type, schema_type_options
1320 - FROM {$table}
1396 + FROM {$wpdb->prefix}aioseo_posts
1321 1397 WHERE (schema_type IS NOT NULL AND schema_type != '')
1322 1398 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1323 1399 ");
1324 1400
@@ -1344,9 +1420,9 @@
1344 1420 $post_id
1345 1421 );
1346 1422
1347 1423 if (!empty($metasync_schema['types'])) {
1348 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1424 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1349 1425 $imported_count++;
1350 1426 }
1351 1427 }
1352 1428
@@ -1721,8 +1797,67 @@
1721 1797 return is_string($text) ? $text : $default;
1722 1798 }
1723 1799
1724 1800 /**
1801 + * Tokens whose value is the post's own body or excerpt.
1802 + *
1803 + * Keyed by source plugin. These are the only tokens that can carry the text
1804 + * a password withholds; everything else (title, author, dates, taxonomy) is
1805 + * already public on a protected post.
1806 + *
1807 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
1808 + * @return string[]
1809 + */
1810 + private function content_derived_tokens($plugin) {
1811 + switch ($plugin) {
1812 + case 'yoast':
1813 + return ['%%excerpt%%', '%%excerpt_only%%'];
1814 +
1815 + case 'rankmath':
1816 + return ['%excerpt%', '%excerpt_only%'];
1817 +
1818 + case 'aioseo':
1819 + return [
1820 + '#post_content',
1821 + '#post_excerpt',
1822 + '#post_excerpt_only',
1823 + '#attachment_description',
1824 + '#attachment_caption',
1825 + ];
1826 + }
1827 +
1828 + return [];
1829 + }
1830 +
1831 + /**
1832 + * Whether a raw template would publish text that a post password withholds.
1833 + *
1834 + * WordPress keeps a protected post's body and excerpt behind the password
1835 + * but leaves the post itself published, so an import walks straight past the
1836 + * usual visibility checks: the source plugin stores a template, we resolve it
1837 + * against the raw row, and the body ends up in a meta description that every
1838 + * visitor and crawler can read without ever entering the password.
1839 + *
1840 + * @param string $text Raw value from the source plugin.
1841 + * @param WP_Post $post Post the value belongs to.
1842 + * @param string $plugin Source plugin.
1843 + * @return bool
1844 + */
1845 + private function references_protected_content($text, $post, $plugin) {
1846 + if (empty($post->post_password)) {
1847 + return false;
1848 + }
1849 +
1850 + foreach ($this->content_derived_tokens($plugin) as $token) {
1851 + if (strpos($text, $token) !== false) {
1852 + return true;
1853 + }
1854 + }
1855 +
1856 + return false;
1857 + }
1858 +
1859 + /**
1725 1860 * Resolve SEO placeholder tokens to their actual values.
1726 1861 *
1727 1862 * Tries each source plugin's own replacement engine first so the result
1728 1863 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
@@ -1742,8 +1877,17 @@
1742 1877 if (!$post) {
1743 1878 return $text;
1744 1879 }
1745 1880
1881 + // Drop the whole value rather than resolve the content token to nothing
1882 + // and keep the rest. A half-template ("Title |") is worse than no import:
1883 + // it still occupies the meta key, and it still outranks whatever OTTO
1884 + // would have recommended for a page we know nothing publishable about.
1885 + // Callers skip empty values, so nothing is persisted.
1886 + if ($this->references_protected_content($text, $post, $plugin)) {
1887 + return '';
1888 + }
1889 +
1746 1890 // Yoast SEO — %%var%% tokens
1747 1891 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1748 1892 try {
1749 1893 $replacer = new WPSEO_Replace_Vars();
@@ -1767,9 +1911,9 @@
1767 1911 // fall through to manual replacement
1768 1912 }
1769 1913 }
1770 1914
1771 - // All in One SEO — #var# tokens
1915 + // All in One SEO — #var tokens
1772 1916 if ($plugin === 'aioseo') {
1773 1917 try {
1774 1918 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1775 1919 $resolved = aioseo()->tags->replaceTags($text, $post_id);
@@ -1781,20 +1925,27 @@
1781 1925 // fall through to manual replacement
1782 1926 }
1783 1927 }
1784 1928
1785 - return $this->manually_replace_seo_placeholders($text, $post_id);
1929 + return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
1786 1930 }
1787 1931
1788 1932 /**
1789 1933 * Manual fallback: replace the most common SEO placeholder tokens from
1790 - * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
1934 + * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
1791 1935 *
1936 + * AIOSEO tags are handled by resolve_aioseo_placeholders() because they
1937 + * need pattern matching rather than plain string replacement — see the
1938 + * docblock there.
1939 + *
1792 1940 * @param string $text Text that may contain placeholder tokens.
1793 1941 * @param int $post_id Post ID used for context.
1942 + * @param string $plugin Optional source plugin ('yoast', 'rankmath',
1943 + * 'aioseo') so the separator is read from the
1944 + * plugin the data actually came from.
1794 1945 * @return string Text with tokens replaced.
1795 1946 */
1796 - private function manually_replace_seo_placeholders($text, $post_id) {
1947 + private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
1797 1948 $post = get_post($post_id);
1798 1949 if (!$post) {
1799 1950 return $text;
1800 1951 }
@@ -1801,9 +1952,9 @@
1801 1952
1802 1953 $site_name = get_bloginfo('name');
1803 1954 $post_title = get_the_title($post_id);
1804 1955 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1805 - $author = get_the_author_meta('display_name', $post->post_author);
1956 + $author = get_the_author_meta('display_name', (int) $post->post_author);
1806 1957 $date = get_the_date('', $post_id);
1807 1958 $modified = get_the_modified_date('', $post_id);
1808 1959
1809 1960 $categories = get_the_category($post_id);
@@ -1811,30 +1962,9 @@
1811 1962
1812 1963 $tags = get_the_tags($post_id);
1813 1964 $first_tag = !empty($tags) ? $tags[0]->name : '';
1814 1965
1815 - // Try to read the separator from whichever plugin is active
1816 - $sep = '-';
1817 - if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1818 - try {
1819 - $raw = WPSEO_Options::get('separator', 'sc-dash');
1820 - // Yoast stores separator keys like 'sc-dash'; convert to glyph
1821 - // via the public get_separator_options() lookup table.
1822 - if (class_exists('WPSEO_Option_Titles')) {
1823 - $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
1824 - if (isset($options[$raw])) {
1825 - $sep = html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
1826 - }
1827 - }
1828 - } catch (Exception $e) {
1829 - $sep = '-';
1830 - }
1831 - } elseif (defined('RANK_MATH_VERSION')) {
1832 - $rm_settings = get_option('rank_math_general_settings', []);
1833 - if (!empty($rm_settings['title_separator'])) {
1834 - $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1835 - }
1836 - }
1966 + $sep = $this->resolve_source_separator($plugin);
1837 1967
1838 1968 $map = [
1839 1969 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1840 1970 '%%title%%' => $post_title,
@@ -1865,19 +1995,8 @@
1865 1995 '%page%' => '',
1866 1996 '%category%' => $primary_category,
1867 1997 '%tag%' => $first_tag,
1868 1998 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1869 - // ── AIOSEO (#var#) ───────────────────────────────────────────────
1870 - '#site_title#' => $site_name,
1871 - '#post_title#' => $post_title,
1872 - '#post_excerpt#' => $post_excerpt,
1873 - '#separator_sa#' => $sep,
1874 - '#author_name#' => $author,
1875 - '#current_date#' => $date,
1876 - '#post_date#' => $date,
1877 - '#category_title#' => $primary_category,
1878 - '#tag_title#' => $first_tag,
1879 - '#id#' => (string) $post_id,
1880 1999 ];
1881 2000
1882 2001 // Sort longest token first to avoid partial matches
1883 2002 // e.g. %%primary_category%% must replace before %%category%%
@@ -1884,12 +2003,1047 @@
1884 2003 uksort($map, function ($a, $b) {
1885 2004 return strlen($b) - strlen($a);
1886 2005 });
1887 2006
1888 - return str_replace(array_keys($map), array_values($map), $text);
2007 + $text = str_replace(array_keys($map), array_values($map), $text);
2008 +
2009 + // ── AIOSEO (#var) ────────────────────────────────────────────────────
2010 + // Only for AIOSEO data, or when the caller did not name a source. Yoast
2011 + // and Rank Math leave a literal '#categories' alone, so running this on
2012 + // their imports would rewrite text they would have preserved.
2013 + if ($plugin === 'aioseo' || $plugin === '') {
2014 + $text = $this->resolve_aioseo_placeholders($text, $post_id, $sep);
2015 + }
2016 +
2017 + return $text;
1889 2018 }
1890 2019
1891 2020 /**
2021 + * Resolve the title separator for the plugin the data came from.
2022 + *
2023 + * Each plugin stores its separator in its own option, so an AIOSEO import
2024 + * must not inherit Rank Math's separator just because Rank Math happens to
2025 + * still be active on the site. When the source is known we read only that
2026 + * plugin's setting and otherwise fall back to '-', which is the default
2027 + * every one of the three ships anyway.
2028 + *
2029 + * With no source named, keep the original precedence — whichever plugin is
2030 + * active, Yoast first — so callers that predate the $plugin argument see no
2031 + * behaviour change.
2032 + *
2033 + * @param string $plugin Source plugin ('yoast', 'rankmath', 'aioseo') or ''.
2034 + * @return string Separator glyph, defaulting to '-'.
2035 + */
2036 + private function resolve_source_separator($plugin = '') {
2037 + switch ($plugin) {
2038 + case 'yoast':
2039 + $sep = $this->get_yoast_separator();
2040 + return $sep !== '' ? $sep : '-';
2041 + case 'rankmath':
2042 + $sep = $this->get_rankmath_separator();
2043 + return $sep !== '' ? $sep : '-';
2044 + case 'aioseo':
2045 + $sep = $this->get_aioseo_separator();
2046 + return $sep !== '' ? $sep : '-';
2047 + }
2048 +
2049 + // Source unknown: original active-plugin precedence, including the
2050 + // class_exists() guard the old code used, so a site with WPSEO_VERSION
2051 + // defined but Yoast's classes unloaded still falls through to Rank Math
2052 + // exactly as before.
2053 + if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
2054 + $sep = $this->get_yoast_separator();
2055 + return $sep !== '' ? $sep : '-';
2056 + }
2057 + if (defined('RANK_MATH_VERSION')) {
2058 + $sep = $this->get_rankmath_separator();
2059 + return $sep !== '' ? $sep : '-';
2060 + }
2061 +
2062 + return '-';
2063 + }
2064 +
2065 + /**
2066 + * Read Yoast's separator. Yoast stores separator keys like 'sc-dash';
2067 + * convert to a glyph via the public get_separator_options() lookup table.
2068 + *
2069 + * @return string Separator glyph, or '' when unavailable.
2070 + */
2071 + private function get_yoast_separator() {
2072 + if (!defined('WPSEO_VERSION') || !class_exists('WPSEO_Options') || !class_exists('WPSEO_Option_Titles')) {
2073 + return '';
2074 + }
2075 +
2076 + try {
2077 + $raw = WPSEO_Options::get('separator', 'sc-dash');
2078 + $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
2079 + if (isset($options[$raw])) {
2080 + return html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
2081 + }
2082 + } catch (Exception $e) {
2083 + return '';
2084 + }
2085 +
2086 + return '';
2087 + }
2088 +
2089 + /**
2090 + * Read Rank Math's separator from its general settings option.
2091 + *
2092 + * @return string Separator glyph, or '' when unavailable.
2093 + */
2094 + private function get_rankmath_separator() {
2095 + if (!defined('RANK_MATH_VERSION')) {
2096 + return '';
2097 + }
2098 +
2099 + $settings = get_option('rank_math_general_settings', []);
2100 + if (is_array($settings) && !empty($settings['title_separator'])) {
2101 + return html_entity_decode($settings['title_separator'], ENT_QUOTES, 'UTF-8');
2102 + }
2103 +
2104 + return '';
2105 + }
2106 +
2107 + /**
2108 + * Read AIOSEO's separator from aioseo_options.
2109 + *
2110 + * Lives at searchAppearance.global.separator (NOT breadcrumbs.separator)
2111 + * and is stored HTML-entity encoded, e.g. '-'. The option itself may
2112 + * be a JSON string or an already-decoded array depending on how it was
2113 + * written, so handle both. Read straight from the option rather than
2114 + * through aioseo() so it still works once AIOSEO is deactivated.
2115 + *
2116 + * @return string Separator glyph, or '' when unavailable.
2117 + */
2118 + private function get_aioseo_separator() {
2119 + $options = get_option('aioseo_options', '');
2120 + if (is_string($options) && $options !== '') {
2121 + $options = json_decode($options, true);
2122 + }
2123 + if (!is_array($options)) {
2124 + return '';
2125 + }
2126 +
2127 + $raw = $options['searchAppearance']['global']['separator'] ?? '';
2128 + if (!is_string($raw) || $raw === '') {
2129 + return '';
2130 + }
2131 +
2132 + return html_entity_decode($raw, ENT_QUOTES, 'UTF-8');
2133 + }
2134 +
2135 + /**
2136 + * AIOSEO's site-wide title / meta description templates, per post type.
2137 + *
2138 + * A post with no per-post override still gets a title and description from
2139 + * these — AIOSEO's own getPostDescription() falls back to the post type
2140 + * template before anything else. Reading only the per-post columns therefore
2141 + * imports nothing at all on a site that never overrode individual posts,
2142 + * which is the common case: templates are the default, overrides are the
2143 + * exception.
2144 + *
2145 + * Stored in the 'aioseo_options_dynamic' option, which may be a JSON string
2146 + * or an already-decoded array depending on how it was written — same as
2147 + * get_aioseo_separator() above, and read straight from the option so it keeps
2148 + * working once AIOSEO is deactivated.
2149 + *
2150 + * A post type is included only when AIOSEO is set to show SEO for it AND
2151 + * WordPress registers it as public. The public test is deliberately
2152 + * $obj->public rather than is_post_type_viewable(): a logging CPT registered
2153 + * public=false, publicly_queryable=true is "viewable" by that helper, and
2154 + * would drag hundreds of internal records into the import.
2155 + *
2156 + * @return array<string,array{title:string,description:string}> Keyed by post type.
2157 + */
2158 + private function get_aioseo_post_type_templates() {
2159 + $options = get_option('aioseo_options_dynamic', '');
2160 + if (is_string($options) && $options !== '') {
2161 + $options = json_decode($options, true);
2162 + }
2163 + if (!is_array($options)) {
2164 + return [];
2165 + }
2166 +
2167 + $post_types = $options['searchAppearance']['postTypes'] ?? [];
2168 + if (!is_array($post_types)) {
2169 + return [];
2170 + }
2171 +
2172 + $templates = [];
2173 +
2174 + foreach ($post_types as $post_type => $config) {
2175 + if (!is_array($config)) {
2176 + continue;
2177 + }
2178 +
2179 + // 'show' false means AIOSEO emits no SEO for the post type at all.
2180 + if (isset($config['show']) && !$config['show']) {
2181 + continue;
2182 + }
2183 +
2184 + $object = get_post_type_object($post_type);
2185 + if (!$object || empty($object->public)) {
2186 + continue;
2187 + }
2188 +
2189 + $title = isset($config['title']) && is_string($config['title']) ? $config['title'] : '';
2190 + $description = isset($config['metaDescription']) && is_string($config['metaDescription'])
2191 + ? $config['metaDescription']
2192 + : '';
2193 +
2194 + if ($title === '' && $description === '') {
2195 + continue;
2196 + }
2197 +
2198 + $templates[$post_type] = [
2199 + 'title' => $title,
2200 + 'description' => $description,
2201 + ];
2202 + }
2203 +
2204 + return $templates;
2205 + }
2206 +
2207 + /**
2208 + * Trim a template-derived meta description to a sane length.
2209 + *
2210 + * AIOSEO does not truncate — its sanitize() only collapses whitespace — so a
2211 + * post type whose template is '#post_content' renders the entire body as the
2212 + * description. Copying that verbatim into post meta would hand every page a
2213 + * multi-thousand-character description, so template-derived values are cut at
2214 + * a word boundary instead. Values the customer typed on the post itself are
2215 + * never passed through here.
2216 + *
2217 + * @param string $text Resolved description.
2218 + * @param int $limit Maximum length in characters.
2219 + * @return string Trimmed description.
2220 + */
2221 + private function truncate_meta_description($text, $limit = 160) {
2222 + $text = trim(preg_replace('/\s+/u', ' ', (string) $text));
2223 +
2224 + if ($text === '' || mb_strlen($text) <= $limit) {
2225 + return $text;
2226 + }
2227 +
2228 + // Leave room for the ellipsis so the result never exceeds $limit.
2229 + $clipped = mb_substr($text, 0, $limit - 1);
2230 +
2231 + // Cut back to the last whole word so the value never ends mid-word.
2232 + $last_space = mb_strrpos($clipped, ' ');
2233 + if ($last_space !== false && $last_space > 0) {
2234 + $clipped = mb_substr($clipped, 0, $last_space);
2235 + }
2236 +
2237 + $trimmed = rtrim($clipped, " \t\n\r\0\x0B.,;:!?-");
2238 +
2239 + // Punctuation-only input would otherwise collapse to a bare ellipsis.
2240 + return ($trimmed === '' ? $clipped : $trimmed) . '…';
2241 + }
2242 +
2243 + /**
2244 + * Every smart-tag id AIOSEO defines, taken from its own Tags class
2245 + * (all-in-one-seo-pack/app/Common/Utils/Tags.php) plus the ids the Pro
2246 + * build adds for WooCommerce.
2247 + *
2248 + * Used for detection as well as replacement: a tag missing from this list
2249 + * is neither resolved nor flagged, so it would be written to post meta as
2250 + * literal text. Restricting detection to AIOSEO's real vocabulary means
2251 + * ordinary text containing a hash — "#1 Rated Clinic", "#hashtag" — is
2252 + * never mistaken for a tag, and tokens AIOSEO does not define (#category_title,
2253 + * #tag_title) stay literal exactly as AIOSEO itself renders them.
2254 + *
2255 + * Supersets are safe in any order thanks to the negative lookahead used
2256 + * for matching: #post_date cannot consume the start of #post_date_w3c,
2257 + * nor #featured_image the start of #featured_image_url.
2258 + *
2259 + * @return string[]
2260 + */
2261 + private function get_aioseo_tag_ids() {
2262 + return [
2263 + 'alt_tag', 'archive_date', 'archive_title', 'attachment_caption',
2264 + 'attachment_description', 'author_bio', 'author_first_name',
2265 + 'author_last_name', 'author_link', 'author_link_alt', 'author_name',
2266 + 'author_url', 'blog_link', 'blog_title', 'categories', 'category',
2267 + 'category_link', 'category_link_alt', 'current_date', 'current_day',
2268 + 'current_month', 'current_year', 'custom_field', 'description',
2269 + 'event_end_date', 'event_start_date', 'featured_image',
2270 + 'featured_image_url', 'page_number', 'parent_title', 'permalink',
2271 + 'post_content', 'post_date', 'post_date_w3c', 'post_day',
2272 + 'post_excerpt', 'post_excerpt_only', 'post_link', 'post_link_alt',
2273 + 'post_modified_date', 'post_modified_date_w3c', 'post_month',
2274 + 'post_title', 'post_year', 'search_term', 'separator_sa',
2275 + 'site_description', 'site_link', 'site_link_alt', 'site_title',
2276 + 'tagline', 'tax_name', 'tax_parent_name', 'taxonomy_description',
2277 + 'taxonomy_title', 'woocommerce_brand', 'woocommerce_price',
2278 + 'woocommerce_sku',
2279 + ];
2280 + }
2281 +
2282 + /**
2283 + * Resolve AIOSEO smart tags to their values.
2284 + *
2285 + * AIOSEO denotes a tag with a single leading '#' and no closing delimiter
2286 + * (Tags::$denotationChar), ending the match with a negative lookahead so
2287 + * supersets do not collide — #post_excerpt must not consume the start of
2288 + * #post_excerpt_only, nor #post_link the start of #post_link_alt. We use
2289 + * the same rule here so results match what AIOSEO itself would render,
2290 + * and so replacement order is irrelevant.
2291 + *
2292 + * @param string $text Text that may contain AIOSEO tags.
2293 + * @param int $post_id Post ID used for context.
2294 + * @param string $sep Separator glyph for #separator_sa.
2295 + * @return string Text with AIOSEO tags replaced.
2296 + */
2297 + private function resolve_aioseo_placeholders($text, $post_id, $sep = '-') {
2298 + if (!is_string($text) || strpos($text, '#') === false) {
2299 + return (string) $text;
2300 + }
2301 +
2302 + $post = get_post($post_id);
2303 + if (!$post) {
2304 + return $text;
2305 + }
2306 +
2307 + // Tags that carry an argument have to be handled before the plain ones.
2308 + // The plain pattern's lookahead permits '-', so '#custom_field-price'
2309 + // would otherwise match bare '#custom_field' and leave an orphan
2310 + // '-price' glued to the resolved value. AIOSEO splits these out for the
2311 + // same reason (Tags::replaceTags() skips them, then parseCustomFields()
2312 + // and parseTaxonomyNames() run afterwards).
2313 + $text = $this->resolve_aioseo_parameterised_tags($text, $post_id);
2314 +
2315 + // Resolve only the tags the text actually references. Building every
2316 + // value up front meant generating an excerpt for every imported post,
2317 + // and excerpt generation runs the whole the_content filter chain — ~8x
2318 + // the cost per post on real (page-builder) content, for a value the
2319 + // template usually never mentions.
2320 + foreach ($this->get_aioseo_tag_ids() as $tag) {
2321 + $pattern = '/#' . preg_quote($tag, '/') . '(?![a-zA-Z0-9_])/i';
2322 + if (!preg_match($pattern, $text)) {
2323 + continue;
2324 + }
2325 +
2326 + $value = $this->resolve_aioseo_tag($tag, $post_id, $post, $sep);
2327 + if ($value === null) {
2328 + // Not resolvable outside AIOSEO. Leave it in place so
2329 + // has_unresolved_placeholders() can stop the write.
2330 + continue;
2331 + }
2332 +
2333 + // Escape backslashes first, then '$', so a value containing "$1"
2334 + // is not treated as a backreference by preg_replace().
2335 + $replacement = str_replace(['\\', '$'], ['\\\\', '\\$'], (string) $value);
2336 + $result = preg_replace($pattern, $replacement, $text);
2337 + if (is_string($result)) {
2338 + $text = $result;
2339 + }
2340 + }
2341 +
2342 + return $text;
2343 + }
2344 +
2345 + /**
2346 + * Resolve the two AIOSEO tags that take an argument after a hyphen:
2347 + * '#custom_field-{meta_key}' and '#tax_name-{taxonomy}'.
2348 + *
2349 + * Patterns and fallback order mirror AIOSEO's parseCustomFields() and
2350 + * parseTaxonomyNames(), including the detail that a bare '#custom_field' or
2351 + * '#tax_name' with no argument resolves to an empty string rather than
2352 + * being left in place.
2353 + *
2354 + * @param string $text Text that may contain parameterised tags.
2355 + * @param int $post_id Post ID used for context.
2356 + * @return string Text with parameterised tags replaced.
2357 + */
2358 + private function resolve_aioseo_parameterised_tags($text, $post_id) {
2359 + $text = preg_replace_callback(
2360 + '/#custom_field-([a-zA-Z0-9_-]+)/i',
2361 + function ($m) use ($post_id) {
2362 + return $this->resolve_aioseo_custom_field($m[1], $post_id);
2363 + },
2364 + $text
2365 + );
2366 +
2367 + $text = preg_replace_callback(
2368 + '/#tax_name-([a-zA-Z0-9_-]+)/i',
2369 + function ($m) use ($post_id) {
2370 + return $this->resolve_aioseo_taxonomy_name($m[1], $post_id);
2371 + },
2372 + $text
2373 + );
2374 +
2375 + // With no argument AIOSEO drops the token entirely.
2376 + $text = preg_replace('/#custom_field(?![a-zA-Z0-9_-])/i', '', (string) $text);
2377 + $text = preg_replace('/#tax_name(?![a-zA-Z0-9_-])/i', '', (string) $text);
2378 +
2379 + return (string) $text;
2380 + }
2381 +
2382 + /**
2383 + * Value of a custom field referenced by '#custom_field-{key}'.
2384 + *
2385 + * ACF is consulted first when present, matching AIOSEO's own order, so a
2386 + * field with both an ACF definition and raw post meta resolves the way the
2387 + * live site rendered it. Non-scalar values (arrays, objects) have no textual
2388 + * form and become an empty string.
2389 + *
2390 + * @param string $field_key Meta key captured from the tag.
2391 + * @param int $post_id Post ID.
2392 + * @return string Field value as text.
2393 + */
2394 + private function resolve_aioseo_custom_field($field_key, $post_id) {
2395 + $value = '';
2396 +
2397 + if (function_exists('get_field')) {
2398 + $value = get_field($field_key, $post_id);
2399 + }
2400 +
2401 + if (empty($value)) {
2402 + $value = get_post_meta($post_id, $field_key, true);
2403 + }
2404 +
2405 + return is_scalar($value) ? wp_strip_all_tags((string) $value) : '';
2406 + }
2407 +
2408 + /**
2409 + * Term name for the taxonomy referenced by '#tax_name-{taxonomy}'.
2410 + *
2411 + * AIOSEO prefers its own "primary term" for the taxonomy and falls back to
2412 + * the first assigned term. We have no primary-term store to read once
2413 + * AIOSEO is gone, so the first assigned term is used directly.
2414 + *
2415 + * @param string $taxonomy Taxonomy slug captured from the tag.
2416 + * @param int $post_id Post ID.
2417 + * @return string Term name, or '' when unavailable.
2418 + */
2419 + private function resolve_aioseo_taxonomy_name($taxonomy, $post_id) {
2420 + if (!taxonomy_exists($taxonomy)) {
2421 + return '';
2422 + }
2423 +
2424 + $terms = get_the_terms($post_id, $taxonomy);
2425 + if (empty($terms) || is_wp_error($terms)) {
2426 + return '';
2427 + }
2428 +
2429 + $term = reset($terms);
2430 +
2431 + return (string) $term->name;
2432 + }
2433 +
2434 + /**
2435 + * Value for a single AIOSEO tag in the context of one post.
2436 + *
2437 + * Every tag AIOSEO can render for a post is resolved here. The two that take
2438 + * an argument (#custom_field, #tax_name) are handled before this switch and
2439 + * so return null, which callers treat as "leave in place" — a leftover then
2440 + * gets reported and the write skipped rather than saved half-resolved.
2441 + *
2442 + * @param string $tag AIOSEO tag id, without the leading '#'.
2443 + * @param int $post_id Post ID.
2444 + * @param WP_Post $post Post object.
2445 + * @param string $sep Separator glyph.
2446 + * @return string|null Replacement value, or null if unresolvable.
2447 + */
2448 + private function resolve_aioseo_tag($tag, $post_id, $post, $sep) {
2449 + // Second line of defence behind the whole-value check in
2450 + // resolve_seo_placeholders(). Any future caller that reaches a single
2451 + // tag directly still cannot read out a protected post's body.
2452 + if (!empty($post->post_password)
2453 + && in_array('#' . $tag, $this->content_derived_tokens('aioseo'), true)) {
2454 + return '';
2455 + }
2456 +
2457 + switch ($tag) {
2458 + case 'site_title':
2459 + case 'blog_title':
2460 + return (string) get_bloginfo('name');
2461 +
2462 + case 'tagline':
2463 + case 'site_description':
2464 + return (string) get_bloginfo('description');
2465 +
2466 + case 'post_title':
2467 + return (string) get_the_title($post_id);
2468 +
2469 + case 'parent_title':
2470 + $parent = isset($post->post_parent) ? (int) $post->post_parent : 0;
2471 + return $parent ? (string) get_the_title($parent) : '';
2472 +
2473 + case 'post_excerpt_only':
2474 + // Manual excerpt only — AIOSEO never falls back for this one.
2475 + return isset($post->post_excerpt)
2476 + ? $this->strip_builder_markup((string) $post->post_excerpt)
2477 + : '';
2478 +
2479 + case 'post_excerpt':
2480 + // Manual excerpt if set, otherwise a content-derived one, which
2481 + // is what AIOSEO does. This is the expensive branch, so it only
2482 + // runs when the template actually references the tag.
2483 + $manual = isset($post->post_excerpt) ? (string) $post->post_excerpt : '';
2484 + return $this->strip_builder_markup(
2485 + $manual !== '' ? $manual : (string) get_the_excerpt($post)
2486 + );
2487 +
2488 + case 'post_content':
2489 + return $this->strip_builder_markup((string) $post->post_content);
2490 +
2491 + case 'separator_sa':
2492 + return (string) $sep;
2493 +
2494 + case 'author_name':
2495 + return (string) get_the_author_meta('display_name', (int) $post->post_author);
2496 + case 'author_first_name':
2497 + return (string) get_the_author_meta('first_name', (int) $post->post_author);
2498 + case 'author_last_name':
2499 + return (string) get_the_author_meta('last_name', (int) $post->post_author);
2500 + case 'author_bio':
2501 + return (string) get_the_author_meta('description', (int) $post->post_author);
2502 +
2503 + case 'author_link':
2504 + case 'author_link_alt':
2505 + case 'author_url':
2506 + $author = isset($post->post_author) ? (int) $post->post_author : 0;
2507 + return $author ? (string) get_author_posts_url($author) : '';
2508 +
2509 + case 'current_date':
2510 + $format = get_option('date_format');
2511 + $format = (is_string($format) && $format !== '') ? $format : 'F j, Y';
2512 + return (string) date_i18n($format);
2513 + case 'current_day':
2514 + return (string) date_i18n('d');
2515 + case 'current_month':
2516 + return (string) date_i18n('F');
2517 + case 'current_year':
2518 + return (string) date_i18n('Y');
2519 +
2520 + case 'post_date':
2521 + return (string) get_the_date('', $post_id);
2522 + case 'post_day':
2523 + return (string) get_the_date('d', $post_id);
2524 + case 'post_month':
2525 + return (string) get_the_date('F', $post_id);
2526 + case 'post_year':
2527 + return (string) get_the_date('Y', $post_id);
2528 +
2529 + case 'permalink':
2530 + case 'post_link':
2531 + case 'post_link_alt':
2532 + return (string) get_permalink($post_id);
2533 +
2534 + case 'site_link':
2535 + case 'site_link_alt':
2536 + case 'blog_link':
2537 + return (string) home_url();
2538 +
2539 + case 'categories':
2540 + $names = [];
2541 + foreach ($this->get_post_categories($post_id) as $category) {
2542 + if (isset($category->name)) {
2543 + $names[] = $category->name;
2544 + }
2545 + }
2546 + return implode(', ', $names);
2547 +
2548 + case 'category':
2549 + case 'taxonomy_title':
2550 + $categories = $this->get_post_categories($post_id);
2551 + return !empty($categories) && isset($categories[0]->name) ? (string) $categories[0]->name : '';
2552 +
2553 + case 'taxonomy_description':
2554 + $categories = $this->get_post_categories($post_id);
2555 + return !empty($categories) && isset($categories[0]->description) ? (string) $categories[0]->description : '';
2556 +
2557 + case 'category_link':
2558 + case 'category_link_alt':
2559 + $categories = $this->get_post_categories($post_id);
2560 + return !empty($categories) && isset($categories[0]->term_id)
2561 + ? (string) get_category_link($categories[0]->term_id)
2562 + : '';
2563 +
2564 + case 'featured_image':
2565 + return (string) get_the_post_thumbnail_url($post_id, 'full');
2566 +
2567 + case 'featured_image_url':
2568 + $thumbnail_id = get_post_thumbnail_id($post_id);
2569 + if (!$thumbnail_id) {
2570 + return '';
2571 + }
2572 + $src = wp_get_attachment_image_src($thumbnail_id, 'full');
2573 + return (is_array($src) && !empty($src[0])) ? (string) $src[0] : '';
2574 +
2575 + case 'alt_tag':
2576 + // AIOSEO reads the alt off the post id itself, not off the
2577 + // featured image's attachment. Mirror that: on an ordinary post
2578 + // this is empty, which is what the live site rendered.
2579 + return (string) get_post_meta($post_id, '_wp_attachment_image_alt', true);
2580 +
2581 + case 'attachment_caption':
2582 + return (string) wp_get_attachment_caption($post_id);
2583 +
2584 + case 'attachment_description':
2585 + return $this->strip_builder_markup((string) $post->post_content);
2586 +
2587 + case 'post_modified_date':
2588 + return (string) get_the_modified_date('', $post_id);
2589 +
2590 + case 'post_date_w3c':
2591 + return (string) mysql2date(DATE_W3C, $post->post_date, false);
2592 +
2593 + case 'post_modified_date_w3c':
2594 + return (string) mysql2date(DATE_W3C, $post->post_modified, false);
2595 +
2596 + case 'woocommerce_sku':
2597 + return (string) get_post_meta($post_id, '_sku', true);
2598 +
2599 + case 'woocommerce_price':
2600 + return (string) get_post_meta($post_id, '_price', true);
2601 +
2602 + // AIOSEO renders these empty in a single-post context.
2603 + case 'description':
2604 + case 'page_number':
2605 + case 'search_term':
2606 + case 'archive_date':
2607 + case 'archive_title':
2608 + // Term-context only. AIOSEO resolves #tax_parent_name off a term id,
2609 + // so it is empty for a post.
2610 + case 'tax_parent_name':
2611 + // Supplied by AIOSEO's event add-on, which has no data to read here.
2612 + case 'event_start_date':
2613 + case 'event_end_date':
2614 + case 'woocommerce_brand':
2615 + return '';
2616 + }
2617 +
2618 + // #custom_field and #tax_name take an argument and are resolved ahead of
2619 + // this switch, in resolve_aioseo_parameterised_tags().
2620 + return null;
2621 + }
2622 +
2623 + /**
2624 + * Reduce raw post content to plain prose.
2625 + *
2626 + * Page-builder pages are almost entirely shortcode markup, so a description
2627 + * template built on the content imports as '[et_pb_section][et_pb_row]…'
2628 + * without this. AIOSEO strips shortcodes too, but strip_shortcodes() only
2629 + * removes tags that are *registered* at the time it runs, and a builder
2630 + * registers its own only in the contexts it renders in — not necessarily
2631 + * during an import request. So sweep any remaining shortcode-shaped tokens
2632 + * as well, which also covers content left behind by a builder that is no
2633 + * longer installed.
2634 + *
2635 + * @param string $content Raw post content.
2636 + * @return string Plain text.
2637 + */
2638 + private function strip_builder_markup($content) {
2639 + $content = strip_shortcodes($content);
2640 +
2641 + // Opening/closing shortcode-shaped tokens: [tag], [tag a="b"], [/tag].
2642 + $content = preg_replace('/\[\/?[a-z][a-z0-9_-]*(?:\s[^\]]*)?\]/i', ' ', (string) $content);
2643 +
2644 + return trim(preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $content)));
2645 + }
2646 +
2647 + /**
2648 + * Categories for a post, normalised to an array.
2649 + *
2650 + * @param int $post_id Post ID.
2651 + * @return array
2652 + */
2653 + private function get_post_categories($post_id) {
2654 + $categories = get_the_category($post_id);
2655 + return is_array($categories) ? $categories : [];
2656 + }
2657 +
2658 + /**
2659 + * Whether a value still contains AIOSEO tags after resolution.
2660 + *
2661 + * Callers use this to skip the write instead of persisting a literal
2662 + * token such as '#post_title' as a page title. A tag that legitimately
2663 + * resolves to an empty string is gone from the text and so is not
2664 + * reported here.
2665 + *
2666 + * Only AIOSEO is checked — see the fall-through comment at the end for why
2667 + * Yoast and Rank Math are excluded.
2668 + *
2669 + * @param string $text Resolved text.
2670 + * @param string $plugin Source plugin; only 'aioseo' is checked.
2671 + * @return bool True when at least one tag survived.
2672 + */
2673 + private function has_unresolved_placeholders($text, $plugin) {
2674 + if (!is_string($text) || $text === '') {
2675 + return false;
2676 + }
2677 +
2678 + switch ($plugin) {
2679 + case 'aioseo':
2680 + if (strpos($text, '#') === false) {
2681 + return false;
2682 + }
2683 + foreach ($this->get_aioseo_tag_ids() as $tag) {
2684 + if (preg_match('/#' . preg_quote($tag, '/') . '(?![a-zA-Z0-9_])/i', $text)) {
2685 + return true;
2686 + }
2687 + }
2688 + return false;
2689 + }
2690 +
2691 + // Yoast and Rank Math deliberately fall through. Their manual fallback
2692 + // maps cover only a fraction of the variables those plugins define
2693 + // (16 of ~60 for Yoast, 12 of 49 for Rank Math), so treating a leftover
2694 + // token as a reason to skip would drop common real-world titles
2695 + // wholesale — "Best Dentist in %currentyear% %sep% %sitename%" resolves
2696 + // only partly and would import as nothing at all. Until those maps are
2697 + // filled in, their existing behaviour (save the partly-resolved value)
2698 + // is the lesser evil and is left untouched.
2699 + return false;
2700 + }
2701 +
2702 + /**
2703 + * Resolve an imported value and decide whether it is safe to persist.
2704 + *
2705 + * Returns null when a placeholder survived resolution, so the caller skips
2706 + * the write rather than saving a literal '#post_title' that would then
2707 + * render as the page title and outrank OTTO's recommendation. Skipped
2708 + * values are collected in $unresolved so the import report can surface
2709 + * them.
2710 + *
2711 + * @param string $raw Raw value from the source plugin.
2712 + * @param int $post_id Post ID used for context.
2713 + * @param string $plugin Source plugin ('yoast', 'rankmath', 'aioseo').
2714 + * @param string $meta_key Destination meta key, for the report.
2715 + * @param array $unresolved Collected failures, appended to by reference.
2716 + * @return string|null Resolved value, or null when it must be skipped.
2717 + */
2718 + private function resolve_import_value($raw, $post_id, $plugin, $meta_key, array &$unresolved) {
2719 + $resolved = $this->resolve_seo_placeholders($raw, $post_id, $plugin);
2720 +
2721 + if ($this->has_unresolved_placeholders($resolved, $plugin)) {
2722 + $unresolved[] = [
2723 + 'post_id' => (int) $post_id,
2724 + 'meta_key' => $meta_key,
2725 + 'raw' => (string) $raw,
2726 + 'resolved' => (string) $resolved,
2727 + ];
2728 +
2729 + if (defined('WP_DEBUG') && WP_DEBUG) {
2730 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
2731 + 'MetaSync importer: unresolved %s placeholder for post %d (%s) — %s',
2732 + $plugin,
2733 + (int) $post_id,
2734 + $meta_key,
2735 + (string) $raw
2736 + ));
2737 + }
2738 +
2739 + return null;
2740 + }
2741 +
2742 + return $resolved;
2743 + }
2744 +
2745 + /**
2746 + * Sanitize an imported value according to what its destination key holds.
2747 + *
2748 + * Kept in one place because the post and term writers must agree: they are
2749 + * two copies of the same rule, and the pair has already had to be patched in
2750 + * lockstep once (image keys were reaching the free-text sanitizer).
2751 + *
2752 + * An image key goes through esc_url_raw(), which returns '' for anything it
2753 + * will not vouch for — a bare word, a javascript: scheme, a mangled host.
2754 + * Callers must treat that '' as "could not sanitize", not as "empty value";
2755 + * the two mean different things to an overwrite run.
2756 + *
2757 + * @param string $value Resolved value from the source plugin.
2758 + * @param string $meta_key Imported destination key.
2759 + * @return string Sanitized value, or '' if it did not survive.
2760 + */
2761 + private function sanitize_imported_value($value, $meta_key) {
2762 + if ($meta_key === Metasync_Seo_Precedence::KEY_IMPORTED_TITLE) {
2763 + return sanitize_text_field($value);
2764 + }
2765 +
2766 + if (in_array($meta_key, array(
2767 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
2768 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
2769 + ), true)) {
2770 + return esc_url_raw($value);
2771 + }
2772 +
2773 + return sanitize_textarea_field($value);
2774 + }
2775 +
2776 + /**
2777 + * Persist an imported title or description on its low-priority key.
2778 + *
2779 + * Every importer routes through here so the three source plugins cannot
2780 + * drift apart on the rules that matter: imported values never land on the
2781 + * customer-override keys, a value that resolves to nothing is not written,
2782 + * and an overwrite run clears a now-stale value instead of preserving it.
2783 + *
2784 + * @param int $post_id Post being imported.
2785 + * @param string $raw Raw value from the source plugin.
2786 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
2787 + * @param string $meta_key Imported destination key.
2788 + * @param bool $overwrite Whether the run replaces existing values.
2789 + * @param array $unresolved Collected failures, appended to by reference.
2790 + * @param bool $truncate Trim to meta-description length first.
2791 + * @return bool Whether the post's meta changed.
2792 + */
2793 + private function store_imported_post_value($post_id, $raw, $plugin, $meta_key, $overwrite, array &$unresolved, $truncate = false) {
2794 + if ($raw === '') {
2795 + return false;
2796 + }
2797 +
2798 + $existing = get_post_meta($post_id, $meta_key, true);
2799 + if (!empty($existing) && !$overwrite) {
2800 + return false;
2801 + }
2802 +
2803 + $resolved = $this->resolve_import_value($raw, $post_id, $plugin, $meta_key, $unresolved);
2804 +
2805 + if ($resolved !== null && $truncate) {
2806 + $resolved = $this->truncate_meta_description($resolved);
2807 + }
2808 +
2809 + if ($resolved !== null && trim($resolved) !== '') {
2810 + $sanitized = $this->sanitize_imported_value($resolved, $meta_key);
2811 +
2812 + // The source gave us something, but it could not survive
2813 + // sanitization — an og:image/twitter:image URL rejected by
2814 + // esc_url_raw() is the case this guards. Writing the resulting ''
2815 + // would blank a good stored image and still report the import as a
2816 + // success. Skip instead: no write, no success, and whatever is
2817 + // already stored stays. A genuinely empty source is a different
2818 + // thing and is handled by the overwrite branch below.
2819 + if ($sanitized === '') {
2820 + return false;
2821 + }
2822 +
2823 + // update_post_meta() unslashes what it is given, so a value holding
2824 + // a backslash ("AC\DC") would be stored stripped ("ACDC"). Slash it
2825 + // here so the round trip through meta storage is lossless.
2826 + update_post_meta($post_id, $meta_key, wp_slash($sanitized));
2827 + return true;
2828 + }
2829 +
2830 + // Overwrite means "make this match the source". The source now yields
2831 + // nothing, so a value left from an earlier run is stale — clear it so
2832 + // re-importing repairs rather than preserves. Only ever touches the
2833 + // imported key, never anything the customer typed.
2834 + if ($overwrite && !empty($existing)) {
2835 + delete_post_meta($post_id, $meta_key);
2836 + return true;
2837 + }
2838 +
2839 + return false;
2840 + }
2841 +
2842 + /**
2843 + * Resolve placeholder tokens in a value imported for a taxonomy term.
2844 + *
2845 + * The post resolver cannot be reused. Every token it answers comes off a
2846 + * WP_Post, and a term has no post to answer from — asking it would either
2847 + * blank the value or, worse, resolve it against whatever post happens to
2848 + * share the term's ID. The vocabularies barely overlap anyway: a term
2849 + * template is built from the term's own name and description, so this is a
2850 + * deliberately small, separate map.
2851 + *
2852 + * @param string $text Raw value from the source plugin.
2853 + * @param WP_Term $term Term the value belongs to.
2854 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
2855 + * @return string Resolved string.
2856 + */
2857 + private function resolve_term_placeholders($text, $term, $plugin) {
2858 + if (empty($text) || empty($term->term_id)) {
2859 + return (string) $text;
2860 + }
2861 +
2862 + // Yoast and Rank Math both accept a term in place of a post, so their
2863 + // own engines still give the exact string the source site rendered.
2864 + if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
2865 + try {
2866 + $replacer = new WPSEO_Replace_Vars();
2867 + $resolved = $replacer->replace($text, $term);
2868 + if (is_string($resolved) && $resolved !== '') {
2869 + return $resolved;
2870 + }
2871 + } catch (Exception $e) {
2872 + // fall through to manual replacement
2873 + }
2874 + }
2875 +
2876 + if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
2877 + try {
2878 + $resolved = rank_math_replace_vars($text, $term);
2879 + if (is_string($resolved) && $resolved !== '') {
2880 + return $resolved;
2881 + }
2882 + } catch (Exception $e) {
2883 + // fall through to manual replacement
2884 + }
2885 + }
2886 +
2887 + // AIOSEO's replaceTags() takes a post ID. Handing it a term ID does not
2888 + // fail — it resolves against whatever post shares that ID and returns
2889 + // another page's title, so the native engine is deliberately skipped
2890 + // here and the manual map below is the only path for AIOSEO terms.
2891 + return $this->manually_replace_term_placeholders($text, $term, $plugin);
2892 + }
2893 +
2894 + /**
2895 + * Manual token replacement for term values.
2896 + *
2897 + * @param string $text
2898 + * @param WP_Term $term
2899 + * @param string $plugin
2900 + * @return string
2901 + */
2902 + private function manually_replace_term_placeholders($text, $term, $plugin) {
2903 + $site_name = (string) get_bloginfo('name');
2904 + $site_desc = (string) get_bloginfo('description');
2905 + $sep = $this->resolve_source_separator($plugin);
2906 + $year = (string) gmdate('Y');
2907 +
2908 + $term_name = (string) $term->name;
2909 + $term_desc = wp_strip_all_tags((string) $term->description);
2910 +
2911 + $map = [
2912 + // ── Yoast (%%var%%) ──────────────────────────────────────────────
2913 + '%%term_title%%' => $term_name,
2914 + '%%term_description%%' => $term_desc,
2915 + '%%category%%' => $term_name,
2916 + '%%category_description%%' => $term_desc,
2917 + '%%tag%%' => $term_name,
2918 + '%%sitename%%' => $site_name,
2919 + '%%sitedesc%%' => $site_desc,
2920 + '%%sep%%' => $sep,
2921 + '%%currentyear%%' => $year,
2922 + '%%page%%' => '',
2923 + '%%pagenumber%%' => '',
2924 + '%%pagetotal%%' => '',
2925 + // ── Rank Math (%var%) ────────────────────────────────────────────
2926 + '%term%' => $term_name,
2927 + '%term_description%' => $term_desc,
2928 + '%category%' => $term_name,
2929 + '%tag%' => $term_name,
2930 + '%sitename%' => $site_name,
2931 + '%sitedesc%' => $site_desc,
2932 + '%sep%' => $sep,
2933 + '%currentyear%' => $year,
2934 + '%page%' => '',
2935 + // ── AIOSEO (#var) ────────────────────────────────────────────────
2936 + '#taxonomy_title' => $term_name,
2937 + '#taxonomy_description' => $term_desc,
2938 + '#archive_title' => $term_name,
2939 + '#site_title' => $site_name,
2940 + '#blog_title' => $site_name,
2941 + '#site_description' => $site_desc,
2942 + '#tagline' => $site_desc,
2943 + '#separator_sa' => $sep,
2944 + '#current_year' => $year,
2945 + '#page_number' => '',
2946 + ];
2947 +
2948 + // Longest token first, so a short token never eats the start of a
2949 + // longer one (#site_title must not consume #site_description).
2950 + uksort($map, function ($a, $b) {
2951 + return strlen($b) - strlen($a);
2952 + });
2953 +
2954 + $resolved = str_replace(array_keys($map), array_values($map), $text);
2955 +
2956 + return trim(preg_replace('/\s+/', ' ', $resolved));
2957 + }
2958 +
2959 + /**
2960 + * Persist an imported term title or description on its low-priority key.
2961 + *
2962 + * Mirrors store_imported_post_value(): imported values never land on the
2963 + * key that means "the customer set this", a value that resolves to nothing
2964 + * is not written, and an overwrite run clears a now-stale value.
2965 + *
2966 + * @param WP_Term $term Term being imported.
2967 + * @param string $raw Raw value from the source plugin.
2968 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
2969 + * @param string $meta_key Imported destination key.
2970 + * @param bool $overwrite Whether the run replaces existing values.
2971 + * @return bool Whether the term's meta changed.
2972 + */
2973 + private function store_imported_term_value($term, $raw, $plugin, $meta_key, $overwrite) {
2974 + if ($raw === '') {
2975 + return false;
2976 + }
2977 +
2978 + $term_id = (int) $term->term_id;
2979 + $existing = get_term_meta($term_id, $meta_key, true);
2980 + if (!empty($existing) && !$overwrite) {
2981 + return false;
2982 + }
2983 +
2984 + $resolved = $this->resolve_term_placeholders($raw, $term, $plugin);
2985 +
2986 + // A token we do not know would otherwise be stored literally and render
2987 + // as '#taxonomy_title' on the archive — the defect this path exists to
2988 + // prevent. Skipping leaves the slot free for OTTO.
2989 + if ($this->has_unresolved_placeholders($resolved, $plugin)) {
2990 + return false;
2991 + }
2992 +
2993 + if (trim($resolved) !== '') {
2994 + $sanitized = $this->sanitize_imported_value($resolved, $meta_key);
2995 +
2996 + // See store_imported_post_value(): a value the source supplied but
2997 + // sanitization rejected must not blank an existing one, and must
2998 + // not count as a successful update.
2999 + if ($sanitized === '') {
3000 + return false;
3001 + }
3002 +
3003 + // update_term_meta() unslashes what it stores; slash first so a
3004 + // backslash in the value survives the round trip.
3005 + update_term_meta($term_id, $meta_key, wp_slash($sanitized));
3006 + return true;
3007 + }
3008 +
3009 + if ($overwrite && !empty($existing)) {
3010 + delete_term_meta($term_id, $meta_key);
3011 + return true;
3012 + }
3013 +
3014 + return false;
3015 + }
3016 +
3017 + /**
3018 + * Add unresolved-placeholder reporting to an import result.
3019 + *
3020 + * @param array $result Result array to augment.
3021 + * @param array $unresolved Failures collected during the batch.
3022 + * @return array Result with the report attached.
3023 + */
3024 + private function attach_unresolved_report(array $result, array $unresolved) {
3025 + $count = count($unresolved);
3026 + $result['unresolved'] = $count;
3027 +
3028 + if ($count === 0) {
3029 + return $result;
3030 + }
3031 +
3032 + // Keep the payload small — a few examples are enough to diagnose.
3033 + $result['unresolved_samples'] = array_slice($unresolved, 0, 5);
3034 +
3035 + if (!empty($result['message'])) {
3036 + $result['message'] .= sprintf(
3037 + ' %d value(s) skipped because placeholders could not be resolved.',
3038 + $count
3039 + );
3040 + }
3041 +
3042 + return $result;
3043 + }
3044 +
3045 + /**
1892 3046 * Import SEO Metadata (Titles and Descriptions)
1893 3047 * Supports batch processing via AJAX
1894 3048 *
1895 3049 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
@@ -1973,22 +3127,23 @@
1973 3127 ];
1974 3128 }
1975 3129
1976 3130 // Get total count (for progress tracking)
1977 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1978 - $total_posts = (int) $wpdb->get_var($wpdb->prepare("
3131 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3132 + $total_posts = (int) $wpdb->get_var($wpdb->prepare('
1979 3133 SELECT COUNT(DISTINCT post_id)
1980 - FROM {$wpdb->postmeta}
1981 - WHERE meta_key IN ($meta_keys_placeholders)
3134 + FROM ' . $wpdb->postmeta . '
3135 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1982 3136 AND meta_value != ''
1983 3137 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1984 3138 ", $meta_keys));
1985 3139
1986 3140 // Get batch of posts with Yoast data
1987 - $posts = $wpdb->get_results($wpdb->prepare("
3141 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3142 + $posts = $wpdb->get_results($wpdb->prepare('
1988 3143 SELECT DISTINCT post_id
1989 - FROM {$wpdb->postmeta}
1990 - WHERE meta_key IN ($meta_keys_placeholders)
3144 + FROM ' . $wpdb->postmeta . '
3145 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1991 3146 AND meta_value != ''
1992 3147 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1993 3148 ORDER BY post_id ASC
1994 3149 LIMIT %d OFFSET %d
@@ -1995,8 +3150,9 @@
1995 3150 ", array_merge($meta_keys, [$batch_size, $offset])));
1996 3151
1997 3152 $imported_count = 0;
1998 3153 $skipped_count = 0;
3154 + $unresolved = [];
1999 3155
2000 3156 foreach ($posts as $post_obj) {
2001 3157 $post_id = $post_obj->post_id;
2002 3158 $updated = false;
@@ -2004,15 +3160,16 @@
2004 3160 // Import title
2005 3161 if ($import_titles) {
2006 3162 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
2007 3163 if (!empty($yoast_title)) {
2008 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2009 -
2010 - if (empty($existing_title) || $overwrite) {
2011 - $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
2012 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
2013 - $updated = true;
2014 - }
3164 + $updated = $this->store_imported_post_value(
3165 + $post_id,
3166 + (string) $yoast_title,
3167 + 'yoast',
3168 + Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3169 + $overwrite,
3170 + $unresolved
3171 + );
2015 3172 }
2016 3173 }
2017 3174
2018 3175 // Import description
@@ -2018,15 +3175,16 @@
2018 3175 // Import description
2019 3176 if ($import_descriptions) {
2020 3177 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
2021 3178 if (!empty($yoast_desc)) {
2022 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2023 -
2024 - if (empty($existing_desc) || $overwrite) {
2025 - $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
2026 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
2027 - $updated = true;
2028 - }
3179 + $updated = $this->store_imported_post_value(
3180 + $post_id,
3181 + (string) $yoast_desc,
3182 + 'yoast',
3183 + Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3184 + $overwrite,
3185 + $unresolved
3186 + ) || $updated;
2029 3187 }
2030 3188 }
2031 3189
2032 3190 // Import social text
@@ -2032,44 +3190,52 @@
2032 3190 // Import social text
2033 3191 if ($import_social_text) {
2034 3192 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
2035 3193 if (!empty($yoast_og_title)) {
2036 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2037 - if (empty($existing) || $overwrite) {
2038 - $yoast_og_title = $this->resolve_seo_placeholders($yoast_og_title, $post_id, 'yoast');
2039 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($yoast_og_title));
2040 - $updated = true;
2041 - }
3194 + $updated = $this->store_imported_post_value(
3195 + $post_id,
3196 + (string) $yoast_og_title,
3197 + 'yoast',
3198 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
3199 + $overwrite,
3200 + $unresolved
3201 + ) || $updated;
2042 3202 }
2043 3203
2044 3204 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
2045 3205 if (!empty($yoast_og_desc)) {
2046 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2047 - if (empty($existing) || $overwrite) {
2048 - $yoast_og_desc = $this->resolve_seo_placeholders($yoast_og_desc, $post_id, 'yoast');
2049 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($yoast_og_desc));
2050 - $updated = true;
2051 - }
3206 + $updated = $this->store_imported_post_value(
3207 + $post_id,
3208 + (string) $yoast_og_desc,
3209 + 'yoast',
3210 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
3211 + $overwrite,
3212 + $unresolved
3213 + ) || $updated;
2052 3214 }
2053 3215
2054 3216 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
2055 3217 if (!empty($yoast_tw_title)) {
2056 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2057 - if (empty($existing) || $overwrite) {
2058 - $yoast_tw_title = $this->resolve_seo_placeholders($yoast_tw_title, $post_id, 'yoast');
2059 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($yoast_tw_title));
2060 - $updated = true;
2061 - }
3218 + $updated = $this->store_imported_post_value(
3219 + $post_id,
3220 + (string) $yoast_tw_title,
3221 + 'yoast',
3222 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
3223 + $overwrite,
3224 + $unresolved
3225 + ) || $updated;
2062 3226 }
2063 3227
2064 3228 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
2065 3229 if (!empty($yoast_tw_desc)) {
2066 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2067 - if (empty($existing) || $overwrite) {
2068 - $yoast_tw_desc = $this->resolve_seo_placeholders($yoast_tw_desc, $post_id, 'yoast');
2069 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($yoast_tw_desc));
2070 - $updated = true;
2071 - }
3230 + $updated = $this->store_imported_post_value(
3231 + $post_id,
3232 + (string) $yoast_tw_desc,
3233 + 'yoast',
3234 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
3235 + $overwrite,
3236 + $unresolved
3237 + ) || $updated;
2072 3238 }
2073 3239 }
2074 3240
2075 3241 // Import social images
@@ -2075,22 +3241,28 @@
2075 3241 // Import social images
2076 3242 if ($import_social_images) {
2077 3243 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
2078 3244 if (!empty($yoast_og_image)) {
2079 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2080 - if (empty($existing_og) || $overwrite) {
2081 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($yoast_og_image));
2082 - $updated = true;
2083 - }
3245 + $updated = $this->store_imported_post_value(
3246 + $post_id,
3247 + (string) $yoast_og_image,
3248 + 'yoast',
3249 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
3250 + $overwrite,
3251 + $unresolved
3252 + ) || $updated;
2084 3253 }
2085 3254
2086 3255 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
2087 3256 if (!empty($yoast_twitter_image)) {
2088 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2089 - if (empty($existing_twitter) || $overwrite) {
2090 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($yoast_twitter_image));
2091 - $updated = true;
2092 - }
3257 + $updated = $this->store_imported_post_value(
3258 + $post_id,
3259 + (string) $yoast_twitter_image,
3260 + 'yoast',
3261 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
3262 + $overwrite,
3263 + $unresolved
3264 + ) || $updated;
2093 3265 }
2094 3266 }
2095 3267
2096 3268 if ($updated) {
@@ -2102,9 +3274,9 @@
2102 3274
2103 3275 $processed = $offset + count($posts);
2104 3276 $is_complete = $processed >= $total_posts;
2105 3277
2106 - return [
3278 + return $this->attach_unresolved_report([
2107 3279 'success' => true,
2108 3280 'imported' => $imported_count,
2109 3281 'skipped' => $skipped_count,
2110 3282 'total' => $total_posts,
@@ -2113,9 +3285,9 @@
2113 3285 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2114 3286 'message' => $is_complete
2115 3287 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2116 3288 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2117 - ];
3289 + ], $unresolved);
2118 3290 }
2119 3291
2120 3292 /**
2121 3293 * Import SEO metadata from Rank Math
@@ -2158,22 +3330,23 @@
2158 3330 ];
2159 3331 }
2160 3332
2161 3333 // Get total count
2162 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
2163 - $total_posts = (int) $wpdb->get_var($wpdb->prepare("
3334 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3335 + $total_posts = (int) $wpdb->get_var($wpdb->prepare('
2164 3336 SELECT COUNT(DISTINCT post_id)
2165 - FROM {$wpdb->postmeta}
2166 - WHERE meta_key IN ($meta_keys_placeholders)
3337 + FROM ' . $wpdb->postmeta . '
3338 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2167 3339 AND meta_value != ''
2168 3340 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2169 3341 ", $meta_keys));
2170 3342
2171 3343 // Get batch of posts
2172 - $posts = $wpdb->get_results($wpdb->prepare("
3344 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3345 + $posts = $wpdb->get_results($wpdb->prepare('
2173 3346 SELECT DISTINCT post_id
2174 - FROM {$wpdb->postmeta}
2175 - WHERE meta_key IN ($meta_keys_placeholders)
3347 + FROM ' . $wpdb->postmeta . '
3348 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2176 3349 AND meta_value != ''
2177 3350 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2178 3351 ORDER BY post_id ASC
2179 3352 LIMIT %d OFFSET %d
@@ -2180,8 +3353,9 @@
2180 3353 ", array_merge($meta_keys, [$batch_size, $offset])));
2181 3354
2182 3355 $imported_count = 0;
2183 3356 $skipped_count = 0;
3357 + $unresolved = [];
2184 3358
2185 3359 foreach ($posts as $post_obj) {
2186 3360 $post_id = $post_obj->post_id;
2187 3361 $updated = false;
@@ -2189,15 +3363,16 @@
2189 3363 // Import title
2190 3364 if ($import_titles) {
2191 3365 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2192 3366 if (!empty($rm_title)) {
2193 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2194 -
2195 - if (empty($existing_title) || $overwrite) {
2196 - $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2197 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2198 - $updated = true;
2199 - }
3367 + $updated = $this->store_imported_post_value(
3368 + $post_id,
3369 + (string) $rm_title,
3370 + 'rankmath',
3371 + Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3372 + $overwrite,
3373 + $unresolved
3374 + );
2200 3375 }
2201 3376 }
2202 3377
2203 3378 // Import description
@@ -2203,15 +3378,16 @@
2203 3378 // Import description
2204 3379 if ($import_descriptions) {
2205 3380 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2206 3381 if (!empty($rm_desc)) {
2207 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2208 -
2209 - if (empty($existing_desc) || $overwrite) {
2210 - $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2211 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2212 - $updated = true;
2213 - }
3382 + $updated = $this->store_imported_post_value(
3383 + $post_id,
3384 + (string) $rm_desc,
3385 + 'rankmath',
3386 + Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3387 + $overwrite,
3388 + $unresolved
3389 + ) || $updated;
2214 3390 }
2215 3391 }
2216 3392
2217 3393 // Import social text
@@ -2217,44 +3393,52 @@
2217 3393 // Import social text
2218 3394 if ($import_social_text) {
2219 3395 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
2220 3396 if (!empty($rm_og_title)) {
2221 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2222 - if (empty($existing) || $overwrite) {
2223 - $rm_og_title = $this->resolve_seo_placeholders($rm_og_title, $post_id, 'rankmath');
2224 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($rm_og_title));
2225 - $updated = true;
2226 - }
3397 + $updated = $this->store_imported_post_value(
3398 + $post_id,
3399 + (string) $rm_og_title,
3400 + 'rankmath',
3401 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
3402 + $overwrite,
3403 + $unresolved
3404 + ) || $updated;
2227 3405 }
2228 3406
2229 3407 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
2230 3408 if (!empty($rm_og_desc)) {
2231 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2232 - if (empty($existing) || $overwrite) {
2233 - $rm_og_desc = $this->resolve_seo_placeholders($rm_og_desc, $post_id, 'rankmath');
2234 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($rm_og_desc));
2235 - $updated = true;
2236 - }
3409 + $updated = $this->store_imported_post_value(
3410 + $post_id,
3411 + (string) $rm_og_desc,
3412 + 'rankmath',
3413 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
3414 + $overwrite,
3415 + $unresolved
3416 + ) || $updated;
2237 3417 }
2238 3418
2239 3419 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
2240 3420 if (!empty($rm_tw_title)) {
2241 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2242 - if (empty($existing) || $overwrite) {
2243 - $rm_tw_title = $this->resolve_seo_placeholders($rm_tw_title, $post_id, 'rankmath');
2244 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($rm_tw_title));
2245 - $updated = true;
2246 - }
3421 + $updated = $this->store_imported_post_value(
3422 + $post_id,
3423 + (string) $rm_tw_title,
3424 + 'rankmath',
3425 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
3426 + $overwrite,
3427 + $unresolved
3428 + ) || $updated;
2247 3429 }
2248 3430
2249 3431 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
2250 3432 if (!empty($rm_tw_desc)) {
2251 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2252 - if (empty($existing) || $overwrite) {
2253 - $rm_tw_desc = $this->resolve_seo_placeholders($rm_tw_desc, $post_id, 'rankmath');
2254 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($rm_tw_desc));
2255 - $updated = true;
2256 - }
3433 + $updated = $this->store_imported_post_value(
3434 + $post_id,
3435 + (string) $rm_tw_desc,
3436 + 'rankmath',
3437 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
3438 + $overwrite,
3439 + $unresolved
3440 + ) || $updated;
2257 3441 }
2258 3442 }
2259 3443
2260 3444 // Import social images
@@ -2260,22 +3444,28 @@
2260 3444 // Import social images
2261 3445 if ($import_social_images) {
2262 3446 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
2263 3447 if (!empty($rm_og_image)) {
2264 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2265 - if (empty($existing_og) || $overwrite) {
2266 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($rm_og_image));
2267 - $updated = true;
2268 - }
3448 + $updated = $this->store_imported_post_value(
3449 + $post_id,
3450 + (string) $rm_og_image,
3451 + 'rankmath',
3452 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
3453 + $overwrite,
3454 + $unresolved
3455 + ) || $updated;
2269 3456 }
2270 3457
2271 3458 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
2272 3459 if (!empty($rm_twitter_image)) {
2273 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2274 - if (empty($existing_twitter) || $overwrite) {
2275 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($rm_twitter_image));
2276 - $updated = true;
2277 - }
3460 + $updated = $this->store_imported_post_value(
3461 + $post_id,
3462 + (string) $rm_twitter_image,
3463 + 'rankmath',
3464 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
3465 + $overwrite,
3466 + $unresolved
3467 + ) || $updated;
2278 3468 }
2279 3469 }
2280 3470
2281 3471 if ($updated) {
@@ -2287,9 +3477,9 @@
2287 3477
2288 3478 $processed = $offset + count($posts);
2289 3479 $is_complete = $processed >= $total_posts;
2290 3480
2291 - return [
3481 + return $this->attach_unresolved_report([
2292 3482 'success' => true,
2293 3483 'imported' => $imported_count,
2294 3484 'skipped' => $skipped_count,
2295 3485 'total' => $total_posts,
@@ -2298,9 +3488,9 @@
2298 3488 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2299 3489 'message' => $is_complete
2300 3490 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2301 3491 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2302 - ];
3492 + ], $unresolved);
2303 3493 }
2304 3494
2305 3495 /**
2306 3496 * Import SEO metadata from All in One SEO
@@ -2318,9 +3508,9 @@
2318 3508 $overwrite = (bool) $options['overwrite_existing'];
2319 3509
2320 3510 // Check if AIOSEO table exists
2321 3511 $table = $wpdb->prefix . 'aioseo_posts';
2322 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
3512 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
2323 3513 return [
2324 3514 'success' => false,
2325 3515 'message' => 'AIOSEO database table not found.'
2326 3516 ];
@@ -2332,93 +3522,209 @@
2332 3522 'message' => 'No import options selected.'
2333 3523 ];
2334 3524 }
2335 3525
2336 - // Build WHERE clause
2337 - $where_conditions = [];
3526 + // Columns read off the AIOSEO row, excluding post_id. The same list gates
3527 + // row selection, so deriving both from one array keeps them in step — a
3528 + // WHERE that admitted a column the SELECT did not fetch would read a
3529 + // property that isn't there.
3530 + $value_columns = [];
2338 3531 if ($import_titles) {
2339 - $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
3532 + $value_columns[] = 'title';
2340 3533 }
2341 3534 if ($import_descriptions) {
2342 - $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
3535 + $value_columns[] = 'description';
2343 3536 }
2344 3537 if ($import_social_text) {
2345 - $where_conditions[] = '(og_title IS NOT NULL AND og_title != \'\')';
2346 - $where_conditions[] = '(og_description IS NOT NULL AND og_description != \'\')';
2347 - $where_conditions[] = '(twitter_title IS NOT NULL AND twitter_title != \'\')';
2348 - $where_conditions[] = '(twitter_description IS NOT NULL AND twitter_description != \'\')';
3538 + $value_columns[] = 'og_title';
3539 + $value_columns[] = 'og_description';
3540 + $value_columns[] = 'twitter_title';
3541 + $value_columns[] = 'twitter_description';
2349 3542 }
2350 3543 if ($import_social_images) {
2351 - $where_conditions[] = '(og_image_custom_url IS NOT NULL AND og_image_custom_url != \'\')';
2352 - $where_conditions[] = '(twitter_image_custom_url IS NOT NULL AND twitter_image_custom_url != \'\')';
3544 + $value_columns[] = 'og_image_custom_url';
3545 + $value_columns[] = 'twitter_image_custom_url';
2353 3546 }
2354 - $where_clause = implode(' OR ', $where_conditions);
2355 3547
2356 - // Build SELECT columns
2357 - $select_columns = ['post_id'];
2358 - if ($import_titles) {
2359 - $select_columns[] = 'title';
3548 + $build_has_value_clause = function ($prefix) use ($value_columns) {
3549 + $conditions = [];
3550 + foreach ($value_columns as $column) {
3551 + $col = $prefix . $column;
3552 + $conditions[] = "({$col} IS NOT NULL AND {$col} != '')";
3553 + }
3554 + return implode(' OR ', $conditions);
3555 + };
3556 +
3557 + $where_clause = $build_has_value_clause('');
3558 + $joined_has_value = $build_has_value_clause('a.');
3559 +
3560 + // AIOSEO serves a title and description for every post through its
3561 + // site-wide templates, not only for posts carrying a per-post override.
3562 + // Walking the AIOSEO table alone therefore reaches almost nothing on a
3563 + // typical site — overrides are the exception, templates are the rule. So
3564 + // when templates are in play, anchor the walk on the posts table and join
3565 + // the AIOSEO row in; the per-post value still wins wherever it exists.
3566 + $templates = ($import_titles || $import_descriptions)
3567 + ? $this->get_aioseo_post_type_templates()
3568 + : [];
3569 +
3570 + $template_post_types = [];
3571 + foreach ($templates as $post_type => $template) {
3572 + if (($import_titles && $template['title'] !== '')
3573 + || ($import_descriptions && $template['description'] !== '')) {
3574 + $template_post_types[] = $post_type;
3575 + }
2360 3576 }
2361 - if ($import_descriptions) {
2362 - $select_columns[] = 'description';
2363 - }
2364 - if ($import_social_text) {
2365 - $select_columns[] = 'og_title';
2366 - $select_columns[] = 'og_description';
2367 - $select_columns[] = 'twitter_title';
2368 - $select_columns[] = 'twitter_description';
2369 - }
2370 - if ($import_social_images) {
2371 - $select_columns[] = 'og_image_custom_url';
2372 - $select_columns[] = 'twitter_image_custom_url';
2373 - }
2374 - $select_clause = implode(', ', $select_columns);
2375 3577
2376 - // Get total count
2377 - $total_posts = (int) $wpdb->get_var("
2378 - SELECT COUNT(post_id)
2379 - FROM {$table}
2380 - WHERE ({$where_clause})
2381 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2382 - ");
3578 + if (!empty($template_post_types)) {
3579 + $type_placeholders = implode(', ', array_fill(0, count($template_post_types), '%s'));
2383 3580
2384 - // Get batch of posts
2385 - $posts = $wpdb->get_results($wpdb->prepare("
2386 - SELECT {$select_clause}
2387 - FROM {$table}
2388 - WHERE ({$where_clause})
2389 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2390 - ORDER BY post_id ASC
2391 - LIMIT %d OFFSET %d
2392 - ", $batch_size, $offset));
3581 + // Template mode visits every published post, not just the few with an
3582 + // override, and AIOSEO's default description template is #post_excerpt
3583 + // — which on a post without a manual excerpt runs the whole the_content
3584 + // filter chain. On page-builder content that is a large fraction of a
3585 + // second each, so the caller's batch of 50 can outlive a proxy read
3586 + // timeout. Smaller batches cost more requests and finish.
3587 + $batch_size = min($batch_size, 20);
2393 3588
3589 + // COUNT and SELECT must describe the identical row set. is_complete
3590 + // compares (offset + rows returned) against this count and the client
3591 + // loops until they meet, with no iteration cap — a count broader than
3592 + // the select spins forever, a narrower one stops early and silently
3593 + // drops the remainder. Counting the joined set rather than the posts
3594 + // table alone also keeps cardinality identical if the AIOSEO table
3595 + // ever held two rows for one post: its post_id index is not unique.
3596 + //
3597 + // The post-type list governs which posts get a *template*, so it must
3598 + // not also decide which posts get read at all: a post type with no
3599 + // template — one AIOSEO never configured, one with SEO switched off,
3600 + // one registered non-public — can still carry per-post overrides that
3601 + // the override-only walk always imported. The second arm keeps every
3602 + // row holding real AIOSEO data in scope whatever its post type, so
3603 + // adding a template never removes anything from the import.
3604 + // $joined_has_value is built only from the fixed $value_columns
3605 + // allowlist above; table names are inline literals.
3606 + $from_clause = "FROM {$wpdb->posts} p
3607 + LEFT JOIN {$wpdb->prefix}aioseo_posts a ON a.post_id = p.ID
3608 + WHERE p.post_status = 'publish'
3609 + AND (p.post_type IN (" . implode(', ', array_fill(0, count($template_post_types), '%s')) . ") OR ({$joined_has_value}))";
3610 +
3611 + // Assembled from the literal fragments above; post types bind via prepare().
3612 + $count_query = "SELECT COUNT(*) {$from_clause}";
3613 + $total_posts = (int) $wpdb->get_var($wpdb->prepare($count_query, $template_post_types)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fragments built from fixed allowlists, values bound via prepare()
3614 +
3615 + $joined_columns = array_map(
3616 + function ($column) {
3617 + return "a.{$column}";
3618 + },
3619 + $value_columns
3620 + );
3621 + // At least one import flag is set — the "no import options selected"
3622 + // guard above returns before this point — so there is always at
3623 + // least one value column to select.
3624 + $joined_select = 'p.ID AS post_id, p.post_type AS post_type, '
3625 + . implode(', ', $joined_columns);
3626 +
3627 + // Assembled from the literal fragments above; post types bind via prepare().
3628 + $posts_query = "SELECT {$joined_select}
3629 + {$from_clause}
3630 + ORDER BY p.ID ASC
3631 + LIMIT %d OFFSET %d";
3632 + $posts = $wpdb->get_results($wpdb->prepare($posts_query, array_merge($template_post_types, [$batch_size, $offset]))); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fragments built from fixed allowlists, values bound via prepare()
3633 + } else {
3634 + // No usable templates — e.g. only the social options are selected, or
3635 + // aioseo_options_dynamic is missing. Fall back to the original
3636 + // override-only walk so those sites behave exactly as before.
3637 + $select_clause = implode(', ', array_merge(['post_id'], $value_columns));
3638 +
3639 + // $where_clause / $select_clause are built only from the fixed
3640 + // $value_columns allowlist above.
3641 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where_clause is built only from the fixed $value_columns allowlist above
3642 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3643 + $total_posts = (int) $wpdb->get_var("
3644 + SELECT COUNT(post_id)
3645 + FROM {$wpdb->prefix}aioseo_posts
3646 + WHERE ({$where_clause})
3647 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3648 + ");
3649 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
3650 +
3651 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select_clause / $where_clause are built only from the fixed $value_columns allowlist above
3652 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3653 + $posts = $wpdb->get_results($wpdb->prepare("
3654 + SELECT {$select_clause}
3655 + FROM {$wpdb->prefix}aioseo_posts
3656 + WHERE ({$where_clause})
3657 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3658 + ORDER BY post_id ASC
3659 + LIMIT %d OFFSET %d
3660 + ", $batch_size, $offset));
3661 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
3662 + }
3663 +
2394 3664 $imported_count = 0;
2395 3665 $skipped_count = 0;
3666 + $unresolved = [];
2396 3667
2397 3668 foreach ($posts as $aioseo_data) {
2398 3669 $post_id = $aioseo_data->post_id;
2399 3670 $updated = false;
2400 3671
2401 - // Import title
2402 - if ($import_titles && !empty($aioseo_data->title)) {
2403 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
3672 + // Present only on the template-anchored query; the fallback walk has
3673 + // no join to read it from.
3674 + $post_type = isset($aioseo_data->post_type)
3675 + ? (string) $aioseo_data->post_type
3676 + : (string) get_post_type($post_id);
2404 3677
2405 - if (empty($existing_title) || $overwrite) {
2406 - $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2407 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2408 - $updated = true;
3678 + $title_template = $templates[$post_type]['title'] ?? '';
3679 + $desc_template = $templates[$post_type]['description'] ?? '';
3680 +
3681 + // Import title.
3682 + //
3683 + // The per-post value wins; the site-wide template is the fallback,
3684 + // matching the order AIOSEO itself resolves in. Destination and
3685 + // write rules come from store_imported_post_value().
3686 + if ($import_titles) {
3687 + $raw_title = !empty($aioseo_data->title) ? (string) $aioseo_data->title : '';
3688 +
3689 + if ($raw_title === '' && $title_template !== '') {
3690 + $raw_title = $title_template;
2409 3691 }
3692 +
3693 + $updated = $this->store_imported_post_value(
3694 + $post_id,
3695 + $raw_title,
3696 + 'aioseo',
3697 + Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3698 + $overwrite,
3699 + $unresolved
3700 + );
2410 3701 }
2411 3702
2412 - // Import description
2413 - if ($import_descriptions && !empty($aioseo_data->description)) {
2414 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
3703 + // Import description — same reasoning as the title above.
3704 + if ($import_descriptions) {
3705 + $raw_desc = !empty($aioseo_data->description) ? (string) $aioseo_data->description : '';
3706 + $desc_from_template = false;
2415 3707
2416 - if (empty($existing_desc) || $overwrite) {
2417 - $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2418 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2419 - $updated = true;
3708 + if ($raw_desc === '' && $desc_template !== '') {
3709 + $raw_desc = $desc_template;
3710 + $desc_from_template = true;
2420 3711 }
3712 +
3713 + // Only template-derived values are trimmed. A '#post_content'
3714 + // template resolves to the whole body, and AIOSEO emits that
3715 + // untruncated — copying it verbatim would give every page a
3716 + // multi-thousand-character description. What the customer
3717 + // typed on the post is left exactly as they wrote it.
3718 + $updated = $this->store_imported_post_value(
3719 + $post_id,
3720 + $raw_desc,
3721 + 'aioseo',
3722 + Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3723 + $overwrite,
3724 + $unresolved,
3725 + $desc_from_template
3726 + ) || $updated;
2421 3727 }
2422 3728
2423 3729 // Import social text
2424 3730 if ($import_social_text) {
@@ -2423,44 +3729,52 @@
2423 3729 // Import social text
2424 3730 if ($import_social_text) {
2425 3731 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
2426 3732 if (!empty($aioseo_og_title)) {
2427 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2428 - if (empty($existing) || $overwrite) {
2429 - $aioseo_og_title = $this->resolve_seo_placeholders($aioseo_og_title, $post_id, 'aioseo');
2430 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($aioseo_og_title));
2431 - $updated = true;
2432 - }
3733 + $updated = $this->store_imported_post_value(
3734 + $post_id,
3735 + (string) $aioseo_og_title,
3736 + 'aioseo',
3737 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
3738 + $overwrite,
3739 + $unresolved
3740 + ) || $updated;
2433 3741 }
2434 3742
2435 3743 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
2436 3744 if (!empty($aioseo_og_desc)) {
2437 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2438 - if (empty($existing) || $overwrite) {
2439 - $aioseo_og_desc = $this->resolve_seo_placeholders($aioseo_og_desc, $post_id, 'aioseo');
2440 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($aioseo_og_desc));
2441 - $updated = true;
2442 - }
3745 + $updated = $this->store_imported_post_value(
3746 + $post_id,
3747 + (string) $aioseo_og_desc,
3748 + 'aioseo',
3749 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
3750 + $overwrite,
3751 + $unresolved
3752 + ) || $updated;
2443 3753 }
2444 3754
2445 3755 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
2446 3756 if (!empty($aioseo_tw_title)) {
2447 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2448 - if (empty($existing) || $overwrite) {
2449 - $aioseo_tw_title = $this->resolve_seo_placeholders($aioseo_tw_title, $post_id, 'aioseo');
2450 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($aioseo_tw_title));
2451 - $updated = true;
2452 - }
3757 + $updated = $this->store_imported_post_value(
3758 + $post_id,
3759 + (string) $aioseo_tw_title,
3760 + 'aioseo',
3761 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
3762 + $overwrite,
3763 + $unresolved
3764 + ) || $updated;
2453 3765 }
2454 3766
2455 3767 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
2456 3768 if (!empty($aioseo_tw_desc)) {
2457 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2458 - if (empty($existing) || $overwrite) {
2459 - $aioseo_tw_desc = $this->resolve_seo_placeholders($aioseo_tw_desc, $post_id, 'aioseo');
2460 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($aioseo_tw_desc));
2461 - $updated = true;
2462 - }
3769 + $updated = $this->store_imported_post_value(
3770 + $post_id,
3771 + (string) $aioseo_tw_desc,
3772 + 'aioseo',
3773 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
3774 + $overwrite,
3775 + $unresolved
3776 + ) || $updated;
2463 3777 }
2464 3778 }
2465 3779
2466 3780 // Import social images
@@ -2466,22 +3780,28 @@
2466 3780 // Import social images
2467 3781 if ($import_social_images) {
2468 3782 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
2469 3783 if (!empty($aioseo_og_image)) {
2470 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2471 - if (empty($existing_og) || $overwrite) {
2472 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($aioseo_og_image));
2473 - $updated = true;
2474 - }
3784 + $updated = $this->store_imported_post_value(
3785 + $post_id,
3786 + (string) $aioseo_og_image,
3787 + 'aioseo',
3788 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
3789 + $overwrite,
3790 + $unresolved
3791 + ) || $updated;
2475 3792 }
2476 3793
2477 3794 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
2478 3795 if (!empty($aioseo_twitter_image)) {
2479 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2480 - if (empty($existing_twitter) || $overwrite) {
2481 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($aioseo_twitter_image));
2482 - $updated = true;
2483 - }
3796 + $updated = $this->store_imported_post_value(
3797 + $post_id,
3798 + (string) $aioseo_twitter_image,
3799 + 'aioseo',
3800 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
3801 + $overwrite,
3802 + $unresolved
3803 + ) || $updated;
2484 3804 }
2485 3805 }
2486 3806
2487 3807 if ($updated) {
@@ -2491,11 +3811,15 @@
2491 3811 }
2492 3812 }
2493 3813
2494 3814 $processed = $offset + count($posts);
2495 - $is_complete = $processed >= $total_posts;
3815 + // An empty batch always ends the run. The client loops on !is_complete
3816 + // with no iteration cap, so if the count and the select ever disagreed,
3817 + // a batch returning no rows would leave processed stuck below total and
3818 + // spin forever against the server.
3819 + $is_complete = $processed >= $total_posts || empty($posts);
2496 3820
2497 - return [
3821 + return $this->attach_unresolved_report([
2498 3822 'success' => true,
2499 3823 'imported' => $imported_count,
2500 3824 'skipped' => $skipped_count,
2501 3825 'total' => $total_posts,
@@ -2504,9 +3828,9 @@
2504 3828 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2505 3829 'message' => $is_complete
2506 3830 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2507 3831 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2508 - ];
3832 + ], $unresolved);
2509 3833 }
2510 3834
2511 3835 /**
2512 3836 * Import term-level SEO metadata from a third-party SEO plugin into
@@ -2588,16 +3912,22 @@
2588 3912 'message' => $terms->get_error_message(),
2589 3913 ];
2590 3914 }
2591 3915
3916 + // Title and description are handled separately below: they go to the
3917 + // imported keys so they lose to OTTO, and they need their tokens
3918 + // resolved. The rest keep their own keys.
2592 3919 $field_map = [
2593 - 'wpseo_title' => '_metasync_metatitle',
2594 - 'wpseo_desc' => '_metasync_metadesc',
2595 3920 'wpseo_opengraph-title' => '_metasync_og_title',
2596 3921 'wpseo_opengraph-description' => '_metasync_og_description',
2597 3922 'wpseo_canonical' => '_metasync_canonical_url',
2598 3923 ];
2599 3924
3925 + $imported_map = [
3926 + 'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3927 + 'wpseo_desc' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3928 + ];
3929 +
2600 3930 $imported = 0;
2601 3931 $skipped = 0;
2602 3932
2603 3933 foreach ($terms as $term) {
@@ -2608,8 +3938,18 @@
2608 3938 if (!is_array($yoast_meta)) {
2609 3939 $yoast_meta = [];
2610 3940 }
2611 3941
3942 + foreach ($imported_map as $src_key => $dest_key) {
3943 + $term_updated = $this->store_imported_term_value(
3944 + $term,
3945 + isset($yoast_meta[$src_key]) ? (string) $yoast_meta[$src_key] : '',
3946 + 'yoast',
3947 + $dest_key,
3948 + $overwrite
3949 + ) || $term_updated;
3950 + }
3951 +
2612 3952 foreach ($field_map as $src_key => $dest_key) {
2613 3953 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2614 3954 if ($src_value === '' || $src_value === null) {
2615 3955 continue;
@@ -2619,8 +3959,21 @@
2619 3959 if (!empty($existing) && !$overwrite) {
2620 3960 continue;
2621 3961 }
2622 3962
3963 + // Validate canonical: never import a corrupted value.
3964 + if ($dest_key === '_metasync_canonical_url') {
3965 + $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
3966 + if ($src_value === '') {
3967 + continue;
3968 + }
3969 + } else {
3970 + $src_value = $this->resolve_term_placeholders($src_value, $term, 'yoast');
3971 + if (trim($src_value) === '') {
3972 + continue;
3973 + }
3974 + }
3975 +
2623 3976 update_term_meta($term->term_id, $dest_key, $src_value);
2624 3977 $term_updated = true;
2625 3978 }
2626 3979
@@ -2678,16 +4031,21 @@
2678 4031 'message' => $terms->get_error_message(),
2679 4032 ];
2680 4033 }
2681 4034
4035 + // See import_yoast_term_meta(): title and description are routed to the
4036 + // imported keys and token-resolved; the rest keep their own keys.
2682 4037 $field_map = [
2683 - 'rank_math_title' => '_metasync_metatitle',
2684 - 'rank_math_description' => '_metasync_metadesc',
2685 4038 'rank_math_facebook_title' => '_metasync_og_title',
2686 4039 'rank_math_facebook_description' => '_metasync_og_description',
2687 4040 'rank_math_canonical_url' => '_metasync_canonical_url',
2688 4041 ];
2689 4042
4043 + $imported_map = [
4044 + 'rank_math_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4045 + 'rank_math_description' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4046 + ];
4047 +
2690 4048 $imported = 0;
2691 4049 $skipped = 0;
2692 4050
2693 4051 foreach ($terms as $term) {
@@ -2692,8 +4050,18 @@
2692 4050
2693 4051 foreach ($terms as $term) {
2694 4052 $term_updated = false;
2695 4053
4054 + foreach ($imported_map as $src_key => $dest_key) {
4055 + $term_updated = $this->store_imported_term_value(
4056 + $term,
4057 + (string) get_term_meta($term->term_id, $src_key, true),
4058 + 'rankmath',
4059 + $dest_key,
4060 + $overwrite
4061 + ) || $term_updated;
4062 + }
4063 +
2696 4064 foreach ($field_map as $src_key => $dest_key) {
2697 4065 $src_value = get_term_meta($term->term_id, $src_key, true);
2698 4066 if ($src_value === '' || $src_value === null) {
2699 4067 continue;
@@ -2703,8 +4071,21 @@
2703 4071 if (!empty($existing) && !$overwrite) {
2704 4072 continue;
2705 4073 }
2706 4074
4075 + // Validate canonical: never import a corrupted value.
4076 + if ($dest_key === '_metasync_canonical_url') {
4077 + $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
4078 + if ($src_value === '') {
4079 + continue;
4080 + }
4081 + } else {
4082 + $src_value = $this->resolve_term_placeholders($src_value, $term, 'rankmath');
4083 + if (trim($src_value) === '') {
4084 + continue;
4085 + }
4086 + }
4087 +
2707 4088 update_term_meta($term->term_id, $dest_key, $src_value);
2708 4089 $term_updated = true;
2709 4090 }
2710 4091
@@ -2752,9 +4133,9 @@
2752 4133 $overwrite = (bool) $options['overwrite_existing'];
2753 4134
2754 4135 $table = $wpdb->prefix . 'aioseo_terms';
2755 4136
2756 - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
4137 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
2757 4138 if ($table_exists !== $table) {
2758 4139 return [
2759 4140 'success' => false,
2760 4141 'message' => 'AIOSEO terms table not found.',
@@ -2760,11 +4141,11 @@
2760 4141 'message' => 'AIOSEO terms table not found.',
2761 4142 ];
2762 4143 }
2763 4144
2764 - $rows = $wpdb->get_results($wpdb->prepare(
4145 + $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
2765 4146 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2766 - FROM {$table}
4147 + FROM {$wpdb->prefix}aioseo_terms
2767 4148 ORDER BY term_id ASC
2768 4149 LIMIT %d OFFSET %d",
2769 4150 $batch_size,
2770 4151 $offset
@@ -2778,18 +4159,41 @@
2778 4159 if ($term_id <= 0) {
2779 4160 continue;
2780 4161 }
2781 4162
4163 + // The resolver needs the term's own name and description, and a row
4164 + // can outlive the term it points at.
4165 + $term = get_term($term_id);
4166 + if (!$term || is_wp_error($term)) {
4167 + $skipped++;
4168 + continue;
4169 + }
4170 +
2782 4171 $term_updated = false;
2783 4172
4173 + // See import_yoast_term_meta(): title and description are routed to
4174 + // the imported keys and token-resolved; the rest keep their own keys.
2784 4175 $field_map = [
2785 - 'title' => '_metasync_metatitle',
2786 - 'description' => '_metasync_metadesc',
2787 4176 'og_title' => '_metasync_og_title',
2788 4177 'og_description' => '_metasync_og_description',
2789 4178 'canonical_url' => '_metasync_canonical_url',
2790 4179 ];
2791 4180
4181 + $imported_map = [
4182 + 'title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4183 + 'description' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4184 + ];
4185 +
4186 + foreach ($imported_map as $column => $dest_key) {
4187 + $term_updated = $this->store_imported_term_value(
4188 + $term,
4189 + isset($row->$column) ? (string) $row->$column : '',
4190 + 'aioseo',
4191 + $dest_key,
4192 + $overwrite
4193 + ) || $term_updated;
4194 + }
4195 +
2792 4196 foreach ($field_map as $column => $dest_key) {
2793 4197 $value = isset($row->$column) ? $row->$column : '';
2794 4198 if ($value === '' || $value === null) {
2795 4199 continue;
@@ -2797,8 +4201,21 @@
2797 4201
2798 4202 $existing = get_term_meta($term_id, $dest_key, true);
2799 4203 if (!empty($existing) && !$overwrite) {
2800 4204 continue;
4205 + }
4206 +
4207 + // Validate canonical: never import a corrupted value.
4208 + if ($dest_key === '_metasync_canonical_url') {
4209 + $value = Metasync_Canonical_Sanitizer::sanitize_for_save($value);
4210 + if ($value === '') {
4211 + continue;
4212 + }
4213 + } else {
4214 + $value = $this->resolve_term_placeholders($value, $term, 'aioseo');
4215 + if (trim($value) === '' || $this->has_unresolved_placeholders($value, 'aioseo')) {
4216 + continue;
4217 + }
2801 4218 }
2802 4219
2803 4220 update_term_meta($term_id, $dest_key, $value);
2804 4221 $term_updated = true;