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 +1941 -193 2.6.10 → 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'
@@ -703,51 +745,58 @@
703 745
704 746 /**
705 747 * Import Indexation Options (Per-Post Robots Meta)
706 748 */
707 - public function import_indexation($plugin)
749 + public function import_indexation($plugin, $options = [])
708 750 {
709 - $imported_count = 0;
751 + $defaults = ['batch_size' => 50, 'offset' => 0];
752 + $options = array_merge($defaults, $options);
710 753
711 754 switch ($plugin) {
712 755 case 'yoast':
713 - $imported_count = $this->import_yoast_indexation();
714 - break;
756 + return $this->import_yoast_indexation($options);
715 757
716 758 case 'rankmath':
717 - $imported_count = $this->import_rankmath_indexation();
718 - break;
759 + return $this->import_rankmath_indexation($options);
719 760
720 761 case 'aioseo':
721 - $imported_count = $this->import_aioseo_indexation();
722 - break;
762 + return $this->import_aioseo_indexation($options);
723 763
724 764 default:
725 765 return ['success' => false, 'message' => 'Invalid plugin specified.'];
726 766 }
727 -
728 - if ($imported_count > 0) {
729 - return ['success' => true, 'message' => "Successfully imported indexation settings for $imported_count posts."];
730 - }
731 -
732 - return ['success' => false, 'message' => 'No post-level indexation settings found to import.'];
733 767 }
734 768
735 769 /**
736 770 * Import per-post indexation settings from Yoast SEO
737 771 */
738 - private function import_yoast_indexation()
772 + private function import_yoast_indexation($options = [])
739 773 {
740 774 global $wpdb;
741 775 $imported_count = 0;
742 776
743 - // Get all posts with Yoast robots meta
744 - $posts = $wpdb->get_results("
777 + $batch_size = intval($options['batch_size']);
778 + $offset = intval($options['offset']);
779 +
780 + // Get total count (for progress tracking)
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("
783 + SELECT COUNT(DISTINCT post_id)
784 + FROM {$wpdb->postmeta}
785 + WHERE meta_key LIKE %s
786 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
787 + ", '_yoast_wpseo_%'));
788 +
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
791 + $posts = $wpdb->get_results($wpdb->prepare("
745 792 SELECT DISTINCT post_id
746 793 FROM {$wpdb->postmeta}
747 - WHERE meta_key LIKE '_yoast_wpseo_%'
794 + WHERE meta_key LIKE %s
748 795 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
749 - ");
796 + ORDER BY post_id ASC
797 + LIMIT %d OFFSET %d
798 + ", '_yoast_wpseo_%', $batch_size, $offset));
750 799
751 800 foreach ($posts as $post_obj) {
752 801 $post_id = $post_obj->post_id;
753 802 $has_changes = false;
@@ -762,11 +811,12 @@
762 811 $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
763 812 if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
764 813 $metasync_robots['noindex'] = 'noindex';
765 814 $has_changes = true;
766 - } elseif ($yoast_noindex === '2' && !isset($metasync_robots['index'])) {
767 - // '2' means 'index' in Yoast
768 - $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']);
769 819 $has_changes = true;
770 820 }
771 821
772 822 // Import nofollow
@@ -777,8 +827,16 @@
777 827 }
778 828
779 829 // Import advanced robots (noarchive, nosnippet, noimageindex)
780 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 + }
781 839 if (!empty($yoast_adv)) {
782 840 $adv_directives = explode(',', $yoast_adv);
783 841 foreach ($adv_directives as $directive) {
784 842 $directive = trim($directive);
@@ -788,9 +846,9 @@
788 846 }
789 847 }
790 848 }
791 849
792 - // WP-197: Also write to _metasync_robots_advanced JSON
850 + // Also write to _metasync_robots_advanced JSON
793 851 $robots_advanced = [];
794 852 if (!empty($yoast_adv)) {
795 853 $adv_directives = array_map('trim', explode(',', $yoast_adv));
796 854 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
@@ -811,10 +869,15 @@
811 869 $yoast_canonical = get_post_meta($post_id, '_yoast_wpseo_canonical', true);
812 870 if (!empty($yoast_canonical)) {
813 871 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
814 872 if (empty($existing_canonical)) {
815 - update_post_meta($post_id, 'meta_canonical', sanitize_url($yoast_canonical));
816 - $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 + }
817 880 }
818 881 }
819 882
820 883 // Save Metasync robots meta if changes were made
@@ -823,28 +886,60 @@
823 886 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
824 887 }
825 888 $imported_count++;
826 889 }
890 +
891 + // Flush per-post object cache to prevent unbounded memory growth across batches
892 + clean_post_cache($post_id);
827 893 }
828 894
829 - return $imported_count;
895 + $processed = $offset + count($posts);
896 + $is_complete = $processed >= $total;
897 +
898 + return [
899 + 'success' => true,
900 + 'imported' => $imported_count,
901 + 'skipped' => count($posts) - $imported_count,
902 + 'total' => $total,
903 + 'processed' => $processed,
904 + 'is_complete' => $is_complete,
905 + 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
906 + 'message' => $is_complete
907 + ? "Import complete! Imported {$imported_count} posts."
908 + : "Processing... {$imported_count} imported."
909 + ];
830 910 }
831 911
832 912 /**
833 913 * Import per-post indexation settings from Rank Math
834 914 */
835 - private function import_rankmath_indexation()
915 + private function import_rankmath_indexation($options = [])
836 916 {
837 917 global $wpdb;
838 918 $imported_count = 0;
839 919
840 - // Get all posts with Rank Math robots meta
841 - $posts = $wpdb->get_results("
920 + $batch_size = intval($options['batch_size']);
921 + $offset = intval($options['offset']);
922 +
923 + // Get total count (for progress tracking)
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("
926 + SELECT COUNT(DISTINCT post_id)
927 + FROM {$wpdb->postmeta}
928 + WHERE meta_key LIKE %s
929 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
930 + ", 'rank_math_%'));
931 +
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
934 + $posts = $wpdb->get_results($wpdb->prepare("
842 935 SELECT DISTINCT post_id
843 936 FROM {$wpdb->postmeta}
844 - WHERE meta_key LIKE 'rank_math_%'
937 + WHERE meta_key LIKE %s
845 938 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
846 - ");
939 + ORDER BY post_id ASC
940 + LIMIT %d OFFSET %d
941 + ", 'rank_math_%', $batch_size, $offset));
847 942
848 943 foreach ($posts as $post_obj) {
849 944 $post_id = $post_obj->post_id;
850 945 $has_changes = false;
@@ -879,9 +974,9 @@
879 974 }
880 975 }
881 976 }
882 977
883 - // WP-197: Parse max-* values and write _metasync_robots_advanced JSON
978 + // Parse max-* values and write _metasync_robots_advanced JSON
884 979 $robots_advanced = [];
885 980 // Boolean directives from rank_math_robots
886 981 if (is_array($rm_robots)) {
887 982 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
@@ -913,10 +1008,15 @@
913 1008 $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
914 1009 if (!empty($rm_canonical)) {
915 1010 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
916 1011 if (empty($existing_canonical)) {
917 - update_post_meta($post_id, 'meta_canonical', sanitize_url($rm_canonical));
918 - $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 + }
919 1019 }
920 1020 }
921 1021
922 1022 // Save Metasync robots meta if changes were made
@@ -925,40 +1025,71 @@
925 1025 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
926 1026 }
927 1027 $imported_count++;
928 1028 }
1029 +
1030 + // Flush per-post object cache to prevent unbounded memory growth across batches
1031 + clean_post_cache($post_id);
929 1032 }
930 1033
931 - return $imported_count;
1034 + $processed = $offset + count($posts);
1035 + $is_complete = $processed >= $total;
1036 +
1037 + return [
1038 + 'success' => true,
1039 + 'imported' => $imported_count,
1040 + 'skipped' => count($posts) - $imported_count,
1041 + 'total' => $total,
1042 + 'processed' => $processed,
1043 + 'is_complete' => $is_complete,
1044 + 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1045 + 'message' => $is_complete
1046 + ? "Import complete! Imported {$imported_count} posts."
1047 + : "Processing... {$imported_count} imported."
1048 + ];
932 1049 }
933 1050
934 1051 /**
935 1052 * Import per-post indexation settings from AIOSEO
936 1053 */
937 - private function import_aioseo_indexation()
1054 + private function import_aioseo_indexation($options = [])
938 1055 {
939 1056 global $wpdb;
940 1057 $imported_count = 0;
941 1058
1059 + $batch_size = intval($options['batch_size']);
1060 + $offset = intval($options['offset']);
1061 +
942 1062 // AIOSEO stores data in a custom table
943 1063 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
944 1064
945 1065 // Check if table exists
946 - $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
947 1067
948 1068 if (!$table_exists) {
949 - return 0;
1069 + return ['success' => false, 'message' => 'AIOSEO table not found.'];
950 1070 }
951 1071
952 - // Get all posts with AIOSEO settings
953 - $posts = $wpdb->get_results("
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
1074 + $total = (int) $wpdb->get_var("
1075 + SELECT COUNT(*)
1076 + FROM {$wpdb->prefix}aioseo_posts
1077 + WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1078 + ");
1079 +
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
1082 + $posts = $wpdb->get_results($wpdb->prepare("
954 1083 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
955 1084 robots_noarchive, robots_nosnippet, robots_noimageindex,
956 1085 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
957 1086 canonical_url
958 - FROM {$aioseo_table}
1087 + FROM {$wpdb->prefix}aioseo_posts
959 1088 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
960 - ");
1089 + ORDER BY post_id ASC
1090 + LIMIT %d OFFSET %d
1091 + ", $batch_size, $offset));
961 1092
962 1093 foreach ($posts as $aioseo_data) {
963 1094 $post_id = $aioseo_data->post_id;
964 1095 $has_changes = false;
@@ -1001,9 +1132,9 @@
1001 1132 $has_changes = true;
1002 1133 }
1003 1134 }
1004 1135
1005 - // WP-197: Write _metasync_robots_advanced JSON
1136 + // Write _metasync_robots_advanced JSON
1006 1137 $robots_advanced = [];
1007 1138 if ($aioseo_data->robots_default == 0) {
1008 1139 if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
1009 1140 if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
@@ -1027,10 +1158,15 @@
1027 1158 // Import canonical URL
1028 1159 if (!empty($aioseo_data->canonical_url)) {
1029 1160 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1030 1161 if (empty($existing_canonical)) {
1031 - update_post_meta($post_id, 'meta_canonical', sanitize_url($aioseo_data->canonical_url));
1032 - $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 + }
1033 1169 }
1034 1170 }
1035 1171
1036 1172 // Save Metasync robots meta if changes were made
@@ -1039,11 +1175,28 @@
1039 1175 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1040 1176 }
1041 1177 $imported_count++;
1042 1178 }
1179 +
1180 + // Flush per-post object cache to prevent unbounded memory growth across batches
1181 + clean_post_cache($post_id);
1043 1182 }
1044 1183
1045 - return $imported_count;
1184 + $processed = $offset + count($posts);
1185 + $is_complete = $processed >= $total;
1186 +
1187 + return [
1188 + 'success' => true,
1189 + 'imported' => $imported_count,
1190 + 'skipped' => count($posts) - $imported_count,
1191 + 'total' => $total,
1192 + 'processed' => $processed,
1193 + 'is_complete' => $is_complete,
1194 + 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1195 + 'message' => $is_complete
1196 + ? "Import complete! Imported {$imported_count} posts."
1197 + : "Processing... {$imported_count} imported."
1198 + ];
1046 1199 }
1047 1200
1048 1201 /**
1049 1202 * Import Schema Settings (Per-Post Schema)
@@ -1084,8 +1237,9 @@
1084 1237 global $wpdb;
1085 1238 $imported_count = 0;
1086 1239
1087 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
1088 1242 $posts = $wpdb->get_results("
1089 1243 SELECT post_id, meta_value
1090 1244 FROM {$wpdb->postmeta}
1091 1245 WHERE meta_key = '_yoast_wpseo_schema'
@@ -1110,14 +1264,15 @@
1110 1264 // Convert Yoast schema to Metasync format
1111 1265 $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1112 1266
1113 1267 if (!empty($metasync_schema['types'])) {
1114 - 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)));
1115 1269 $imported_count++;
1116 1270 }
1117 1271 }
1118 1272
1119 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
1120 1275 $posts = $wpdb->get_results("
1121 1276 SELECT post_id, meta_value
1122 1277 FROM {$wpdb->postmeta}
1123 1278 WHERE meta_key = '_yoast_wpseo_schema_article_type'
@@ -1153,9 +1308,9 @@
1153 1308 ]
1154 1309 ]
1155 1310 ];
1156 1311
1157 - 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)));
1158 1313 $imported_count++;
1159 1314 }
1160 1315 }
1161 1316
@@ -1171,8 +1326,9 @@
1171 1326 $imported_count = 0;
1172 1327
1173 1328 // Get all posts with any Rank Math schema (dynamically detect schema types)
1174 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
1175 1331 $posts = $wpdb->get_results("
1176 1332 SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1177 1333 FROM {$wpdb->postmeta} pm
1178 1334 WHERE pm.meta_key LIKE 'rank_math_schema_%'
@@ -1209,9 +1365,9 @@
1209 1365 // Convert Rank Math schema to Metasync format
1210 1366 $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1211 1367
1212 1368 if (!empty($metasync_schema['types'])) {
1213 - 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)));
1214 1370 $imported_count++;
1215 1371 $processed_posts[] = $post_id; // Mark post as processed
1216 1372 }
1217 1373 }
@@ -1228,16 +1384,17 @@
1228 1384 $imported_count = 0;
1229 1385
1230 1386 // Check if AIOSEO table exists
1231 1387 $table = $wpdb->prefix . 'aioseo_posts';
1232 - 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
1233 1389 return 0;
1234 1390 }
1235 1391
1236 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
1237 1394 $posts = $wpdb->get_results("
1238 1395 SELECT post_id, schema_type, schema_type_options
1239 - FROM {$table}
1396 + FROM {$wpdb->prefix}aioseo_posts
1240 1397 WHERE (schema_type IS NOT NULL AND schema_type != '')
1241 1398 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1242 1399 ");
1243 1400
@@ -1263,9 +1420,9 @@
1263 1420 $post_id
1264 1421 );
1265 1422
1266 1423 if (!empty($metasync_schema['types'])) {
1267 - 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)));
1268 1425 $imported_count++;
1269 1426 }
1270 1427 }
1271 1428
@@ -1640,8 +1797,67 @@
1640 1797 return is_string($text) ? $text : $default;
1641 1798 }
1642 1799
1643 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 + /**
1644 1860 * Resolve SEO placeholder tokens to their actual values.
1645 1861 *
1646 1862 * Tries each source plugin's own replacement engine first so the result
1647 1863 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
@@ -1661,8 +1877,17 @@
1661 1877 if (!$post) {
1662 1878 return $text;
1663 1879 }
1664 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 +
1665 1890 // Yoast SEO — %%var%% tokens
1666 1891 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1667 1892 try {
1668 1893 $replacer = new WPSEO_Replace_Vars();
@@ -1686,9 +1911,9 @@
1686 1911 // fall through to manual replacement
1687 1912 }
1688 1913 }
1689 1914
1690 - // All in One SEO — #var# tokens
1915 + // All in One SEO — #var tokens
1691 1916 if ($plugin === 'aioseo') {
1692 1917 try {
1693 1918 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1694 1919 $resolved = aioseo()->tags->replaceTags($text, $post_id);
@@ -1700,20 +1925,27 @@
1700 1925 // fall through to manual replacement
1701 1926 }
1702 1927 }
1703 1928
1704 - return $this->manually_replace_seo_placeholders($text, $post_id);
1929 + return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
1705 1930 }
1706 1931
1707 1932 /**
1708 1933 * Manual fallback: replace the most common SEO placeholder tokens from
1709 - * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
1934 + * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
1710 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 + *
1711 1940 * @param string $text Text that may contain placeholder tokens.
1712 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.
1713 1945 * @return string Text with tokens replaced.
1714 1946 */
1715 - private function manually_replace_seo_placeholders($text, $post_id) {
1947 + private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
1716 1948 $post = get_post($post_id);
1717 1949 if (!$post) {
1718 1950 return $text;
1719 1951 }
@@ -1720,9 +1952,9 @@
1720 1952
1721 1953 $site_name = get_bloginfo('name');
1722 1954 $post_title = get_the_title($post_id);
1723 1955 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1724 - $author = get_the_author_meta('display_name', $post->post_author);
1956 + $author = get_the_author_meta('display_name', (int) $post->post_author);
1725 1957 $date = get_the_date('', $post_id);
1726 1958 $modified = get_the_modified_date('', $post_id);
1727 1959
1728 1960 $categories = get_the_category($post_id);
@@ -1730,28 +1962,9 @@
1730 1962
1731 1963 $tags = get_the_tags($post_id);
1732 1964 $first_tag = !empty($tags) ? $tags[0]->name : '';
1733 1965
1734 - // Try to read the separator from whichever plugin is active
1735 - $sep = '-';
1736 - if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1737 - try {
1738 - $raw = WPSEO_Options::get('separator', '-');
1739 - // Yoast stores separator keys like 'sc-dash'; convert to glyph
1740 - $sep = html_entity_decode(
1741 - WPSEO_Option_Titles::get_instance()->get_title_separator(),
1742 - ENT_QUOTES,
1743 - 'UTF-8'
1744 - );
1745 - } catch (Exception $e) {
1746 - $sep = '-';
1747 - }
1748 - } elseif (defined('RANK_MATH_VERSION')) {
1749 - $rm_settings = get_option('rank_math_general_settings', []);
1750 - if (!empty($rm_settings['title_separator'])) {
1751 - $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1752 - }
1753 - }
1966 + $sep = $this->resolve_source_separator($plugin);
1754 1967
1755 1968 $map = [
1756 1969 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1757 1970 '%%title%%' => $post_title,
@@ -1782,19 +1995,8 @@
1782 1995 '%page%' => '',
1783 1996 '%category%' => $primary_category,
1784 1997 '%tag%' => $first_tag,
1785 1998 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1786 - // ── AIOSEO (#var#) ───────────────────────────────────────────────
1787 - '#site_title#' => $site_name,
1788 - '#post_title#' => $post_title,
1789 - '#post_excerpt#' => $post_excerpt,
1790 - '#separator_sa#' => $sep,
1791 - '#author_name#' => $author,
1792 - '#current_date#' => $date,
1793 - '#post_date#' => $date,
1794 - '#category_title#' => $primary_category,
1795 - '#tag_title#' => $first_tag,
1796 - '#id#' => (string) $post_id,
1797 1999 ];
1798 2000
1799 2001 // Sort longest token first to avoid partial matches
1800 2002 // e.g. %%primary_category%% must replace before %%category%%
@@ -1801,12 +2003,1047 @@
1801 2003 uksort($map, function ($a, $b) {
1802 2004 return strlen($b) - strlen($a);
1803 2005 });
1804 2006
1805 - 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;
1806 2018 }
1807 2019
1808 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 + /**
1809 3046 * Import SEO Metadata (Titles and Descriptions)
1810 3047 * Supports batch processing via AJAX
1811 3048 *
1812 3049 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
@@ -1859,8 +3096,10 @@
1859 3096 $batch_size = intval($options['batch_size']);
1860 3097 $offset = intval($options['offset']);
1861 3098 $import_titles = (bool) $options['import_titles'];
1862 3099 $import_descriptions = (bool) $options['import_descriptions'];
3100 + $import_social_text = !empty($options['import_social_text']);
3101 + $import_social_images = !empty($options['import_social_images']);
1863 3102 $overwrite = (bool) $options['overwrite_existing'];
1864 3103
1865 3104 // Build WHERE clause for meta keys
1866 3105 $meta_keys = [];
@@ -1869,10 +3108,20 @@
1869 3108 }
1870 3109 if ($import_descriptions) {
1871 3110 $meta_keys[] = '_yoast_wpseo_metadesc';
1872 3111 }
3112 + if ($import_social_text) {
3113 + $meta_keys[] = '_yoast_wpseo_opengraph-title';
3114 + $meta_keys[] = '_yoast_wpseo_opengraph-description';
3115 + $meta_keys[] = '_yoast_wpseo_twitter-title';
3116 + $meta_keys[] = '_yoast_wpseo_twitter-description';
3117 + }
3118 + if ($import_social_images) {
3119 + $meta_keys[] = '_yoast_wpseo_opengraph-image';
3120 + $meta_keys[] = '_yoast_wpseo_twitter-image';
3121 + }
1873 3122
1874 - if (empty($meta_keys)) {
3123 + if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1875 3124 return [
1876 3125 'success' => false,
1877 3126 'message' => 'No import options selected.'
1878 3127 ];
@@ -1878,22 +3127,23 @@
1878 3127 ];
1879 3128 }
1880 3129
1881 3130 // Get total count (for progress tracking)
1882 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1883 - $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('
1884 3133 SELECT COUNT(DISTINCT post_id)
1885 - FROM {$wpdb->postmeta}
1886 - WHERE meta_key IN ($meta_keys_placeholders)
3134 + FROM ' . $wpdb->postmeta . '
3135 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1887 3136 AND meta_value != ''
1888 3137 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1889 3138 ", $meta_keys));
1890 3139
1891 3140 // Get batch of posts with Yoast data
1892 - $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('
1893 3143 SELECT DISTINCT post_id
1894 - FROM {$wpdb->postmeta}
1895 - WHERE meta_key IN ($meta_keys_placeholders)
3144 + FROM ' . $wpdb->postmeta . '
3145 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1896 3146 AND meta_value != ''
1897 3147 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1898 3148 ORDER BY post_id ASC
1899 3149 LIMIT %d OFFSET %d
@@ -1900,8 +3150,9 @@
1900 3150 ", array_merge($meta_keys, [$batch_size, $offset])));
1901 3151
1902 3152 $imported_count = 0;
1903 3153 $skipped_count = 0;
3154 + $unresolved = [];
1904 3155
1905 3156 foreach ($posts as $post_obj) {
1906 3157 $post_id = $post_obj->post_id;
1907 3158 $updated = false;
@@ -1909,15 +3160,16 @@
1909 3160 // Import title
1910 3161 if ($import_titles) {
1911 3162 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
1912 3163 if (!empty($yoast_title)) {
1913 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
1914 -
1915 - if (empty($existing_title) || $overwrite) {
1916 - $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
1917 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
1918 - $updated = true;
1919 - }
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 + );
1920 3172 }
1921 3173 }
1922 3174
1923 3175 // Import description
@@ -1923,18 +3175,97 @@
1923 3175 // Import description
1924 3176 if ($import_descriptions) {
1925 3177 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
1926 3178 if (!empty($yoast_desc)) {
1927 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
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;
3187 + }
3188 + }
1928 3189
1929 - if (empty($existing_desc) || $overwrite) {
1930 - $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
1931 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
1932 - $updated = true;
1933 - }
3190 + // Import social text
3191 + if ($import_social_text) {
3192 + $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
3193 + if (!empty($yoast_og_title)) {
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;
1934 3202 }
3203 +
3204 + $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
3205 + if (!empty($yoast_og_desc)) {
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;
3214 + }
3215 +
3216 + $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
3217 + if (!empty($yoast_tw_title)) {
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;
3226 + }
3227 +
3228 + $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
3229 + if (!empty($yoast_tw_desc)) {
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;
3238 + }
1935 3239 }
1936 3240
3241 + // Import social images
3242 + if ($import_social_images) {
3243 + $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
3244 + if (!empty($yoast_og_image)) {
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;
3253 + }
3254 +
3255 + $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
3256 + if (!empty($yoast_twitter_image)) {
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;
3265 + }
3266 + }
3267 +
1937 3268 if ($updated) {
1938 3269 $imported_count++;
1939 3270 } else {
1940 3271 $skipped_count++;
@@ -1943,9 +3274,9 @@
1943 3274
1944 3275 $processed = $offset + count($posts);
1945 3276 $is_complete = $processed >= $total_posts;
1946 3277
1947 - return [
3278 + return $this->attach_unresolved_report([
1948 3279 'success' => true,
1949 3280 'imported' => $imported_count,
1950 3281 'skipped' => $skipped_count,
1951 3282 'total' => $total_posts,
@@ -1954,9 +3285,9 @@
1954 3285 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
1955 3286 'message' => $is_complete
1956 3287 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
1957 3288 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
1958 - ];
3289 + ], $unresolved);
1959 3290 }
1960 3291
1961 3292 /**
1962 3293 * Import SEO metadata from Rank Math
@@ -1968,8 +3299,10 @@
1968 3299 $batch_size = intval($options['batch_size']);
1969 3300 $offset = intval($options['offset']);
1970 3301 $import_titles = (bool) $options['import_titles'];
1971 3302 $import_descriptions = (bool) $options['import_descriptions'];
3303 + $import_social_text = !empty($options['import_social_text']);
3304 + $import_social_images = !empty($options['import_social_images']);
1972 3305 $overwrite = (bool) $options['overwrite_existing'];
1973 3306
1974 3307 // Build WHERE clause for meta keys
1975 3308 $meta_keys = [];
@@ -1978,10 +3311,20 @@
1978 3311 }
1979 3312 if ($import_descriptions) {
1980 3313 $meta_keys[] = 'rank_math_description';
1981 3314 }
3315 + if ($import_social_text) {
3316 + $meta_keys[] = 'rank_math_facebook_title';
3317 + $meta_keys[] = 'rank_math_facebook_description';
3318 + $meta_keys[] = 'rank_math_twitter_title';
3319 + $meta_keys[] = 'rank_math_twitter_description';
3320 + }
3321 + if ($import_social_images) {
3322 + $meta_keys[] = 'rank_math_facebook_image';
3323 + $meta_keys[] = 'rank_math_twitter_image';
3324 + }
1982 3325
1983 - if (empty($meta_keys)) {
3326 + if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1984 3327 return [
1985 3328 'success' => false,
1986 3329 'message' => 'No import options selected.'
1987 3330 ];
@@ -1987,22 +3330,23 @@
1987 3330 ];
1988 3331 }
1989 3332
1990 3333 // Get total count
1991 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1992 - $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('
1993 3336 SELECT COUNT(DISTINCT post_id)
1994 - FROM {$wpdb->postmeta}
1995 - WHERE meta_key IN ($meta_keys_placeholders)
3337 + FROM ' . $wpdb->postmeta . '
3338 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1996 3339 AND meta_value != ''
1997 3340 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1998 3341 ", $meta_keys));
1999 3342
2000 3343 // Get batch of posts
2001 - $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('
2002 3346 SELECT DISTINCT post_id
2003 - FROM {$wpdb->postmeta}
2004 - WHERE meta_key IN ($meta_keys_placeholders)
3347 + FROM ' . $wpdb->postmeta . '
3348 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2005 3349 AND meta_value != ''
2006 3350 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2007 3351 ORDER BY post_id ASC
2008 3352 LIMIT %d OFFSET %d
@@ -2009,8 +3353,9 @@
2009 3353 ", array_merge($meta_keys, [$batch_size, $offset])));
2010 3354
2011 3355 $imported_count = 0;
2012 3356 $skipped_count = 0;
3357 + $unresolved = [];
2013 3358
2014 3359 foreach ($posts as $post_obj) {
2015 3360 $post_id = $post_obj->post_id;
2016 3361 $updated = false;
@@ -2018,15 +3363,16 @@
2018 3363 // Import title
2019 3364 if ($import_titles) {
2020 3365 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2021 3366 if (!empty($rm_title)) {
2022 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2023 -
2024 - if (empty($existing_title) || $overwrite) {
2025 - $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2026 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2027 - $updated = true;
2028 - }
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 + );
2029 3375 }
2030 3376 }
2031 3377
2032 3378 // Import description
@@ -2032,18 +3378,97 @@
2032 3378 // Import description
2033 3379 if ($import_descriptions) {
2034 3380 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2035 3381 if (!empty($rm_desc)) {
2036 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
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;
3390 + }
3391 + }
2037 3392
2038 - if (empty($existing_desc) || $overwrite) {
2039 - $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2040 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2041 - $updated = true;
2042 - }
3393 + // Import social text
3394 + if ($import_social_text) {
3395 + $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
3396 + if (!empty($rm_og_title)) {
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;
2043 3405 }
3406 +
3407 + $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
3408 + if (!empty($rm_og_desc)) {
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;
3417 + }
3418 +
3419 + $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
3420 + if (!empty($rm_tw_title)) {
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;
3429 + }
3430 +
3431 + $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
3432 + if (!empty($rm_tw_desc)) {
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;
3441 + }
2044 3442 }
2045 3443
3444 + // Import social images
3445 + if ($import_social_images) {
3446 + $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
3447 + if (!empty($rm_og_image)) {
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;
3456 + }
3457 +
3458 + $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
3459 + if (!empty($rm_twitter_image)) {
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;
3468 + }
3469 + }
3470 +
2046 3471 if ($updated) {
2047 3472 $imported_count++;
2048 3473 } else {
2049 3474 $skipped_count++;
@@ -2052,9 +3477,9 @@
2052 3477
2053 3478 $processed = $offset + count($posts);
2054 3479 $is_complete = $processed >= $total_posts;
2055 3480
2056 - return [
3481 + return $this->attach_unresolved_report([
2057 3482 'success' => true,
2058 3483 'imported' => $imported_count,
2059 3484 'skipped' => $skipped_count,
2060 3485 'total' => $total_posts,
@@ -2063,9 +3488,9 @@
2063 3488 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2064 3489 'message' => $is_complete
2065 3490 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2066 3491 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2067 - ];
3492 + ], $unresolved);
2068 3493 }
2069 3494
2070 3495 /**
2071 3496 * Import SEO metadata from All in One SEO
@@ -2077,13 +3502,15 @@
2077 3502 $batch_size = intval($options['batch_size']);
2078 3503 $offset = intval($options['offset']);
2079 3504 $import_titles = (bool) $options['import_titles'];
2080 3505 $import_descriptions = (bool) $options['import_descriptions'];
3506 + $import_social_text = !empty($options['import_social_text']);
3507 + $import_social_images = !empty($options['import_social_images']);
2081 3508 $overwrite = (bool) $options['overwrite_existing'];
2082 3509
2083 3510 // Check if AIOSEO table exists
2084 3511 $table = $wpdb->prefix . 'aioseo_posts';
2085 - 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
2086 3513 return [
2087 3514 'success' => false,
2088 3515 'message' => 'AIOSEO database table not found.'
2089 3516 ];
@@ -2088,9 +3515,9 @@
2088 3515 'message' => 'AIOSEO database table not found.'
2089 3516 ];
2090 3517 }
2091 3518
2092 - if (!$import_titles && !$import_descriptions) {
3519 + if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2093 3520 return [
2094 3521 'success' => false,
2095 3522 'message' => 'No import options selected.'
2096 3523 ];
@@ -2095,65 +3522,289 @@
2095 3522 'message' => 'No import options selected.'
2096 3523 ];
2097 3524 }
2098 3525
2099 - // Build WHERE clause
2100 - $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 = [];
2101 3531 if ($import_titles) {
2102 - $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
3532 + $value_columns[] = 'title';
2103 3533 }
2104 3534 if ($import_descriptions) {
2105 - $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
3535 + $value_columns[] = 'description';
2106 3536 }
2107 - $where_clause = implode(' OR ', $where_conditions);
3537 + if ($import_social_text) {
3538 + $value_columns[] = 'og_title';
3539 + $value_columns[] = 'og_description';
3540 + $value_columns[] = 'twitter_title';
3541 + $value_columns[] = 'twitter_description';
3542 + }
3543 + if ($import_social_images) {
3544 + $value_columns[] = 'og_image_custom_url';
3545 + $value_columns[] = 'twitter_image_custom_url';
3546 + }
2108 3547
2109 - // Get total count
2110 - $total_posts = (int) $wpdb->get_var("
2111 - SELECT COUNT(post_id)
2112 - FROM {$table}
2113 - WHERE ({$where_clause})
2114 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2115 - ");
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 + };
2116 3556
2117 - // Get batch of posts
2118 - $posts = $wpdb->get_results($wpdb->prepare("
2119 - SELECT post_id, title, description
2120 - FROM {$table}
2121 - WHERE ({$where_clause})
2122 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2123 - ORDER BY post_id ASC
2124 - LIMIT %d OFFSET %d
2125 - ", $batch_size, $offset));
3557 + $where_clause = $build_has_value_clause('');
3558 + $joined_has_value = $build_has_value_clause('a.');
2126 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 + }
3576 + }
3577 +
3578 + if (!empty($template_post_types)) {
3579 + $type_placeholders = implode(', ', array_fill(0, count($template_post_types), '%s'));
3580 +
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);
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 +
2127 3664 $imported_count = 0;
2128 3665 $skipped_count = 0;
3666 + $unresolved = [];
2129 3667
2130 3668 foreach ($posts as $aioseo_data) {
2131 3669 $post_id = $aioseo_data->post_id;
2132 3670 $updated = false;
2133 3671
2134 - // Import title
2135 - if ($import_titles && !empty($aioseo_data->title)) {
2136 - $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);
2137 3677
2138 - if (empty($existing_title) || $overwrite) {
2139 - $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2140 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2141 - $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;
2142 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 + );
2143 3701 }
2144 3702
2145 - // Import description
2146 - if ($import_descriptions && !empty($aioseo_data->description)) {
2147 - $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;
2148 3707
2149 - if (empty($existing_desc) || $overwrite) {
2150 - $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2151 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2152 - $updated = true;
3708 + if ($raw_desc === '' && $desc_template !== '') {
3709 + $raw_desc = $desc_template;
3710 + $desc_from_template = true;
2153 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;
2154 3727 }
2155 3728
3729 + // Import social text
3730 + if ($import_social_text) {
3731 + $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
3732 + if (!empty($aioseo_og_title)) {
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;
3741 + }
3742 +
3743 + $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
3744 + if (!empty($aioseo_og_desc)) {
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;
3753 + }
3754 +
3755 + $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
3756 + if (!empty($aioseo_tw_title)) {
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;
3765 + }
3766 +
3767 + $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
3768 + if (!empty($aioseo_tw_desc)) {
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;
3777 + }
3778 + }
3779 +
3780 + // Import social images
3781 + if ($import_social_images) {
3782 + $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
3783 + if (!empty($aioseo_og_image)) {
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;
3792 + }
3793 +
3794 + $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
3795 + if (!empty($aioseo_twitter_image)) {
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;
3804 + }
3805 + }
3806 +
2156 3807 if ($updated) {
2157 3808 $imported_count++;
2158 3809 } else {
2159 3810 $skipped_count++;
@@ -2160,11 +3811,15 @@
2160 3811 }
2161 3812 }
2162 3813
2163 3814 $processed = $offset + count($posts);
2164 - $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);
2165 3820
2166 - return [
3821 + return $this->attach_unresolved_report([
2167 3822 'success' => true,
2168 3823 'imported' => $imported_count,
2169 3824 'skipped' => $skipped_count,
2170 3825 'total' => $total_posts,
@@ -2173,9 +3828,9 @@
2173 3828 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2174 3829 'message' => $is_complete
2175 3830 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2176 3831 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2177 - ];
3832 + ], $unresolved);
2178 3833 }
2179 3834
2180 3835 /**
2181 3836 * Import term-level SEO metadata from a third-party SEO plugin into
@@ -2257,16 +3912,22 @@
2257 3912 'message' => $terms->get_error_message(),
2258 3913 ];
2259 3914 }
2260 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.
2261 3919 $field_map = [
2262 - 'wpseo_title' => '_metasync_metatitle',
2263 - 'wpseo_desc' => '_metasync_metadesc',
2264 3920 'wpseo_opengraph-title' => '_metasync_og_title',
2265 3921 'wpseo_opengraph-description' => '_metasync_og_description',
2266 3922 'wpseo_canonical' => '_metasync_canonical_url',
2267 3923 ];
2268 3924
3925 + $imported_map = [
3926 + 'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3927 + 'wpseo_desc' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3928 + ];
3929 +
2269 3930 $imported = 0;
2270 3931 $skipped = 0;
2271 3932
2272 3933 foreach ($terms as $term) {
@@ -2277,8 +3938,18 @@
2277 3938 if (!is_array($yoast_meta)) {
2278 3939 $yoast_meta = [];
2279 3940 }
2280 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 +
2281 3952 foreach ($field_map as $src_key => $dest_key) {
2282 3953 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2283 3954 if ($src_value === '' || $src_value === null) {
2284 3955 continue;
@@ -2288,8 +3959,21 @@
2288 3959 if (!empty($existing) && !$overwrite) {
2289 3960 continue;
2290 3961 }
2291 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 +
2292 3976 update_term_meta($term->term_id, $dest_key, $src_value);
2293 3977 $term_updated = true;
2294 3978 }
2295 3979
@@ -2347,16 +4031,21 @@
2347 4031 'message' => $terms->get_error_message(),
2348 4032 ];
2349 4033 }
2350 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.
2351 4037 $field_map = [
2352 - 'rank_math_title' => '_metasync_metatitle',
2353 - 'rank_math_description' => '_metasync_metadesc',
2354 4038 'rank_math_facebook_title' => '_metasync_og_title',
2355 4039 'rank_math_facebook_description' => '_metasync_og_description',
2356 4040 'rank_math_canonical_url' => '_metasync_canonical_url',
2357 4041 ];
2358 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 +
2359 4048 $imported = 0;
2360 4049 $skipped = 0;
2361 4050
2362 4051 foreach ($terms as $term) {
@@ -2361,8 +4050,18 @@
2361 4050
2362 4051 foreach ($terms as $term) {
2363 4052 $term_updated = false;
2364 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 +
2365 4064 foreach ($field_map as $src_key => $dest_key) {
2366 4065 $src_value = get_term_meta($term->term_id, $src_key, true);
2367 4066 if ($src_value === '' || $src_value === null) {
2368 4067 continue;
@@ -2372,8 +4071,21 @@
2372 4071 if (!empty($existing) && !$overwrite) {
2373 4072 continue;
2374 4073 }
2375 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 +
2376 4088 update_term_meta($term->term_id, $dest_key, $src_value);
2377 4089 $term_updated = true;
2378 4090 }
2379 4091
@@ -2421,9 +4133,9 @@
2421 4133 $overwrite = (bool) $options['overwrite_existing'];
2422 4134
2423 4135 $table = $wpdb->prefix . 'aioseo_terms';
2424 4136
2425 - $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
2426 4138 if ($table_exists !== $table) {
2427 4139 return [
2428 4140 'success' => false,
2429 4141 'message' => 'AIOSEO terms table not found.',
@@ -2429,11 +4141,11 @@
2429 4141 'message' => 'AIOSEO terms table not found.',
2430 4142 ];
2431 4143 }
2432 4144
2433 - $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
2434 4146 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2435 - FROM {$table}
4147 + FROM {$wpdb->prefix}aioseo_terms
2436 4148 ORDER BY term_id ASC
2437 4149 LIMIT %d OFFSET %d",
2438 4150 $batch_size,
2439 4151 $offset
@@ -2447,18 +4159,41 @@
2447 4159 if ($term_id <= 0) {
2448 4160 continue;
2449 4161 }
2450 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 +
2451 4171 $term_updated = false;
2452 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.
2453 4175 $field_map = [
2454 - 'title' => '_metasync_metatitle',
2455 - 'description' => '_metasync_metadesc',
2456 4176 'og_title' => '_metasync_og_title',
2457 4177 'og_description' => '_metasync_og_description',
2458 4178 'canonical_url' => '_metasync_canonical_url',
2459 4179 ];
2460 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 +
2461 4196 foreach ($field_map as $column => $dest_key) {
2462 4197 $value = isset($row->$column) ? $row->$column : '';
2463 4198 if ($value === '' || $value === null) {
2464 4199 continue;
@@ -2466,8 +4201,21 @@
2466 4201
2467 4202 $existing = get_term_meta($term_id, $dest_key, true);
2468 4203 if (!empty($existing) && !$overwrite) {
2469 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 + }
2470 4218 }
2471 4219
2472 4220 update_term_meta($term_id, $dest_key, $value);
2473 4221 $term_updated = true;