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 +1949 -194 2.6.5 → 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
@@ -1288,9 +1445,16 @@
1288 1445 if (!isset($item['@type'])) {
1289 1446 continue;
1290 1447 }
1291 1448
1292 - $type = strtolower($item['@type']);
1449 + $raw_type = $item['@type'];
1450 + if ( is_array( $raw_type ) ) {
1451 + $raw_type = reset( $raw_type );
1452 + }
1453 + if ( ! is_string( $raw_type ) || '' === $raw_type ) {
1454 + continue;
1455 + }
1456 + $type = strtolower( $raw_type );
1293 1457
1294 1458 // Map Yoast types to Metasync types
1295 1459 if ($type === 'article' || $type === 'newsarticle' || $type === 'blogposting') {
1296 1460 $metasync_schema['types'][] = [
@@ -1633,8 +1797,67 @@
1633 1797 return is_string($text) ? $text : $default;
1634 1798 }
1635 1799
1636 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 + /**
1637 1860 * Resolve SEO placeholder tokens to their actual values.
1638 1861 *
1639 1862 * Tries each source plugin's own replacement engine first so the result
1640 1863 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
@@ -1654,8 +1877,17 @@
1654 1877 if (!$post) {
1655 1878 return $text;
1656 1879 }
1657 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 +
1658 1890 // Yoast SEO — %%var%% tokens
1659 1891 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1660 1892 try {
1661 1893 $replacer = new WPSEO_Replace_Vars();
@@ -1679,9 +1911,9 @@
1679 1911 // fall through to manual replacement
1680 1912 }
1681 1913 }
1682 1914
1683 - // All in One SEO — #var# tokens
1915 + // All in One SEO — #var tokens
1684 1916 if ($plugin === 'aioseo') {
1685 1917 try {
1686 1918 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1687 1919 $resolved = aioseo()->tags->replaceTags($text, $post_id);
@@ -1693,20 +1925,27 @@
1693 1925 // fall through to manual replacement
1694 1926 }
1695 1927 }
1696 1928
1697 - return $this->manually_replace_seo_placeholders($text, $post_id);
1929 + return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
1698 1930 }
1699 1931
1700 1932 /**
1701 1933 * Manual fallback: replace the most common SEO placeholder tokens from
1702 - * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
1934 + * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
1703 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 + *
1704 1940 * @param string $text Text that may contain placeholder tokens.
1705 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.
1706 1945 * @return string Text with tokens replaced.
1707 1946 */
1708 - private function manually_replace_seo_placeholders($text, $post_id) {
1947 + private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
1709 1948 $post = get_post($post_id);
1710 1949 if (!$post) {
1711 1950 return $text;
1712 1951 }
@@ -1713,9 +1952,9 @@
1713 1952
1714 1953 $site_name = get_bloginfo('name');
1715 1954 $post_title = get_the_title($post_id);
1716 1955 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1717 - $author = get_the_author_meta('display_name', $post->post_author);
1956 + $author = get_the_author_meta('display_name', (int) $post->post_author);
1718 1957 $date = get_the_date('', $post_id);
1719 1958 $modified = get_the_modified_date('', $post_id);
1720 1959
1721 1960 $categories = get_the_category($post_id);
@@ -1723,28 +1962,9 @@
1723 1962
1724 1963 $tags = get_the_tags($post_id);
1725 1964 $first_tag = !empty($tags) ? $tags[0]->name : '';
1726 1965
1727 - // Try to read the separator from whichever plugin is active
1728 - $sep = '-';
1729 - if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1730 - try {
1731 - $raw = WPSEO_Options::get('separator', '-');
1732 - // Yoast stores separator keys like 'sc-dash'; convert to glyph
1733 - $sep = html_entity_decode(
1734 - WPSEO_Option_Titles::get_instance()->get_title_separator(),
1735 - ENT_QUOTES,
1736 - 'UTF-8'
1737 - );
1738 - } catch (Exception $e) {
1739 - $sep = '-';
1740 - }
1741 - } elseif (defined('RANK_MATH_VERSION')) {
1742 - $rm_settings = get_option('rank_math_general_settings', []);
1743 - if (!empty($rm_settings['title_separator'])) {
1744 - $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1745 - }
1746 - }
1966 + $sep = $this->resolve_source_separator($plugin);
1747 1967
1748 1968 $map = [
1749 1969 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1750 1970 '%%title%%' => $post_title,
@@ -1775,19 +1995,8 @@
1775 1995 '%page%' => '',
1776 1996 '%category%' => $primary_category,
1777 1997 '%tag%' => $first_tag,
1778 1998 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1779 - // ── AIOSEO (#var#) ───────────────────────────────────────────────
1780 - '#site_title#' => $site_name,
1781 - '#post_title#' => $post_title,
1782 - '#post_excerpt#' => $post_excerpt,
1783 - '#separator_sa#' => $sep,
1784 - '#author_name#' => $author,
1785 - '#current_date#' => $date,
1786 - '#post_date#' => $date,
1787 - '#category_title#' => $primary_category,
1788 - '#tag_title#' => $first_tag,
1789 - '#id#' => (string) $post_id,
1790 1999 ];
1791 2000
1792 2001 // Sort longest token first to avoid partial matches
1793 2002 // e.g. %%primary_category%% must replace before %%category%%
@@ -1794,12 +2003,1047 @@
1794 2003 uksort($map, function ($a, $b) {
1795 2004 return strlen($b) - strlen($a);
1796 2005 });
1797 2006
1798 - 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;
1799 2018 }
1800 2019
1801 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 + /**
1802 3046 * Import SEO Metadata (Titles and Descriptions)
1803 3047 * Supports batch processing via AJAX
1804 3048 *
1805 3049 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
@@ -1852,8 +3096,10 @@
1852 3096 $batch_size = intval($options['batch_size']);
1853 3097 $offset = intval($options['offset']);
1854 3098 $import_titles = (bool) $options['import_titles'];
1855 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']);
1856 3102 $overwrite = (bool) $options['overwrite_existing'];
1857 3103
1858 3104 // Build WHERE clause for meta keys
1859 3105 $meta_keys = [];
@@ -1862,10 +3108,20 @@
1862 3108 }
1863 3109 if ($import_descriptions) {
1864 3110 $meta_keys[] = '_yoast_wpseo_metadesc';
1865 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 + }
1866 3122
1867 - if (empty($meta_keys)) {
3123 + if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1868 3124 return [
1869 3125 'success' => false,
1870 3126 'message' => 'No import options selected.'
1871 3127 ];
@@ -1871,22 +3127,23 @@
1871 3127 ];
1872 3128 }
1873 3129
1874 3130 // Get total count (for progress tracking)
1875 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1876 - $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('
1877 3133 SELECT COUNT(DISTINCT post_id)
1878 - FROM {$wpdb->postmeta}
1879 - WHERE meta_key IN ($meta_keys_placeholders)
3134 + FROM ' . $wpdb->postmeta . '
3135 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1880 3136 AND meta_value != ''
1881 3137 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1882 3138 ", $meta_keys));
1883 3139
1884 3140 // Get batch of posts with Yoast data
1885 - $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('
1886 3143 SELECT DISTINCT post_id
1887 - FROM {$wpdb->postmeta}
1888 - WHERE meta_key IN ($meta_keys_placeholders)
3144 + FROM ' . $wpdb->postmeta . '
3145 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1889 3146 AND meta_value != ''
1890 3147 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1891 3148 ORDER BY post_id ASC
1892 3149 LIMIT %d OFFSET %d
@@ -1893,8 +3150,9 @@
1893 3150 ", array_merge($meta_keys, [$batch_size, $offset])));
1894 3151
1895 3152 $imported_count = 0;
1896 3153 $skipped_count = 0;
3154 + $unresolved = [];
1897 3155
1898 3156 foreach ($posts as $post_obj) {
1899 3157 $post_id = $post_obj->post_id;
1900 3158 $updated = false;
@@ -1902,15 +3160,16 @@
1902 3160 // Import title
1903 3161 if ($import_titles) {
1904 3162 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
1905 3163 if (!empty($yoast_title)) {
1906 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
1907 -
1908 - if (empty($existing_title) || $overwrite) {
1909 - $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
1910 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
1911 - $updated = true;
1912 - }
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 + );
1913 3172 }
1914 3173 }
1915 3174
1916 3175 // Import description
@@ -1916,18 +3175,97 @@
1916 3175 // Import description
1917 3176 if ($import_descriptions) {
1918 3177 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
1919 3178 if (!empty($yoast_desc)) {
1920 - $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 + }
1921 3189
1922 - if (empty($existing_desc) || $overwrite) {
1923 - $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
1924 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
1925 - $updated = true;
1926 - }
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;
1927 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 + }
1928 3239 }
1929 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 +
1930 3268 if ($updated) {
1931 3269 $imported_count++;
1932 3270 } else {
1933 3271 $skipped_count++;
@@ -1936,9 +3274,9 @@
1936 3274
1937 3275 $processed = $offset + count($posts);
1938 3276 $is_complete = $processed >= $total_posts;
1939 3277
1940 - return [
3278 + return $this->attach_unresolved_report([
1941 3279 'success' => true,
1942 3280 'imported' => $imported_count,
1943 3281 'skipped' => $skipped_count,
1944 3282 'total' => $total_posts,
@@ -1947,9 +3285,9 @@
1947 3285 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
1948 3286 'message' => $is_complete
1949 3287 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
1950 3288 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
1951 - ];
3289 + ], $unresolved);
1952 3290 }
1953 3291
1954 3292 /**
1955 3293 * Import SEO metadata from Rank Math
@@ -1961,8 +3299,10 @@
1961 3299 $batch_size = intval($options['batch_size']);
1962 3300 $offset = intval($options['offset']);
1963 3301 $import_titles = (bool) $options['import_titles'];
1964 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']);
1965 3305 $overwrite = (bool) $options['overwrite_existing'];
1966 3306
1967 3307 // Build WHERE clause for meta keys
1968 3308 $meta_keys = [];
@@ -1971,10 +3311,20 @@
1971 3311 }
1972 3312 if ($import_descriptions) {
1973 3313 $meta_keys[] = 'rank_math_description';
1974 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 + }
1975 3325
1976 - if (empty($meta_keys)) {
3326 + if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1977 3327 return [
1978 3328 'success' => false,
1979 3329 'message' => 'No import options selected.'
1980 3330 ];
@@ -1980,22 +3330,23 @@
1980 3330 ];
1981 3331 }
1982 3332
1983 3333 // Get total count
1984 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1985 - $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('
1986 3336 SELECT COUNT(DISTINCT post_id)
1987 - FROM {$wpdb->postmeta}
1988 - WHERE meta_key IN ($meta_keys_placeholders)
3337 + FROM ' . $wpdb->postmeta . '
3338 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1989 3339 AND meta_value != ''
1990 3340 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1991 3341 ", $meta_keys));
1992 3342
1993 3343 // Get batch of posts
1994 - $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('
1995 3346 SELECT DISTINCT post_id
1996 - FROM {$wpdb->postmeta}
1997 - WHERE meta_key IN ($meta_keys_placeholders)
3347 + FROM ' . $wpdb->postmeta . '
3348 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1998 3349 AND meta_value != ''
1999 3350 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2000 3351 ORDER BY post_id ASC
2001 3352 LIMIT %d OFFSET %d
@@ -2002,8 +3353,9 @@
2002 3353 ", array_merge($meta_keys, [$batch_size, $offset])));
2003 3354
2004 3355 $imported_count = 0;
2005 3356 $skipped_count = 0;
3357 + $unresolved = [];
2006 3358
2007 3359 foreach ($posts as $post_obj) {
2008 3360 $post_id = $post_obj->post_id;
2009 3361 $updated = false;
@@ -2011,15 +3363,16 @@
2011 3363 // Import title
2012 3364 if ($import_titles) {
2013 3365 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2014 3366 if (!empty($rm_title)) {
2015 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2016 -
2017 - if (empty($existing_title) || $overwrite) {
2018 - $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2019 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2020 - $updated = true;
2021 - }
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 + );
2022 3375 }
2023 3376 }
2024 3377
2025 3378 // Import description
@@ -2025,18 +3378,97 @@
2025 3378 // Import description
2026 3379 if ($import_descriptions) {
2027 3380 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2028 3381 if (!empty($rm_desc)) {
2029 - $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 + }
2030 3392
2031 - if (empty($existing_desc) || $overwrite) {
2032 - $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2033 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2034 - $updated = true;
2035 - }
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;
2036 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 + }
2037 3442 }
2038 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 +
2039 3471 if ($updated) {
2040 3472 $imported_count++;
2041 3473 } else {
2042 3474 $skipped_count++;
@@ -2045,9 +3477,9 @@
2045 3477
2046 3478 $processed = $offset + count($posts);
2047 3479 $is_complete = $processed >= $total_posts;
2048 3480
2049 - return [
3481 + return $this->attach_unresolved_report([
2050 3482 'success' => true,
2051 3483 'imported' => $imported_count,
2052 3484 'skipped' => $skipped_count,
2053 3485 'total' => $total_posts,
@@ -2056,9 +3488,9 @@
2056 3488 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2057 3489 'message' => $is_complete
2058 3490 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2059 3491 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2060 - ];
3492 + ], $unresolved);
2061 3493 }
2062 3494
2063 3495 /**
2064 3496 * Import SEO metadata from All in One SEO
@@ -2070,13 +3502,15 @@
2070 3502 $batch_size = intval($options['batch_size']);
2071 3503 $offset = intval($options['offset']);
2072 3504 $import_titles = (bool) $options['import_titles'];
2073 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']);
2074 3508 $overwrite = (bool) $options['overwrite_existing'];
2075 3509
2076 3510 // Check if AIOSEO table exists
2077 3511 $table = $wpdb->prefix . 'aioseo_posts';
2078 - 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
2079 3513 return [
2080 3514 'success' => false,
2081 3515 'message' => 'AIOSEO database table not found.'
2082 3516 ];
@@ -2081,9 +3515,9 @@
2081 3515 'message' => 'AIOSEO database table not found.'
2082 3516 ];
2083 3517 }
2084 3518
2085 - if (!$import_titles && !$import_descriptions) {
3519 + if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2086 3520 return [
2087 3521 'success' => false,
2088 3522 'message' => 'No import options selected.'
2089 3523 ];
@@ -2088,65 +3522,289 @@
2088 3522 'message' => 'No import options selected.'
2089 3523 ];
2090 3524 }
2091 3525
2092 - // Build WHERE clause
2093 - $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 = [];
2094 3531 if ($import_titles) {
2095 - $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
3532 + $value_columns[] = 'title';
2096 3533 }
2097 3534 if ($import_descriptions) {
2098 - $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
3535 + $value_columns[] = 'description';
2099 3536 }
2100 - $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 + }
2101 3547
2102 - // Get total count
2103 - $total_posts = (int) $wpdb->get_var("
2104 - SELECT COUNT(post_id)
2105 - FROM {$table}
2106 - WHERE ({$where_clause})
2107 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2108 - ");
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 + };
2109 3556
2110 - // Get batch of posts
2111 - $posts = $wpdb->get_results($wpdb->prepare("
2112 - SELECT post_id, title, description
2113 - FROM {$table}
2114 - WHERE ({$where_clause})
2115 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2116 - ORDER BY post_id ASC
2117 - LIMIT %d OFFSET %d
2118 - ", $batch_size, $offset));
3557 + $where_clause = $build_has_value_clause('');
3558 + $joined_has_value = $build_has_value_clause('a.');
2119 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 +
2120 3664 $imported_count = 0;
2121 3665 $skipped_count = 0;
3666 + $unresolved = [];
2122 3667
2123 3668 foreach ($posts as $aioseo_data) {
2124 3669 $post_id = $aioseo_data->post_id;
2125 3670 $updated = false;
2126 3671
2127 - // Import title
2128 - if ($import_titles && !empty($aioseo_data->title)) {
2129 - $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);
2130 3677
2131 - if (empty($existing_title) || $overwrite) {
2132 - $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2133 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2134 - $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;
2135 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 + );
2136 3701 }
2137 3702
2138 - // Import description
2139 - if ($import_descriptions && !empty($aioseo_data->description)) {
2140 - $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;
2141 3707
2142 - if (empty($existing_desc) || $overwrite) {
2143 - $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2144 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2145 - $updated = true;
3708 + if ($raw_desc === '' && $desc_template !== '') {
3709 + $raw_desc = $desc_template;
3710 + $desc_from_template = true;
2146 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;
2147 3727 }
2148 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 +
2149 3807 if ($updated) {
2150 3808 $imported_count++;
2151 3809 } else {
2152 3810 $skipped_count++;
@@ -2153,11 +3811,15 @@
2153 3811 }
2154 3812 }
2155 3813
2156 3814 $processed = $offset + count($posts);
2157 - $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);
2158 3820
2159 - return [
3821 + return $this->attach_unresolved_report([
2160 3822 'success' => true,
2161 3823 'imported' => $imported_count,
2162 3824 'skipped' => $skipped_count,
2163 3825 'total' => $total_posts,
@@ -2166,9 +3828,9 @@
2166 3828 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2167 3829 'message' => $is_complete
2168 3830 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2169 3831 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2170 - ];
3832 + ], $unresolved);
2171 3833 }
2172 3834
2173 3835 /**
2174 3836 * Import term-level SEO metadata from a third-party SEO plugin into
@@ -2250,16 +3912,22 @@
2250 3912 'message' => $terms->get_error_message(),
2251 3913 ];
2252 3914 }
2253 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.
2254 3919 $field_map = [
2255 - 'wpseo_title' => '_metasync_metatitle',
2256 - 'wpseo_desc' => '_metasync_metadesc',
2257 3920 'wpseo_opengraph-title' => '_metasync_og_title',
2258 3921 'wpseo_opengraph-description' => '_metasync_og_description',
2259 3922 'wpseo_canonical' => '_metasync_canonical_url',
2260 3923 ];
2261 3924
3925 + $imported_map = [
3926 + 'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3927 + 'wpseo_desc' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3928 + ];
3929 +
2262 3930 $imported = 0;
2263 3931 $skipped = 0;
2264 3932
2265 3933 foreach ($terms as $term) {
@@ -2270,8 +3938,18 @@
2270 3938 if (!is_array($yoast_meta)) {
2271 3939 $yoast_meta = [];
2272 3940 }
2273 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 +
2274 3952 foreach ($field_map as $src_key => $dest_key) {
2275 3953 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2276 3954 if ($src_value === '' || $src_value === null) {
2277 3955 continue;
@@ -2281,8 +3959,21 @@
2281 3959 if (!empty($existing) && !$overwrite) {
2282 3960 continue;
2283 3961 }
2284 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 +
2285 3976 update_term_meta($term->term_id, $dest_key, $src_value);
2286 3977 $term_updated = true;
2287 3978 }
2288 3979
@@ -2340,16 +4031,21 @@
2340 4031 'message' => $terms->get_error_message(),
2341 4032 ];
2342 4033 }
2343 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.
2344 4037 $field_map = [
2345 - 'rank_math_title' => '_metasync_metatitle',
2346 - 'rank_math_description' => '_metasync_metadesc',
2347 4038 'rank_math_facebook_title' => '_metasync_og_title',
2348 4039 'rank_math_facebook_description' => '_metasync_og_description',
2349 4040 'rank_math_canonical_url' => '_metasync_canonical_url',
2350 4041 ];
2351 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 +
2352 4048 $imported = 0;
2353 4049 $skipped = 0;
2354 4050
2355 4051 foreach ($terms as $term) {
@@ -2354,8 +4050,18 @@
2354 4050
2355 4051 foreach ($terms as $term) {
2356 4052 $term_updated = false;
2357 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 +
2358 4064 foreach ($field_map as $src_key => $dest_key) {
2359 4065 $src_value = get_term_meta($term->term_id, $src_key, true);
2360 4066 if ($src_value === '' || $src_value === null) {
2361 4067 continue;
@@ -2365,8 +4071,21 @@
2365 4071 if (!empty($existing) && !$overwrite) {
2366 4072 continue;
2367 4073 }
2368 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 +
2369 4088 update_term_meta($term->term_id, $dest_key, $src_value);
2370 4089 $term_updated = true;
2371 4090 }
2372 4091
@@ -2414,9 +4133,9 @@
2414 4133 $overwrite = (bool) $options['overwrite_existing'];
2415 4134
2416 4135 $table = $wpdb->prefix . 'aioseo_terms';
2417 4136
2418 - $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
2419 4138 if ($table_exists !== $table) {
2420 4139 return [
2421 4140 'success' => false,
2422 4141 'message' => 'AIOSEO terms table not found.',
@@ -2422,11 +4141,11 @@
2422 4141 'message' => 'AIOSEO terms table not found.',
2423 4142 ];
2424 4143 }
2425 4144
2426 - $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
2427 4146 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2428 - FROM {$table}
4147 + FROM {$wpdb->prefix}aioseo_terms
2429 4148 ORDER BY term_id ASC
2430 4149 LIMIT %d OFFSET %d",
2431 4150 $batch_size,
2432 4151 $offset
@@ -2440,18 +4159,41 @@
2440 4159 if ($term_id <= 0) {
2441 4160 continue;
2442 4161 }
2443 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 +
2444 4171 $term_updated = false;
2445 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.
2446 4175 $field_map = [
2447 - 'title' => '_metasync_metatitle',
2448 - 'description' => '_metasync_metadesc',
2449 4176 'og_title' => '_metasync_og_title',
2450 4177 'og_description' => '_metasync_og_description',
2451 4178 'canonical_url' => '_metasync_canonical_url',
2452 4179 ];
2453 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 +
2454 4196 foreach ($field_map as $column => $dest_key) {
2455 4197 $value = isset($row->$column) ? $row->$column : '';
2456 4198 if ($value === '' || $value === null) {
2457 4199 continue;
@@ -2459,8 +4201,21 @@
2459 4201
2460 4202 $existing = get_term_meta($term_id, $dest_key, true);
2461 4203 if (!empty($existing) && !$overwrite) {
2462 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 + }
2463 4218 }
2464 4219
2465 4220 update_term_meta($term_id, $dest_key, $value);
2466 4221 $term_updated = true;