PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +2460 -467 2.6.11 → 2.7.2 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;
@@ -210,59 +212,458 @@
210 212 }
211 213
212 214 /**
213 215 * Check if schema data exists for a plugin
216 + *
217 + * Counts only records the importer would actually write: the source record
218 + * must hold a supported, populated schema, and the post must not already
219 + * carry a MetaSync schema block. The per-record rule is shared with the
220 + * importers (see the *_schema_source_is_supported() helpers and
221 + * post_has_metasync_schema()), so the count the Schema Settings page shows
222 + * is the count an import can deliver — the old detector counted every
223 + * provider row and reported posts that were silently skipped.
214 224 */
215 225 private function check_schema_data($plugin, &$count)
216 226 {
217 - global $wpdb;
218 -
219 227 $count = 0;
228 + $importable_posts = [];
220 229
221 230 switch ($plugin) {
222 231 case 'yoast':
223 - // Check for Yoast schema meta
224 - // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
225 - // Free version may use _yoast_wpseo_schema (JSON)
226 - $count = (int) $wpdb->get_var("
227 - SELECT COUNT(DISTINCT post_id)
228 - FROM {$wpdb->postmeta}
229 - WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
230 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
231 - ");
232 + foreach ($this->yoast_schema_candidate_rows(true) as $row) {
233 + $post_id = (int) $row->post_id;
234 + if (isset($importable_posts[$post_id])) {
235 + continue;
236 + }
237 + if ($this->yoast_schema_source_is_supported($row->meta_key, $row->meta_value) !== null) {
238 + $importable_posts[$post_id] = true;
239 + }
240 + }
232 241 break;
233 242
234 243 case 'rankmath':
235 - // Check for Rank Math schema meta (any schema type)
236 - // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
237 - // Exclude shortcode schemas (rank_math_shortcode_schema_*)
238 - $count = (int) $wpdb->get_var("
239 - SELECT COUNT(DISTINCT post_id)
240 - FROM {$wpdb->postmeta}
241 - WHERE meta_key LIKE 'rank_math_schema_%'
242 - AND meta_key NOT LIKE 'rank_math_shortcode_schema_%'
243 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
244 - ");
244 + foreach ($this->rankmath_schema_candidate_rows(true) as $row) {
245 + $post_id = (int) $row->post_id;
246 + if (isset($importable_posts[$post_id])) {
247 + continue;
248 + }
249 + if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) !== null) {
250 + $importable_posts[$post_id] = true;
251 + }
252 + }
245 253 break;
246 254
247 255 case 'aioseo':
248 - // Check for AIOSEO schema in table
249 - $table = $wpdb->prefix . 'aioseo_posts';
250 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
251 - $count = (int) $wpdb->get_var("
252 - SELECT COUNT(post_id)
253 - FROM {$table}
254 - WHERE (schema_type IS NOT NULL AND schema_type != '')
255 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
256 - ");
256 + // AIOSEO's `default` rows carry no per-post schema at all, so
257 + // they are filtered out before eligibility is even evaluated.
258 + foreach ($this->aioseo_schema_candidate_rows(true) as $row) {
259 + $post_id = (int) $row->post_id;
260 + if (isset($importable_posts[$post_id])) {
261 + continue;
262 + }
263 + if ($this->aioseo_schema_source_is_supported($row->schema_type, $row->schema_type_options) !== null) {
264 + $importable_posts[$post_id] = true;
265 + }
257 266 }
258 267 break;
259 268 }
260 269
270 + $count = count($importable_posts);
271 +
261 272 return $count > 0;
262 273 }
263 274
264 275 /**
276 + * Yoast source rows that could hold per-post schema, for published posts.
277 + *
278 + * Shared by check_schema_data() and import_yoast_schema() so both look at
279 + * exactly the same candidate set.
280 + *
281 + * @param bool $only_new Skip posts that already hold a MetaSync schema block.
282 + * Only the detector sets this: the importers must see
283 + * every candidate so they can report those posts as
284 + * already imported rather than silently dropping them.
285 + * @return array<object> Rows with post_id, meta_key and meta_value.
286 + */
287 + private function yoast_schema_candidate_rows($only_new = false)
288 + {
289 + global $wpdb;
290 +
291 + $query = "SELECT post_id, meta_key, meta_value
292 + FROM {$wpdb->postmeta}
293 + WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
294 + AND meta_value != ''
295 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
296 + " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema($wpdb->postmeta) : '') . "
297 + ORDER BY post_id ASC";
298 +
299 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table names come from wpdb, exclusion fragment is class-built SQL
300 + return (array) $wpdb->get_results($query);
301 + }
302 +
303 + /**
304 + * Rank Math source rows that could hold per-post schema, for published
305 + * posts. Shortcode schemas are excluded, as the importer always has.
306 + *
307 + * @param bool $only_new Skip posts that already hold a MetaSync schema block.
308 + * @return array<object> Rows with post_id, meta_key and meta_value.
309 + */
310 + private function rankmath_schema_candidate_rows($only_new = false)
311 + {
312 + global $wpdb;
313 +
314 + $query = "SELECT pm.post_id, pm.meta_key, pm.meta_value
315 + FROM {$wpdb->postmeta} pm
316 + WHERE pm.meta_key LIKE 'rank_math_schema_%'
317 + AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
318 + AND pm.meta_value != ''
319 + AND pm.meta_value != 'a:0:{}'
320 + AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
321 + " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema('pm') : '') . "
322 + ORDER BY pm.post_id ASC";
323 +
324 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table names come from wpdb, exclusion fragment is class-built SQL
325 + return (array) $wpdb->get_results($query);
326 + }
327 +
328 + /**
329 + * AIOSEO source rows that could hold per-post schema, for published posts.
330 + *
331 + * Returns an empty array when the AIOSEO table is absent (the plugin was
332 + * never installed), matching the previous detector behaviour.
333 + *
334 + * @param bool $only_new Skip posts that already hold a MetaSync schema block.
335 + * @return array<object> Rows with post_id, schema_type and schema_type_options.
336 + */
337 + private function aioseo_schema_candidate_rows($only_new = false)
338 + {
339 + global $wpdb;
340 +
341 + $table = $wpdb->prefix . 'aioseo_posts';
342 + 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
343 + return [];
344 + }
345 +
346 + // AIOSEO writes a row for every post it tracks, and the overwhelming
347 + // majority carry schema_type 'default' with empty schema_type_options.
348 + // Those rows represent the post-type default, not per-post schema, so
349 + // they are dropped here rather than being reported as "unsupported" —
350 + // counting them is what made the card claim hundreds of ready posts
351 + // that no import could ever produce.
352 + $query = "SELECT post_id, schema_type, schema_type_options
353 + FROM {$table}
354 + WHERE schema_type IS NOT NULL
355 + AND schema_type != ''
356 + AND schema_type != 'default'
357 + AND schema_type_options IS NOT NULL
358 + AND schema_type_options NOT IN ('', '{}', '[]', 'null')
359 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
360 + " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema($table) : '') . "
361 + ORDER BY post_id ASC";
362 +
363 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table name comes from wpdb, exclusion fragment is class-built SQL
364 + return (array) $wpdb->get_results($query);
365 + }
366 +
367 + /**
368 + * SQL clause excluding posts that already hold a MetaSync schema block.
369 + *
370 + * Mirrors post_has_metasync_schema() in SQL: a metasync_schema_markup
371 + * value whose "types" array exists and is non-empty. Values without a
372 + * "types" list (an OTTO-deployed otto_jsonld block, for example) are
373 + * deliberately not excluded — the importer does not skip those either.
374 + * post_has_metasync_schema() is still evaluated per record in PHP, so the
375 + * two checks cannot drift apart.
376 + *
377 + * @param string $alias Table name or alias the candidate query exposes post_id under.
378 + * @return string "AND NOT EXISTS (...)" clause.
379 + */
380 + private function sql_exclude_posts_with_metasync_schema($alias)
381 + {
382 + global $wpdb;
383 +
384 + return "AND NOT EXISTS (
385 + SELECT 1
386 + FROM {$wpdb->postmeta} ms
387 + WHERE ms.post_id = {$alias}.post_id
388 + AND ms.meta_key = 'metasync_schema_markup'
389 + AND ms.meta_value LIKE '%s:5:\"types\";a:%'
390 + AND ms.meta_value NOT LIKE '%s:5:\"types\";a:0:{}%'
391 + )";
392 + }
393 +
394 + /**
395 + * Whether a post already holds a MetaSync schema block an import must not
396 + * overwrite.
397 + *
398 + * Same guard the importers have always applied: an existing
399 + * metasync_schema_markup whose "types" list is populated means the schema
400 + * was already migrated or set by hand, so the source record is skipped
401 + * and reported as already imported rather than overwritten.
402 + *
403 + * @param int $post_id Post ID.
404 + * @return bool
405 + */
406 + private function post_has_metasync_schema($post_id)
407 + {
408 + $existing = get_post_meta($post_id, 'metasync_schema_markup', true);
409 +
410 + return is_array($existing) && !empty($existing['types']);
411 + }
412 +
413 + /**
414 + * Normalise a source schema type string to a supported MetaSync type.
415 + *
416 + * Shared by the Yoast and Rank Math eligibility checks and by the
417 + * converters, so "which source types can we actually carry" has a single
418 + * answer per provider.
419 + *
420 + * @param string $raw_type Source @type / schema type, any case.
421 + * @return string|null MetaSync type slug, or null when unsupported.
422 + */
423 + private function supported_schema_type($raw_type)
424 + {
425 + $type = strtolower(trim((string) $raw_type));
426 + if ($type === '') {
427 + return null;
428 + }
429 +
430 + $map = [
431 + 'article' => 'article',
432 + 'newsarticle' => 'article',
433 + 'blogposting' => 'article',
434 + 'faqpage' => 'FAQPage',
435 + 'product' => 'product',
436 + 'recipe' => 'recipe',
437 + ];
438 +
439 + return $map[$type] ?? null;
440 + }
441 +
442 + /**
443 + * Whether a Yoast source row holds schema the importer can convert.
444 + *
445 + * Returns the resolved MetaSync type when the row is supported, or null
446 + * when it must not be counted or imported (unsupported type, empty or
447 + * invalid payload). Used by both check_schema_data() and
448 + * import_yoast_schema() so the two cannot drift.
449 + *
450 + * @param string $meta_key Source meta key.
451 + * @param string $meta_value Raw meta value.
452 + * @return string|null
453 + */
454 + private function yoast_schema_source_is_supported($meta_key, $meta_value)
455 + {
456 + if ($meta_key === '_yoast_wpseo_schema_article_type') {
457 + // Yoast Premium stores a bare type name; the importer builds an
458 + // article skeleton from it.
459 + return $this->supported_schema_type($meta_value);
460 + }
461 +
462 + if ($meta_key !== '_yoast_wpseo_schema') {
463 + return null;
464 + }
465 +
466 + $value = trim((string) $meta_value);
467 + if ($value === '') {
468 + return null;
469 + }
470 +
471 + $decoded = json_decode($value, true);
472 + if (!is_array($decoded) || $decoded === []) {
473 + return null;
474 + }
475 +
476 + foreach ($this->yoast_schema_graph_items($decoded) as $item) {
477 + if (!is_array($item) || !isset($item['@type'])) {
478 + continue;
479 + }
480 +
481 + $raw_type = $item['@type'];
482 + if (is_array($raw_type)) {
483 + $raw_type = reset($raw_type);
484 + }
485 + if (!is_string($raw_type) || $raw_type === '') {
486 + continue;
487 + }
488 +
489 + $supported = $this->supported_schema_type($raw_type);
490 + if ($supported === null) {
491 + continue;
492 + }
493 +
494 + // A FAQPage whose questions cannot be converted produces an empty
495 + // MetaSync block, so it must not be counted as importable either.
496 + if ($supported === 'FAQPage'
497 + && !$this->faq_questions_are_convertible($item['mainEntity'] ?? [], 'yoast')) {
498 + continue;
499 + }
500 +
501 + return $supported;
502 + }
503 +
504 + return null;
505 + }
506 +
507 + /**
508 + * Iterate the schema entries inside a decoded Yoast payload.
509 + *
510 + * Supports the two storage shapes Yoast has used: the current
511 + * `{"@graph": [...]}` wrapper and the legacy root array of entries.
512 + *
513 + * @param array $decoded Decoded `_yoast_wpseo_schema` value.
514 + * @return array Iterable list of schema item arrays.
515 + */
516 + private function yoast_schema_graph_items($decoded)
517 + {
518 + if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
519 + return $decoded['@graph'];
520 + }
521 +
522 + // A single schema object at the root: {"@type":"Article",...}.
523 + if (isset($decoded['@type'])) {
524 + return [$decoded];
525 + }
526 +
527 + // Legacy payload: a root array of entries, e.g.
528 + // [{"@type":"Article","headline":"..."}, ...]. Only treat it as such
529 + // when every element is an array — otherwise the value is an object
530 + // whose keys are property names, not entries, and no entry carries a
531 + // @type anyway.
532 + $is_entry_list = $decoded !== [];
533 + foreach ($decoded as $entry) {
534 + if (!is_array($entry)) {
535 + $is_entry_list = false;
536 + break;
537 + }
538 + }
539 +
540 + return $is_entry_list ? $decoded : [];
541 + }
542 +
543 + /**
544 + * Whether a Rank Math source row holds schema the importer can convert.
545 + *
546 + * Returns the resolved MetaSync type when supported, null otherwise. The
547 + * type lives in the meta key (rank_math_schema_Article), mirroring
548 + * convert_rankmath_schema_to_metasync().
549 + *
550 + * @param string $meta_key Source meta key.
551 + * @param string $meta_value Raw meta value.
552 + * @return string|null
553 + */
554 + private function rankmath_schema_source_is_supported($meta_key, $meta_value)
555 + {
556 + if (strpos((string) $meta_key, 'rank_math_schema_') !== 0) {
557 + return null;
558 + }
559 +
560 + $schema_type = str_replace('rank_math_schema_', '', (string) $meta_key);
561 + $supported = $this->supported_schema_type($schema_type);
562 +
563 + if ($supported === null) {
564 + return null;
565 + }
566 +
567 + $decoded = maybe_unserialize($meta_value);
568 + if (!is_array($decoded) || $decoded === []) {
569 + return null;
570 + }
571 +
572 + // Keep the check in step with the converter: a FAQPage whose questions
573 + // cannot be converted would yield an empty MetaSync block.
574 + if ($supported === 'FAQPage'
575 + && !$this->faq_questions_are_convertible($decoded['questions'] ?? [], 'rankmath')) {
576 + return null;
577 + }
578 +
579 + return $supported;
580 + }
581 +
582 + /**
583 + * Whether an AIOSEO row holds per-post schema the importer can convert.
584 + *
585 + * Returns the resolved MetaSync type when supported, null otherwise.
586 + * `schema_type = 'default'` rows — the post-type default AIOSEO stamps on
587 + * every tracked post — never reach this check from the candidate queries,
588 + * but it is enforced here as well so the importer cannot regress into
589 + * reporting them as "unsupported" source schemas.
590 + *
591 + * @param string $schema_type Raw schema_type column value.
592 + * @param string $schema_type_options Raw schema_type_options JSON.
593 + * @return string|null
594 + */
595 + private function aioseo_schema_source_is_supported($schema_type, $schema_type_options)
596 + {
597 + $type = strtolower(trim((string) $schema_type));
598 +
599 + if ($type === '' || $type === 'default' || $type === 'none') {
600 + return null;
601 + }
602 +
603 + $supported = $this->supported_schema_type($type);
604 + if ($supported === null) {
605 + return null;
606 + }
607 +
608 + $decoded = json_decode(trim((string) $schema_type_options), true);
609 + if (!is_array($decoded) || $decoded === []) {
610 + return null;
611 + }
612 +
613 + // As above: an unconvertible FAQPage must not be counted as importable.
614 + if ($supported === 'FAQPage'
615 + && !$this->faq_questions_are_convertible($decoded['questions'] ?? [], 'aioseo')) {
616 + return null;
617 + }
618 +
619 + return $supported;
620 + }
621 +
622 + /**
623 + * Whether a FAQ question list holds at least one entry the converter can
624 + * turn into a MetaSync FAQ item, for the given provider's key shape.
625 + *
626 + * Eligibility and conversion must agree: a source counted as importable
627 + * has to produce a non-empty MetaSync block, or the detector over-reports
628 + * again — the very defect this class of fix exists to close.
629 + *
630 + * A scalar `mainEntity`/`questions` value (corrupt or foreign meta) is
631 + * simply not convertible; it must never fatal the eligibility check,
632 + * which runs on every render of the Schema import tab.
633 + *
634 + * @param mixed $questions Question list from the source record.
635 + * @param string $provider 'yoast', 'rankmath' or 'aioseo'.
636 + * @return bool
637 + */
638 + private function faq_questions_are_convertible($questions, $provider)
639 + {
640 + if (!is_array($questions)) {
641 + return false;
642 + }
643 +
644 + foreach ($questions as $question) {
645 + if (!is_array($question)) {
646 + continue;
647 + }
648 +
649 + if ($provider === 'yoast') {
650 + if (isset($question['name'], $question['acceptedAnswer']['text'])) {
651 + return true;
652 + }
653 + } elseif ($provider === 'rankmath') {
654 + if (isset($question['name'], $question['text'])) {
655 + return true;
656 + }
657 + } elseif (isset($question['question'], $question['answer'])) {
658 + return true;
659 + }
660 + }
661 +
662 + return false;
663 + }
664 +
665 + /**
265 666 * Check if SEO metadata (titles and descriptions) exists for a plugin
266 667 */
267 668 private function check_seo_metadata($plugin, &$count)
268 669 {
@@ -272,8 +673,9 @@
272 673
273 674 switch ($plugin) {
274 675 case 'yoast':
275 676 // Check for Yoast SEO title or description
677 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
276 678 $count = (int) $wpdb->get_var("
277 679 SELECT COUNT(DISTINCT post_id)
278 680 FROM {$wpdb->postmeta}
279 681 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
@@ -283,8 +685,9 @@
283 685 break;
284 686
285 687 case 'rankmath':
286 688 // Check for Rank Math title or description
689 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
287 690 $count = (int) $wpdb->get_var("
288 691 SELECT COUNT(DISTINCT post_id)
289 692 FROM {$wpdb->postmeta}
290 693 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
@@ -295,15 +698,42 @@
295 698
296 699 case 'aioseo':
297 700 // Check for AIOSEO title or description in their custom table
298 701 $table = $wpdb->prefix . 'aioseo_posts';
299 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
702 + 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 703 $count = (int) $wpdb->get_var("
301 704 SELECT COUNT(post_id)
302 - FROM {$table}
705 + FROM {$wpdb->prefix}aioseo_posts
303 706 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
304 707 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
305 708 ");
709 +
710 + // Per-post overrides are the exception. AIOSEO also serves a
711 + // title and description for every post from its site-wide
712 + // templates, and those are what the import mainly picks up —
713 + // so a site that never overrode an individual post still has
714 + // plenty to migrate. Counting overrides alone reported zero
715 + // there, which rendered the card "Unavailable" with a disabled
716 + // button and made the import impossible to start.
717 + $templates = $this->get_aioseo_post_type_templates();
718 + if (!empty($templates)) {
719 + $post_types = array_keys($templates);
720 + $type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
721 +
722 + $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
723 + "SELECT COUNT(*)
724 + FROM {$wpdb->posts}
725 + WHERE post_status = 'publish'
726 + AND post_type IN ({$type_placeholders})",
727 + $post_types
728 + ));
729 +
730 + // Take the larger of the two. Templates configured for a
731 + // post type with no published posts would otherwise report
732 + // zero and disable the card, hiding override rows that do
733 + // exist — the same dead end from the other direction.
734 + $count = max($count, $template_count);
735 + }
306 736 }
307 737 break;
308 738 }
309 739
@@ -320,8 +750,9 @@
320 750 $count = 0;
321 751
322 752 switch ($plugin) {
323 753 case 'yoast':
754 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
324 755 $count = (int) $wpdb->get_var("
325 756 SELECT COUNT(DISTINCT post_id)
326 757 FROM {$wpdb->postmeta}
327 758 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -330,8 +761,9 @@
330 761 ");
331 762 break;
332 763
333 764 case 'rankmath':
765 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
334 766 $count = (int) $wpdb->get_var("
335 767 SELECT COUNT(DISTINCT post_id)
336 768 FROM {$wpdb->postmeta}
337 769 WHERE meta_key = 'rank_math_primary_category'
@@ -340,8 +772,9 @@
340 772 ");
341 773 break;
342 774
343 775 case 'aioseo':
776 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
344 777 $count = (int) $wpdb->get_var("
345 778 SELECT COUNT(DISTINCT post_id)
346 779 FROM {$wpdb->postmeta}
347 780 WHERE meta_key = '_aioseo_primary_category'
@@ -402,8 +835,9 @@
402 835 $batch_size = intval($options['batch_size']);
403 836 $offset = intval($options['offset']);
404 837 $overwrite = (bool) $options['overwrite_existing'];
405 838
839 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
406 840 $total_posts = (int) $wpdb->get_var("
407 841 SELECT COUNT(DISTINCT post_id)
408 842 FROM {$wpdb->postmeta}
409 843 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -410,8 +844,9 @@
410 844 AND meta_value != ''
411 845 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
412 846 ");
413 847
848 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
414 849 $posts = $wpdb->get_results($wpdb->prepare("
415 850 SELECT DISTINCT post_id
416 851 FROM {$wpdb->postmeta}
417 852 WHERE meta_key = '_yoast_wpseo_primary_category'
@@ -465,8 +900,9 @@
465 900 $batch_size = intval($options['batch_size']);
466 901 $offset = intval($options['offset']);
467 902 $overwrite = (bool) $options['overwrite_existing'];
468 903
904 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
469 905 $total_posts = (int) $wpdb->get_var("
470 906 SELECT COUNT(DISTINCT post_id)
471 907 FROM {$wpdb->postmeta}
472 908 WHERE meta_key = 'rank_math_primary_category'
@@ -473,8 +909,9 @@
473 909 AND meta_value != ''
474 910 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
475 911 ");
476 912
913 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
477 914 $posts = $wpdb->get_results($wpdb->prepare("
478 915 SELECT DISTINCT post_id
479 916 FROM {$wpdb->postmeta}
480 917 WHERE meta_key = 'rank_math_primary_category'
@@ -528,8 +965,9 @@
528 965 $batch_size = intval($options['batch_size']);
529 966 $offset = intval($options['offset']);
530 967 $overwrite = (bool) $options['overwrite_existing'];
531 968
969 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
532 970 $total_posts = (int) $wpdb->get_var("
533 971 SELECT COUNT(DISTINCT post_id)
534 972 FROM {$wpdb->postmeta}
535 973 WHERE meta_key = '_aioseo_primary_category'
@@ -536,8 +974,9 @@
536 974 AND meta_value != ''
537 975 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
538 976 ");
539 977
978 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
540 979 $posts = $wpdb->get_results($wpdb->prepare("
541 980 SELECT DISTINCT post_id
542 981 FROM {$wpdb->postmeta}
543 982 WHERE meta_key = '_aioseo_primary_category'
@@ -703,51 +1142,58 @@
703 1142
704 1143 /**
705 1144 * Import Indexation Options (Per-Post Robots Meta)
706 1145 */
707 - public function import_indexation($plugin)
1146 + public function import_indexation($plugin, $options = [])
708 1147 {
709 - $imported_count = 0;
1148 + $defaults = ['batch_size' => 50, 'offset' => 0];
1149 + $options = array_merge($defaults, $options);
710 1150
711 1151 switch ($plugin) {
712 1152 case 'yoast':
713 - $imported_count = $this->import_yoast_indexation();
714 - break;
1153 + return $this->import_yoast_indexation($options);
715 1154
716 1155 case 'rankmath':
717 - $imported_count = $this->import_rankmath_indexation();
718 - break;
1156 + return $this->import_rankmath_indexation($options);
719 1157
720 1158 case 'aioseo':
721 - $imported_count = $this->import_aioseo_indexation();
722 - break;
1159 + return $this->import_aioseo_indexation($options);
723 1160
724 1161 default:
725 1162 return ['success' => false, 'message' => 'Invalid plugin specified.'];
726 1163 }
727 -
728 - if ($imported_count > 0) {
729 - return ['success' => true, 'message' => "Successfully imported indexation settings for $imported_count posts."];
730 - }
731 -
732 - return ['success' => false, 'message' => 'No post-level indexation settings found to import.'];
733 1164 }
734 1165
735 1166 /**
736 1167 * Import per-post indexation settings from Yoast SEO
737 1168 */
738 - private function import_yoast_indexation()
1169 + private function import_yoast_indexation($options = [])
739 1170 {
740 1171 global $wpdb;
741 1172 $imported_count = 0;
742 1173
743 - // Get all posts with Yoast robots meta
744 - $posts = $wpdb->get_results("
1174 + $batch_size = intval($options['batch_size']);
1175 + $offset = intval($options['offset']);
1176 +
1177 + // Get total count (for progress tracking)
1178 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1179 + $total = (int) $wpdb->get_var($wpdb->prepare("
1180 + SELECT COUNT(DISTINCT post_id)
1181 + FROM {$wpdb->postmeta}
1182 + WHERE meta_key LIKE %s
1183 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1184 + ", '_yoast_wpseo_%'));
1185 +
1186 + // Get batch of posts with Yoast robots meta
1187 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1188 + $posts = $wpdb->get_results($wpdb->prepare("
745 1189 SELECT DISTINCT post_id
746 1190 FROM {$wpdb->postmeta}
747 - WHERE meta_key LIKE '_yoast_wpseo_%'
1191 + WHERE meta_key LIKE %s
748 1192 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
749 - ");
1193 + ORDER BY post_id ASC
1194 + LIMIT %d OFFSET %d
1195 + ", '_yoast_wpseo_%', $batch_size, $offset));
750 1196
751 1197 foreach ($posts as $post_obj) {
752 1198 $post_id = $post_obj->post_id;
753 1199 $has_changes = false;
@@ -762,11 +1208,12 @@
762 1208 $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
763 1209 if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
764 1210 $metasync_robots['noindex'] = 'noindex';
765 1211 $has_changes = true;
766 - } elseif ($yoast_noindex === '2' && !isset($metasync_robots['index'])) {
767 - // '2' means 'index' in Yoast
768 - $metasync_robots['index'] = 'index';
1212 + } elseif ($yoast_noindex === '2' && isset($metasync_robots['noindex'])) {
1213 + // '2' means 'index' in Yoast. Index is the implicit default, so
1214 + // clear any stored noindex rather than recording a redundant flag.
1215 + unset($metasync_robots['noindex']);
769 1216 $has_changes = true;
770 1217 }
771 1218
772 1219 // Import nofollow
@@ -777,8 +1224,16 @@
777 1224 }
778 1225
779 1226 // Import advanced robots (noarchive, nosnippet, noimageindex)
780 1227 $yoast_adv = get_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', true);
1228 + // Normalise the meta value to a string before calling explode().
1229 + // Some sites store malformed/legacy data where this meta value is
1230 + // an array, which would otherwise throw a TypeError on explode().
1231 + if (is_array($yoast_adv)) {
1232 + $yoast_adv = implode(',', $yoast_adv);
1233 + } elseif (!is_string($yoast_adv)) {
1234 + $yoast_adv = '';
1235 + }
781 1236 if (!empty($yoast_adv)) {
782 1237 $adv_directives = explode(',', $yoast_adv);
783 1238 foreach ($adv_directives as $directive) {
784 1239 $directive = trim($directive);
@@ -788,9 +1243,9 @@
788 1243 }
789 1244 }
790 1245 }
791 1246
792 - // WP-197: Also write to _metasync_robots_advanced JSON
1247 + // Also write to _metasync_robots_advanced JSON
793 1248 $robots_advanced = [];
794 1249 if (!empty($yoast_adv)) {
795 1250 $adv_directives = array_map('trim', explode(',', $yoast_adv));
796 1251 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
@@ -811,10 +1266,15 @@
811 1266 $yoast_canonical = get_post_meta($post_id, '_yoast_wpseo_canonical', true);
812 1267 if (!empty($yoast_canonical)) {
813 1268 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
814 1269 if (empty($existing_canonical)) {
815 - update_post_meta($post_id, 'meta_canonical', sanitize_url($yoast_canonical));
816 - $has_changes = true;
1270 + // Validate: never import a corrupted value such as
1271 + // "http://Array" from third-party storage.
1272 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($yoast_canonical);
1273 + if ($clean_canonical !== '') {
1274 + update_post_meta($post_id, 'meta_canonical', $clean_canonical);
1275 + $has_changes = true;
1276 + }
817 1277 }
818 1278 }
819 1279
820 1280 // Save Metasync robots meta if changes were made
@@ -823,28 +1283,60 @@
823 1283 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
824 1284 }
825 1285 $imported_count++;
826 1286 }
1287 +
1288 + // Flush per-post object cache to prevent unbounded memory growth across batches
1289 + clean_post_cache($post_id);
827 1290 }
828 1291
829 - return $imported_count;
1292 + $processed = $offset + count($posts);
1293 + $is_complete = $processed >= $total;
1294 +
1295 + return [
1296 + 'success' => true,
1297 + 'imported' => $imported_count,
1298 + 'skipped' => count($posts) - $imported_count,
1299 + 'total' => $total,
1300 + 'processed' => $processed,
1301 + 'is_complete' => $is_complete,
1302 + 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1303 + 'message' => $is_complete
1304 + ? "Import complete! Imported {$imported_count} posts."
1305 + : "Processing... {$imported_count} imported."
1306 + ];
830 1307 }
831 1308
832 1309 /**
833 1310 * Import per-post indexation settings from Rank Math
834 1311 */
835 - private function import_rankmath_indexation()
1312 + private function import_rankmath_indexation($options = [])
836 1313 {
837 1314 global $wpdb;
838 1315 $imported_count = 0;
839 1316
840 - // Get all posts with Rank Math robots meta
841 - $posts = $wpdb->get_results("
1317 + $batch_size = intval($options['batch_size']);
1318 + $offset = intval($options['offset']);
1319 +
1320 + // Get total count (for progress tracking)
1321 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1322 + $total = (int) $wpdb->get_var($wpdb->prepare("
1323 + SELECT COUNT(DISTINCT post_id)
1324 + FROM {$wpdb->postmeta}
1325 + WHERE meta_key LIKE %s
1326 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1327 + ", 'rank_math_%'));
1328 +
1329 + // Get batch of posts with Rank Math robots meta
1330 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1331 + $posts = $wpdb->get_results($wpdb->prepare("
842 1332 SELECT DISTINCT post_id
843 1333 FROM {$wpdb->postmeta}
844 - WHERE meta_key LIKE 'rank_math_%'
1334 + WHERE meta_key LIKE %s
845 1335 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
846 - ");
1336 + ORDER BY post_id ASC
1337 + LIMIT %d OFFSET %d
1338 + ", 'rank_math_%', $batch_size, $offset));
847 1339
848 1340 foreach ($posts as $post_obj) {
849 1341 $post_id = $post_obj->post_id;
850 1342 $has_changes = false;
@@ -879,9 +1371,9 @@
879 1371 }
880 1372 }
881 1373 }
882 1374
883 - // WP-197: Parse max-* values and write _metasync_robots_advanced JSON
1375 + // Parse max-* values and write _metasync_robots_advanced JSON
884 1376 $robots_advanced = [];
885 1377 // Boolean directives from rank_math_robots
886 1378 if (is_array($rm_robots)) {
887 1379 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
@@ -913,10 +1405,15 @@
913 1405 $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
914 1406 if (!empty($rm_canonical)) {
915 1407 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
916 1408 if (empty($existing_canonical)) {
917 - update_post_meta($post_id, 'meta_canonical', sanitize_url($rm_canonical));
918 - $has_changes = true;
1409 + // Validate: never import a corrupted value such as
1410 + // "http://Array" from third-party storage.
1411 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($rm_canonical);
1412 + if ($clean_canonical !== '') {
1413 + update_post_meta($post_id, 'meta_canonical', $clean_canonical);
1414 + $has_changes = true;
1415 + }
919 1416 }
920 1417 }
921 1418
922 1419 // Save Metasync robots meta if changes were made
@@ -925,40 +1422,71 @@
925 1422 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
926 1423 }
927 1424 $imported_count++;
928 1425 }
1426 +
1427 + // Flush per-post object cache to prevent unbounded memory growth across batches
1428 + clean_post_cache($post_id);
929 1429 }
930 1430
931 - return $imported_count;
1431 + $processed = $offset + count($posts);
1432 + $is_complete = $processed >= $total;
1433 +
1434 + return [
1435 + 'success' => true,
1436 + 'imported' => $imported_count,
1437 + 'skipped' => count($posts) - $imported_count,
1438 + 'total' => $total,
1439 + 'processed' => $processed,
1440 + 'is_complete' => $is_complete,
1441 + 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1442 + 'message' => $is_complete
1443 + ? "Import complete! Imported {$imported_count} posts."
1444 + : "Processing... {$imported_count} imported."
1445 + ];
932 1446 }
933 1447
934 1448 /**
935 1449 * Import per-post indexation settings from AIOSEO
936 1450 */
937 - private function import_aioseo_indexation()
1451 + private function import_aioseo_indexation($options = [])
938 1452 {
939 1453 global $wpdb;
940 1454 $imported_count = 0;
941 1455
1456 + $batch_size = intval($options['batch_size']);
1457 + $offset = intval($options['offset']);
1458 +
942 1459 // AIOSEO stores data in a custom table
943 1460 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
944 1461
945 1462 // Check if table exists
946 - $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$aioseo_table'") === $aioseo_table;
1463 + $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($aioseo_table))) === $aioseo_table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
947 1464
948 1465 if (!$table_exists) {
949 - return 0;
1466 + return ['success' => false, 'message' => 'AIOSEO table not found.'];
950 1467 }
951 1468
952 - // Get all posts with AIOSEO settings
953 - $posts = $wpdb->get_results("
1469 + // Get total count (for progress tracking)
1470 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1471 + $total = (int) $wpdb->get_var("
1472 + SELECT COUNT(*)
1473 + FROM {$wpdb->prefix}aioseo_posts
1474 + WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1475 + ");
1476 +
1477 + // Get batch of posts with AIOSEO settings
1478 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
1479 + $posts = $wpdb->get_results($wpdb->prepare("
954 1480 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
955 1481 robots_noarchive, robots_nosnippet, robots_noimageindex,
956 1482 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
957 1483 canonical_url
958 - FROM {$aioseo_table}
1484 + FROM {$wpdb->prefix}aioseo_posts
959 1485 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
960 - ");
1486 + ORDER BY post_id ASC
1487 + LIMIT %d OFFSET %d
1488 + ", $batch_size, $offset));
961 1489
962 1490 foreach ($posts as $aioseo_data) {
963 1491 $post_id = $aioseo_data->post_id;
964 1492 $has_changes = false;
@@ -1001,9 +1529,9 @@
1001 1529 $has_changes = true;
1002 1530 }
1003 1531 }
1004 1532
1005 - // WP-197: Write _metasync_robots_advanced JSON
1533 + // Write _metasync_robots_advanced JSON
1006 1534 $robots_advanced = [];
1007 1535 if ($aioseo_data->robots_default == 0) {
1008 1536 if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
1009 1537 if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
@@ -1027,10 +1555,15 @@
1027 1555 // Import canonical URL
1028 1556 if (!empty($aioseo_data->canonical_url)) {
1029 1557 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1030 1558 if (empty($existing_canonical)) {
1031 - update_post_meta($post_id, 'meta_canonical', sanitize_url($aioseo_data->canonical_url));
1032 - $has_changes = true;
1559 + // Validate: never import a corrupted value such as
1560 + // "http://Array" from third-party storage.
1561 + $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($aioseo_data->canonical_url);
1562 + if ($clean_canonical !== '') {
1563 + update_post_meta($post_id, 'meta_canonical', $clean_canonical);
1564 + $has_changes = true;
1565 + }
1033 1566 }
1034 1567 }
1035 1568
1036 1569 // Save Metasync robots meta if changes were made
@@ -1039,31 +1572,51 @@
1039 1572 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1040 1573 }
1041 1574 $imported_count++;
1042 1575 }
1576 +
1577 + // Flush per-post object cache to prevent unbounded memory growth across batches
1578 + clean_post_cache($post_id);
1043 1579 }
1044 1580
1045 - return $imported_count;
1581 + $processed = $offset + count($posts);
1582 + $is_complete = $processed >= $total;
1583 +
1584 + return [
1585 + 'success' => true,
1586 + 'imported' => $imported_count,
1587 + 'skipped' => count($posts) - $imported_count,
1588 + 'total' => $total,
1589 + 'processed' => $processed,
1590 + 'is_complete' => $is_complete,
1591 + 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1592 + 'message' => $is_complete
1593 + ? "Import complete! Imported {$imported_count} posts."
1594 + : "Processing... {$imported_count} imported."
1595 + ];
1046 1596 }
1047 1597
1048 1598 /**
1049 1599 * Import Schema Settings (Per-Post Schema)
1600 + *
1601 + * Composes the user-facing result from the importer's breakdown so the
1602 + * message states what actually happened: no source schema, everything
1603 + * already migrated, everything unsupported/empty, or an import — including
1604 + * the partial case where some posts were skipped alongside the imports.
1050 1605 */
1051 1606 public function import_schema($plugin)
1052 1607 {
1053 - $imported_count = 0;
1054 -
1055 1608 switch ($plugin) {
1056 1609 case 'yoast':
1057 - $imported_count = $this->import_yoast_schema();
1610 + $breakdown = $this->import_yoast_schema();
1058 1611 break;
1059 1612
1060 1613 case 'rankmath':
1061 - $imported_count = $this->import_rankmath_schema();
1614 + $breakdown = $this->import_rankmath_schema();
1062 1615 break;
1063 1616
1064 1617 case 'aioseo':
1065 - $imported_count = $this->import_aioseo_schema();
1618 + $breakdown = $this->import_aioseo_schema();
1066 1619 break;
1067 1620
1068 1621 default:
1069 1622 return ['success' => false, 'message' => 'Invalid plugin specified.'];
@@ -1068,77 +1621,147 @@
1068 1621 default:
1069 1622 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1070 1623 }
1071 1624
1072 - if ($imported_count > 0) {
1073 - return ['success' => true, 'message' => "Successfully imported schema settings for $imported_count posts."];
1625 + $imported = (int) $breakdown['imported'];
1626 + $already_imported = (int) $breakdown['already_imported'];
1627 + $unsupported = (int) $breakdown['unsupported'];
1628 + $candidates = (int) $breakdown['total_candidates'];
1629 +
1630 + // No source rows at all — nothing was ever detected to begin with.
1631 + if ($candidates === 0) {
1632 + return [
1633 + 'success' => false,
1634 + 'message' => 'No post-level schema settings found to import.',
1635 + 'breakdown' => $breakdown,
1636 + ];
1074 1637 }
1075 1638
1076 - return ['success' => false, 'message' => 'No post-level schema settings found to import.'];
1639 + if ($imported === 0) {
1640 + // Every eligible source record is already in MetaSync.
1641 + if ($already_imported > 0 && $unsupported === 0) {
1642 + return [
1643 + 'success' => false,
1644 + 'message' => sprintf(
1645 + 'All %d eligible post(s) already have MetaSync schema — nothing left to import.',
1646 + $already_imported
1647 + ),
1648 + 'breakdown' => $breakdown,
1649 + ];
1650 + }
1651 +
1652 + // Source schema exists, but none of it maps to a supported type.
1653 + if ($unsupported > 0 && $already_imported === 0) {
1654 + return [
1655 + 'success' => false,
1656 + 'message' => sprintf(
1657 + 'Found %d post(s) with schema settings, but none use a supported schema type '
1658 + . '(Article, FAQPage, Product, Recipe) or their contents were empty.',
1659 + $unsupported
1660 + ),
1661 + 'breakdown' => $breakdown,
1662 + ];
1663 + }
1664 +
1665 + // Both skip reasons applied, nothing imported.
1666 + return [
1667 + 'success' => false,
1668 + 'message' => sprintf(
1669 + 'No schema settings imported: %d post(s) already had MetaSync schema and %d post(s) had unsupported or empty schema settings.',
1670 + $already_imported,
1671 + $unsupported
1672 + ),
1673 + 'breakdown' => $breakdown,
1674 + ];
1675 + }
1676 +
1677 + $message = sprintf('Successfully imported schema settings for %d post(s).', $imported);
1678 +
1679 + if ($already_imported > 0) {
1680 + $message .= sprintf(' Skipped %d post(s) that already had MetaSync schema.', $already_imported);
1681 + }
1682 + if ($unsupported > 0) {
1683 + $message .= sprintf(' Skipped %d post(s) with unsupported or empty schema settings.', $unsupported);
1684 + }
1685 +
1686 + return ['success' => true, 'message' => $message, 'breakdown' => $breakdown];
1077 1687 }
1078 1688
1079 1689 /**
1690 + * Fresh per-import counters shared by the three schema importers.
1691 + *
1692 + * `total_candidates` counts distinct posts the importer looked at, so a
1693 + * post with several source rows is one candidate. Posts already holding a
1694 + * MetaSync schema count as `already_imported`; source rows that carry no
1695 + * supported, populated schema count as `unsupported`.
1696 + *
1697 + * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1698 + */
1699 + private function empty_schema_breakdown()
1700 + {
1701 + return [
1702 + 'imported' => 0,
1703 + 'already_imported' => 0,
1704 + 'unsupported' => 0,
1705 + 'total_candidates' => 0,
1706 + ];
1707 + }
1708 +
1709 + /**
1080 1710 * Import per-post schema from Yoast SEO
1711 + *
1712 + * Rows are grouped per post first, because a post can carry both source
1713 + * keys and the outcome must be decided once per post: a MetaSync schema
1714 + * that already exists wins outright; otherwise the full JSON payload under
1715 + * `_yoast_wpseo_schema` is preferred, and only when it yields nothing does
1716 + * the bare Premium article type under `_yoast_wpseo_schema_article_type`
1717 + * produce a placeholder article schema. Every other combination is
1718 + * unsupported.
1719 + *
1720 + * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1081 1721 */
1082 1722 private function import_yoast_schema()
1083 1723 {
1084 - global $wpdb;
1085 - $imported_count = 0;
1724 + $breakdown = $this->empty_schema_breakdown();
1086 1725
1087 - // First, try to import from full schema JSON (free version or old approach)
1088 - $posts = $wpdb->get_results("
1089 - SELECT post_id, meta_value
1090 - FROM {$wpdb->postmeta}
1091 - WHERE meta_key = '_yoast_wpseo_schema'
1092 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1093 - ");
1726 + // post_id => [meta_key => row]
1727 + $rows_by_post = [];
1728 + foreach ($this->yoast_schema_candidate_rows() as $row) {
1729 + $rows_by_post[(int) $row->post_id][$row->meta_key] = $row;
1730 + }
1094 1731
1095 - foreach ($posts as $post_obj) {
1096 - $post_id = $post_obj->post_id;
1097 -
1098 - // Check if Metasync schema already exists
1732 + foreach ($rows_by_post as $post_id => $rows) {
1733 + // A MetaSync schema that is already there outranks whatever the
1734 + // source plugin holds — the schema migration ran before (or an
1735 + // editor set it by hand) and overwriting it is not this tool's job.
1099 1736 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1100 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1101 - continue; // Skip if already has Metasync schema
1102 - }
1103 -
1104 - // Decode Yoast schema JSON
1105 - $yoast_schema = json_decode((string)($post_obj->meta_value ?? ''), true);
1106 - if (empty($yoast_schema) || !is_array($yoast_schema)) {
1737 + if ($this->post_has_metasync_schema($post_id)) {
1738 + $breakdown['already_imported']++;
1107 1739 continue;
1108 1740 }
1109 1741
1110 - // Convert Yoast schema to Metasync format
1111 - $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1742 + $schema_row = $rows['_yoast_wpseo_schema'] ?? null;
1743 + $article_type_row = $rows['_yoast_wpseo_schema_article_type'] ?? null;
1112 1744
1113 - if (!empty($metasync_schema['types'])) {
1114 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1115 - $imported_count++;
1116 - }
1117 - }
1745 + // Full JSON payload first (free version, or the legacy root array).
1746 + if ($schema_row !== null
1747 + && $this->yoast_schema_source_is_supported($schema_row->meta_key, $schema_row->meta_value) !== null) {
1748 + $yoast_schema = json_decode((string) ($schema_row->meta_value ?? ''), true);
1749 + $metasync_schema = is_array($yoast_schema)
1750 + ? $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1751 + : ['types' => []];
1118 1752
1119 - // Second, try to import from schema type (Premium version)
1120 - $posts = $wpdb->get_results("
1121 - SELECT post_id, meta_value
1122 - FROM {$wpdb->postmeta}
1123 - WHERE meta_key = '_yoast_wpseo_schema_article_type'
1124 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1125 - ");
1126 -
1127 - foreach ($posts as $post_obj) {
1128 - $post_id = $post_obj->post_id;
1129 -
1130 - // Check if Metasync schema already exists
1131 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1132 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1133 - continue; // Skip if already has Metasync schema
1753 + if (!empty($metasync_schema['types'])) {
1754 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1755 + $breakdown['imported']++;
1756 + continue;
1757 + }
1134 1758 }
1135 1759
1136 - $schema_type = strtolower($post_obj->meta_value);
1137 -
1138 - // Create basic article schema with placeholders
1139 - // Yoast Premium generates schema dynamically, so we create a minimal version
1140 - if ($schema_type === 'article' || $schema_type === 'newsarticle' || $schema_type === 'blogposting') {
1760 + // Bare Premium article type: build a minimal article schema with
1761 + // placeholders, since Yoast Premium generates schema dynamically.
1762 + if ($article_type_row !== null
1763 + && $this->yoast_schema_source_is_supported($article_type_row->meta_key, $article_type_row->meta_value) !== null) {
1141 1764 $metasync_schema = [
1142 1765 'enabled' => true,
1143 1766 'types' => [
1144 1767 [
@@ -1153,102 +1776,115 @@
1153 1776 ]
1154 1777 ]
1155 1778 ];
1156 1779
1157 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1158 - $imported_count++;
1780 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1781 + $breakdown['imported']++;
1782 + continue;
1159 1783 }
1784 +
1785 + $breakdown['unsupported']++;
1160 1786 }
1161 1787
1162 - return $imported_count;
1788 + $breakdown['total_candidates'] = count($rows_by_post);
1789 +
1790 + return $breakdown;
1163 1791 }
1164 1792
1165 1793 /**
1166 1794 * Import per-post schema from Rank Math
1795 + *
1796 + * Rows are grouped per post, mirroring check_schema_data(): a post with
1797 + * several `rank_math_schema_*` rows is a single candidate, imported from
1798 + * the first row that carries a supported, populated schema.
1799 + *
1800 + * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1167 1801 */
1168 1802 private function import_rankmath_schema()
1169 1803 {
1170 - global $wpdb;
1171 - $imported_count = 0;
1804 + $breakdown = $this->empty_schema_breakdown();
1172 1805
1173 - // Get all posts with any Rank Math schema (dynamically detect schema types)
1174 - // Exclude shortcode schemas
1175 - $posts = $wpdb->get_results("
1176 - SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1177 - FROM {$wpdb->postmeta} pm
1178 - WHERE pm.meta_key LIKE 'rank_math_schema_%'
1179 - AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
1180 - AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1181 - ORDER BY pm.post_id
1182 - ");
1806 + $rows_by_post = [];
1807 + foreach ($this->rankmath_schema_candidate_rows() as $row) {
1808 + $rows_by_post[(int) $row->post_id][] = $row;
1809 + }
1183 1810
1184 - $processed_posts = [];
1811 + foreach ($rows_by_post as $post_id => $rows) {
1812 + $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1813 + if ($this->post_has_metasync_schema($post_id)) {
1814 + $breakdown['already_imported']++;
1815 + continue;
1816 + }
1185 1817
1186 - foreach ($posts as $post_obj) {
1187 - $post_id = $post_obj->post_id;
1818 + $imported = false;
1188 1819
1189 - // Skip if we already processed this post
1190 - if (in_array($post_id, $processed_posts)) {
1191 - continue;
1192 - }
1820 + foreach ($rows as $row) {
1821 + if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) === null) {
1822 + continue;
1823 + }
1193 1824
1194 - // Check if Metasync schema already exists for this post
1195 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1196 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1197 - continue; // Skip if already has Metasync schema
1198 - }
1825 + // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1826 + $schema_type = str_replace('rank_math_schema_', '', $row->meta_key);
1199 1827
1200 - // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1201 - $schema_type = str_replace('rank_math_schema_', '', $post_obj->meta_key);
1828 + // Decode Rank Math schema
1829 + $rm_schema = maybe_unserialize($row->meta_value);
1830 + if (empty($rm_schema) || !is_array($rm_schema)) {
1831 + continue;
1832 + }
1202 1833
1203 - // Decode Rank Math schema
1204 - $rm_schema = maybe_unserialize($post_obj->meta_value);
1205 - if (empty($rm_schema) || !is_array($rm_schema)) {
1206 - continue;
1834 + // Convert Rank Math schema to Metasync format
1835 + $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1836 +
1837 + if (!empty($metasync_schema['types'])) {
1838 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1839 + $breakdown['imported']++;
1840 + $imported = true;
1841 + break; // One schema block per post.
1842 + }
1207 1843 }
1208 1844
1209 - // Convert Rank Math schema to Metasync format
1210 - $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1211 -
1212 - if (!empty($metasync_schema['types'])) {
1213 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1214 - $imported_count++;
1215 - $processed_posts[] = $post_id; // Mark post as processed
1845 + if (!$imported) {
1846 + $breakdown['unsupported']++;
1216 1847 }
1217 1848 }
1218 1849
1219 - return $imported_count;
1850 + $breakdown['total_candidates'] = count($rows_by_post);
1851 +
1852 + return $breakdown;
1220 1853 }
1221 1854
1222 1855 /**
1223 1856 * Import per-post schema from AIOSEO
1857 + *
1858 + * `schema_type = 'default'` rows and rows whose schema options are empty
1859 + * never reach this loop — they are excluded by aioseo_schema_candidate_rows()
1860 + * for the detector and the importer alike, so the two can never disagree
1861 + * about what counts as a candidate.
1862 + *
1863 + * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
1224 1864 */
1225 1865 private function import_aioseo_schema()
1226 1866 {
1227 - global $wpdb;
1228 - $imported_count = 0;
1867 + $breakdown = $this->empty_schema_breakdown();
1229 1868
1230 - // Check if AIOSEO table exists
1231 - $table = $wpdb->prefix . 'aioseo_posts';
1232 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
1233 - return 0;
1869 + // An absent AIOSEO table means there is nothing to import at all.
1870 + // Rows are keyed per post so the count matches check_schema_data(),
1871 + // which also counts distinct posts.
1872 + $rows_by_post = [];
1873 + foreach ($this->aioseo_schema_candidate_rows() as $aioseo_data) {
1874 + $rows_by_post[(int) $aioseo_data->post_id] = $aioseo_data;
1234 1875 }
1235 1876
1236 - // Get all posts with AIOSEO schema
1237 - $posts = $wpdb->get_results("
1238 - SELECT post_id, schema_type, schema_type_options
1239 - FROM {$table}
1240 - WHERE (schema_type IS NOT NULL AND schema_type != '')
1241 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1242 - ");
1877 + foreach ($rows_by_post as $post_id => $aioseo_data) {
1878 + $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1879 + if ($this->post_has_metasync_schema($post_id)) {
1880 + $breakdown['already_imported']++;
1881 + continue;
1882 + }
1243 1883
1244 - foreach ($posts as $aioseo_data) {
1245 - $post_id = $aioseo_data->post_id;
1246 -
1247 - // Check if Metasync schema already exists
1248 - $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1249 - if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1250 - continue; // Skip if already has Metasync schema
1884 + if ($this->aioseo_schema_source_is_supported($aioseo_data->schema_type, $aioseo_data->schema_type_options) === null) {
1885 + $breakdown['unsupported']++;
1886 + continue;
1251 1887 }
1252 1888
1253 1889 // Decode AIOSEO schema options
1254 1890 $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
@@ -1263,18 +1899,28 @@
1263 1899 $post_id
1264 1900 );
1265 1901
1266 1902 if (!empty($metasync_schema['types'])) {
1267 - update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1268 - $imported_count++;
1903 + update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
1904 + $breakdown['imported']++;
1905 + } else {
1906 + $breakdown['unsupported']++;
1269 1907 }
1270 1908 }
1271 1909
1272 - return $imported_count;
1910 + $breakdown['total_candidates'] = count($rows_by_post);
1911 +
1912 + return $breakdown;
1273 1913 }
1274 1914
1275 1915 /**
1276 1916 * Convert Yoast schema to Metasync format
1917 + *
1918 + * Handles both storage shapes Yoast has used: the current `@graph`
1919 + * wrapper and the legacy root array of entries. Both are iterated through
1920 + * yoast_schema_graph_items(), which is also what the eligibility check
1921 + * walks — so a payload the detector counts is a payload the importer can
1922 + * convert.
1277 1923 */
1278 1924 private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1279 1925 {
1280 1926 $metasync_schema = [
@@ -1281,12 +1927,14 @@
1281 1927 'enabled' => true,
1282 1928 'types' => []
1283 1929 ];
1284 1930
1285 - // Yoast stores schema as a graph array
1286 - if (isset($yoast_schema['@graph']) && is_array($yoast_schema['@graph'])) {
1287 - foreach ($yoast_schema['@graph'] as $item) {
1288 - if (!isset($item['@type'])) {
1931 + // Yoast stores schema as a graph array (legacy installs: a root array
1932 + // of entries, or a single root object, instead of an @graph wrapper).
1933 + $graph_items = $this->yoast_schema_graph_items($yoast_schema);
1934 + if ($graph_items !== []) {
1935 + foreach ($graph_items as $item) {
1936 + if (!is_array($item) || !isset($item['@type'])) {
1289 1937 continue;
1290 1938 }
1291 1939
1292 1940 $raw_type = $item['@type'];
@@ -1473,9 +2121,13 @@
1473 2121 'enabled' => true,
1474 2122 'types' => []
1475 2123 ];
1476 2124
1477 - $type = strtolower($schema_type);
2125 + // Normalise through the shared map so the article family
2126 + // (NewsArticle, BlogPosting) imports the same way the Yoast and Rank
2127 + // Math converters handle it — eligibility resolves them to 'article'
2128 + // too, and the two must agree on every type they let through.
2129 + $type = $this->supported_schema_type($schema_type);
1478 2130
1479 2131 if ($type === 'article') {
1480 2132 $metasync_schema['types'][] = [
1481 2133 'type' => 'article',
@@ -1486,9 +2138,9 @@
1486 2138 'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
1487 2139 'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
1488 2140 ]
1489 2141 ];
1490 - } elseif ($type === 'faqpage') {
2142 + } elseif ($type === 'FAQPage') {
1491 2143 $faq_items = [];
1492 2144 if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
1493 2145 foreach ($schema_options['questions'] as $question) {
1494 2146 if (isset($question['question']) && isset($question['answer'])) {
@@ -1640,8 +2292,67 @@
1640 2292 return is_string($text) ? $text : $default;
1641 2293 }
1642 2294
1643 2295 /**
2296 + * Tokens whose value is the post's own body or excerpt.
2297 + *
2298 + * Keyed by source plugin. These are the only tokens that can carry the text
2299 + * a password withholds; everything else (title, author, dates, taxonomy) is
2300 + * already public on a protected post.
2301 + *
2302 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
2303 + * @return string[]
2304 + */
2305 + private function content_derived_tokens($plugin) {
2306 + switch ($plugin) {
2307 + case 'yoast':
2308 + return ['%%excerpt%%', '%%excerpt_only%%'];
2309 +
2310 + case 'rankmath':
2311 + return ['%excerpt%', '%excerpt_only%'];
2312 +
2313 + case 'aioseo':
2314 + return [
2315 + '#post_content',
2316 + '#post_excerpt',
2317 + '#post_excerpt_only',
2318 + '#attachment_description',
2319 + '#attachment_caption',
2320 + ];
2321 + }
2322 +
2323 + return [];
2324 + }
2325 +
2326 + /**
2327 + * Whether a raw template would publish text that a post password withholds.
2328 + *
2329 + * WordPress keeps a protected post's body and excerpt behind the password
2330 + * but leaves the post itself published, so an import walks straight past the
2331 + * usual visibility checks: the source plugin stores a template, we resolve it
2332 + * against the raw row, and the body ends up in a meta description that every
2333 + * visitor and crawler can read without ever entering the password.
2334 + *
2335 + * @param string $text Raw value from the source plugin.
2336 + * @param WP_Post $post Post the value belongs to.
2337 + * @param string $plugin Source plugin.
2338 + * @return bool
2339 + */
2340 + private function references_protected_content($text, $post, $plugin) {
2341 + if (empty($post->post_password)) {
2342 + return false;
2343 + }
2344 +
2345 + foreach ($this->content_derived_tokens($plugin) as $token) {
2346 + if (strpos($text, $token) !== false) {
2347 + return true;
2348 + }
2349 + }
2350 +
2351 + return false;
2352 + }
2353 +
2354 + /**
1644 2355 * Resolve SEO placeholder tokens to their actual values.
1645 2356 *
1646 2357 * Tries each source plugin's own replacement engine first so the result
1647 2358 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
@@ -1661,8 +2372,17 @@
1661 2372 if (!$post) {
1662 2373 return $text;
1663 2374 }
1664 2375
2376 + // Drop the whole value rather than resolve the content token to nothing
2377 + // and keep the rest. A half-template ("Title |") is worse than no import:
2378 + // it still occupies the meta key, and it still outranks whatever OTTO
2379 + // would have recommended for a page we know nothing publishable about.
2380 + // Callers skip empty values, so nothing is persisted.
2381 + if ($this->references_protected_content($text, $post, $plugin)) {
2382 + return '';
2383 + }
2384 +
1665 2385 // Yoast SEO — %%var%% tokens
1666 2386 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1667 2387 try {
1668 2388 $replacer = new WPSEO_Replace_Vars();
@@ -1686,9 +2406,9 @@
1686 2406 // fall through to manual replacement
1687 2407 }
1688 2408 }
1689 2409
1690 - // All in One SEO — #var# tokens
2410 + // All in One SEO — #var tokens
1691 2411 if ($plugin === 'aioseo') {
1692 2412 try {
1693 2413 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1694 2414 $resolved = aioseo()->tags->replaceTags($text, $post_id);
@@ -1700,20 +2420,27 @@
1700 2420 // fall through to manual replacement
1701 2421 }
1702 2422 }
1703 2423
1704 - return $this->manually_replace_seo_placeholders($text, $post_id);
2424 + return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
1705 2425 }
1706 2426
1707 2427 /**
1708 2428 * Manual fallback: replace the most common SEO placeholder tokens from
1709 - * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
2429 + * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
1710 2430 *
2431 + * AIOSEO tags are handled by resolve_aioseo_placeholders() because they
2432 + * need pattern matching rather than plain string replacement — see the
2433 + * docblock there.
2434 + *
1711 2435 * @param string $text Text that may contain placeholder tokens.
1712 2436 * @param int $post_id Post ID used for context.
2437 + * @param string $plugin Optional source plugin ('yoast', 'rankmath',
2438 + * 'aioseo') so the separator is read from the
2439 + * plugin the data actually came from.
1713 2440 * @return string Text with tokens replaced.
1714 2441 */
1715 - private function manually_replace_seo_placeholders($text, $post_id) {
2442 + private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
1716 2443 $post = get_post($post_id);
1717 2444 if (!$post) {
1718 2445 return $text;
1719 2446 }
@@ -1720,9 +2447,9 @@
1720 2447
1721 2448 $site_name = get_bloginfo('name');
1722 2449 $post_title = get_the_title($post_id);
1723 2450 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1724 - $author = get_the_author_meta('display_name', $post->post_author);
2451 + $author = get_the_author_meta('display_name', (int) $post->post_author);
1725 2452 $date = get_the_date('', $post_id);
1726 2453 $modified = get_the_modified_date('', $post_id);
1727 2454
1728 2455 $categories = get_the_category($post_id);
@@ -1730,30 +2457,9 @@
1730 2457
1731 2458 $tags = get_the_tags($post_id);
1732 2459 $first_tag = !empty($tags) ? $tags[0]->name : '';
1733 2460
1734 - // Try to read the separator from whichever plugin is active
1735 - $sep = '-';
1736 - if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1737 - try {
1738 - $raw = WPSEO_Options::get('separator', 'sc-dash');
1739 - // Yoast stores separator keys like 'sc-dash'; convert to glyph
1740 - // via the public get_separator_options() lookup table.
1741 - if (class_exists('WPSEO_Option_Titles')) {
1742 - $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
1743 - if (isset($options[$raw])) {
1744 - $sep = html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
1745 - }
1746 - }
1747 - } catch (Exception $e) {
1748 - $sep = '-';
1749 - }
1750 - } elseif (defined('RANK_MATH_VERSION')) {
1751 - $rm_settings = get_option('rank_math_general_settings', []);
1752 - if (!empty($rm_settings['title_separator'])) {
1753 - $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1754 - }
1755 - }
2461 + $sep = $this->resolve_source_separator($plugin);
1756 2462
1757 2463 $map = [
1758 2464 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1759 2465 '%%title%%' => $post_title,
@@ -1784,19 +2490,8 @@
1784 2490 '%page%' => '',
1785 2491 '%category%' => $primary_category,
1786 2492 '%tag%' => $first_tag,
1787 2493 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1788 - // ── AIOSEO (#var#) ───────────────────────────────────────────────
1789 - '#site_title#' => $site_name,
1790 - '#post_title#' => $post_title,
1791 - '#post_excerpt#' => $post_excerpt,
1792 - '#separator_sa#' => $sep,
1793 - '#author_name#' => $author,
1794 - '#current_date#' => $date,
1795 - '#post_date#' => $date,
1796 - '#category_title#' => $primary_category,
1797 - '#tag_title#' => $first_tag,
1798 - '#id#' => (string) $post_id,
1799 2494 ];
1800 2495
1801 2496 // Sort longest token first to avoid partial matches
1802 2497 // e.g. %%primary_category%% must replace before %%category%%
@@ -1803,12 +2498,1047 @@
1803 2498 uksort($map, function ($a, $b) {
1804 2499 return strlen($b) - strlen($a);
1805 2500 });
1806 2501
1807 - return str_replace(array_keys($map), array_values($map), $text);
2502 + $text = str_replace(array_keys($map), array_values($map), $text);
2503 +
2504 + // ── AIOSEO (#var) ────────────────────────────────────────────────────
2505 + // Only for AIOSEO data, or when the caller did not name a source. Yoast
2506 + // and Rank Math leave a literal '#categories' alone, so running this on
2507 + // their imports would rewrite text they would have preserved.
2508 + if ($plugin === 'aioseo' || $plugin === '') {
2509 + $text = $this->resolve_aioseo_placeholders($text, $post_id, $sep);
2510 + }
2511 +
2512 + return $text;
1808 2513 }
1809 2514
1810 2515 /**
2516 + * Resolve the title separator for the plugin the data came from.
2517 + *
2518 + * Each plugin stores its separator in its own option, so an AIOSEO import
2519 + * must not inherit Rank Math's separator just because Rank Math happens to
2520 + * still be active on the site. When the source is known we read only that
2521 + * plugin's setting and otherwise fall back to '-', which is the default
2522 + * every one of the three ships anyway.
2523 + *
2524 + * With no source named, keep the original precedence — whichever plugin is
2525 + * active, Yoast first — so callers that predate the $plugin argument see no
2526 + * behaviour change.
2527 + *
2528 + * @param string $plugin Source plugin ('yoast', 'rankmath', 'aioseo') or ''.
2529 + * @return string Separator glyph, defaulting to '-'.
2530 + */
2531 + private function resolve_source_separator($plugin = '') {
2532 + switch ($plugin) {
2533 + case 'yoast':
2534 + $sep = $this->get_yoast_separator();
2535 + return $sep !== '' ? $sep : '-';
2536 + case 'rankmath':
2537 + $sep = $this->get_rankmath_separator();
2538 + return $sep !== '' ? $sep : '-';
2539 + case 'aioseo':
2540 + $sep = $this->get_aioseo_separator();
2541 + return $sep !== '' ? $sep : '-';
2542 + }
2543 +
2544 + // Source unknown: original active-plugin precedence, including the
2545 + // class_exists() guard the old code used, so a site with WPSEO_VERSION
2546 + // defined but Yoast's classes unloaded still falls through to Rank Math
2547 + // exactly as before.
2548 + if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
2549 + $sep = $this->get_yoast_separator();
2550 + return $sep !== '' ? $sep : '-';
2551 + }
2552 + if (defined('RANK_MATH_VERSION')) {
2553 + $sep = $this->get_rankmath_separator();
2554 + return $sep !== '' ? $sep : '-';
2555 + }
2556 +
2557 + return '-';
2558 + }
2559 +
2560 + /**
2561 + * Read Yoast's separator. Yoast stores separator keys like 'sc-dash';
2562 + * convert to a glyph via the public get_separator_options() lookup table.
2563 + *
2564 + * @return string Separator glyph, or '' when unavailable.
2565 + */
2566 + private function get_yoast_separator() {
2567 + if (!defined('WPSEO_VERSION') || !class_exists('WPSEO_Options') || !class_exists('WPSEO_Option_Titles')) {
2568 + return '';
2569 + }
2570 +
2571 + try {
2572 + $raw = WPSEO_Options::get('separator', 'sc-dash');
2573 + $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
2574 + if (isset($options[$raw])) {
2575 + return html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
2576 + }
2577 + } catch (Exception $e) {
2578 + return '';
2579 + }
2580 +
2581 + return '';
2582 + }
2583 +
2584 + /**
2585 + * Read Rank Math's separator from its general settings option.
2586 + *
2587 + * @return string Separator glyph, or '' when unavailable.
2588 + */
2589 + private function get_rankmath_separator() {
2590 + if (!defined('RANK_MATH_VERSION')) {
2591 + return '';
2592 + }
2593 +
2594 + $settings = get_option('rank_math_general_settings', []);
2595 + if (is_array($settings) && !empty($settings['title_separator'])) {
2596 + return html_entity_decode($settings['title_separator'], ENT_QUOTES, 'UTF-8');
2597 + }
2598 +
2599 + return '';
2600 + }
2601 +
2602 + /**
2603 + * Read AIOSEO's separator from aioseo_options.
2604 + *
2605 + * Lives at searchAppearance.global.separator (NOT breadcrumbs.separator)
2606 + * and is stored HTML-entity encoded, e.g. '&#45;'. The option itself may
2607 + * be a JSON string or an already-decoded array depending on how it was
2608 + * written, so handle both. Read straight from the option rather than
2609 + * through aioseo() so it still works once AIOSEO is deactivated.
2610 + *
2611 + * @return string Separator glyph, or '' when unavailable.
2612 + */
2613 + private function get_aioseo_separator() {
2614 + $options = get_option('aioseo_options', '');
2615 + if (is_string($options) && $options !== '') {
2616 + $options = json_decode($options, true);
2617 + }
2618 + if (!is_array($options)) {
2619 + return '';
2620 + }
2621 +
2622 + $raw = $options['searchAppearance']['global']['separator'] ?? '';
2623 + if (!is_string($raw) || $raw === '') {
2624 + return '';
2625 + }
2626 +
2627 + return html_entity_decode($raw, ENT_QUOTES, 'UTF-8');
2628 + }
2629 +
2630 + /**
2631 + * AIOSEO's site-wide title / meta description templates, per post type.
2632 + *
2633 + * A post with no per-post override still gets a title and description from
2634 + * these — AIOSEO's own getPostDescription() falls back to the post type
2635 + * template before anything else. Reading only the per-post columns therefore
2636 + * imports nothing at all on a site that never overrode individual posts,
2637 + * which is the common case: templates are the default, overrides are the
2638 + * exception.
2639 + *
2640 + * Stored in the 'aioseo_options_dynamic' option, which may be a JSON string
2641 + * or an already-decoded array depending on how it was written — same as
2642 + * get_aioseo_separator() above, and read straight from the option so it keeps
2643 + * working once AIOSEO is deactivated.
2644 + *
2645 + * A post type is included only when AIOSEO is set to show SEO for it AND
2646 + * WordPress registers it as public. The public test is deliberately
2647 + * $obj->public rather than is_post_type_viewable(): a logging CPT registered
2648 + * public=false, publicly_queryable=true is "viewable" by that helper, and
2649 + * would drag hundreds of internal records into the import.
2650 + *
2651 + * @return array<string,array{title:string,description:string}> Keyed by post type.
2652 + */
2653 + private function get_aioseo_post_type_templates() {
2654 + $options = get_option('aioseo_options_dynamic', '');
2655 + if (is_string($options) && $options !== '') {
2656 + $options = json_decode($options, true);
2657 + }
2658 + if (!is_array($options)) {
2659 + return [];
2660 + }
2661 +
2662 + $post_types = $options['searchAppearance']['postTypes'] ?? [];
2663 + if (!is_array($post_types)) {
2664 + return [];
2665 + }
2666 +
2667 + $templates = [];
2668 +
2669 + foreach ($post_types as $post_type => $config) {
2670 + if (!is_array($config)) {
2671 + continue;
2672 + }
2673 +
2674 + // 'show' false means AIOSEO emits no SEO for the post type at all.
2675 + if (isset($config['show']) && !$config['show']) {
2676 + continue;
2677 + }
2678 +
2679 + $object = get_post_type_object($post_type);
2680 + if (!$object || empty($object->public)) {
2681 + continue;
2682 + }
2683 +
2684 + $title = isset($config['title']) && is_string($config['title']) ? $config['title'] : '';
2685 + $description = isset($config['metaDescription']) && is_string($config['metaDescription'])
2686 + ? $config['metaDescription']
2687 + : '';
2688 +
2689 + if ($title === '' && $description === '') {
2690 + continue;
2691 + }
2692 +
2693 + $templates[$post_type] = [
2694 + 'title' => $title,
2695 + 'description' => $description,
2696 + ];
2697 + }
2698 +
2699 + return $templates;
2700 + }
2701 +
2702 + /**
2703 + * Trim a template-derived meta description to a sane length.
2704 + *
2705 + * AIOSEO does not truncate — its sanitize() only collapses whitespace — so a
2706 + * post type whose template is '#post_content' renders the entire body as the
2707 + * description. Copying that verbatim into post meta would hand every page a
2708 + * multi-thousand-character description, so template-derived values are cut at
2709 + * a word boundary instead. Values the customer typed on the post itself are
2710 + * never passed through here.
2711 + *
2712 + * @param string $text Resolved description.
2713 + * @param int $limit Maximum length in characters.
2714 + * @return string Trimmed description.
2715 + */
2716 + private function truncate_meta_description($text, $limit = 160) {
2717 + $text = trim(preg_replace('/\s+/u', ' ', (string) $text));
2718 +
2719 + if ($text === '' || mb_strlen($text) <= $limit) {
2720 + return $text;
2721 + }
2722 +
2723 + // Leave room for the ellipsis so the result never exceeds $limit.
2724 + $clipped = mb_substr($text, 0, $limit - 1);
2725 +
2726 + // Cut back to the last whole word so the value never ends mid-word.
2727 + $last_space = mb_strrpos($clipped, ' ');
2728 + if ($last_space !== false && $last_space > 0) {
2729 + $clipped = mb_substr($clipped, 0, $last_space);
2730 + }
2731 +
2732 + $trimmed = rtrim($clipped, " \t\n\r\0\x0B.,;:!?-");
2733 +
2734 + // Punctuation-only input would otherwise collapse to a bare ellipsis.
2735 + return ($trimmed === '' ? $clipped : $trimmed) . '…';
2736 + }
2737 +
2738 + /**
2739 + * Every smart-tag id AIOSEO defines, taken from its own Tags class
2740 + * (all-in-one-seo-pack/app/Common/Utils/Tags.php) plus the ids the Pro
2741 + * build adds for WooCommerce.
2742 + *
2743 + * Used for detection as well as replacement: a tag missing from this list
2744 + * is neither resolved nor flagged, so it would be written to post meta as
2745 + * literal text. Restricting detection to AIOSEO's real vocabulary means
2746 + * ordinary text containing a hash — "#1 Rated Clinic", "#hashtag" — is
2747 + * never mistaken for a tag, and tokens AIOSEO does not define (#category_title,
2748 + * #tag_title) stay literal exactly as AIOSEO itself renders them.
2749 + *
2750 + * Supersets are safe in any order thanks to the negative lookahead used
2751 + * for matching: #post_date cannot consume the start of #post_date_w3c,
2752 + * nor #featured_image the start of #featured_image_url.
2753 + *
2754 + * @return string[]
2755 + */
2756 + private function get_aioseo_tag_ids() {
2757 + return [
2758 + 'alt_tag', 'archive_date', 'archive_title', 'attachment_caption',
2759 + 'attachment_description', 'author_bio', 'author_first_name',
2760 + 'author_last_name', 'author_link', 'author_link_alt', 'author_name',
2761 + 'author_url', 'blog_link', 'blog_title', 'categories', 'category',
2762 + 'category_link', 'category_link_alt', 'current_date', 'current_day',
2763 + 'current_month', 'current_year', 'custom_field', 'description',
2764 + 'event_end_date', 'event_start_date', 'featured_image',
2765 + 'featured_image_url', 'page_number', 'parent_title', 'permalink',
2766 + 'post_content', 'post_date', 'post_date_w3c', 'post_day',
2767 + 'post_excerpt', 'post_excerpt_only', 'post_link', 'post_link_alt',
2768 + 'post_modified_date', 'post_modified_date_w3c', 'post_month',
2769 + 'post_title', 'post_year', 'search_term', 'separator_sa',
2770 + 'site_description', 'site_link', 'site_link_alt', 'site_title',
2771 + 'tagline', 'tax_name', 'tax_parent_name', 'taxonomy_description',
2772 + 'taxonomy_title', 'woocommerce_brand', 'woocommerce_price',
2773 + 'woocommerce_sku',
2774 + ];
2775 + }
2776 +
2777 + /**
2778 + * Resolve AIOSEO smart tags to their values.
2779 + *
2780 + * AIOSEO denotes a tag with a single leading '#' and no closing delimiter
2781 + * (Tags::$denotationChar), ending the match with a negative lookahead so
2782 + * supersets do not collide — #post_excerpt must not consume the start of
2783 + * #post_excerpt_only, nor #post_link the start of #post_link_alt. We use
2784 + * the same rule here so results match what AIOSEO itself would render,
2785 + * and so replacement order is irrelevant.
2786 + *
2787 + * @param string $text Text that may contain AIOSEO tags.
2788 + * @param int $post_id Post ID used for context.
2789 + * @param string $sep Separator glyph for #separator_sa.
2790 + * @return string Text with AIOSEO tags replaced.
2791 + */
2792 + private function resolve_aioseo_placeholders($text, $post_id, $sep = '-') {
2793 + if (!is_string($text) || strpos($text, '#') === false) {
2794 + return (string) $text;
2795 + }
2796 +
2797 + $post = get_post($post_id);
2798 + if (!$post) {
2799 + return $text;
2800 + }
2801 +
2802 + // Tags that carry an argument have to be handled before the plain ones.
2803 + // The plain pattern's lookahead permits '-', so '#custom_field-price'
2804 + // would otherwise match bare '#custom_field' and leave an orphan
2805 + // '-price' glued to the resolved value. AIOSEO splits these out for the
2806 + // same reason (Tags::replaceTags() skips them, then parseCustomFields()
2807 + // and parseTaxonomyNames() run afterwards).
2808 + $text = $this->resolve_aioseo_parameterised_tags($text, $post_id);
2809 +
2810 + // Resolve only the tags the text actually references. Building every
2811 + // value up front meant generating an excerpt for every imported post,
2812 + // and excerpt generation runs the whole the_content filter chain — ~8x
2813 + // the cost per post on real (page-builder) content, for a value the
2814 + // template usually never mentions.
2815 + foreach ($this->get_aioseo_tag_ids() as $tag) {
2816 + $pattern = '/#' . preg_quote($tag, '/') . '(?![a-zA-Z0-9_])/i';
2817 + if (!preg_match($pattern, $text)) {
2818 + continue;
2819 + }
2820 +
2821 + $value = $this->resolve_aioseo_tag($tag, $post_id, $post, $sep);
2822 + if ($value === null) {
2823 + // Not resolvable outside AIOSEO. Leave it in place so
2824 + // has_unresolved_placeholders() can stop the write.
2825 + continue;
2826 + }
2827 +
2828 + // Escape backslashes first, then '$', so a value containing "$1"
2829 + // is not treated as a backreference by preg_replace().
2830 + $replacement = str_replace(['\\', '$'], ['\\\\', '\\$'], (string) $value);
2831 + $result = preg_replace($pattern, $replacement, $text);
2832 + if (is_string($result)) {
2833 + $text = $result;
2834 + }
2835 + }
2836 +
2837 + return $text;
2838 + }
2839 +
2840 + /**
2841 + * Resolve the two AIOSEO tags that take an argument after a hyphen:
2842 + * '#custom_field-{meta_key}' and '#tax_name-{taxonomy}'.
2843 + *
2844 + * Patterns and fallback order mirror AIOSEO's parseCustomFields() and
2845 + * parseTaxonomyNames(), including the detail that a bare '#custom_field' or
2846 + * '#tax_name' with no argument resolves to an empty string rather than
2847 + * being left in place.
2848 + *
2849 + * @param string $text Text that may contain parameterised tags.
2850 + * @param int $post_id Post ID used for context.
2851 + * @return string Text with parameterised tags replaced.
2852 + */
2853 + private function resolve_aioseo_parameterised_tags($text, $post_id) {
2854 + $text = preg_replace_callback(
2855 + '/#custom_field-([a-zA-Z0-9_-]+)/i',
2856 + function ($m) use ($post_id) {
2857 + return $this->resolve_aioseo_custom_field($m[1], $post_id);
2858 + },
2859 + $text
2860 + );
2861 +
2862 + $text = preg_replace_callback(
2863 + '/#tax_name-([a-zA-Z0-9_-]+)/i',
2864 + function ($m) use ($post_id) {
2865 + return $this->resolve_aioseo_taxonomy_name($m[1], $post_id);
2866 + },
2867 + $text
2868 + );
2869 +
2870 + // With no argument AIOSEO drops the token entirely.
2871 + $text = preg_replace('/#custom_field(?![a-zA-Z0-9_-])/i', '', (string) $text);
2872 + $text = preg_replace('/#tax_name(?![a-zA-Z0-9_-])/i', '', (string) $text);
2873 +
2874 + return (string) $text;
2875 + }
2876 +
2877 + /**
2878 + * Value of a custom field referenced by '#custom_field-{key}'.
2879 + *
2880 + * ACF is consulted first when present, matching AIOSEO's own order, so a
2881 + * field with both an ACF definition and raw post meta resolves the way the
2882 + * live site rendered it. Non-scalar values (arrays, objects) have no textual
2883 + * form and become an empty string.
2884 + *
2885 + * @param string $field_key Meta key captured from the tag.
2886 + * @param int $post_id Post ID.
2887 + * @return string Field value as text.
2888 + */
2889 + private function resolve_aioseo_custom_field($field_key, $post_id) {
2890 + $value = '';
2891 +
2892 + if (function_exists('get_field')) {
2893 + $value = get_field($field_key, $post_id);
2894 + }
2895 +
2896 + if (empty($value)) {
2897 + $value = get_post_meta($post_id, $field_key, true);
2898 + }
2899 +
2900 + return is_scalar($value) ? wp_strip_all_tags((string) $value) : '';
2901 + }
2902 +
2903 + /**
2904 + * Term name for the taxonomy referenced by '#tax_name-{taxonomy}'.
2905 + *
2906 + * AIOSEO prefers its own "primary term" for the taxonomy and falls back to
2907 + * the first assigned term. We have no primary-term store to read once
2908 + * AIOSEO is gone, so the first assigned term is used directly.
2909 + *
2910 + * @param string $taxonomy Taxonomy slug captured from the tag.
2911 + * @param int $post_id Post ID.
2912 + * @return string Term name, or '' when unavailable.
2913 + */
2914 + private function resolve_aioseo_taxonomy_name($taxonomy, $post_id) {
2915 + if (!taxonomy_exists($taxonomy)) {
2916 + return '';
2917 + }
2918 +
2919 + $terms = get_the_terms($post_id, $taxonomy);
2920 + if (empty($terms) || is_wp_error($terms)) {
2921 + return '';
2922 + }
2923 +
2924 + $term = reset($terms);
2925 +
2926 + return (string) $term->name;
2927 + }
2928 +
2929 + /**
2930 + * Value for a single AIOSEO tag in the context of one post.
2931 + *
2932 + * Every tag AIOSEO can render for a post is resolved here. The two that take
2933 + * an argument (#custom_field, #tax_name) are handled before this switch and
2934 + * so return null, which callers treat as "leave in place" — a leftover then
2935 + * gets reported and the write skipped rather than saved half-resolved.
2936 + *
2937 + * @param string $tag AIOSEO tag id, without the leading '#'.
2938 + * @param int $post_id Post ID.
2939 + * @param WP_Post $post Post object.
2940 + * @param string $sep Separator glyph.
2941 + * @return string|null Replacement value, or null if unresolvable.
2942 + */
2943 + private function resolve_aioseo_tag($tag, $post_id, $post, $sep) {
2944 + // Second line of defence behind the whole-value check in
2945 + // resolve_seo_placeholders(). Any future caller that reaches a single
2946 + // tag directly still cannot read out a protected post's body.
2947 + if (!empty($post->post_password)
2948 + && in_array('#' . $tag, $this->content_derived_tokens('aioseo'), true)) {
2949 + return '';
2950 + }
2951 +
2952 + switch ($tag) {
2953 + case 'site_title':
2954 + case 'blog_title':
2955 + return (string) get_bloginfo('name');
2956 +
2957 + case 'tagline':
2958 + case 'site_description':
2959 + return (string) get_bloginfo('description');
2960 +
2961 + case 'post_title':
2962 + return (string) get_the_title($post_id);
2963 +
2964 + case 'parent_title':
2965 + $parent = isset($post->post_parent) ? (int) $post->post_parent : 0;
2966 + return $parent ? (string) get_the_title($parent) : '';
2967 +
2968 + case 'post_excerpt_only':
2969 + // Manual excerpt only — AIOSEO never falls back for this one.
2970 + return isset($post->post_excerpt)
2971 + ? $this->strip_builder_markup((string) $post->post_excerpt)
2972 + : '';
2973 +
2974 + case 'post_excerpt':
2975 + // Manual excerpt if set, otherwise a content-derived one, which
2976 + // is what AIOSEO does. This is the expensive branch, so it only
2977 + // runs when the template actually references the tag.
2978 + $manual = isset($post->post_excerpt) ? (string) $post->post_excerpt : '';
2979 + return $this->strip_builder_markup(
2980 + $manual !== '' ? $manual : (string) get_the_excerpt($post)
2981 + );
2982 +
2983 + case 'post_content':
2984 + return $this->strip_builder_markup((string) $post->post_content);
2985 +
2986 + case 'separator_sa':
2987 + return (string) $sep;
2988 +
2989 + case 'author_name':
2990 + return (string) get_the_author_meta('display_name', (int) $post->post_author);
2991 + case 'author_first_name':
2992 + return (string) get_the_author_meta('first_name', (int) $post->post_author);
2993 + case 'author_last_name':
2994 + return (string) get_the_author_meta('last_name', (int) $post->post_author);
2995 + case 'author_bio':
2996 + return (string) get_the_author_meta('description', (int) $post->post_author);
2997 +
2998 + case 'author_link':
2999 + case 'author_link_alt':
3000 + case 'author_url':
3001 + $author = isset($post->post_author) ? (int) $post->post_author : 0;
3002 + return $author ? (string) get_author_posts_url($author) : '';
3003 +
3004 + case 'current_date':
3005 + $format = get_option('date_format');
3006 + $format = (is_string($format) && $format !== '') ? $format : 'F j, Y';
3007 + return (string) date_i18n($format);
3008 + case 'current_day':
3009 + return (string) date_i18n('d');
3010 + case 'current_month':
3011 + return (string) date_i18n('F');
3012 + case 'current_year':
3013 + return (string) date_i18n('Y');
3014 +
3015 + case 'post_date':
3016 + return (string) get_the_date('', $post_id);
3017 + case 'post_day':
3018 + return (string) get_the_date('d', $post_id);
3019 + case 'post_month':
3020 + return (string) get_the_date('F', $post_id);
3021 + case 'post_year':
3022 + return (string) get_the_date('Y', $post_id);
3023 +
3024 + case 'permalink':
3025 + case 'post_link':
3026 + case 'post_link_alt':
3027 + return (string) get_permalink($post_id);
3028 +
3029 + case 'site_link':
3030 + case 'site_link_alt':
3031 + case 'blog_link':
3032 + return (string) home_url();
3033 +
3034 + case 'categories':
3035 + $names = [];
3036 + foreach ($this->get_post_categories($post_id) as $category) {
3037 + if (isset($category->name)) {
3038 + $names[] = $category->name;
3039 + }
3040 + }
3041 + return implode(', ', $names);
3042 +
3043 + case 'category':
3044 + case 'taxonomy_title':
3045 + $categories = $this->get_post_categories($post_id);
3046 + return !empty($categories) && isset($categories[0]->name) ? (string) $categories[0]->name : '';
3047 +
3048 + case 'taxonomy_description':
3049 + $categories = $this->get_post_categories($post_id);
3050 + return !empty($categories) && isset($categories[0]->description) ? (string) $categories[0]->description : '';
3051 +
3052 + case 'category_link':
3053 + case 'category_link_alt':
3054 + $categories = $this->get_post_categories($post_id);
3055 + return !empty($categories) && isset($categories[0]->term_id)
3056 + ? (string) get_category_link($categories[0]->term_id)
3057 + : '';
3058 +
3059 + case 'featured_image':
3060 + return (string) get_the_post_thumbnail_url($post_id, 'full');
3061 +
3062 + case 'featured_image_url':
3063 + $thumbnail_id = get_post_thumbnail_id($post_id);
3064 + if (!$thumbnail_id) {
3065 + return '';
3066 + }
3067 + $src = wp_get_attachment_image_src($thumbnail_id, 'full');
3068 + return (is_array($src) && !empty($src[0])) ? (string) $src[0] : '';
3069 +
3070 + case 'alt_tag':
3071 + // AIOSEO reads the alt off the post id itself, not off the
3072 + // featured image's attachment. Mirror that: on an ordinary post
3073 + // this is empty, which is what the live site rendered.
3074 + return (string) get_post_meta($post_id, '_wp_attachment_image_alt', true);
3075 +
3076 + case 'attachment_caption':
3077 + return (string) wp_get_attachment_caption($post_id);
3078 +
3079 + case 'attachment_description':
3080 + return $this->strip_builder_markup((string) $post->post_content);
3081 +
3082 + case 'post_modified_date':
3083 + return (string) get_the_modified_date('', $post_id);
3084 +
3085 + case 'post_date_w3c':
3086 + return (string) mysql2date(DATE_W3C, $post->post_date, false);
3087 +
3088 + case 'post_modified_date_w3c':
3089 + return (string) mysql2date(DATE_W3C, $post->post_modified, false);
3090 +
3091 + case 'woocommerce_sku':
3092 + return (string) get_post_meta($post_id, '_sku', true);
3093 +
3094 + case 'woocommerce_price':
3095 + return (string) get_post_meta($post_id, '_price', true);
3096 +
3097 + // AIOSEO renders these empty in a single-post context.
3098 + case 'description':
3099 + case 'page_number':
3100 + case 'search_term':
3101 + case 'archive_date':
3102 + case 'archive_title':
3103 + // Term-context only. AIOSEO resolves #tax_parent_name off a term id,
3104 + // so it is empty for a post.
3105 + case 'tax_parent_name':
3106 + // Supplied by AIOSEO's event add-on, which has no data to read here.
3107 + case 'event_start_date':
3108 + case 'event_end_date':
3109 + case 'woocommerce_brand':
3110 + return '';
3111 + }
3112 +
3113 + // #custom_field and #tax_name take an argument and are resolved ahead of
3114 + // this switch, in resolve_aioseo_parameterised_tags().
3115 + return null;
3116 + }
3117 +
3118 + /**
3119 + * Reduce raw post content to plain prose.
3120 + *
3121 + * Page-builder pages are almost entirely shortcode markup, so a description
3122 + * template built on the content imports as '[et_pb_section][et_pb_row]…'
3123 + * without this. AIOSEO strips shortcodes too, but strip_shortcodes() only
3124 + * removes tags that are *registered* at the time it runs, and a builder
3125 + * registers its own only in the contexts it renders in — not necessarily
3126 + * during an import request. So sweep any remaining shortcode-shaped tokens
3127 + * as well, which also covers content left behind by a builder that is no
3128 + * longer installed.
3129 + *
3130 + * @param string $content Raw post content.
3131 + * @return string Plain text.
3132 + */
3133 + private function strip_builder_markup($content) {
3134 + $content = strip_shortcodes($content);
3135 +
3136 + // Opening/closing shortcode-shaped tokens: [tag], [tag a="b"], [/tag].
3137 + $content = preg_replace('/\[\/?[a-z][a-z0-9_-]*(?:\s[^\]]*)?\]/i', ' ', (string) $content);
3138 +
3139 + return trim(preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $content)));
3140 + }
3141 +
3142 + /**
3143 + * Categories for a post, normalised to an array.
3144 + *
3145 + * @param int $post_id Post ID.
3146 + * @return array
3147 + */
3148 + private function get_post_categories($post_id) {
3149 + $categories = get_the_category($post_id);
3150 + return is_array($categories) ? $categories : [];
3151 + }
3152 +
3153 + /**
3154 + * Whether a value still contains AIOSEO tags after resolution.
3155 + *
3156 + * Callers use this to skip the write instead of persisting a literal
3157 + * token such as '#post_title' as a page title. A tag that legitimately
3158 + * resolves to an empty string is gone from the text and so is not
3159 + * reported here.
3160 + *
3161 + * Only AIOSEO is checked — see the fall-through comment at the end for why
3162 + * Yoast and Rank Math are excluded.
3163 + *
3164 + * @param string $text Resolved text.
3165 + * @param string $plugin Source plugin; only 'aioseo' is checked.
3166 + * @return bool True when at least one tag survived.
3167 + */
3168 + private function has_unresolved_placeholders($text, $plugin) {
3169 + if (!is_string($text) || $text === '') {
3170 + return false;
3171 + }
3172 +
3173 + switch ($plugin) {
3174 + case 'aioseo':
3175 + if (strpos($text, '#') === false) {
3176 + return false;
3177 + }
3178 + foreach ($this->get_aioseo_tag_ids() as $tag) {
3179 + if (preg_match('/#' . preg_quote($tag, '/') . '(?![a-zA-Z0-9_])/i', $text)) {
3180 + return true;
3181 + }
3182 + }
3183 + return false;
3184 + }
3185 +
3186 + // Yoast and Rank Math deliberately fall through. Their manual fallback
3187 + // maps cover only a fraction of the variables those plugins define
3188 + // (16 of ~60 for Yoast, 12 of 49 for Rank Math), so treating a leftover
3189 + // token as a reason to skip would drop common real-world titles
3190 + // wholesale — "Best Dentist in %currentyear% %sep% %sitename%" resolves
3191 + // only partly and would import as nothing at all. Until those maps are
3192 + // filled in, their existing behaviour (save the partly-resolved value)
3193 + // is the lesser evil and is left untouched.
3194 + return false;
3195 + }
3196 +
3197 + /**
3198 + * Resolve an imported value and decide whether it is safe to persist.
3199 + *
3200 + * Returns null when a placeholder survived resolution, so the caller skips
3201 + * the write rather than saving a literal '#post_title' that would then
3202 + * render as the page title and outrank OTTO's recommendation. Skipped
3203 + * values are collected in $unresolved so the import report can surface
3204 + * them.
3205 + *
3206 + * @param string $raw Raw value from the source plugin.
3207 + * @param int $post_id Post ID used for context.
3208 + * @param string $plugin Source plugin ('yoast', 'rankmath', 'aioseo').
3209 + * @param string $meta_key Destination meta key, for the report.
3210 + * @param array $unresolved Collected failures, appended to by reference.
3211 + * @return string|null Resolved value, or null when it must be skipped.
3212 + */
3213 + private function resolve_import_value($raw, $post_id, $plugin, $meta_key, array &$unresolved) {
3214 + $resolved = $this->resolve_seo_placeholders($raw, $post_id, $plugin);
3215 +
3216 + if ($this->has_unresolved_placeholders($resolved, $plugin)) {
3217 + $unresolved[] = [
3218 + 'post_id' => (int) $post_id,
3219 + 'meta_key' => $meta_key,
3220 + 'raw' => (string) $raw,
3221 + 'resolved' => (string) $resolved,
3222 + ];
3223 +
3224 + if (defined('WP_DEBUG') && WP_DEBUG) {
3225 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
3226 + 'MetaSync importer: unresolved %s placeholder for post %d (%s) — %s',
3227 + $plugin,
3228 + (int) $post_id,
3229 + $meta_key,
3230 + (string) $raw
3231 + ));
3232 + }
3233 +
3234 + return null;
3235 + }
3236 +
3237 + return $resolved;
3238 + }
3239 +
3240 + /**
3241 + * Sanitize an imported value according to what its destination key holds.
3242 + *
3243 + * Kept in one place because the post and term writers must agree: they are
3244 + * two copies of the same rule, and the pair has already had to be patched in
3245 + * lockstep once (image keys were reaching the free-text sanitizer).
3246 + *
3247 + * An image key goes through esc_url_raw(), which returns '' for anything it
3248 + * will not vouch for — a bare word, a javascript: scheme, a mangled host.
3249 + * Callers must treat that '' as "could not sanitize", not as "empty value";
3250 + * the two mean different things to an overwrite run.
3251 + *
3252 + * @param string $value Resolved value from the source plugin.
3253 + * @param string $meta_key Imported destination key.
3254 + * @return string Sanitized value, or '' if it did not survive.
3255 + */
3256 + private function sanitize_imported_value($value, $meta_key) {
3257 + if ($meta_key === Metasync_Seo_Precedence::KEY_IMPORTED_TITLE) {
3258 + return sanitize_text_field($value);
3259 + }
3260 +
3261 + if (in_array($meta_key, array(
3262 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
3263 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
3264 + ), true)) {
3265 + return esc_url_raw($value);
3266 + }
3267 +
3268 + return sanitize_textarea_field($value);
3269 + }
3270 +
3271 + /**
3272 + * Persist an imported title or description on its low-priority key.
3273 + *
3274 + * Every importer routes through here so the three source plugins cannot
3275 + * drift apart on the rules that matter: imported values never land on the
3276 + * customer-override keys, a value that resolves to nothing is not written,
3277 + * and an overwrite run clears a now-stale value instead of preserving it.
3278 + *
3279 + * @param int $post_id Post being imported.
3280 + * @param string $raw Raw value from the source plugin.
3281 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
3282 + * @param string $meta_key Imported destination key.
3283 + * @param bool $overwrite Whether the run replaces existing values.
3284 + * @param array $unresolved Collected failures, appended to by reference.
3285 + * @param bool $truncate Trim to meta-description length first.
3286 + * @return bool Whether the post's meta changed.
3287 + */
3288 + private function store_imported_post_value($post_id, $raw, $plugin, $meta_key, $overwrite, array &$unresolved, $truncate = false) {
3289 + if ($raw === '') {
3290 + return false;
3291 + }
3292 +
3293 + $existing = get_post_meta($post_id, $meta_key, true);
3294 + if (!empty($existing) && !$overwrite) {
3295 + return false;
3296 + }
3297 +
3298 + $resolved = $this->resolve_import_value($raw, $post_id, $plugin, $meta_key, $unresolved);
3299 +
3300 + if ($resolved !== null && $truncate) {
3301 + $resolved = $this->truncate_meta_description($resolved);
3302 + }
3303 +
3304 + if ($resolved !== null && trim($resolved) !== '') {
3305 + $sanitized = $this->sanitize_imported_value($resolved, $meta_key);
3306 +
3307 + // The source gave us something, but it could not survive
3308 + // sanitization — an og:image/twitter:image URL rejected by
3309 + // esc_url_raw() is the case this guards. Writing the resulting ''
3310 + // would blank a good stored image and still report the import as a
3311 + // success. Skip instead: no write, no success, and whatever is
3312 + // already stored stays. A genuinely empty source is a different
3313 + // thing and is handled by the overwrite branch below.
3314 + if ($sanitized === '') {
3315 + return false;
3316 + }
3317 +
3318 + // update_post_meta() unslashes what it is given, so a value holding
3319 + // a backslash ("AC\DC") would be stored stripped ("ACDC"). Slash it
3320 + // here so the round trip through meta storage is lossless.
3321 + update_post_meta($post_id, $meta_key, wp_slash($sanitized));
3322 + return true;
3323 + }
3324 +
3325 + // Overwrite means "make this match the source". The source now yields
3326 + // nothing, so a value left from an earlier run is stale — clear it so
3327 + // re-importing repairs rather than preserves. Only ever touches the
3328 + // imported key, never anything the customer typed.
3329 + if ($overwrite && !empty($existing)) {
3330 + delete_post_meta($post_id, $meta_key);
3331 + return true;
3332 + }
3333 +
3334 + return false;
3335 + }
3336 +
3337 + /**
3338 + * Resolve placeholder tokens in a value imported for a taxonomy term.
3339 + *
3340 + * The post resolver cannot be reused. Every token it answers comes off a
3341 + * WP_Post, and a term has no post to answer from — asking it would either
3342 + * blank the value or, worse, resolve it against whatever post happens to
3343 + * share the term's ID. The vocabularies barely overlap anyway: a term
3344 + * template is built from the term's own name and description, so this is a
3345 + * deliberately small, separate map.
3346 + *
3347 + * @param string $text Raw value from the source plugin.
3348 + * @param WP_Term $term Term the value belongs to.
3349 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
3350 + * @return string Resolved string.
3351 + */
3352 + private function resolve_term_placeholders($text, $term, $plugin) {
3353 + if (empty($text) || empty($term->term_id)) {
3354 + return (string) $text;
3355 + }
3356 +
3357 + // Yoast and Rank Math both accept a term in place of a post, so their
3358 + // own engines still give the exact string the source site rendered.
3359 + if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
3360 + try {
3361 + $replacer = new WPSEO_Replace_Vars();
3362 + $resolved = $replacer->replace($text, $term);
3363 + if (is_string($resolved) && $resolved !== '') {
3364 + return $resolved;
3365 + }
3366 + } catch (Exception $e) {
3367 + // fall through to manual replacement
3368 + }
3369 + }
3370 +
3371 + if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
3372 + try {
3373 + $resolved = rank_math_replace_vars($text, $term);
3374 + if (is_string($resolved) && $resolved !== '') {
3375 + return $resolved;
3376 + }
3377 + } catch (Exception $e) {
3378 + // fall through to manual replacement
3379 + }
3380 + }
3381 +
3382 + // AIOSEO's replaceTags() takes a post ID. Handing it a term ID does not
3383 + // fail — it resolves against whatever post shares that ID and returns
3384 + // another page's title, so the native engine is deliberately skipped
3385 + // here and the manual map below is the only path for AIOSEO terms.
3386 + return $this->manually_replace_term_placeholders($text, $term, $plugin);
3387 + }
3388 +
3389 + /**
3390 + * Manual token replacement for term values.
3391 + *
3392 + * @param string $text
3393 + * @param WP_Term $term
3394 + * @param string $plugin
3395 + * @return string
3396 + */
3397 + private function manually_replace_term_placeholders($text, $term, $plugin) {
3398 + $site_name = (string) get_bloginfo('name');
3399 + $site_desc = (string) get_bloginfo('description');
3400 + $sep = $this->resolve_source_separator($plugin);
3401 + $year = (string) gmdate('Y');
3402 +
3403 + $term_name = (string) $term->name;
3404 + $term_desc = wp_strip_all_tags((string) $term->description);
3405 +
3406 + $map = [
3407 + // ── Yoast (%%var%%) ──────────────────────────────────────────────
3408 + '%%term_title%%' => $term_name,
3409 + '%%term_description%%' => $term_desc,
3410 + '%%category%%' => $term_name,
3411 + '%%category_description%%' => $term_desc,
3412 + '%%tag%%' => $term_name,
3413 + '%%sitename%%' => $site_name,
3414 + '%%sitedesc%%' => $site_desc,
3415 + '%%sep%%' => $sep,
3416 + '%%currentyear%%' => $year,
3417 + '%%page%%' => '',
3418 + '%%pagenumber%%' => '',
3419 + '%%pagetotal%%' => '',
3420 + // ── Rank Math (%var%) ────────────────────────────────────────────
3421 + '%term%' => $term_name,
3422 + '%term_description%' => $term_desc,
3423 + '%category%' => $term_name,
3424 + '%tag%' => $term_name,
3425 + '%sitename%' => $site_name,
3426 + '%sitedesc%' => $site_desc,
3427 + '%sep%' => $sep,
3428 + '%currentyear%' => $year,
3429 + '%page%' => '',
3430 + // ── AIOSEO (#var) ────────────────────────────────────────────────
3431 + '#taxonomy_title' => $term_name,
3432 + '#taxonomy_description' => $term_desc,
3433 + '#archive_title' => $term_name,
3434 + '#site_title' => $site_name,
3435 + '#blog_title' => $site_name,
3436 + '#site_description' => $site_desc,
3437 + '#tagline' => $site_desc,
3438 + '#separator_sa' => $sep,
3439 + '#current_year' => $year,
3440 + '#page_number' => '',
3441 + ];
3442 +
3443 + // Longest token first, so a short token never eats the start of a
3444 + // longer one (#site_title must not consume #site_description).
3445 + uksort($map, function ($a, $b) {
3446 + return strlen($b) - strlen($a);
3447 + });
3448 +
3449 + $resolved = str_replace(array_keys($map), array_values($map), $text);
3450 +
3451 + return trim(preg_replace('/\s+/', ' ', $resolved));
3452 + }
3453 +
3454 + /**
3455 + * Persist an imported term title or description on its low-priority key.
3456 + *
3457 + * Mirrors store_imported_post_value(): imported values never land on the
3458 + * key that means "the customer set this", a value that resolves to nothing
3459 + * is not written, and an overwrite run clears a now-stale value.
3460 + *
3461 + * @param WP_Term $term Term being imported.
3462 + * @param string $raw Raw value from the source plugin.
3463 + * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
3464 + * @param string $meta_key Imported destination key.
3465 + * @param bool $overwrite Whether the run replaces existing values.
3466 + * @return bool Whether the term's meta changed.
3467 + */
3468 + private function store_imported_term_value($term, $raw, $plugin, $meta_key, $overwrite) {
3469 + if ($raw === '') {
3470 + return false;
3471 + }
3472 +
3473 + $term_id = (int) $term->term_id;
3474 + $existing = get_term_meta($term_id, $meta_key, true);
3475 + if (!empty($existing) && !$overwrite) {
3476 + return false;
3477 + }
3478 +
3479 + $resolved = $this->resolve_term_placeholders($raw, $term, $plugin);
3480 +
3481 + // A token we do not know would otherwise be stored literally and render
3482 + // as '#taxonomy_title' on the archive — the defect this path exists to
3483 + // prevent. Skipping leaves the slot free for OTTO.
3484 + if ($this->has_unresolved_placeholders($resolved, $plugin)) {
3485 + return false;
3486 + }
3487 +
3488 + if (trim($resolved) !== '') {
3489 + $sanitized = $this->sanitize_imported_value($resolved, $meta_key);
3490 +
3491 + // See store_imported_post_value(): a value the source supplied but
3492 + // sanitization rejected must not blank an existing one, and must
3493 + // not count as a successful update.
3494 + if ($sanitized === '') {
3495 + return false;
3496 + }
3497 +
3498 + // update_term_meta() unslashes what it stores; slash first so a
3499 + // backslash in the value survives the round trip.
3500 + update_term_meta($term_id, $meta_key, wp_slash($sanitized));
3501 + return true;
3502 + }
3503 +
3504 + if ($overwrite && !empty($existing)) {
3505 + delete_term_meta($term_id, $meta_key);
3506 + return true;
3507 + }
3508 +
3509 + return false;
3510 + }
3511 +
3512 + /**
3513 + * Add unresolved-placeholder reporting to an import result.
3514 + *
3515 + * @param array $result Result array to augment.
3516 + * @param array $unresolved Failures collected during the batch.
3517 + * @return array Result with the report attached.
3518 + */
3519 + private function attach_unresolved_report(array $result, array $unresolved) {
3520 + $count = count($unresolved);
3521 + $result['unresolved'] = $count;
3522 +
3523 + if ($count === 0) {
3524 + return $result;
3525 + }
3526 +
3527 + // Keep the payload small — a few examples are enough to diagnose.
3528 + $result['unresolved_samples'] = array_slice($unresolved, 0, 5);
3529 +
3530 + if (!empty($result['message'])) {
3531 + $result['message'] .= sprintf(
3532 + ' %d value(s) skipped because placeholders could not be resolved.',
3533 + $count
3534 + );
3535 + }
3536 +
3537 + return $result;
3538 + }
3539 +
3540 + /**
1811 3541 * Import SEO Metadata (Titles and Descriptions)
1812 3542 * Supports batch processing via AJAX
1813 3543 *
1814 3544 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
@@ -1892,22 +3622,23 @@
1892 3622 ];
1893 3623 }
1894 3624
1895 3625 // Get total count (for progress tracking)
1896 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1897 - $total_posts = (int) $wpdb->get_var($wpdb->prepare("
3626 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3627 + $total_posts = (int) $wpdb->get_var($wpdb->prepare('
1898 3628 SELECT COUNT(DISTINCT post_id)
1899 - FROM {$wpdb->postmeta}
1900 - WHERE meta_key IN ($meta_keys_placeholders)
3629 + FROM ' . $wpdb->postmeta . '
3630 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1901 3631 AND meta_value != ''
1902 3632 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1903 3633 ", $meta_keys));
1904 3634
1905 3635 // Get batch of posts with Yoast data
1906 - $posts = $wpdb->get_results($wpdb->prepare("
3636 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3637 + $posts = $wpdb->get_results($wpdb->prepare('
1907 3638 SELECT DISTINCT post_id
1908 - FROM {$wpdb->postmeta}
1909 - WHERE meta_key IN ($meta_keys_placeholders)
3639 + FROM ' . $wpdb->postmeta . '
3640 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
1910 3641 AND meta_value != ''
1911 3642 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1912 3643 ORDER BY post_id ASC
1913 3644 LIMIT %d OFFSET %d
@@ -1914,8 +3645,9 @@
1914 3645 ", array_merge($meta_keys, [$batch_size, $offset])));
1915 3646
1916 3647 $imported_count = 0;
1917 3648 $skipped_count = 0;
3649 + $unresolved = [];
1918 3650
1919 3651 foreach ($posts as $post_obj) {
1920 3652 $post_id = $post_obj->post_id;
1921 3653 $updated = false;
@@ -1923,15 +3655,16 @@
1923 3655 // Import title
1924 3656 if ($import_titles) {
1925 3657 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
1926 3658 if (!empty($yoast_title)) {
1927 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
1928 -
1929 - if (empty($existing_title) || $overwrite) {
1930 - $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
1931 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
1932 - $updated = true;
1933 - }
3659 + $updated = $this->store_imported_post_value(
3660 + $post_id,
3661 + (string) $yoast_title,
3662 + 'yoast',
3663 + Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3664 + $overwrite,
3665 + $unresolved
3666 + );
1934 3667 }
1935 3668 }
1936 3669
1937 3670 // Import description
@@ -1937,15 +3670,16 @@
1937 3670 // Import description
1938 3671 if ($import_descriptions) {
1939 3672 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
1940 3673 if (!empty($yoast_desc)) {
1941 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
1942 -
1943 - if (empty($existing_desc) || $overwrite) {
1944 - $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
1945 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
1946 - $updated = true;
1947 - }
3674 + $updated = $this->store_imported_post_value(
3675 + $post_id,
3676 + (string) $yoast_desc,
3677 + 'yoast',
3678 + Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3679 + $overwrite,
3680 + $unresolved
3681 + ) || $updated;
1948 3682 }
1949 3683 }
1950 3684
1951 3685 // Import social text
@@ -1951,44 +3685,52 @@
1951 3685 // Import social text
1952 3686 if ($import_social_text) {
1953 3687 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
1954 3688 if (!empty($yoast_og_title)) {
1955 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
1956 - if (empty($existing) || $overwrite) {
1957 - $yoast_og_title = $this->resolve_seo_placeholders($yoast_og_title, $post_id, 'yoast');
1958 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($yoast_og_title));
1959 - $updated = true;
1960 - }
3689 + $updated = $this->store_imported_post_value(
3690 + $post_id,
3691 + (string) $yoast_og_title,
3692 + 'yoast',
3693 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
3694 + $overwrite,
3695 + $unresolved
3696 + ) || $updated;
1961 3697 }
1962 3698
1963 3699 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
1964 3700 if (!empty($yoast_og_desc)) {
1965 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
1966 - if (empty($existing) || $overwrite) {
1967 - $yoast_og_desc = $this->resolve_seo_placeholders($yoast_og_desc, $post_id, 'yoast');
1968 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($yoast_og_desc));
1969 - $updated = true;
1970 - }
3701 + $updated = $this->store_imported_post_value(
3702 + $post_id,
3703 + (string) $yoast_og_desc,
3704 + 'yoast',
3705 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
3706 + $overwrite,
3707 + $unresolved
3708 + ) || $updated;
1971 3709 }
1972 3710
1973 3711 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
1974 3712 if (!empty($yoast_tw_title)) {
1975 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
1976 - if (empty($existing) || $overwrite) {
1977 - $yoast_tw_title = $this->resolve_seo_placeholders($yoast_tw_title, $post_id, 'yoast');
1978 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($yoast_tw_title));
1979 - $updated = true;
1980 - }
3713 + $updated = $this->store_imported_post_value(
3714 + $post_id,
3715 + (string) $yoast_tw_title,
3716 + 'yoast',
3717 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
3718 + $overwrite,
3719 + $unresolved
3720 + ) || $updated;
1981 3721 }
1982 3722
1983 3723 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
1984 3724 if (!empty($yoast_tw_desc)) {
1985 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
1986 - if (empty($existing) || $overwrite) {
1987 - $yoast_tw_desc = $this->resolve_seo_placeholders($yoast_tw_desc, $post_id, 'yoast');
1988 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($yoast_tw_desc));
1989 - $updated = true;
1990 - }
3725 + $updated = $this->store_imported_post_value(
3726 + $post_id,
3727 + (string) $yoast_tw_desc,
3728 + 'yoast',
3729 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
3730 + $overwrite,
3731 + $unresolved
3732 + ) || $updated;
1991 3733 }
1992 3734 }
1993 3735
1994 3736 // Import social images
@@ -1994,22 +3736,28 @@
1994 3736 // Import social images
1995 3737 if ($import_social_images) {
1996 3738 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
1997 3739 if (!empty($yoast_og_image)) {
1998 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
1999 - if (empty($existing_og) || $overwrite) {
2000 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($yoast_og_image));
2001 - $updated = true;
2002 - }
3740 + $updated = $this->store_imported_post_value(
3741 + $post_id,
3742 + (string) $yoast_og_image,
3743 + 'yoast',
3744 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
3745 + $overwrite,
3746 + $unresolved
3747 + ) || $updated;
2003 3748 }
2004 3749
2005 3750 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
2006 3751 if (!empty($yoast_twitter_image)) {
2007 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2008 - if (empty($existing_twitter) || $overwrite) {
2009 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($yoast_twitter_image));
2010 - $updated = true;
2011 - }
3752 + $updated = $this->store_imported_post_value(
3753 + $post_id,
3754 + (string) $yoast_twitter_image,
3755 + 'yoast',
3756 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
3757 + $overwrite,
3758 + $unresolved
3759 + ) || $updated;
2012 3760 }
2013 3761 }
2014 3762
2015 3763 if ($updated) {
@@ -2021,9 +3769,9 @@
2021 3769
2022 3770 $processed = $offset + count($posts);
2023 3771 $is_complete = $processed >= $total_posts;
2024 3772
2025 - return [
3773 + return $this->attach_unresolved_report([
2026 3774 'success' => true,
2027 3775 'imported' => $imported_count,
2028 3776 'skipped' => $skipped_count,
2029 3777 'total' => $total_posts,
@@ -2032,9 +3780,9 @@
2032 3780 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2033 3781 'message' => $is_complete
2034 3782 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2035 3783 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2036 - ];
3784 + ], $unresolved);
2037 3785 }
2038 3786
2039 3787 /**
2040 3788 * Import SEO metadata from Rank Math
@@ -2077,22 +3825,23 @@
2077 3825 ];
2078 3826 }
2079 3827
2080 3828 // Get total count
2081 - $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
2082 - $total_posts = (int) $wpdb->get_var($wpdb->prepare("
3829 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3830 + $total_posts = (int) $wpdb->get_var($wpdb->prepare('
2083 3831 SELECT COUNT(DISTINCT post_id)
2084 - FROM {$wpdb->postmeta}
2085 - WHERE meta_key IN ($meta_keys_placeholders)
3832 + FROM ' . $wpdb->postmeta . '
3833 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2086 3834 AND meta_value != ''
2087 3835 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2088 3836 ", $meta_keys));
2089 3837
2090 3838 // Get batch of posts
2091 - $posts = $wpdb->get_results($wpdb->prepare("
3839 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
3840 + $posts = $wpdb->get_results($wpdb->prepare('
2092 3841 SELECT DISTINCT post_id
2093 - FROM {$wpdb->postmeta}
2094 - WHERE meta_key IN ($meta_keys_placeholders)
3842 + FROM ' . $wpdb->postmeta . '
3843 + WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
2095 3844 AND meta_value != ''
2096 3845 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2097 3846 ORDER BY post_id ASC
2098 3847 LIMIT %d OFFSET %d
@@ -2099,8 +3848,9 @@
2099 3848 ", array_merge($meta_keys, [$batch_size, $offset])));
2100 3849
2101 3850 $imported_count = 0;
2102 3851 $skipped_count = 0;
3852 + $unresolved = [];
2103 3853
2104 3854 foreach ($posts as $post_obj) {
2105 3855 $post_id = $post_obj->post_id;
2106 3856 $updated = false;
@@ -2108,15 +3858,16 @@
2108 3858 // Import title
2109 3859 if ($import_titles) {
2110 3860 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2111 3861 if (!empty($rm_title)) {
2112 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2113 -
2114 - if (empty($existing_title) || $overwrite) {
2115 - $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2116 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2117 - $updated = true;
2118 - }
3862 + $updated = $this->store_imported_post_value(
3863 + $post_id,
3864 + (string) $rm_title,
3865 + 'rankmath',
3866 + Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
3867 + $overwrite,
3868 + $unresolved
3869 + );
2119 3870 }
2120 3871 }
2121 3872
2122 3873 // Import description
@@ -2122,15 +3873,16 @@
2122 3873 // Import description
2123 3874 if ($import_descriptions) {
2124 3875 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2125 3876 if (!empty($rm_desc)) {
2126 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2127 -
2128 - if (empty($existing_desc) || $overwrite) {
2129 - $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2130 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2131 - $updated = true;
2132 - }
3877 + $updated = $this->store_imported_post_value(
3878 + $post_id,
3879 + (string) $rm_desc,
3880 + 'rankmath',
3881 + Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
3882 + $overwrite,
3883 + $unresolved
3884 + ) || $updated;
2133 3885 }
2134 3886 }
2135 3887
2136 3888 // Import social text
@@ -2136,44 +3888,52 @@
2136 3888 // Import social text
2137 3889 if ($import_social_text) {
2138 3890 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
2139 3891 if (!empty($rm_og_title)) {
2140 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2141 - if (empty($existing) || $overwrite) {
2142 - $rm_og_title = $this->resolve_seo_placeholders($rm_og_title, $post_id, 'rankmath');
2143 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($rm_og_title));
2144 - $updated = true;
2145 - }
3892 + $updated = $this->store_imported_post_value(
3893 + $post_id,
3894 + (string) $rm_og_title,
3895 + 'rankmath',
3896 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
3897 + $overwrite,
3898 + $unresolved
3899 + ) || $updated;
2146 3900 }
2147 3901
2148 3902 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
2149 3903 if (!empty($rm_og_desc)) {
2150 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2151 - if (empty($existing) || $overwrite) {
2152 - $rm_og_desc = $this->resolve_seo_placeholders($rm_og_desc, $post_id, 'rankmath');
2153 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($rm_og_desc));
2154 - $updated = true;
2155 - }
3904 + $updated = $this->store_imported_post_value(
3905 + $post_id,
3906 + (string) $rm_og_desc,
3907 + 'rankmath',
3908 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
3909 + $overwrite,
3910 + $unresolved
3911 + ) || $updated;
2156 3912 }
2157 3913
2158 3914 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
2159 3915 if (!empty($rm_tw_title)) {
2160 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2161 - if (empty($existing) || $overwrite) {
2162 - $rm_tw_title = $this->resolve_seo_placeholders($rm_tw_title, $post_id, 'rankmath');
2163 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($rm_tw_title));
2164 - $updated = true;
2165 - }
3916 + $updated = $this->store_imported_post_value(
3917 + $post_id,
3918 + (string) $rm_tw_title,
3919 + 'rankmath',
3920 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
3921 + $overwrite,
3922 + $unresolved
3923 + ) || $updated;
2166 3924 }
2167 3925
2168 3926 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
2169 3927 if (!empty($rm_tw_desc)) {
2170 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2171 - if (empty($existing) || $overwrite) {
2172 - $rm_tw_desc = $this->resolve_seo_placeholders($rm_tw_desc, $post_id, 'rankmath');
2173 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($rm_tw_desc));
2174 - $updated = true;
2175 - }
3928 + $updated = $this->store_imported_post_value(
3929 + $post_id,
3930 + (string) $rm_tw_desc,
3931 + 'rankmath',
3932 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
3933 + $overwrite,
3934 + $unresolved
3935 + ) || $updated;
2176 3936 }
2177 3937 }
2178 3938
2179 3939 // Import social images
@@ -2179,22 +3939,28 @@
2179 3939 // Import social images
2180 3940 if ($import_social_images) {
2181 3941 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
2182 3942 if (!empty($rm_og_image)) {
2183 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2184 - if (empty($existing_og) || $overwrite) {
2185 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($rm_og_image));
2186 - $updated = true;
2187 - }
3943 + $updated = $this->store_imported_post_value(
3944 + $post_id,
3945 + (string) $rm_og_image,
3946 + 'rankmath',
3947 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
3948 + $overwrite,
3949 + $unresolved
3950 + ) || $updated;
2188 3951 }
2189 3952
2190 3953 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
2191 3954 if (!empty($rm_twitter_image)) {
2192 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2193 - if (empty($existing_twitter) || $overwrite) {
2194 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($rm_twitter_image));
2195 - $updated = true;
2196 - }
3955 + $updated = $this->store_imported_post_value(
3956 + $post_id,
3957 + (string) $rm_twitter_image,
3958 + 'rankmath',
3959 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
3960 + $overwrite,
3961 + $unresolved
3962 + ) || $updated;
2197 3963 }
2198 3964 }
2199 3965
2200 3966 if ($updated) {
@@ -2206,9 +3972,9 @@
2206 3972
2207 3973 $processed = $offset + count($posts);
2208 3974 $is_complete = $processed >= $total_posts;
2209 3975
2210 - return [
3976 + return $this->attach_unresolved_report([
2211 3977 'success' => true,
2212 3978 'imported' => $imported_count,
2213 3979 'skipped' => $skipped_count,
2214 3980 'total' => $total_posts,
@@ -2217,9 +3983,9 @@
2217 3983 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2218 3984 'message' => $is_complete
2219 3985 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2220 3986 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2221 - ];
3987 + ], $unresolved);
2222 3988 }
2223 3989
2224 3990 /**
2225 3991 * Import SEO metadata from All in One SEO
@@ -2237,9 +4003,9 @@
2237 4003 $overwrite = (bool) $options['overwrite_existing'];
2238 4004
2239 4005 // Check if AIOSEO table exists
2240 4006 $table = $wpdb->prefix . 'aioseo_posts';
2241 - if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
4007 + 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
2242 4008 return [
2243 4009 'success' => false,
2244 4010 'message' => 'AIOSEO database table not found.'
2245 4011 ];
@@ -2251,93 +4017,209 @@
2251 4017 'message' => 'No import options selected.'
2252 4018 ];
2253 4019 }
2254 4020
2255 - // Build WHERE clause
2256 - $where_conditions = [];
4021 + // Columns read off the AIOSEO row, excluding post_id. The same list gates
4022 + // row selection, so deriving both from one array keeps them in step — a
4023 + // WHERE that admitted a column the SELECT did not fetch would read a
4024 + // property that isn't there.
4025 + $value_columns = [];
2257 4026 if ($import_titles) {
2258 - $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
4027 + $value_columns[] = 'title';
2259 4028 }
2260 4029 if ($import_descriptions) {
2261 - $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
4030 + $value_columns[] = 'description';
2262 4031 }
2263 4032 if ($import_social_text) {
2264 - $where_conditions[] = '(og_title IS NOT NULL AND og_title != \'\')';
2265 - $where_conditions[] = '(og_description IS NOT NULL AND og_description != \'\')';
2266 - $where_conditions[] = '(twitter_title IS NOT NULL AND twitter_title != \'\')';
2267 - $where_conditions[] = '(twitter_description IS NOT NULL AND twitter_description != \'\')';
4033 + $value_columns[] = 'og_title';
4034 + $value_columns[] = 'og_description';
4035 + $value_columns[] = 'twitter_title';
4036 + $value_columns[] = 'twitter_description';
2268 4037 }
2269 4038 if ($import_social_images) {
2270 - $where_conditions[] = '(og_image_custom_url IS NOT NULL AND og_image_custom_url != \'\')';
2271 - $where_conditions[] = '(twitter_image_custom_url IS NOT NULL AND twitter_image_custom_url != \'\')';
4039 + $value_columns[] = 'og_image_custom_url';
4040 + $value_columns[] = 'twitter_image_custom_url';
2272 4041 }
2273 - $where_clause = implode(' OR ', $where_conditions);
2274 4042
2275 - // Build SELECT columns
2276 - $select_columns = ['post_id'];
2277 - if ($import_titles) {
2278 - $select_columns[] = 'title';
4043 + $build_has_value_clause = function ($prefix) use ($value_columns) {
4044 + $conditions = [];
4045 + foreach ($value_columns as $column) {
4046 + $col = $prefix . $column;
4047 + $conditions[] = "({$col} IS NOT NULL AND {$col} != '')";
4048 + }
4049 + return implode(' OR ', $conditions);
4050 + };
4051 +
4052 + $where_clause = $build_has_value_clause('');
4053 + $joined_has_value = $build_has_value_clause('a.');
4054 +
4055 + // AIOSEO serves a title and description for every post through its
4056 + // site-wide templates, not only for posts carrying a per-post override.
4057 + // Walking the AIOSEO table alone therefore reaches almost nothing on a
4058 + // typical site — overrides are the exception, templates are the rule. So
4059 + // when templates are in play, anchor the walk on the posts table and join
4060 + // the AIOSEO row in; the per-post value still wins wherever it exists.
4061 + $templates = ($import_titles || $import_descriptions)
4062 + ? $this->get_aioseo_post_type_templates()
4063 + : [];
4064 +
4065 + $template_post_types = [];
4066 + foreach ($templates as $post_type => $template) {
4067 + if (($import_titles && $template['title'] !== '')
4068 + || ($import_descriptions && $template['description'] !== '')) {
4069 + $template_post_types[] = $post_type;
4070 + }
2279 4071 }
2280 - if ($import_descriptions) {
2281 - $select_columns[] = 'description';
2282 - }
2283 - if ($import_social_text) {
2284 - $select_columns[] = 'og_title';
2285 - $select_columns[] = 'og_description';
2286 - $select_columns[] = 'twitter_title';
2287 - $select_columns[] = 'twitter_description';
2288 - }
2289 - if ($import_social_images) {
2290 - $select_columns[] = 'og_image_custom_url';
2291 - $select_columns[] = 'twitter_image_custom_url';
2292 - }
2293 - $select_clause = implode(', ', $select_columns);
2294 4072
2295 - // Get total count
2296 - $total_posts = (int) $wpdb->get_var("
2297 - SELECT COUNT(post_id)
2298 - FROM {$table}
2299 - WHERE ({$where_clause})
2300 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2301 - ");
4073 + if (!empty($template_post_types)) {
4074 + $type_placeholders = implode(', ', array_fill(0, count($template_post_types), '%s'));
2302 4075
2303 - // Get batch of posts
2304 - $posts = $wpdb->get_results($wpdb->prepare("
2305 - SELECT {$select_clause}
2306 - FROM {$table}
2307 - WHERE ({$where_clause})
2308 - AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2309 - ORDER BY post_id ASC
2310 - LIMIT %d OFFSET %d
2311 - ", $batch_size, $offset));
4076 + // Template mode visits every published post, not just the few with an
4077 + // override, and AIOSEO's default description template is #post_excerpt
4078 + // — which on a post without a manual excerpt runs the whole the_content
4079 + // filter chain. On page-builder content that is a large fraction of a
4080 + // second each, so the caller's batch of 50 can outlive a proxy read
4081 + // timeout. Smaller batches cost more requests and finish.
4082 + $batch_size = min($batch_size, 20);
2312 4083
4084 + // COUNT and SELECT must describe the identical row set. is_complete
4085 + // compares (offset + rows returned) against this count and the client
4086 + // loops until they meet, with no iteration cap — a count broader than
4087 + // the select spins forever, a narrower one stops early and silently
4088 + // drops the remainder. Counting the joined set rather than the posts
4089 + // table alone also keeps cardinality identical if the AIOSEO table
4090 + // ever held two rows for one post: its post_id index is not unique.
4091 + //
4092 + // The post-type list governs which posts get a *template*, so it must
4093 + // not also decide which posts get read at all: a post type with no
4094 + // template — one AIOSEO never configured, one with SEO switched off,
4095 + // one registered non-public — can still carry per-post overrides that
4096 + // the override-only walk always imported. The second arm keeps every
4097 + // row holding real AIOSEO data in scope whatever its post type, so
4098 + // adding a template never removes anything from the import.
4099 + // $joined_has_value is built only from the fixed $value_columns
4100 + // allowlist above; table names are inline literals.
4101 + $from_clause = "FROM {$wpdb->posts} p
4102 + LEFT JOIN {$wpdb->prefix}aioseo_posts a ON a.post_id = p.ID
4103 + WHERE p.post_status = 'publish'
4104 + AND (p.post_type IN (" . implode(', ', array_fill(0, count($template_post_types), '%s')) . ") OR ({$joined_has_value}))";
4105 +
4106 + // Assembled from the literal fragments above; post types bind via prepare().
4107 + $count_query = "SELECT COUNT(*) {$from_clause}";
4108 + $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()
4109 +
4110 + $joined_columns = array_map(
4111 + function ($column) {
4112 + return "a.{$column}";
4113 + },
4114 + $value_columns
4115 + );
4116 + // At least one import flag is set — the "no import options selected"
4117 + // guard above returns before this point — so there is always at
4118 + // least one value column to select.
4119 + $joined_select = 'p.ID AS post_id, p.post_type AS post_type, '
4120 + . implode(', ', $joined_columns);
4121 +
4122 + // Assembled from the literal fragments above; post types bind via prepare().
4123 + $posts_query = "SELECT {$joined_select}
4124 + {$from_clause}
4125 + ORDER BY p.ID ASC
4126 + LIMIT %d OFFSET %d";
4127 + $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()
4128 + } else {
4129 + // No usable templates — e.g. only the social options are selected, or
4130 + // aioseo_options_dynamic is missing. Fall back to the original
4131 + // override-only walk so those sites behave exactly as before.
4132 + $select_clause = implode(', ', array_merge(['post_id'], $value_columns));
4133 +
4134 + // $where_clause / $select_clause are built only from the fixed
4135 + // $value_columns allowlist above.
4136 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where_clause is built only from the fixed $value_columns allowlist above
4137 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
4138 + $total_posts = (int) $wpdb->get_var("
4139 + SELECT COUNT(post_id)
4140 + FROM {$wpdb->prefix}aioseo_posts
4141 + WHERE ({$where_clause})
4142 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
4143 + ");
4144 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
4145 +
4146 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select_clause / $where_clause are built only from the fixed $value_columns allowlist above
4147 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
4148 + $posts = $wpdb->get_results($wpdb->prepare("
4149 + SELECT {$select_clause}
4150 + FROM {$wpdb->prefix}aioseo_posts
4151 + WHERE ({$where_clause})
4152 + AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
4153 + ORDER BY post_id ASC
4154 + LIMIT %d OFFSET %d
4155 + ", $batch_size, $offset));
4156 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
4157 + }
4158 +
2313 4159 $imported_count = 0;
2314 4160 $skipped_count = 0;
4161 + $unresolved = [];
2315 4162
2316 4163 foreach ($posts as $aioseo_data) {
2317 4164 $post_id = $aioseo_data->post_id;
2318 4165 $updated = false;
2319 4166
2320 - // Import title
2321 - if ($import_titles && !empty($aioseo_data->title)) {
2322 - $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
4167 + // Present only on the template-anchored query; the fallback walk has
4168 + // no join to read it from.
4169 + $post_type = isset($aioseo_data->post_type)
4170 + ? (string) $aioseo_data->post_type
4171 + : (string) get_post_type($post_id);
2323 4172
2324 - if (empty($existing_title) || $overwrite) {
2325 - $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2326 - update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2327 - $updated = true;
4173 + $title_template = $templates[$post_type]['title'] ?? '';
4174 + $desc_template = $templates[$post_type]['description'] ?? '';
4175 +
4176 + // Import title.
4177 + //
4178 + // The per-post value wins; the site-wide template is the fallback,
4179 + // matching the order AIOSEO itself resolves in. Destination and
4180 + // write rules come from store_imported_post_value().
4181 + if ($import_titles) {
4182 + $raw_title = !empty($aioseo_data->title) ? (string) $aioseo_data->title : '';
4183 +
4184 + if ($raw_title === '' && $title_template !== '') {
4185 + $raw_title = $title_template;
2328 4186 }
4187 +
4188 + $updated = $this->store_imported_post_value(
4189 + $post_id,
4190 + $raw_title,
4191 + 'aioseo',
4192 + Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4193 + $overwrite,
4194 + $unresolved
4195 + );
2329 4196 }
2330 4197
2331 - // Import description
2332 - if ($import_descriptions && !empty($aioseo_data->description)) {
2333 - $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
4198 + // Import description — same reasoning as the title above.
4199 + if ($import_descriptions) {
4200 + $raw_desc = !empty($aioseo_data->description) ? (string) $aioseo_data->description : '';
4201 + $desc_from_template = false;
2334 4202
2335 - if (empty($existing_desc) || $overwrite) {
2336 - $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2337 - update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2338 - $updated = true;
4203 + if ($raw_desc === '' && $desc_template !== '') {
4204 + $raw_desc = $desc_template;
4205 + $desc_from_template = true;
2339 4206 }
4207 +
4208 + // Only template-derived values are trimmed. A '#post_content'
4209 + // template resolves to the whole body, and AIOSEO emits that
4210 + // untruncated — copying it verbatim would give every page a
4211 + // multi-thousand-character description. What the customer
4212 + // typed on the post is left exactly as they wrote it.
4213 + $updated = $this->store_imported_post_value(
4214 + $post_id,
4215 + $raw_desc,
4216 + 'aioseo',
4217 + Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4218 + $overwrite,
4219 + $unresolved,
4220 + $desc_from_template
4221 + ) || $updated;
2340 4222 }
2341 4223
2342 4224 // Import social text
2343 4225 if ($import_social_text) {
@@ -2342,44 +4224,52 @@
2342 4224 // Import social text
2343 4225 if ($import_social_text) {
2344 4226 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
2345 4227 if (!empty($aioseo_og_title)) {
2346 - $existing = get_post_meta($post_id, '_metasync_og_title', true);
2347 - if (empty($existing) || $overwrite) {
2348 - $aioseo_og_title = $this->resolve_seo_placeholders($aioseo_og_title, $post_id, 'aioseo');
2349 - update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($aioseo_og_title));
2350 - $updated = true;
2351 - }
4228 + $updated = $this->store_imported_post_value(
4229 + $post_id,
4230 + (string) $aioseo_og_title,
4231 + 'aioseo',
4232 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
4233 + $overwrite,
4234 + $unresolved
4235 + ) || $updated;
2352 4236 }
2353 4237
2354 4238 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
2355 4239 if (!empty($aioseo_og_desc)) {
2356 - $existing = get_post_meta($post_id, '_metasync_og_description', true);
2357 - if (empty($existing) || $overwrite) {
2358 - $aioseo_og_desc = $this->resolve_seo_placeholders($aioseo_og_desc, $post_id, 'aioseo');
2359 - update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($aioseo_og_desc));
2360 - $updated = true;
2361 - }
4240 + $updated = $this->store_imported_post_value(
4241 + $post_id,
4242 + (string) $aioseo_og_desc,
4243 + 'aioseo',
4244 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
4245 + $overwrite,
4246 + $unresolved
4247 + ) || $updated;
2362 4248 }
2363 4249
2364 4250 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
2365 4251 if (!empty($aioseo_tw_title)) {
2366 - $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2367 - if (empty($existing) || $overwrite) {
2368 - $aioseo_tw_title = $this->resolve_seo_placeholders($aioseo_tw_title, $post_id, 'aioseo');
2369 - update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($aioseo_tw_title));
2370 - $updated = true;
2371 - }
4252 + $updated = $this->store_imported_post_value(
4253 + $post_id,
4254 + (string) $aioseo_tw_title,
4255 + 'aioseo',
4256 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
4257 + $overwrite,
4258 + $unresolved
4259 + ) || $updated;
2372 4260 }
2373 4261
2374 4262 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
2375 4263 if (!empty($aioseo_tw_desc)) {
2376 - $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2377 - if (empty($existing) || $overwrite) {
2378 - $aioseo_tw_desc = $this->resolve_seo_placeholders($aioseo_tw_desc, $post_id, 'aioseo');
2379 - update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($aioseo_tw_desc));
2380 - $updated = true;
2381 - }
4264 + $updated = $this->store_imported_post_value(
4265 + $post_id,
4266 + (string) $aioseo_tw_desc,
4267 + 'aioseo',
4268 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
4269 + $overwrite,
4270 + $unresolved
4271 + ) || $updated;
2382 4272 }
2383 4273 }
2384 4274
2385 4275 // Import social images
@@ -2385,22 +4275,28 @@
2385 4275 // Import social images
2386 4276 if ($import_social_images) {
2387 4277 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
2388 4278 if (!empty($aioseo_og_image)) {
2389 - $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2390 - if (empty($existing_og) || $overwrite) {
2391 - update_post_meta($post_id, '_metasync_og_image', esc_url_raw($aioseo_og_image));
2392 - $updated = true;
2393 - }
4279 + $updated = $this->store_imported_post_value(
4280 + $post_id,
4281 + (string) $aioseo_og_image,
4282 + 'aioseo',
4283 + Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
4284 + $overwrite,
4285 + $unresolved
4286 + ) || $updated;
2394 4287 }
2395 4288
2396 4289 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
2397 4290 if (!empty($aioseo_twitter_image)) {
2398 - $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2399 - if (empty($existing_twitter) || $overwrite) {
2400 - update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($aioseo_twitter_image));
2401 - $updated = true;
2402 - }
4291 + $updated = $this->store_imported_post_value(
4292 + $post_id,
4293 + (string) $aioseo_twitter_image,
4294 + 'aioseo',
4295 + Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
4296 + $overwrite,
4297 + $unresolved
4298 + ) || $updated;
2403 4299 }
2404 4300 }
2405 4301
2406 4302 if ($updated) {
@@ -2410,11 +4306,15 @@
2410 4306 }
2411 4307 }
2412 4308
2413 4309 $processed = $offset + count($posts);
2414 - $is_complete = $processed >= $total_posts;
4310 + // An empty batch always ends the run. The client loops on !is_complete
4311 + // with no iteration cap, so if the count and the select ever disagreed,
4312 + // a batch returning no rows would leave processed stuck below total and
4313 + // spin forever against the server.
4314 + $is_complete = $processed >= $total_posts || empty($posts);
2415 4315
2416 - return [
4316 + return $this->attach_unresolved_report([
2417 4317 'success' => true,
2418 4318 'imported' => $imported_count,
2419 4319 'skipped' => $skipped_count,
2420 4320 'total' => $total_posts,
@@ -2423,9 +4323,9 @@
2423 4323 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2424 4324 'message' => $is_complete
2425 4325 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2426 4326 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2427 - ];
4327 + ], $unresolved);
2428 4328 }
2429 4329
2430 4330 /**
2431 4331 * Import term-level SEO metadata from a third-party SEO plugin into
@@ -2507,16 +4407,22 @@
2507 4407 'message' => $terms->get_error_message(),
2508 4408 ];
2509 4409 }
2510 4410
4411 + // Title and description are handled separately below: they go to the
4412 + // imported keys so they lose to OTTO, and they need their tokens
4413 + // resolved. The rest keep their own keys.
2511 4414 $field_map = [
2512 - 'wpseo_title' => '_metasync_metatitle',
2513 - 'wpseo_desc' => '_metasync_metadesc',
2514 4415 'wpseo_opengraph-title' => '_metasync_og_title',
2515 4416 'wpseo_opengraph-description' => '_metasync_og_description',
2516 4417 'wpseo_canonical' => '_metasync_canonical_url',
2517 4418 ];
2518 4419
4420 + $imported_map = [
4421 + 'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4422 + 'wpseo_desc' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4423 + ];
4424 +
2519 4425 $imported = 0;
2520 4426 $skipped = 0;
2521 4427
2522 4428 foreach ($terms as $term) {
@@ -2527,8 +4433,18 @@
2527 4433 if (!is_array($yoast_meta)) {
2528 4434 $yoast_meta = [];
2529 4435 }
2530 4436
4437 + foreach ($imported_map as $src_key => $dest_key) {
4438 + $term_updated = $this->store_imported_term_value(
4439 + $term,
4440 + isset($yoast_meta[$src_key]) ? (string) $yoast_meta[$src_key] : '',
4441 + 'yoast',
4442 + $dest_key,
4443 + $overwrite
4444 + ) || $term_updated;
4445 + }
4446 +
2531 4447 foreach ($field_map as $src_key => $dest_key) {
2532 4448 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2533 4449 if ($src_value === '' || $src_value === null) {
2534 4450 continue;
@@ -2538,8 +4454,21 @@
2538 4454 if (!empty($existing) && !$overwrite) {
2539 4455 continue;
2540 4456 }
2541 4457
4458 + // Validate canonical: never import a corrupted value.
4459 + if ($dest_key === '_metasync_canonical_url') {
4460 + $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
4461 + if ($src_value === '') {
4462 + continue;
4463 + }
4464 + } else {
4465 + $src_value = $this->resolve_term_placeholders($src_value, $term, 'yoast');
4466 + if (trim($src_value) === '') {
4467 + continue;
4468 + }
4469 + }
4470 +
2542 4471 update_term_meta($term->term_id, $dest_key, $src_value);
2543 4472 $term_updated = true;
2544 4473 }
2545 4474
@@ -2597,16 +4526,21 @@
2597 4526 'message' => $terms->get_error_message(),
2598 4527 ];
2599 4528 }
2600 4529
4530 + // See import_yoast_term_meta(): title and description are routed to the
4531 + // imported keys and token-resolved; the rest keep their own keys.
2601 4532 $field_map = [
2602 - 'rank_math_title' => '_metasync_metatitle',
2603 - 'rank_math_description' => '_metasync_metadesc',
2604 4533 'rank_math_facebook_title' => '_metasync_og_title',
2605 4534 'rank_math_facebook_description' => '_metasync_og_description',
2606 4535 'rank_math_canonical_url' => '_metasync_canonical_url',
2607 4536 ];
2608 4537
4538 + $imported_map = [
4539 + 'rank_math_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4540 + 'rank_math_description' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4541 + ];
4542 +
2609 4543 $imported = 0;
2610 4544 $skipped = 0;
2611 4545
2612 4546 foreach ($terms as $term) {
@@ -2611,8 +4545,18 @@
2611 4545
2612 4546 foreach ($terms as $term) {
2613 4547 $term_updated = false;
2614 4548
4549 + foreach ($imported_map as $src_key => $dest_key) {
4550 + $term_updated = $this->store_imported_term_value(
4551 + $term,
4552 + (string) get_term_meta($term->term_id, $src_key, true),
4553 + 'rankmath',
4554 + $dest_key,
4555 + $overwrite
4556 + ) || $term_updated;
4557 + }
4558 +
2615 4559 foreach ($field_map as $src_key => $dest_key) {
2616 4560 $src_value = get_term_meta($term->term_id, $src_key, true);
2617 4561 if ($src_value === '' || $src_value === null) {
2618 4562 continue;
@@ -2622,8 +4566,21 @@
2622 4566 if (!empty($existing) && !$overwrite) {
2623 4567 continue;
2624 4568 }
2625 4569
4570 + // Validate canonical: never import a corrupted value.
4571 + if ($dest_key === '_metasync_canonical_url') {
4572 + $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
4573 + if ($src_value === '') {
4574 + continue;
4575 + }
4576 + } else {
4577 + $src_value = $this->resolve_term_placeholders($src_value, $term, 'rankmath');
4578 + if (trim($src_value) === '') {
4579 + continue;
4580 + }
4581 + }
4582 +
2626 4583 update_term_meta($term->term_id, $dest_key, $src_value);
2627 4584 $term_updated = true;
2628 4585 }
2629 4586
@@ -2671,9 +4628,9 @@
2671 4628 $overwrite = (bool) $options['overwrite_existing'];
2672 4629
2673 4630 $table = $wpdb->prefix . 'aioseo_terms';
2674 4631
2675 - $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
4632 + $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
2676 4633 if ($table_exists !== $table) {
2677 4634 return [
2678 4635 'success' => false,
2679 4636 'message' => 'AIOSEO terms table not found.',
@@ -2679,11 +4636,11 @@
2679 4636 'message' => 'AIOSEO terms table not found.',
2680 4637 ];
2681 4638 }
2682 4639
2683 - $rows = $wpdb->get_results($wpdb->prepare(
4640 + $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
2684 4641 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2685 - FROM {$table}
4642 + FROM {$wpdb->prefix}aioseo_terms
2686 4643 ORDER BY term_id ASC
2687 4644 LIMIT %d OFFSET %d",
2688 4645 $batch_size,
2689 4646 $offset
@@ -2697,18 +4654,41 @@
2697 4654 if ($term_id <= 0) {
2698 4655 continue;
2699 4656 }
2700 4657
4658 + // The resolver needs the term's own name and description, and a row
4659 + // can outlive the term it points at.
4660 + $term = get_term($term_id);
4661 + if (!$term || is_wp_error($term)) {
4662 + $skipped++;
4663 + continue;
4664 + }
4665 +
2701 4666 $term_updated = false;
2702 4667
4668 + // See import_yoast_term_meta(): title and description are routed to
4669 + // the imported keys and token-resolved; the rest keep their own keys.
2703 4670 $field_map = [
2704 - 'title' => '_metasync_metatitle',
2705 - 'description' => '_metasync_metadesc',
2706 4671 'og_title' => '_metasync_og_title',
2707 4672 'og_description' => '_metasync_og_description',
2708 4673 'canonical_url' => '_metasync_canonical_url',
2709 4674 ];
2710 4675
4676 + $imported_map = [
4677 + 'title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4678 + 'description' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4679 + ];
4680 +
4681 + foreach ($imported_map as $column => $dest_key) {
4682 + $term_updated = $this->store_imported_term_value(
4683 + $term,
4684 + isset($row->$column) ? (string) $row->$column : '',
4685 + 'aioseo',
4686 + $dest_key,
4687 + $overwrite
4688 + ) || $term_updated;
4689 + }
4690 +
2711 4691 foreach ($field_map as $column => $dest_key) {
2712 4692 $value = isset($row->$column) ? $row->$column : '';
2713 4693 if ($value === '' || $value === null) {
2714 4694 continue;
@@ -2716,8 +4696,21 @@
2716 4696
2717 4697 $existing = get_term_meta($term_id, $dest_key, true);
2718 4698 if (!empty($existing) && !$overwrite) {
2719 4699 continue;
4700 + }
4701 +
4702 + // Validate canonical: never import a corrupted value.
4703 + if ($dest_key === '_metasync_canonical_url') {
4704 + $value = Metasync_Canonical_Sanitizer::sanitize_for_save($value);
4705 + if ($value === '') {
4706 + continue;
4707 + }
4708 + } else {
4709 + $value = $this->resolve_term_placeholders($value, $term, 'aioseo');
4710 + if (trim($value) === '' || $this->has_unresolved_placeholders($value, 'aioseo')) {
4711 + continue;
4712 + }
2720 4713 }
2721 4714
2722 4715 update_term_meta($term_id, $dest_key, $value);
2723 4716 $term_updated = true;