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 +1668 -290 2.6.18 → 2.7.1 View file →
@@ -176,8 +176,9 @@
176 176 $count = 0;
177 177
178 178 switch ($plugin) {
179 179 case 'yoast':
180 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
180 181 $count = (int) $wpdb->get_var("
181 182 SELECT COUNT(DISTINCT post_id)
182 183 FROM {$wpdb->postmeta}
183 184 WHERE meta_key LIKE '_yoast_wpseo_%'
@@ -185,22 +186,23 @@
185 186 ");
186 187 break;
187 188
188 189 case 'rankmath':
189 - $count = (int) $wpdb->get_var("
190 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
191 + $count = (int) $wpdb->get_var($wpdb->prepare("
190 192 SELECT COUNT(DISTINCT post_id)
191 193 FROM {$wpdb->postmeta}
192 - WHERE meta_key LIKE 'rank_math_%'
194 + WHERE meta_key LIKE %s
193 195 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
194 - ");
196 + ", 'rank_math_%'));
195 197 break;
196 198
197 199 case 'aioseo':
198 200 $table = $wpdb->prefix . 'aioseo_posts';
199 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
201 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
200 202 $count = (int) $wpdb->get_var("
201 203 SELECT COUNT(post_id)
202 - FROM {$table}
204 + FROM {$wpdb->prefix}aioseo_posts
203 205 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
204 206 ");
205 207 }
206 208 break;
@@ -222,8 +224,9 @@
222 224 case 'yoast':
223 225 // Check for Yoast schema meta
224 226 // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
225 227 // Free version may use _yoast_wpseo_schema (JSON)
228 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
226 229 $count = (int) $wpdb->get_var("
227 230 SELECT COUNT(DISTINCT post_id)
228 231 FROM {$wpdb->postmeta}
229 232 WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
@@ -234,8 +237,9 @@
234 237 case 'rankmath':
235 238 // Check for Rank Math schema meta (any schema type)
236 239 // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
237 240 // Exclude shortcode schemas (rank_math_shortcode_schema_*)
241 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
238 242 $count = (int) $wpdb->get_var("
239 243 SELECT COUNT(DISTINCT post_id)
240 244 FROM {$wpdb->postmeta}
241 245 WHERE meta_key LIKE 'rank_math_schema_%'
@@ -246,12 +250,12 @@
246 250
247 251 case 'aioseo':
248 252 // Check for AIOSEO schema in table
249 253 $table = $wpdb->prefix . 'aioseo_posts';
250 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
254 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
251 255 $count = (int) $wpdb->get_var("
252 256 SELECT COUNT(post_id)
253 - FROM {$table}
257 + FROM {$wpdb->prefix}aioseo_posts
254 258 WHERE (schema_type IS NOT NULL AND schema_type != '')
255 259 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
256 260 ");
257 261 }
@@ -272,8 +276,9 @@
272 276
273 277 switch ($plugin) {
274 278 case 'yoast':
275 279 // Check for Yoast SEO title or description
280 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
276 281 $count = (int) $wpdb->get_var("
277 282 SELECT COUNT(DISTINCT post_id)
278 283 FROM {$wpdb->postmeta}
279 284 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
@@ -283,8 +288,9 @@
283 288 break;
284 289
285 290 case 'rankmath':
286 291 // Check for Rank Math title or description
292 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
287 293 $count = (int) $wpdb->get_var("
288 294 SELECT COUNT(DISTINCT post_id)
289 295 FROM {$wpdb->postmeta}
290 296 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
@@ -295,15 +301,42 @@
295 301
296 302 case 'aioseo':
297 303 // Check for AIOSEO title or description in their custom table
298 304 $table = $wpdb->prefix . 'aioseo_posts';
299 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
305 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
300 306 $count = (int) $wpdb->get_var("
301 307 SELECT COUNT(post_id)
302 - FROM {$table}
308 + FROM {$wpdb->prefix}aioseo_posts
303 309 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
304 310 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
305 311 ");
312 +
313 + // Per-post overrides are the exception. AIOSEO also serves a
314 + // title and description for every post from its site-wide
315 + // templates, and those are what the import mainly picks up —
316 + // so a site that never overrode an individual post still has
317 + // plenty to migrate. Counting overrides alone reported zero
318 + // there, which rendered the card "Unavailable" with a disabled
319 + // button and made the import impossible to start.
320 + $templates = $this->get_aioseo_post_type_templates();
321 + if (!empty($templates)) {
322 + $post_types = array_keys($templates);
323 + $type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
324 +
325 + $template_count = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
326 + "SELECT COUNT(*)
327 + FROM {$wpdb->posts}
328 + WHERE post_status = 'publish'
329 + AND post_type IN ({$type_placeholders})",
330 + $post_types
331 + ));
332 +
333 + // Take the larger of the two. Templates configured for a
334 + // post type with no published posts would otherwise report
335 + // zero and disable the card, hiding override rows that do
336 + // exist — the same dead end from the other direction.
337 + $count = max($count, $template_count);
338 + }
306 339 }
307 340 break;
308 341 }
309 342
@@ -320,8 +353,9 @@
320 353 $count = 0;
321 354
322 355 switch ($plugin) {
323 356 case 'yoast':
357 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
324 358 $count = (int) $wpdb->get_var("
325 359 SELECT COUNT(DISTINCT post_id)
326 360 FROM {$wpdb->postmeta}
327 361 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -330,8 +364,9 @@
330 364 ");
331 365 break;
332 366
333 367 case 'rankmath':
368 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
334 369 $count = (int) $wpdb->get_var("
335 370 SELECT COUNT(DISTINCT post_id)
336 371 FROM {$wpdb->postmeta}
337 372 WHERE meta_key = 'rank_math_primary_category'
@@ -340,8 +375,9 @@
340 375 ");
341 376 break;
342 377
343 378 case 'aioseo':
379 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
344 380 $count = (int) $wpdb->get_var("
345 381 SELECT COUNT(DISTINCT post_id)
346 382 FROM {$wpdb->postmeta}
347 383 WHERE meta_key = '_aioseo_primary_category'
@@ -402,8 +438,9 @@
402 438 $batch_size = intval($options['batch_size']);
403 439 $offset = intval($options['offset']);
404 440 $overwrite = (bool) $options['overwrite_existing'];
405 441
442 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
406 443 $total_posts = (int) $wpdb->get_var("
407 444 SELECT COUNT(DISTINCT post_id)
408 445 FROM {$wpdb->postmeta}
409 446 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -410,8 +447,9 @@
410 447 AND meta_value != ''
411 448 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
412 449 ");
413 450
451 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
414 452 $posts = $wpdb->get_results($wpdb->prepare("
415 453 SELECT DISTINCT post_id
416 454 FROM {$wpdb->postmeta}
417 455 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -465,8 +503,9 @@
465 503 $batch_size = intval($options['batch_size']);
466 504 $offset = intval($options['offset']);
467 505 $overwrite = (bool) $options['overwrite_existing'];
468 506
507 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
469 508 $total_posts = (int) $wpdb->get_var("
470 509 SELECT COUNT(DISTINCT post_id)
471 510 FROM {$wpdb->postmeta}
472 511 WHERE meta_key = 'rank_math_primary_category'
@@ -473,8 +512,9 @@
473 512 AND meta_value != ''
474 513 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
475 514 ");
476 515
516 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
477 517 $posts = $wpdb->get_results($wpdb->prepare("
478 518 SELECT DISTINCT post_id
479 519 FROM {$wpdb->postmeta}
480 520 WHERE meta_key = 'rank_math_primary_category'
@@ -528,8 +568,9 @@
528 568 $batch_size = intval($options['batch_size']);
529 569 $offset = intval($options['offset']);
530 570 $overwrite = (bool) $options['overwrite_existing'];
531 571
572 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
532 573 $total_posts = (int) $wpdb->get_var("
533 574 SELECT COUNT(DISTINCT post_id)
534 575 FROM {$wpdb->postmeta}
535 576 WHERE meta_key = '_aioseo_primary_category'
@@ -536,8 +577,9 @@
536 577 AND meta_value != ''
537 578 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
538 579 ");
539 580
581 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
540 582 $posts = $wpdb->get_results($wpdb->prepare("
541 583 SELECT DISTINCT post_id
542 584 FROM {$wpdb->postmeta}
543 585 WHERE meta_key = '_aioseo_primary_category'
@@ -735,24 +777,26 @@
735 777 $batch_size = intval($options['batch_size']);
736 778 $offset = intval($options['offset']);
737 779
738 780 // Get total count (for progress tracking)
739 - $total = (int) $wpdb->get_var("
781 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
782 + $total = (int) $wpdb->get_var($wpdb->prepare("
740 783 SELECT COUNT(DISTINCT post_id)
741 784 FROM {$wpdb->postmeta}
742 - WHERE meta_key LIKE '_yoast_wpseo_%'
785 + WHERE meta_key LIKE %s
743 786 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
744 - ");
787 + ", '_yoast_wpseo_%'));
745 788
746 789 // Get batch of posts with Yoast robots meta
790 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
747 791 $posts = $wpdb->get_results($wpdb->prepare("
748 792 SELECT DISTINCT post_id
749 793 FROM {$wpdb->postmeta}
750 - WHERE meta_key LIKE '_yoast_wpseo_%'
794 + WHERE meta_key LIKE %s
751 795 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
752 796 ORDER BY post_id ASC
753 797 LIMIT %d OFFSET %d
754 - ", $batch_size, $offset));
798 + ", '_yoast_wpseo_%', $batch_size, $offset));
755 799
756 800 foreach ($posts as $post_obj) {
757 801 $post_id = $post_obj->post_id;
758 802 $has_changes = false;
@@ -767,11 +811,12 @@
767 811 $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
768 812 if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
769 813 $metasync_robots['noindex'] = 'noindex';
770 814 $has_changes = true;
771 - } elseif ($yoast_noindex === '2' && !isset($metasync_robots['index'])) {
772 - // '2' means 'index' in Yoast
773 - $metasync_robots['index'] = 'index';
815 + } elseif ($yoast_noindex === '2' && isset($metasync_robots['noindex'])) {
816 + // '2' means 'index' in Yoast. Index is the implicit default, so
817 + // clear any stored noindex rather than recording a redundant flag.
818 + unset($metasync_robots['noindex']);
774 819 $has_changes = true;
775 820 }
776 821
777 822 // Import nofollow
@@ -782,8 +827,16 @@
782 827 }
783 828
784 829 // Import advanced robots (noarchive, nosnippet, noimageindex)
785 830 $yoast_adv = get_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', true);
831 + // Normalise the meta value to a string before calling explode().
832 + // Some sites store malformed/legacy data where this meta value is
833 + // an array, which would otherwise throw a TypeError on explode().
834 + if (is_array($yoast_adv)) {
835 + $yoast_adv = implode(',', $yoast_adv);
836 + } elseif (!is_string($yoast_adv)) {
837 + $yoast_adv = '';
838 + }
786 839 if (!empty($yoast_adv)) {
787 840 $adv_directives = explode(',', $yoast_adv);
788 841 foreach ($adv_directives as $directive) {
789 842 $directive = trim($directive);
@@ -867,24 +920,26 @@
867 920 $batch_size = intval($options['batch_size']);
868 921 $offset = intval($options['offset']);
869 922
870 923 // Get total count (for progress tracking)
871 - $total = (int) $wpdb->get_var("
924 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
925 + $total = (int) $wpdb->get_var($wpdb->prepare("
872 926 SELECT COUNT(DISTINCT post_id)
873 927 FROM {$wpdb->postmeta}
874 - WHERE meta_key LIKE 'rank_math_%'
928 + WHERE meta_key LIKE %s
875 929 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
876 - ");
930 + ", 'rank_math_%'));
877 931
878 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
879 934 $posts = $wpdb->get_results($wpdb->prepare("
880 935 SELECT DISTINCT post_id
881 936 FROM {$wpdb->postmeta}
882 - WHERE meta_key LIKE 'rank_math_%'
937 + WHERE meta_key LIKE %s
883 938 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
884 939 ORDER BY post_id ASC
885 940 LIMIT %d OFFSET %d
886 - ", $batch_size, $offset));
941 + ", 'rank_math_%', $batch_size, $offset));
887 942
888 943 foreach ($posts as $post_obj) {
889 944 $post_id = $post_obj->post_id;
890 945 $has_changes = false;
@@ -1007,9 +1062,9 @@
1007 1062 // AIOSEO stores data in a custom table
1008 1063 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1009 1064
1010 1065 // Check if table exists
1011 - $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
1012 1067
1013 1068 if (!$table_exists) {
1014 1069 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1015 1070 }
@@ -1014,21 +1069,23 @@
1014 1069 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1015 1070 }
1016 1071
1017 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
1018 1074 $total = (int) $wpdb->get_var("
1019 1075 SELECT COUNT(*)
1020 - FROM {$aioseo_table}
1076 + FROM {$wpdb->prefix}aioseo_posts
1021 1077 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1022 1078 ");
1023 1079
1024 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
1025 1082 $posts = $wpdb->get_results($wpdb->prepare("
1026 1083 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
1027 1084 robots_noarchive, robots_nosnippet, robots_noimageindex,
1028 1085 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
1029 1086 canonical_url
1030 - FROM {$aioseo_table}
1087 + FROM {$wpdb->prefix}aioseo_posts
1031 1088 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1032 1089 ORDER BY post_id ASC
1033 1090 LIMIT %d OFFSET %d
1034 1091 ", $batch_size, $offset));
@@ -1180,8 +1237,9 @@
1180 1237 global $wpdb;
1181 1238 $imported_count = 0;
1182 1239
1183 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
1184 1242 $posts = $wpdb->get_results("
1185 1243 SELECT post_id, meta_value
1186 1244 FROM {$wpdb->postmeta}
1187 1245 WHERE meta_key = '_yoast_wpseo_schema'
@@ -1206,14 +1264,15 @@
1206 1264 // Convert Yoast schema to Metasync format
1207 1265 $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1208 1266
1209 1267 if (!empty($metasync_schema['types'])) {
1210 - 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)));
1211 1269 $imported_count++;
1212 1270 }
1213 1271 }
1214 1272
1215 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
1216 1275 $posts = $wpdb->get_results("
1217 1276 SELECT post_id, meta_value
1218 1277 FROM {$wpdb->postmeta}
1219 1278 WHERE meta_key = '_yoast_wpseo_schema_article_type'
@@ -1249,9 +1308,9 @@
1249 1308 ]
1250 1309 ]
1251 1310 ];
1252 1311
1253 - 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)));
1254 1313 $imported_count++;
1255 1314 }
1256 1315 }
1257 1316
@@ -1267,8 +1326,9 @@
1267 1326 $imported_count = 0;
1268 1327
1269 1328 // Get all posts with any Rank Math schema (dynamically detect schema types)
1270 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
1271 1331 $posts = $wpdb->get_results("
1272 1332 SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1273 1333 FROM {$wpdb->postmeta} pm
1274 1334 WHERE pm.meta_key LIKE 'rank_math_schema_%'
@@ -1305,9 +1365,9 @@
1305 1365 // Convert Rank Math schema to Metasync format
1306 1366 $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1307 1367
1308 1368 if (!empty($metasync_schema['types'])) {
1309 - 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)));
1310 1370 $imported_count++;
1311 1371 $processed_posts[] = $post_id; // Mark post as processed
1312 1372 }
1313 1373 }
@@ -1324,16 +1384,17 @@
1324 1384 $imported_count = 0;
1325 1385
1326 1386 // Check if AIOSEO table exists
1327 1387 $table = $wpdb->prefix . 'aioseo_posts';
1328 - 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
1329 1389 return 0;
1330 1390 }
1331 1391
1332 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
1333 1394 $posts = $wpdb->get_results("
1334 1395 SELECT post_id, schema_type, schema_type_options
1335 - FROM {$table}
1396 + FROM {$wpdb->prefix}aioseo_posts
1336 1397 WHERE (schema_type IS NOT NULL AND schema_type != '')
1337 1398 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1338 1399 ");
1339 1400
@@ -1359,9 +1420,9 @@
1359 1420 $post_id
1360 1421 );
1361 1422
1362 1423 if (!empty($metasync_schema['types'])) {
1363 - 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)));
1364 1425 $imported_count++;
1365 1426 }
1366 1427 }
1367 1428
@@ -1736,8 +1797,67 @@
1736 1797 return is_string($text) ? $text : $default;
1737 1798 }
1738 1799
1739 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 + /**
1740 1860 * Resolve SEO placeholder tokens to their actual values.
1741 1861 *
1742 1862 * Tries each source plugin's own replacement engine first so the result
1743 1863 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
@@ -1757,8 +1877,17 @@
1757 1877 if (!$post) {
1758 1878 return $text;
1759 1879 }
1760 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 +
1761 1890 // Yoast SEO — %%var%% tokens
1762 1891 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1763 1892 try {
1764 1893 $replacer = new WPSEO_Replace_Vars();
@@ -1782,9 +1911,9 @@
1782 1911 // fall through to manual replacement
1783 1912 }
1784 1913 }
1785 1914
1786 - // All in One SEO — #var# tokens
1915 + // All in One SEO — #var tokens
1787 1916 if ($plugin === 'aioseo') {
1788 1917 try {
1789 1918 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1790 1919 $resolved = aioseo()->tags->replaceTags($text, $post_id);
@@ -1796,20 +1925,27 @@
1796 1925 // fall through to manual replacement
1797 1926 }
1798 1927 }
1799 1928
1800 - return $this->manually_replace_seo_placeholders($text, $post_id);
1929 + return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
1801 1930 }
1802 1931
1803 1932 /**
1804 1933 * Manual fallback: replace the most common SEO placeholder tokens from
1805 - * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
1934 + * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
1806 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 + *
1807 1940 * @param string $text Text that may contain placeholder tokens.
1808 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.
1809 1945 * @return string Text with tokens replaced.
1810 1946 */
1811 - private function manually_replace_seo_placeholders($text, $post_id) {
1947 + private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
1812 1948 $post = get_post($post_id);
1813 1949 if (!$post) {
1814 1950 return $text;
1815 1951 }
@@ -1816,9 +1952,9 @@
1816 1952
1817 1953 $site_name = get_bloginfo('name');
1818 1954 $post_title = get_the_title($post_id);
1819 1955 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1820 - $author = get_the_author_meta('display_name', $post->post_author);
1956 + $author = get_the_author_meta('display_name', (int) $post->post_author);
1821 1957 $date = get_the_date('', $post_id);
1822 1958 $modified = get_the_modified_date('', $post_id);
1823 1959
1824 1960 $categories = get_the_category($post_id);
@@ -1826,30 +1962,9 @@
1826 1962
1827 1963 $tags = get_the_tags($post_id);
1828 1964 $first_tag = !empty($tags) ? $tags[0]->name : '';
1829 1965
1830 - // Try to read the separator from whichever plugin is active
1831 - $sep = '-';
1832 - if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1833 - try {
1834 - $raw = WPSEO_Options::get('separator', 'sc-dash');
1835 - // Yoast stores separator keys like 'sc-dash'; convert to glyph
1836 - // via the public get_separator_options() lookup table.
1837 - if (class_exists('WPSEO_Option_Titles')) {
1838 - $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
1839 - if (isset($options[$raw])) {
1840 - $sep = html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
1841 - }
1842 - }
1843 - } catch (Exception $e) {
1844 - $sep = '-';
1845 - }
1846 - } elseif (defined('RANK_MATH_VERSION')) {
1847 - $rm_settings = get_option('rank_math_general_settings', []);
1848 - if (!empty($rm_settings['title_separator'])) {
1849 - $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1850 - }
1851 - }
1966 + $sep = $this->resolve_source_separator($plugin);
1852 1967
1853 1968 $map = [
1854 1969 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1855 1970 '%%title%%' => $post_title,
@@ -1880,19 +1995,8 @@
1880 1995 '%page%' => '',
1881 1996 '%category%' => $primary_category,
1882 1997 '%tag%' => $first_tag,
1883 1998 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1884 - // ── AIOSEO (#var#) ───────────────────────────────────────────────
1885 - '#site_title#' => $site_name,
1886 - '#post_title#' => $post_title,
1887 - '#post_excerpt#' => $post_excerpt,
1888 - '#separator_sa#' => $sep,
1889 - '#author_name#' => $author,
1890 - '#current_date#' => $date,
1891 - '#post_date#' => $date,
1892 - '#category_title#' => $primary_category,
1893 - '#tag_title#' => $first_tag,
1894 - '#id#' => (string) $post_id,
1895 1999 ];
1896 2000
1897 2001 // Sort longest token first to avoid partial matches
1898 2002 // e.g. %%primary_category%% must replace before %%category%%
@@ -1899,12 +2003,1047 @@
1899 2003 uksort($map, function ($a, $b) {
1900 2004 return strlen($b) - strlen($a);
1901 2005 });
1902 2006
1903 - 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;
1904 2018 }
1905 2019
1906 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 + /**
1907 3046 * Import SEO Metadata (Titles and Descriptions)
1908 3047 * Supports batch processing via AJAX
1909 3048 *
1910 3049 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
@@ -1988,22 +3127,23 @@
1988 3127 ];
1989 3128 }
1990 3129
1991 3130 // Get total count (for progress tracking)
1992 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1993 - $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('
1994 3133 SELECT COUNT(DISTINCT post_id)
1995 - FROM {$wpdb->postmeta}
1996 - WHERE meta_key IN ($meta_keys_placeholders)
3134 + FROM ' . $wpdb->postmeta . '
3135 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1997 3136 AND meta_value != ''
1998 3137 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1999 3138 ", $meta_keys));
2000 3139
2001 3140 // Get batch of posts with Yoast data
2002 - $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('
2003 3143 SELECT DISTINCT post_id
2004 - FROM {$wpdb->postmeta}
2005 - WHERE meta_key IN ($meta_keys_placeholders)
3144 + FROM ' . $wpdb->postmeta . '
3145 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2006 3146 AND meta_value != ''
2007 3147 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2008 3148 ORDER BY post_id ASC
2009 3149 LIMIT %d OFFSET %d
@@ -2010,8 +3150,9 @@
2010 3150 ", array_merge($meta_keys, [$batch_size, $offset])));
2011 3151
2012 3152 $imported_count = 0;
2013 3153 $skipped_count = 0;
3154 + $unresolved = [];
2014 3155
2015 3156 foreach ($posts as $post_obj) {
2016 3157 $post_id = $post_obj->post_id;
2017 3158 $updated = false;
@@ -2019,15 +3160,16 @@
2019 3160 // Import title
2020 3161 if ($import_titles) {
2021 3162 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
2022 3163 if (!empty($yoast_title)) {
2023 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2024 -
2025 - if (empty($existing_title) || $overwrite) {
2026 - $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
2027 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
2028 - $updated = true;
2029 - }
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 + );
2030 3172 }
2031 3173 }
2032 3174
2033 3175 // Import description
@@ -2033,15 +3175,16 @@
2033 3175 // Import description
2034 3176 if ($import_descriptions) {
2035 3177 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
2036 3178 if (!empty($yoast_desc)) {
2037 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2038 -
2039 - if (empty($existing_desc) || $overwrite) {
2040 - $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
2041 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
2042 - $updated = true;
2043 - }
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;
2044 3187 }
2045 3188 }
2046 3189
2047 3190 // Import social text
@@ -2047,44 +3190,52 @@
2047 3190 // Import social text
2048 3191 if ($import_social_text) {
2049 3192 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
2050 3193 if (!empty($yoast_og_title)) {
2051 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2052 - if (empty($existing) || $overwrite) {
2053 - $yoast_og_title = $this->resolve_seo_placeholders($yoast_og_title, $post_id, 'yoast');
2054 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($yoast_og_title));
2055 - $updated = true;
2056 - }
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;
2057 3202 }
2058 3203
2059 3204 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
2060 3205 if (!empty($yoast_og_desc)) {
2061 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2062 - if (empty($existing) || $overwrite) {
2063 - $yoast_og_desc = $this->resolve_seo_placeholders($yoast_og_desc, $post_id, 'yoast');
2064 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($yoast_og_desc));
2065 - $updated = true;
2066 - }
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;
2067 3214 }
2068 3215
2069 3216 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
2070 3217 if (!empty($yoast_tw_title)) {
2071 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2072 - if (empty($existing) || $overwrite) {
2073 - $yoast_tw_title = $this->resolve_seo_placeholders($yoast_tw_title, $post_id, 'yoast');
2074 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($yoast_tw_title));
2075 - $updated = true;
2076 - }
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;
2077 3226 }
2078 3227
2079 3228 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
2080 3229 if (!empty($yoast_tw_desc)) {
2081 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2082 - if (empty($existing) || $overwrite) {
2083 - $yoast_tw_desc = $this->resolve_seo_placeholders($yoast_tw_desc, $post_id, 'yoast');
2084 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($yoast_tw_desc));
2085 - $updated = true;
2086 - }
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;
2087 3238 }
2088 3239 }
2089 3240
2090 3241 // Import social images
@@ -2090,22 +3241,28 @@
2090 3241 // Import social images
2091 3242 if ($import_social_images) {
2092 3243 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
2093 3244 if (!empty($yoast_og_image)) {
2094 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2095 - if (empty($existing_og) || $overwrite) {
2096 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($yoast_og_image));
2097 - $updated = true;
2098 - }
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;
2099 3253 }
2100 3254
2101 3255 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
2102 3256 if (!empty($yoast_twitter_image)) {
2103 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2104 - if (empty($existing_twitter) || $overwrite) {
2105 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($yoast_twitter_image));
2106 - $updated = true;
2107 - }
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;
2108 3265 }
2109 3266 }
2110 3267
2111 3268 if ($updated) {
@@ -2117,9 +3274,9 @@
2117 3274
2118 3275 $processed = $offset + count($posts);
2119 3276 $is_complete = $processed >= $total_posts;
2120 3277
2121 - return [
3278 + return $this->attach_unresolved_report([
2122 3279 'success' => true,
2123 3280 'imported' => $imported_count,
2124 3281 'skipped' => $skipped_count,
2125 3282 'total' => $total_posts,
@@ -2128,9 +3285,9 @@
2128 3285 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2129 3286 'message' => $is_complete
2130 3287 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2131 3288 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2132 - ];
3289 + ], $unresolved);
2133 3290 }
2134 3291
2135 3292 /**
2136 3293 * Import SEO metadata from Rank Math
@@ -2173,22 +3330,23 @@
2173 3330 ];
2174 3331 }
2175 3332
2176 3333 // Get total count
2177 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
2178 - $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('
2179 3336 SELECT COUNT(DISTINCT post_id)
2180 - FROM {$wpdb->postmeta}
2181 - WHERE meta_key IN ($meta_keys_placeholders)
3337 + FROM ' . $wpdb->postmeta . '
3338 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2182 3339 AND meta_value != ''
2183 3340 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2184 3341 ", $meta_keys));
2185 3342
2186 3343 // Get batch of posts
2187 - $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('
2188 3346 SELECT DISTINCT post_id
2189 - FROM {$wpdb->postmeta}
2190 - WHERE meta_key IN ($meta_keys_placeholders)
3347 + FROM ' . $wpdb->postmeta . '
3348 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2191 3349 AND meta_value != ''
2192 3350 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2193 3351 ORDER BY post_id ASC
2194 3352 LIMIT %d OFFSET %d
@@ -2195,8 +3353,9 @@
2195 3353 ", array_merge($meta_keys, [$batch_size, $offset])));
2196 3354
2197 3355 $imported_count = 0;
2198 3356 $skipped_count = 0;
3357 + $unresolved = [];
2199 3358
2200 3359 foreach ($posts as $post_obj) {
2201 3360 $post_id = $post_obj->post_id;
2202 3361 $updated = false;
@@ -2204,15 +3363,16 @@
2204 3363 // Import title
2205 3364 if ($import_titles) {
2206 3365 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2207 3366 if (!empty($rm_title)) {
2208 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2209 -
2210 - if (empty($existing_title) || $overwrite) {
2211 - $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2212 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2213 - $updated = true;
2214 - }
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 + );
2215 3375 }
2216 3376 }
2217 3377
2218 3378 // Import description
@@ -2218,15 +3378,16 @@
2218 3378 // Import description
2219 3379 if ($import_descriptions) {
2220 3380 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2221 3381 if (!empty($rm_desc)) {
2222 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2223 -
2224 - if (empty($existing_desc) || $overwrite) {
2225 - $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2226 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2227 - $updated = true;
2228 - }
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;
2229 3390 }
2230 3391 }
2231 3392
2232 3393 // Import social text
@@ -2232,44 +3393,52 @@
2232 3393 // Import social text
2233 3394 if ($import_social_text) {
2234 3395 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
2235 3396 if (!empty($rm_og_title)) {
2236 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2237 - if (empty($existing) || $overwrite) {
2238 - $rm_og_title = $this->resolve_seo_placeholders($rm_og_title, $post_id, 'rankmath');
2239 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($rm_og_title));
2240 - $updated = true;
2241 - }
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;
2242 3405 }
2243 3406
2244 3407 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
2245 3408 if (!empty($rm_og_desc)) {
2246 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2247 - if (empty($existing) || $overwrite) {
2248 - $rm_og_desc = $this->resolve_seo_placeholders($rm_og_desc, $post_id, 'rankmath');
2249 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($rm_og_desc));
2250 - $updated = true;
2251 - }
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;
2252 3417 }
2253 3418
2254 3419 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
2255 3420 if (!empty($rm_tw_title)) {
2256 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2257 - if (empty($existing) || $overwrite) {
2258 - $rm_tw_title = $this->resolve_seo_placeholders($rm_tw_title, $post_id, 'rankmath');
2259 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($rm_tw_title));
2260 - $updated = true;
2261 - }
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;
2262 3429 }
2263 3430
2264 3431 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
2265 3432 if (!empty($rm_tw_desc)) {
2266 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2267 - if (empty($existing) || $overwrite) {
2268 - $rm_tw_desc = $this->resolve_seo_placeholders($rm_tw_desc, $post_id, 'rankmath');
2269 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($rm_tw_desc));
2270 - $updated = true;
2271 - }
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;
2272 3441 }
2273 3442 }
2274 3443
2275 3444 // Import social images
@@ -2275,22 +3444,28 @@
2275 3444 // Import social images
2276 3445 if ($import_social_images) {
2277 3446 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
2278 3447 if (!empty($rm_og_image)) {
2279 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2280 - if (empty($existing_og) || $overwrite) {
2281 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($rm_og_image));
2282 - $updated = true;
2283 - }
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;
2284 3456 }
2285 3457
2286 3458 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
2287 3459 if (!empty($rm_twitter_image)) {
2288 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2289 - if (empty($existing_twitter) || $overwrite) {
2290 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($rm_twitter_image));
2291 - $updated = true;
2292 - }
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;
2293 3468 }
2294 3469 }
2295 3470
2296 3471 if ($updated) {
@@ -2302,9 +3477,9 @@
2302 3477
2303 3478 $processed = $offset + count($posts);
2304 3479 $is_complete = $processed >= $total_posts;
2305 3480
2306 - return [
3481 + return $this->attach_unresolved_report([
2307 3482 'success' => true,
2308 3483 'imported' => $imported_count,
2309 3484 'skipped' => $skipped_count,
2310 3485 'total' => $total_posts,
@@ -2313,9 +3488,9 @@
2313 3488 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2314 3489 'message' => $is_complete
2315 3490 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2316 3491 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2317 - ];
3492 + ], $unresolved);
2318 3493 }
2319 3494
2320 3495 /**
2321 3496 * Import SEO metadata from All in One SEO
@@ -2333,9 +3508,9 @@
2333 3508 $overwrite = (bool) $options['overwrite_existing'];
2334 3509
2335 3510 // Check if AIOSEO table exists
2336 3511 $table = $wpdb->prefix . 'aioseo_posts';
2337 - 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
2338 3513 return [
2339 3514 'success' => false,
2340 3515 'message' => 'AIOSEO database table not found.'
2341 3516 ];
@@ -2347,93 +3522,209 @@
2347 3522 'message' => 'No import options selected.'
2348 3523 ];
2349 3524 }
2350 3525
2351 - // Build WHERE clause
2352 - $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 = [];
2353 3531 if ($import_titles) {
2354 - $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
3532 + $value_columns[] = 'title';
2355 3533 }
2356 3534 if ($import_descriptions) {
2357 - $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
3535 + $value_columns[] = 'description';
2358 3536 }
2359 3537 if ($import_social_text) {
2360 - $where_conditions[] = '(og_title IS NOT NULL AND og_title != \'\')';
2361 - $where_conditions[] = '(og_description IS NOT NULL AND og_description != \'\')';
2362 - $where_conditions[] = '(twitter_title IS NOT NULL AND twitter_title != \'\')';
2363 - $where_conditions[] = '(twitter_description IS NOT NULL AND twitter_description != \'\')';
3538 + $value_columns[] = 'og_title';
3539 + $value_columns[] = 'og_description';
3540 + $value_columns[] = 'twitter_title';
3541 + $value_columns[] = 'twitter_description';
2364 3542 }
2365 3543 if ($import_social_images) {
2366 - $where_conditions[] = '(og_image_custom_url IS NOT NULL AND og_image_custom_url != \'\')';
2367 - $where_conditions[] = '(twitter_image_custom_url IS NOT NULL AND twitter_image_custom_url != \'\')';
3544 + $value_columns[] = 'og_image_custom_url';
3545 + $value_columns[] = 'twitter_image_custom_url';
2368 3546 }
2369 - $where_clause = implode(' OR ', $where_conditions);
2370 3547
2371 - // Build SELECT columns
2372 - $select_columns = ['post_id'];
2373 - if ($import_titles) {
2374 - $select_columns[] = 'title';
3548 + $build_has_value_clause = function ($prefix) use ($value_columns) {
3549 + $conditions = [];
3550 + foreach ($value_columns as $column) {
3551 + $col = $prefix . $column;
3552 + $conditions[] = "({$col} IS NOT NULL AND {$col} != '')";
3553 + }
3554 + return implode(' OR ', $conditions);
3555 + };
3556 +
3557 + $where_clause = $build_has_value_clause('');
3558 + $joined_has_value = $build_has_value_clause('a.');
3559 +
3560 + // AIOSEO serves a title and description for every post through its
3561 + // site-wide templates, not only for posts carrying a per-post override.
3562 + // Walking the AIOSEO table alone therefore reaches almost nothing on a
3563 + // typical site — overrides are the exception, templates are the rule. So
3564 + // when templates are in play, anchor the walk on the posts table and join
3565 + // the AIOSEO row in; the per-post value still wins wherever it exists.
3566 + $templates = ($import_titles || $import_descriptions)
3567 + ? $this->get_aioseo_post_type_templates()
3568 + : [];
3569 +
3570 + $template_post_types = [];
3571 + foreach ($templates as $post_type => $template) {
3572 + if (($import_titles && $template['title'] !== '')
3573 + || ($import_descriptions && $template['description'] !== '')) {
3574 + $template_post_types[] = $post_type;
3575 + }
2375 3576 }
2376 - if ($import_descriptions) {
2377 - $select_columns[] = 'description';
2378 - }
2379 - if ($import_social_text) {
2380 - $select_columns[] = 'og_title';
2381 - $select_columns[] = 'og_description';
2382 - $select_columns[] = 'twitter_title';
2383 - $select_columns[] = 'twitter_description';
2384 - }
2385 - if ($import_social_images) {
2386 - $select_columns[] = 'og_image_custom_url';
2387 - $select_columns[] = 'twitter_image_custom_url';
2388 - }
2389 - $select_clause = implode(', ', $select_columns);
2390 3577
2391 - // Get total count
2392 - $total_posts = (int) $wpdb->get_var("
2393 - SELECT COUNT(post_id)
2394 - FROM {$table}
2395 - WHERE ({$where_clause})
2396 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2397 - ");
3578 + if (!empty($template_post_types)) {
3579 + $type_placeholders = implode(', ', array_fill(0, count($template_post_types), '%s'));
2398 3580
2399 - // Get batch of posts
2400 - $posts = $wpdb->get_results($wpdb->prepare("
2401 - SELECT {$select_clause}
2402 - FROM {$table}
2403 - WHERE ({$where_clause})
2404 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2405 - ORDER BY post_id ASC
2406 - LIMIT %d OFFSET %d
2407 - ", $batch_size, $offset));
3581 + // Template mode visits every published post, not just the few with an
3582 + // override, and AIOSEO's default description template is #post_excerpt
3583 + // — which on a post without a manual excerpt runs the whole the_content
3584 + // filter chain. On page-builder content that is a large fraction of a
3585 + // second each, so the caller's batch of 50 can outlive a proxy read
3586 + // timeout. Smaller batches cost more requests and finish.
3587 + $batch_size = min($batch_size, 20);
2408 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 +
2409 3664 $imported_count = 0;
2410 3665 $skipped_count = 0;
3666 + $unresolved = [];
2411 3667
2412 3668 foreach ($posts as $aioseo_data) {
2413 3669 $post_id = $aioseo_data->post_id;
2414 3670 $updated = false;
2415 3671
2416 - // Import title
2417 - if ($import_titles && !empty($aioseo_data->title)) {
2418 - $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);
2419 3677
2420 - if (empty($existing_title) || $overwrite) {
2421 - $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2422 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2423 - $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;
2424 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 + );
2425 3701 }
2426 3702
2427 - // Import description
2428 - if ($import_descriptions && !empty($aioseo_data->description)) {
2429 - $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;
2430 3707
2431 - if (empty($existing_desc) || $overwrite) {
2432 - $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2433 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2434 - $updated = true;
3708 + if ($raw_desc === '' && $desc_template !== '') {
3709 + $raw_desc = $desc_template;
3710 + $desc_from_template = true;
2435 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;
2436 3727 }
2437 3728
2438 3729 // Import social text
2439 3730 if ($import_social_text) {
@@ -2438,44 +3729,52 @@
2438 3729 // Import social text
2439 3730 if ($import_social_text) {
2440 3731 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
2441 3732 if (!empty($aioseo_og_title)) {
2442 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2443 - if (empty($existing) || $overwrite) {
2444 - $aioseo_og_title = $this->resolve_seo_placeholders($aioseo_og_title, $post_id, 'aioseo');
2445 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($aioseo_og_title));
2446 - $updated = true;
2447 - }
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;
2448 3741 }
2449 3742
2450 3743 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
2451 3744 if (!empty($aioseo_og_desc)) {
2452 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2453 - if (empty($existing) || $overwrite) {
2454 - $aioseo_og_desc = $this->resolve_seo_placeholders($aioseo_og_desc, $post_id, 'aioseo');
2455 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($aioseo_og_desc));
2456 - $updated = true;
2457 - }
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;
2458 3753 }
2459 3754
2460 3755 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
2461 3756 if (!empty($aioseo_tw_title)) {
2462 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2463 - if (empty($existing) || $overwrite) {
2464 - $aioseo_tw_title = $this->resolve_seo_placeholders($aioseo_tw_title, $post_id, 'aioseo');
2465 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($aioseo_tw_title));
2466 - $updated = true;
2467 - }
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;
2468 3765 }
2469 3766
2470 3767 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
2471 3768 if (!empty($aioseo_tw_desc)) {
2472 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2473 - if (empty($existing) || $overwrite) {
2474 - $aioseo_tw_desc = $this->resolve_seo_placeholders($aioseo_tw_desc, $post_id, 'aioseo');
2475 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($aioseo_tw_desc));
2476 - $updated = true;
2477 - }
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;
2478 3777 }
2479 3778 }
2480 3779
2481 3780 // Import social images
@@ -2481,22 +3780,28 @@
2481 3780 // Import social images
2482 3781 if ($import_social_images) {
2483 3782 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
2484 3783 if (!empty($aioseo_og_image)) {
2485 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2486 - if (empty($existing_og) || $overwrite) {
2487 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($aioseo_og_image));
2488 - $updated = true;
2489 - }
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;
2490 3792 }
2491 3793
2492 3794 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
2493 3795 if (!empty($aioseo_twitter_image)) {
2494 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2495 - if (empty($existing_twitter) || $overwrite) {
2496 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($aioseo_twitter_image));
2497 - $updated = true;
2498 - }
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;
2499 3804 }
2500 3805 }
2501 3806
2502 3807 if ($updated) {
@@ -2506,11 +3811,15 @@
2506 3811 }
2507 3812 }
2508 3813
2509 3814 $processed = $offset + count($posts);
2510 - $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);
2511 3820
2512 - return [
3821 + return $this->attach_unresolved_report([
2513 3822 'success' => true,
2514 3823 'imported' => $imported_count,
2515 3824 'skipped' => $skipped_count,
2516 3825 'total' => $total_posts,
@@ -2519,9 +3828,9 @@
2519 3828 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2520 3829 'message' => $is_complete
2521 3830 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2522 3831 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2523 - ];
3832 + ], $unresolved);
2524 3833 }
2525 3834
2526 3835 /**
2527 3836 * Import term-level SEO metadata from a third-party SEO plugin into
@@ -2603,16 +3912,22 @@
2603 3912 'message' => $terms->get_error_message(),
2604 3913 ];
2605 3914 }
2606 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.
2607 3919 $field_map = [
2608 - 'wpseo_title' => '_metasync_metatitle',
2609 - 'wpseo_desc' => '_metasync_metadesc',
2610 3920 'wpseo_opengraph-title' => '_metasync_og_title',
2611 3921 'wpseo_opengraph-description' => '_metasync_og_description',
2612 3922 'wpseo_canonical' => '_metasync_canonical_url',
2613 3923 ];
2614 3924
3925 + $imported_map = [
3926 + 'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3927 + 'wpseo_desc' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3928 + ];
3929 +
2615 3930 $imported = 0;
2616 3931 $skipped = 0;
2617 3932
2618 3933 foreach ($terms as $term) {
@@ -2623,8 +3938,18 @@
2623 3938 if (!is_array($yoast_meta)) {
2624 3939 $yoast_meta = [];
2625 3940 }
2626 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 +
2627 3952 foreach ($field_map as $src_key => $dest_key) {
2628 3953 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2629 3954 if ($src_value === '' || $src_value === null) {
2630 3955 continue;
@@ -2640,8 +3965,13 @@
2640 3965 $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
2641 3966 if ($src_value === '') {
2642 3967 continue;
2643 3968 }
3969 + } else {
3970 + $src_value = $this->resolve_term_placeholders($src_value, $term, 'yoast');
3971 + if (trim($src_value) === '') {
3972 + continue;
3973 + }
2644 3974 }
2645 3975
2646 3976 update_term_meta($term->term_id, $dest_key, $src_value);
2647 3977 $term_updated = true;
@@ -2701,16 +4031,21 @@
2701 4031 'message' => $terms->get_error_message(),
2702 4032 ];
2703 4033 }
2704 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.
2705 4037 $field_map = [
2706 - 'rank_math_title' => '_metasync_metatitle',
2707 - 'rank_math_description' => '_metasync_metadesc',
2708 4038 'rank_math_facebook_title' => '_metasync_og_title',
2709 4039 'rank_math_facebook_description' => '_metasync_og_description',
2710 4040 'rank_math_canonical_url' => '_metasync_canonical_url',
2711 4041 ];
2712 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 +
2713 4048 $imported = 0;
2714 4049 $skipped = 0;
2715 4050
2716 4051 foreach ($terms as $term) {
@@ -2715,8 +4050,18 @@
2715 4050
2716 4051 foreach ($terms as $term) {
2717 4052 $term_updated = false;
2718 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 +
2719 4064 foreach ($field_map as $src_key => $dest_key) {
2720 4065 $src_value = get_term_meta($term->term_id, $src_key, true);
2721 4066 if ($src_value === '' || $src_value === null) {
2722 4067 continue;
@@ -2732,8 +4077,13 @@
2732 4077 $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
2733 4078 if ($src_value === '') {
2734 4079 continue;
2735 4080 }
4081 + } else {
4082 + $src_value = $this->resolve_term_placeholders($src_value, $term, 'rankmath');
4083 + if (trim($src_value) === '') {
4084 + continue;
4085 + }
2736 4086 }
2737 4087
2738 4088 update_term_meta($term->term_id, $dest_key, $src_value);
2739 4089 $term_updated = true;
@@ -2783,9 +4133,9 @@
2783 4133 $overwrite = (bool) $options['overwrite_existing'];
2784 4134
2785 4135 $table = $wpdb->prefix . 'aioseo_terms';
2786 4136
2787 - $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
2788 4138 if ($table_exists !== $table) {
2789 4139 return [
2790 4140 'success' => false,
2791 4141 'message' => 'AIOSEO terms table not found.',
@@ -2791,11 +4141,11 @@
2791 4141 'message' => 'AIOSEO terms table not found.',
2792 4142 ];
2793 4143 }
2794 4144
2795 - $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
2796 4146 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2797 - FROM {$table}
4147 + FROM {$wpdb->prefix}aioseo_terms
2798 4148 ORDER BY term_id ASC
2799 4149 LIMIT %d OFFSET %d",
2800 4150 $batch_size,
2801 4151 $offset
@@ -2809,18 +4159,41 @@
2809 4159 if ($term_id <= 0) {
2810 4160 continue;
2811 4161 }
2812 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 +
2813 4171 $term_updated = false;
2814 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.
2815 4175 $field_map = [
2816 - 'title' => '_metasync_metatitle',
2817 - 'description' => '_metasync_metadesc',
2818 4176 'og_title' => '_metasync_og_title',
2819 4177 'og_description' => '_metasync_og_description',
2820 4178 'canonical_url' => '_metasync_canonical_url',
2821 4179 ];
2822 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 +
2823 4196 foreach ($field_map as $column => $dest_key) {
2824 4197 $value = isset($row->$column) ? $row->$column : '';
2825 4198 if ($value === '' || $value === null) {
2826 4199 continue;
@@ -2834,8 +4207,13 @@
2834 4207 // Validate canonical: never import a corrupted value.
2835 4208 if ($dest_key === '_metasync_canonical_url') {
2836 4209 $value = Metasync_Canonical_Sanitizer::sanitize_for_save($value);
2837 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')) {
2838 4216 continue;
2839 4217 }
2840 4218 }
2841 4219