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
metasync / includes / class-metasync-external-importer.php

class-metasync-external-importer.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-external-importer.php

4,745 lines 196.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Import data from other SEO plugins.
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 * @package Metasync
9 * @subpackage Metasync/includes
10 * @author Engineering Team <[email protected]>
11 */
12
13 // Abort if this file is accessed directly.
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 class Metasync_External_Importer
19 {
20 private $db_redirection;
21 private $redirection_importer;
22
23 public function __construct($db_redirection = null)
24 {
25 $this->db_redirection = $db_redirection;
26
27 // Initialize redirection importer if DB resource is provided
28 if ($this->db_redirection) {
29 require_once plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-importer.php';
30 $this->redirection_importer = new Metasync_Redirection_Importer($this->db_redirection);
31 }
32 }
33
34 /**
35 * Get available plugins for a specific import type
36 */
37 public function get_plugins_for_type($type)
38 {
39 $plugins = [
40 'yoast' => ['name' => 'Yoast SEO', 'constant' => 'WPSEO_VERSION'],
41 'rankmath' => ['name' => 'Rank Math', 'constant' => 'RANK_MATH_VERSION'],
42 'aioseo' => ['name' => 'All in One SEO', 'constant' => 'AIOSEO_VERSION'],
43 'redirection' => ['name' => 'Redirection', 'constant' => 'REDIRECTION_VERSION'],
44 'simple301' => ['name' => 'Simple 301 Redirects', 'constant' => 'SIMPLE_301_REDIRECTS_VERSION'],
45 ];
46
47 // Filter plugins based on type support
48 $supported_plugins = [];
49 switch ($type) {
50 case 'redirections':
51 if ($this->redirection_importer) {
52 return $this->redirection_importer->get_available_plugins();
53 }
54 return [];
55 case 'sitemap':
56 case 'robots':
57 // These types are generally supported by the main SEO plugins
58 $supported = ['yoast', 'rankmath', 'aioseo'];
59 foreach ($supported as $slug) {
60 $plugin = $plugins[$slug];
61 $is_active = defined($plugin['constant']);
62
63 // Basic data structure similar to redirection importer
64 $supported_plugins[$slug] = [
65 'name' => $plugin['name'],
66 'key' => $slug,
67 'installed' => $is_active,
68 'has_data' => $is_active, // Assume data exists if active for now
69 'count' => 0, // Count not applicable/calculated yet
70 'version' => $is_active ? constant($plugin['constant']) : ''
71 ];
72 }
73 break;
74
75 case 'schema':
76 // Check for actual per-post schema data
77 // Even if plugin is deactivated, we can still import the data
78 $supported = ['yoast', 'rankmath', 'aioseo'];
79 foreach ($supported as $slug) {
80 $plugin = $plugins[$slug];
81 $is_active = defined($plugin['constant']);
82 $count = 0;
83
84 // Always check for data, regardless of plugin activation status
85 $has_data = $this->check_schema_data($slug, $count);
86
87 $supported_plugins[$slug] = [
88 'name' => $plugin['name'],
89 'key' => $slug,
90 'installed' => $is_active,
91 'has_data' => $has_data,
92 'count' => $count,
93 'version' => $is_active ? constant($plugin['constant']) : ''
94 ];
95 }
96 break;
97
98 case 'indexation':
99 // Check for actual per-post SEO data
100 // Even if plugin is deactivated, we can still import the data
101 $supported = ['yoast', 'rankmath', 'aioseo'];
102 foreach ($supported as $slug) {
103 $plugin = $plugins[$slug];
104 $is_active = defined($plugin['constant']);
105 $count = 0;
106
107 // Always check for data, regardless of plugin activation status
108 $has_data = $this->check_indexation_data($slug, $count);
109
110 $supported_plugins[$slug] = [
111 'name' => $plugin['name'],
112 'key' => $slug,
113 'installed' => $is_active,
114 'has_data' => $has_data,
115 'count' => $count,
116 'version' => $is_active ? constant($plugin['constant']) : ''
117 ];
118 }
119 break;
120
121 case 'primary_category':
122 // Check for primary category data from other SEO plugins
123 $supported = ['yoast', 'rankmath', 'aioseo'];
124 foreach ($supported as $slug) {
125 $plugin = $plugins[$slug];
126 $is_active = defined($plugin['constant']);
127 $count = 0;
128
129 $has_data = $this->check_primary_category_data($slug, $count);
130
131 $supported_plugins[$slug] = [
132 'name' => $plugin['name'],
133 'key' => $slug,
134 'installed' => $is_active,
135 'has_data' => $has_data,
136 'count' => $count,
137 'version' => $is_active ? constant($plugin['constant']) : ''
138 ];
139 }
140 break;
141
142 case 'seo_metadata':
143 // Check for actual SEO metadata (titles and descriptions)
144 // Even if plugin is deactivated, we can still import the data
145 $supported = ['yoast', 'rankmath', 'aioseo'];
146 foreach ($supported as $slug) {
147 $plugin = $plugins[$slug];
148 $is_active = defined($plugin['constant']);
149 $count = 0;
150
151 // Always check for data, regardless of plugin activation status
152 $has_data = $this->check_seo_metadata($slug, $count);
153
154 $supported_plugins[$slug] = [
155 'name' => $plugin['name'],
156 'key' => $slug,
157 'installed' => $is_active,
158 'has_data' => $has_data,
159 'count' => $count,
160 'version' => $is_active ? constant($plugin['constant']) : ''
161 ];
162 }
163 break;
164 }
165
166 return $supported_plugins;
167 }
168
169 /**
170 * Check if indexation data exists for a plugin
171 */
172 private function check_indexation_data($plugin, &$count)
173 {
174 global $wpdb;
175
176 $count = 0;
177
178 switch ($plugin) {
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
181 $count = (int) $wpdb->get_var("
182 SELECT COUNT(DISTINCT post_id)
183 FROM {$wpdb->postmeta}
184 WHERE meta_key LIKE '_yoast_wpseo_%'
185 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
186 ");
187 break;
188
189 case 'rankmath':
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("
192 SELECT COUNT(DISTINCT post_id)
193 FROM {$wpdb->postmeta}
194 WHERE meta_key LIKE %s
195 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
196 ", 'rank_math_%'));
197 break;
198
199 case 'aioseo':
200 $table = $wpdb->prefix . 'aioseo_posts';
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
202 $count = (int) $wpdb->get_var("
203 SELECT COUNT(post_id)
204 FROM {$wpdb->prefix}aioseo_posts
205 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
206 ");
207 }
208 break;
209 }
210
211 return $count > 0;
212 }
213
214 /**
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.
224 */
225 private function check_schema_data($plugin, &$count)
226 {
227 $count = 0;
228 $importable_posts = [];
229
230 switch ($plugin) {
231 case 'yoast':
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 }
241 break;
242
243 case 'rankmath':
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 }
253 break;
254
255 case 'aioseo':
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 }
266 }
267 break;
268 }
269
270 $count = count($importable_posts);
271
272 return $count > 0;
273 }
274
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 /**
666 * Check if SEO metadata (titles and descriptions) exists for a plugin
667 */
668 private function check_seo_metadata($plugin, &$count)
669 {
670 global $wpdb;
671
672 $count = 0;
673
674 switch ($plugin) {
675 case 'yoast':
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
678 $count = (int) $wpdb->get_var("
679 SELECT COUNT(DISTINCT post_id)
680 FROM {$wpdb->postmeta}
681 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
682 AND meta_value != ''
683 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
684 ");
685 break;
686
687 case 'rankmath':
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
690 $count = (int) $wpdb->get_var("
691 SELECT COUNT(DISTINCT post_id)
692 FROM {$wpdb->postmeta}
693 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
694 AND meta_value != ''
695 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
696 ");
697 break;
698
699 case 'aioseo':
700 // Check for AIOSEO title or description in their custom table
701 $table = $wpdb->prefix . 'aioseo_posts';
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
703 $count = (int) $wpdb->get_var("
704 SELECT COUNT(post_id)
705 FROM {$wpdb->prefix}aioseo_posts
706 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
707 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
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 }
736 }
737 break;
738 }
739
740 return $count > 0;
741 }
742
743 /**
744 * Check if primary category data exists for a plugin
745 */
746 private function check_primary_category_data($plugin, &$count)
747 {
748 global $wpdb;
749
750 $count = 0;
751
752 switch ($plugin) {
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
755 $count = (int) $wpdb->get_var("
756 SELECT COUNT(DISTINCT post_id)
757 FROM {$wpdb->postmeta}
758 WHERE meta_key = '_yoast_wpseo_primary_category'
759 AND meta_value != ''
760 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
761 ");
762 break;
763
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
766 $count = (int) $wpdb->get_var("
767 SELECT COUNT(DISTINCT post_id)
768 FROM {$wpdb->postmeta}
769 WHERE meta_key = 'rank_math_primary_category'
770 AND meta_value != ''
771 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
772 ");
773 break;
774
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
777 $count = (int) $wpdb->get_var("
778 SELECT COUNT(DISTINCT post_id)
779 FROM {$wpdb->postmeta}
780 WHERE meta_key = '_aioseo_primary_category'
781 AND meta_value != ''
782 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
783 ");
784 break;
785 }
786
787 return $count > 0;
788 }
789
790 /**
791 * Import primary category from another SEO plugin
792 *
793 * @param string $plugin Plugin slug (yoast, rankmath, aioseo)
794 * @param array $options Import options
795 * @return array Result array with success, imported, skipped, total, etc.
796 */
797 public function import_primary_category($plugin, $options = [])
798 {
799 $defaults = [
800 'overwrite_existing' => false,
801 'batch_size' => 50,
802 'offset' => 0,
803 ];
804 $options = array_merge($defaults, $options);
805
806 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
807 return [
808 'success' => false,
809 'message' => 'Invalid plugin specified.',
810 ];
811 }
812
813 switch ($plugin) {
814 case 'yoast':
815 return $this->import_yoast_primary_category($options);
816 case 'rankmath':
817 return $this->import_rankmath_primary_category($options);
818 case 'aioseo':
819 return $this->import_aioseo_primary_category($options);
820 }
821
822 return [
823 'success' => false,
824 'message' => 'Unknown error occurred.',
825 ];
826 }
827
828 /**
829 * Import primary category from Yoast SEO
830 */
831 private function import_yoast_primary_category($options)
832 {
833 global $wpdb;
834
835 $batch_size = intval($options['batch_size']);
836 $offset = intval($options['offset']);
837 $overwrite = (bool) $options['overwrite_existing'];
838
839 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
840 $total_posts = (int) $wpdb->get_var("
841 SELECT COUNT(DISTINCT post_id)
842 FROM {$wpdb->postmeta}
843 WHERE meta_key = '_yoast_wpseo_primary_category'
844 AND meta_value != ''
845 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
846 ");
847
848 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
849 $posts = $wpdb->get_results($wpdb->prepare("
850 SELECT DISTINCT post_id
851 FROM {$wpdb->postmeta}
852 WHERE meta_key = '_yoast_wpseo_primary_category'
853 AND meta_value != ''
854 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
855 ORDER BY post_id ASC
856 LIMIT %d OFFSET %d
857 ", $batch_size, $offset));
858
859 $imported_count = 0;
860 $skipped_count = 0;
861
862 foreach ($posts as $post_obj) {
863 $post_id = $post_obj->post_id;
864 $term_id = absint(get_post_meta($post_id, '_yoast_wpseo_primary_category', true));
865
866 if ($term_id <= 0 || !get_term($term_id, 'category')) {
867 $skipped_count++;
868 continue;
869 }
870
871 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
872 if ($existing > 0 && !$overwrite) {
873 $skipped_count++;
874 continue;
875 }
876
877 update_post_meta($post_id, '_metasync_primary_category', $term_id);
878 $imported_count++;
879 }
880
881 $processed = $offset + count($posts);
882 $has_more = $processed < $total_posts;
883
884 return [
885 'success' => true,
886 'imported' => $imported_count,
887 'skipped' => $skipped_count,
888 'total' => $total_posts,
889 'has_more' => $has_more,
890 ];
891 }
892
893 /**
894 * Import primary category from Rank Math
895 */
896 private function import_rankmath_primary_category($options)
897 {
898 global $wpdb;
899
900 $batch_size = intval($options['batch_size']);
901 $offset = intval($options['offset']);
902 $overwrite = (bool) $options['overwrite_existing'];
903
904 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
905 $total_posts = (int) $wpdb->get_var("
906 SELECT COUNT(DISTINCT post_id)
907 FROM {$wpdb->postmeta}
908 WHERE meta_key = 'rank_math_primary_category'
909 AND meta_value != ''
910 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
911 ");
912
913 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
914 $posts = $wpdb->get_results($wpdb->prepare("
915 SELECT DISTINCT post_id
916 FROM {$wpdb->postmeta}
917 WHERE meta_key = 'rank_math_primary_category'
918 AND meta_value != ''
919 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
920 ORDER BY post_id ASC
921 LIMIT %d OFFSET %d
922 ", $batch_size, $offset));
923
924 $imported_count = 0;
925 $skipped_count = 0;
926
927 foreach ($posts as $post_obj) {
928 $post_id = $post_obj->post_id;
929 $term_id = absint(get_post_meta($post_id, 'rank_math_primary_category', true));
930
931 if ($term_id <= 0 || !get_term($term_id, 'category')) {
932 $skipped_count++;
933 continue;
934 }
935
936 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
937 if ($existing > 0 && !$overwrite) {
938 $skipped_count++;
939 continue;
940 }
941
942 update_post_meta($post_id, '_metasync_primary_category', $term_id);
943 $imported_count++;
944 }
945
946 $processed = $offset + count($posts);
947 $has_more = $processed < $total_posts;
948
949 return [
950 'success' => true,
951 'imported' => $imported_count,
952 'skipped' => $skipped_count,
953 'total' => $total_posts,
954 'has_more' => $has_more,
955 ];
956 }
957
958 /**
959 * Import primary category from AIOSEO
960 */
961 private function import_aioseo_primary_category($options)
962 {
963 global $wpdb;
964
965 $batch_size = intval($options['batch_size']);
966 $offset = intval($options['offset']);
967 $overwrite = (bool) $options['overwrite_existing'];
968
969 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
970 $total_posts = (int) $wpdb->get_var("
971 SELECT COUNT(DISTINCT post_id)
972 FROM {$wpdb->postmeta}
973 WHERE meta_key = '_aioseo_primary_category'
974 AND meta_value != ''
975 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
976 ");
977
978 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
979 $posts = $wpdb->get_results($wpdb->prepare("
980 SELECT DISTINCT post_id
981 FROM {$wpdb->postmeta}
982 WHERE meta_key = '_aioseo_primary_category'
983 AND meta_value != ''
984 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
985 ORDER BY post_id ASC
986 LIMIT %d OFFSET %d
987 ", $batch_size, $offset));
988
989 $imported_count = 0;
990 $skipped_count = 0;
991
992 foreach ($posts as $post_obj) {
993 $post_id = $post_obj->post_id;
994 $term_id = absint(get_post_meta($post_id, '_aioseo_primary_category', true));
995
996 if ($term_id <= 0 || !get_term($term_id, 'category')) {
997 $skipped_count++;
998 continue;
999 }
1000
1001 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
1002 if ($existing > 0 && !$overwrite) {
1003 $skipped_count++;
1004 continue;
1005 }
1006
1007 update_post_meta($post_id, '_metasync_primary_category', $term_id);
1008 $imported_count++;
1009 }
1010
1011 $processed = $offset + count($posts);
1012 $has_more = $processed < $total_posts;
1013
1014 return [
1015 'success' => true,
1016 'imported' => $imported_count,
1017 'skipped' => $skipped_count,
1018 'total' => $total_posts,
1019 'has_more' => $has_more,
1020 ];
1021 }
1022
1023 /**
1024 * Import Redirections
1025 */
1026 public function import_redirections($plugin)
1027 {
1028 if (!$this->redirection_importer) {
1029 return ['success' => false, 'message' => 'Redirection database not initialized.'];
1030 }
1031 return $this->redirection_importer->import_from_plugin($plugin);
1032 }
1033
1034 /**
1035 * Import Sitemap Settings
1036 */
1037 public function import_sitemap($plugin)
1038 {
1039 $imported = false;
1040 $message = '';
1041
1042 switch ($plugin) {
1043 case 'yoast':
1044 $options = get_option('wpseo_xml');
1045 if ($options && isset($options['enablexmlsitemap'])) {
1046 // Yoast stores sitemap settings in wpseo_xml option
1047 // Metasync sitemap is auto-generated, so we just acknowledge the import
1048 $message = 'Sitemap settings imported from Yoast.';
1049 $imported = true;
1050 }
1051 break;
1052
1053 case 'rankmath':
1054 $options = get_option('rank-math-options-sitemap');
1055 if ($options) {
1056 // Import logic here
1057 $message = 'Sitemap settings imported from Rank Math.';
1058 $imported = true;
1059 }
1060 break;
1061
1062 case 'aioseo':
1063 $options = get_option('aioseo_options');
1064 if ($options && isset($options['sitemap'])) {
1065 // Import logic here
1066 $message = 'Sitemap settings imported from AIOSEO.';
1067 $imported = true;
1068 }
1069 break;
1070 }
1071
1072 if (!$imported) {
1073 return ['success' => false, 'message' => 'No sitemap settings found or plugin not active.'];
1074 }
1075
1076 return ['success' => true, 'message' => $message];
1077 }
1078
1079 /**
1080 * Import Robots.txt
1081 */
1082 public function import_robots($plugin)
1083 {
1084 $content = '';
1085
1086 switch ($plugin) {
1087 case 'yoast':
1088 // Yoast doesn't store robots.txt in DB, it edits the file.
1089 // But it might have settings for it.
1090 // If we are "importing", we might just want to read the current file if managed by them?
1091 // Actually, if they have a custom robots.txt editor, they might store it.
1092 // Yoast uses the file system directly.
1093 $content = $this->get_robots_content_from_file();
1094 break;
1095
1096 case 'rankmath':
1097 $options = get_option('rank-math-options-general');
1098 if (isset($options['robots_txt_content'])) {
1099 $content = $options['robots_txt_content'];
1100 } else {
1101 $content = $this->get_robots_content_from_file();
1102 }
1103 break;
1104
1105 case 'aioseo':
1106 $options = get_option('aioseo_options');
1107 if (isset($options['tools']['robots']['rules'])) {
1108 // AIOSEO stores rules as array, need to reconstruct
1109 // For simplicity, let's try reading the file first as it's the source of truth
1110 $content = $this->get_robots_content_from_file();
1111 }
1112 break;
1113 }
1114
1115 if (empty($content)) {
1116 return ['success' => false, 'message' => 'No robots.txt content found.'];
1117 }
1118
1119 // Save to Metasync Robots.txt
1120 // Load the class if not already loaded
1121 if (!class_exists('Metasync_Robots_Txt')) {
1122 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
1123 }
1124
1125 $robots_class = Metasync_Robots_Txt::get_instance();
1126 $result = $robots_class->write_robots_file($content);
1127
1128 if (is_wp_error($result)) {
1129 return ['success' => false, 'message' => $result->get_error_message()];
1130 }
1131
1132 return ['success' => true, 'message' => 'Robots.txt content imported successfully.'];
1133 }
1134
1135 private function get_robots_content_from_file() {
1136 $robots_file = ABSPATH . 'robots.txt';
1137 if (file_exists($robots_file)) {
1138 return file_get_contents($robots_file);
1139 }
1140 return '';
1141 }
1142
1143 /**
1144 * Import Indexation Options (Per-Post Robots Meta)
1145 */
1146 public function import_indexation($plugin, $options = [])
1147 {
1148 $defaults = ['batch_size' => 50, 'offset' => 0];
1149 $options = array_merge($defaults, $options);
1150
1151 switch ($plugin) {
1152 case 'yoast':
1153 return $this->import_yoast_indexation($options);
1154
1155 case 'rankmath':
1156 return $this->import_rankmath_indexation($options);
1157
1158 case 'aioseo':
1159 return $this->import_aioseo_indexation($options);
1160
1161 default:
1162 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1163 }
1164 }
1165
1166 /**
1167 * Import per-post indexation settings from Yoast SEO
1168 */
1169 private function import_yoast_indexation($options = [])
1170 {
1171 global $wpdb;
1172 $imported_count = 0;
1173
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("
1189 SELECT DISTINCT post_id
1190 FROM {$wpdb->postmeta}
1191 WHERE meta_key LIKE %s
1192 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1193 ORDER BY post_id ASC
1194 LIMIT %d OFFSET %d
1195 ", '_yoast_wpseo_%', $batch_size, $offset));
1196
1197 foreach ($posts as $post_obj) {
1198 $post_id = $post_obj->post_id;
1199 $has_changes = false;
1200
1201 // Get existing Metasync robots meta
1202 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1203 if (!is_array($metasync_robots)) {
1204 $metasync_robots = [];
1205 }
1206
1207 // Import noindex
1208 $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
1209 if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
1210 $metasync_robots['noindex'] = 'noindex';
1211 $has_changes = true;
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']);
1216 $has_changes = true;
1217 }
1218
1219 // Import nofollow
1220 $yoast_nofollow = get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true);
1221 if ($yoast_nofollow === '1' && !isset($metasync_robots['nofollow'])) {
1222 $metasync_robots['nofollow'] = 'nofollow';
1223 $has_changes = true;
1224 }
1225
1226 // Import advanced robots (noarchive, nosnippet, noimageindex)
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 }
1236 if (!empty($yoast_adv)) {
1237 $adv_directives = explode(',', $yoast_adv);
1238 foreach ($adv_directives as $directive) {
1239 $directive = trim($directive);
1240 if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
1241 $metasync_robots[$directive] = $directive;
1242 $has_changes = true;
1243 }
1244 }
1245 }
1246
1247 // Also write to _metasync_robots_advanced JSON
1248 $robots_advanced = [];
1249 if (!empty($yoast_adv)) {
1250 $adv_directives = array_map('trim', explode(',', $yoast_adv));
1251 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1252 if (in_array($dir, $adv_directives, true)) {
1253 $robots_advanced[$dir] = true;
1254 }
1255 }
1256 }
1257 // nofollow from Yoast
1258 if ($yoast_nofollow === '1') {
1259 $robots_advanced['nofollow'] = true;
1260 }
1261 if (!empty($robots_advanced)) {
1262 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
1263 }
1264
1265 // Import canonical URL
1266 $yoast_canonical = get_post_meta($post_id, '_yoast_wpseo_canonical', true);
1267 if (!empty($yoast_canonical)) {
1268 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1269 if (empty($existing_canonical)) {
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 }
1277 }
1278 }
1279
1280 // Save Metasync robots meta if changes were made
1281 if ($has_changes) {
1282 if (!empty($metasync_robots)) {
1283 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1284 }
1285 $imported_count++;
1286 }
1287
1288 // Flush per-post object cache to prevent unbounded memory growth across batches
1289 clean_post_cache($post_id);
1290 }
1291
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 ];
1307 }
1308
1309 /**
1310 * Import per-post indexation settings from Rank Math
1311 */
1312 private function import_rankmath_indexation($options = [])
1313 {
1314 global $wpdb;
1315 $imported_count = 0;
1316
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("
1332 SELECT DISTINCT post_id
1333 FROM {$wpdb->postmeta}
1334 WHERE meta_key LIKE %s
1335 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1336 ORDER BY post_id ASC
1337 LIMIT %d OFFSET %d
1338 ", 'rank_math_%', $batch_size, $offset));
1339
1340 foreach ($posts as $post_obj) {
1341 $post_id = $post_obj->post_id;
1342 $has_changes = false;
1343
1344 // Get existing Metasync robots meta
1345 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1346 if (!is_array($metasync_robots)) {
1347 $metasync_robots = [];
1348 }
1349
1350 // Import robots array
1351 $rm_robots = get_post_meta($post_id, 'rank_math_robots', true);
1352 if (is_array($rm_robots)) {
1353 // Rank Math stores as array like ['noindex', 'nofollow']
1354 foreach ($rm_robots as $directive) {
1355 $directive = strtolower(trim($directive));
1356 if (in_array($directive, ['index', 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
1357 $metasync_robots[$directive] = $directive;
1358 $has_changes = true;
1359 }
1360 }
1361 }
1362
1363 // Import advanced robots
1364 $rm_adv_robots = get_post_meta($post_id, 'rank_math_advanced_robots', true);
1365 if (is_array($rm_adv_robots)) {
1366 foreach ($rm_adv_robots as $directive) {
1367 $directive = strtolower(trim($directive));
1368 if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex', 'max-snippet', 'max-video-preview', 'max-image-preview']) && !isset($metasync_robots[$directive])) {
1369 $metasync_robots[$directive] = $directive;
1370 $has_changes = true;
1371 }
1372 }
1373 }
1374
1375 // Parse max-* values and write _metasync_robots_advanced JSON
1376 $robots_advanced = [];
1377 // Boolean directives from rank_math_robots
1378 if (is_array($rm_robots)) {
1379 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1380 if (in_array($dir, $rm_robots, true)) {
1381 $robots_advanced[$dir] = true;
1382 }
1383 }
1384 }
1385 // max-* directives from rank_math_advanced_robots
1386 if (is_array($rm_adv_robots)) {
1387 foreach ($rm_adv_robots as $key => $value) {
1388 // Values are formatted like "max-snippet:-1"
1389 if (strpos($key, 'max-snippet') !== false && strpos($value, ':') !== false) {
1390 $robots_advanced['max_snippet'] = (int) explode(':', $value)[1];
1391 }
1392 if (strpos($key, 'max-image-preview') !== false && strpos($value, ':') !== false) {
1393 $robots_advanced['max_image_preview'] = explode(':', $value)[1];
1394 }
1395 if (strpos($key, 'max-video-preview') !== false && strpos($value, ':') !== false) {
1396 $robots_advanced['max_video_preview'] = (int) explode(':', $value)[1];
1397 }
1398 }
1399 }
1400 if (!empty($robots_advanced)) {
1401 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
1402 }
1403
1404 // Import canonical URL
1405 $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
1406 if (!empty($rm_canonical)) {
1407 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1408 if (empty($existing_canonical)) {
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 }
1416 }
1417 }
1418
1419 // Save Metasync robots meta if changes were made
1420 if ($has_changes) {
1421 if (!empty($metasync_robots)) {
1422 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1423 }
1424 $imported_count++;
1425 }
1426
1427 // Flush per-post object cache to prevent unbounded memory growth across batches
1428 clean_post_cache($post_id);
1429 }
1430
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 ];
1446 }
1447
1448 /**
1449 * Import per-post indexation settings from AIOSEO
1450 */
1451 private function import_aioseo_indexation($options = [])
1452 {
1453 global $wpdb;
1454 $imported_count = 0;
1455
1456 $batch_size = intval($options['batch_size']);
1457 $offset = intval($options['offset']);
1458
1459 // AIOSEO stores data in a custom table
1460 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1461
1462 // Check if table exists
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
1464
1465 if (!$table_exists) {
1466 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1467 }
1468
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("
1480 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
1481 robots_noarchive, robots_nosnippet, robots_noimageindex,
1482 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
1483 canonical_url
1484 FROM {$wpdb->prefix}aioseo_posts
1485 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1486 ORDER BY post_id ASC
1487 LIMIT %d OFFSET %d
1488 ", $batch_size, $offset));
1489
1490 foreach ($posts as $aioseo_data) {
1491 $post_id = $aioseo_data->post_id;
1492 $has_changes = false;
1493
1494 // Get existing Metasync robots meta
1495 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1496 if (!is_array($metasync_robots)) {
1497 $metasync_robots = [];
1498 }
1499
1500 // Only import if not using default (robots_default = 0)
1501 if ($aioseo_data->robots_default == 0) {
1502 // Import noindex
1503 if ($aioseo_data->robots_noindex == 1 && !isset($metasync_robots['noindex'])) {
1504 $metasync_robots['noindex'] = 'noindex';
1505 $has_changes = true;
1506 }
1507
1508 // Import nofollow
1509 if ($aioseo_data->robots_nofollow == 1 && !isset($metasync_robots['nofollow'])) {
1510 $metasync_robots['nofollow'] = 'nofollow';
1511 $has_changes = true;
1512 }
1513
1514 // Import noarchive
1515 if ($aioseo_data->robots_noarchive == 1 && !isset($metasync_robots['noarchive'])) {
1516 $metasync_robots['noarchive'] = 'noarchive';
1517 $has_changes = true;
1518 }
1519
1520 // Import nosnippet
1521 if ($aioseo_data->robots_nosnippet == 1 && !isset($metasync_robots['nosnippet'])) {
1522 $metasync_robots['nosnippet'] = 'nosnippet';
1523 $has_changes = true;
1524 }
1525
1526 // Import noimageindex
1527 if ($aioseo_data->robots_noimageindex == 1 && !isset($metasync_robots['noimageindex'])) {
1528 $metasync_robots['noimageindex'] = 'noimageindex';
1529 $has_changes = true;
1530 }
1531 }
1532
1533 // Write _metasync_robots_advanced JSON
1534 $robots_advanced = [];
1535 if ($aioseo_data->robots_default == 0) {
1536 if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
1537 if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
1538 if ($aioseo_data->robots_nosnippet == 1) $robots_advanced['nosnippet'] = true;
1539 if ($aioseo_data->robots_noimageindex == 1) $robots_advanced['noimageindex'] = true;
1540 }
1541 if (isset($aioseo_data->robots_max_snippet) && is_numeric($aioseo_data->robots_max_snippet)) {
1542 $robots_advanced['max_snippet'] = (int) $aioseo_data->robots_max_snippet;
1543 }
1544 if (!empty($aioseo_data->robots_max_imagepreview)) {
1545 $robots_advanced['max_image_preview'] = $aioseo_data->robots_max_imagepreview;
1546 }
1547 if (isset($aioseo_data->robots_max_videopreview) && is_numeric($aioseo_data->robots_max_videopreview)) {
1548 $robots_advanced['max_video_preview'] = (int) $aioseo_data->robots_max_videopreview;
1549 }
1550 if (!empty($robots_advanced)) {
1551 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
1552 $has_changes = true;
1553 }
1554
1555 // Import canonical URL
1556 if (!empty($aioseo_data->canonical_url)) {
1557 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1558 if (empty($existing_canonical)) {
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 }
1566 }
1567 }
1568
1569 // Save Metasync robots meta if changes were made
1570 if ($has_changes) {
1571 if (!empty($metasync_robots)) {
1572 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1573 }
1574 $imported_count++;
1575 }
1576
1577 // Flush per-post object cache to prevent unbounded memory growth across batches
1578 clean_post_cache($post_id);
1579 }
1580
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 ];
1596 }
1597
1598 /**
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.
1605 */
1606 public function import_schema($plugin)
1607 {
1608 switch ($plugin) {
1609 case 'yoast':
1610 $breakdown = $this->import_yoast_schema();
1611 break;
1612
1613 case 'rankmath':
1614 $breakdown = $this->import_rankmath_schema();
1615 break;
1616
1617 case 'aioseo':
1618 $breakdown = $this->import_aioseo_schema();
1619 break;
1620
1621 default:
1622 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1623 }
1624
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 ];
1637 }
1638
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];
1687 }
1688
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 /**
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}
1721 */
1722 private function import_yoast_schema()
1723 {
1724 $breakdown = $this->empty_schema_breakdown();
1725
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 }
1731
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.
1736 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1737 if ($this->post_has_metasync_schema($post_id)) {
1738 $breakdown['already_imported']++;
1739 continue;
1740 }
1741
1742 $schema_row = $rows['_yoast_wpseo_schema'] ?? null;
1743 $article_type_row = $rows['_yoast_wpseo_schema_article_type'] ?? null;
1744
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' => []];
1752
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 }
1758 }
1759
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) {
1764 $metasync_schema = [
1765 'enabled' => true,
1766 'types' => [
1767 [
1768 'type' => 'article',
1769 'fields' => [
1770 'title_override' => '{{post_title}}',
1771 'description_override' => '{{post_description}}',
1772 'image_override' => '{{featured_image}}',
1773 'organization_name' => '',
1774 'organization_logo' => ''
1775 ]
1776 ]
1777 ]
1778 ];
1779
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;
1783 }
1784
1785 $breakdown['unsupported']++;
1786 }
1787
1788 $breakdown['total_candidates'] = count($rows_by_post);
1789
1790 return $breakdown;
1791 }
1792
1793 /**
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}
1801 */
1802 private function import_rankmath_schema()
1803 {
1804 $breakdown = $this->empty_schema_breakdown();
1805
1806 $rows_by_post = [];
1807 foreach ($this->rankmath_schema_candidate_rows() as $row) {
1808 $rows_by_post[(int) $row->post_id][] = $row;
1809 }
1810
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 }
1817
1818 $imported = false;
1819
1820 foreach ($rows as $row) {
1821 if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) === null) {
1822 continue;
1823 }
1824
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);
1827
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 }
1833
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 }
1843 }
1844
1845 if (!$imported) {
1846 $breakdown['unsupported']++;
1847 }
1848 }
1849
1850 $breakdown['total_candidates'] = count($rows_by_post);
1851
1852 return $breakdown;
1853 }
1854
1855 /**
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}
1864 */
1865 private function import_aioseo_schema()
1866 {
1867 $breakdown = $this->empty_schema_breakdown();
1868
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;
1875 }
1876
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 }
1883
1884 if ($this->aioseo_schema_source_is_supported($aioseo_data->schema_type, $aioseo_data->schema_type_options) === null) {
1885 $breakdown['unsupported']++;
1886 continue;
1887 }
1888
1889 // Decode AIOSEO schema options
1890 $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
1891 if (!is_array($schema_options)) {
1892 $schema_options = [];
1893 }
1894
1895 // Convert AIOSEO schema to Metasync format
1896 $metasync_schema = $this->convert_aioseo_schema_to_metasync(
1897 $aioseo_data->schema_type,
1898 $schema_options,
1899 $post_id
1900 );
1901
1902 if (!empty($metasync_schema['types'])) {
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']++;
1907 }
1908 }
1909
1910 $breakdown['total_candidates'] = count($rows_by_post);
1911
1912 return $breakdown;
1913 }
1914
1915 /**
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.
1923 */
1924 private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1925 {
1926 $metasync_schema = [
1927 'enabled' => true,
1928 'types' => []
1929 ];
1930
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'])) {
1937 continue;
1938 }
1939
1940 $raw_type = $item['@type'];
1941 if ( is_array( $raw_type ) ) {
1942 $raw_type = reset( $raw_type );
1943 }
1944 if ( ! is_string( $raw_type ) || '' === $raw_type ) {
1945 continue;
1946 }
1947 $type = strtolower( $raw_type );
1948
1949 // Map Yoast types to Metasync types
1950 if ($type === 'article' || $type === 'newsarticle' || $type === 'blogposting') {
1951 $metasync_schema['types'][] = [
1952 'type' => 'article',
1953 'fields' => [
1954 'title_override' => isset($item['headline']) ? $item['headline'] : '{{post_title}}',
1955 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1956 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1957 'organization_name' => isset($item['publisher']['name']) ? $item['publisher']['name'] : '',
1958 'organization_logo' => isset($item['publisher']['logo']['url']) ? $item['publisher']['logo']['url'] : ''
1959 ]
1960 ];
1961 } elseif ($type === 'faqpage') {
1962 $faq_items = [];
1963 if (isset($item['mainEntity']) && is_array($item['mainEntity'])) {
1964 foreach ($item['mainEntity'] as $question) {
1965 if (isset($question['name']) && isset($question['acceptedAnswer']['text'])) {
1966 $faq_items[] = [
1967 'question' => $question['name'],
1968 'answer' => $question['acceptedAnswer']['text']
1969 ];
1970 }
1971 }
1972 }
1973 if (!empty($faq_items)) {
1974 $metasync_schema['types'][] = [
1975 'type' => 'FAQPage',
1976 'fields' => [
1977 'faq_items' => $faq_items
1978 ]
1979 ];
1980 }
1981 } elseif ($type === 'product') {
1982 $metasync_schema['types'][] = [
1983 'type' => 'product',
1984 'fields' => [
1985 'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
1986 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1987 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1988 'sku' => isset($item['sku']) ? $item['sku'] : '',
1989 'brand' => isset($item['brand']['name']) ? $item['brand']['name'] : '',
1990 'price' => isset($item['offers']['price']) ? floatval($item['offers']['price']) : 0,
1991 'currency' => isset($item['offers']['priceCurrency']) ? $item['offers']['priceCurrency'] : 'USD',
1992 'availability' => isset($item['offers']['availability']) ? basename($item['offers']['availability']) : 'InStock',
1993 'condition' => isset($item['offers']['itemCondition']) ? basename($item['offers']['itemCondition']) : 'NewCondition'
1994 ]
1995 ];
1996 } elseif ($type === 'recipe') {
1997 $ingredients = [];
1998 if (isset($item['recipeIngredient']) && is_array($item['recipeIngredient'])) {
1999 $ingredients = $item['recipeIngredient'];
2000 }
2001
2002 $instructions = [];
2003 if (isset($item['recipeInstructions']) && is_array($item['recipeInstructions'])) {
2004 foreach ($item['recipeInstructions'] as $step) {
2005 if (is_string($step)) {
2006 $instructions[] = $step;
2007 } elseif (isset($step['text'])) {
2008 $instructions[] = $step['text'];
2009 }
2010 }
2011 }
2012
2013 $metasync_schema['types'][] = [
2014 'type' => 'recipe',
2015 'fields' => [
2016 'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
2017 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
2018 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
2019 'yield' => isset($item['recipeYield']) ? $item['recipeYield'] : '',
2020 'ingredients' => $ingredients,
2021 'instructions' => $instructions,
2022 'prep_time' => isset($item['prepTime']) ? $this->parse_duration($item['prepTime']) : 0,
2023 'cook_time' => isset($item['cookTime']) ? $this->parse_duration($item['cookTime']) : 0,
2024 'total_time' => isset($item['totalTime']) ? $this->parse_duration($item['totalTime']) : 0,
2025 'calories' => isset($item['nutrition']['calories']) ? intval($item['nutrition']['calories']) : 0
2026 ]
2027 ];
2028 }
2029 }
2030 }
2031
2032 return $metasync_schema;
2033 }
2034
2035 /**
2036 * Convert Rank Math schema to Metasync format
2037 */
2038 private function convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id)
2039 {
2040 $metasync_schema = [
2041 'enabled' => true,
2042 'types' => []
2043 ];
2044
2045 $type = strtolower($schema_type);
2046
2047 // Handle article-like schema types (Article, BlogPosting, NewsArticle, etc.)
2048 if ($type === 'article' || $type === 'blogposting' || $type === 'newsarticle') {
2049 $metasync_schema['types'][] = [
2050 'type' => 'article',
2051 'fields' => [
2052 'title_override' => $this->normalize_text_value($rm_schema['headline'] ?? null, '{{post_title}}'),
2053 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
2054 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
2055 'organization_name' => isset($rm_schema['publisher']) ? $rm_schema['publisher'] : '',
2056 'organization_logo' => isset($rm_schema['publisher_logo']) ? $rm_schema['publisher_logo'] : ''
2057 ]
2058 ];
2059 } elseif ($type === 'faqpage') {
2060 $faq_items = [];
2061 if (isset($rm_schema['questions']) && is_array($rm_schema['questions'])) {
2062 foreach ($rm_schema['questions'] as $question) {
2063 if (isset($question['name']) && isset($question['text'])) {
2064 $faq_items[] = [
2065 'question' => $question['name'],
2066 'answer' => $question['text']
2067 ];
2068 }
2069 }
2070 }
2071 if (!empty($faq_items)) {
2072 $metasync_schema['types'][] = [
2073 'type' => 'FAQPage',
2074 'fields' => [
2075 'faq_items' => $faq_items
2076 ]
2077 ];
2078 }
2079 } elseif ($type === 'product') {
2080 $metasync_schema['types'][] = [
2081 'type' => 'product',
2082 'fields' => [
2083 'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
2084 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
2085 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
2086 'sku' => isset($rm_schema['sku']) ? $rm_schema['sku'] : '',
2087 'brand' => isset($rm_schema['brand']) ? $rm_schema['brand'] : '',
2088 'price' => isset($rm_schema['price']) ? floatval($rm_schema['price']) : 0,
2089 'currency' => isset($rm_schema['currency']) ? $rm_schema['currency'] : 'USD',
2090 'availability' => isset($rm_schema['inStock']) ? ($rm_schema['inStock'] ? 'InStock' : 'OutOfStock') : 'InStock',
2091 'condition' => 'NewCondition'
2092 ]
2093 ];
2094 } elseif ($type === 'recipe') {
2095 $metasync_schema['types'][] = [
2096 'type' => 'recipe',
2097 'fields' => [
2098 'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
2099 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
2100 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
2101 'yield' => isset($rm_schema['recipeYield']) ? $rm_schema['recipeYield'] : '',
2102 'ingredients' => isset($rm_schema['recipeIngredient']) ? $rm_schema['recipeIngredient'] : [],
2103 'instructions' => isset($rm_schema['recipeInstructions']) ? $rm_schema['recipeInstructions'] : [],
2104 'prep_time' => isset($rm_schema['prepTime']) ? intval($rm_schema['prepTime']) : 0,
2105 'cook_time' => isset($rm_schema['cookTime']) ? intval($rm_schema['cookTime']) : 0,
2106 'total_time' => isset($rm_schema['totalTime']) ? intval($rm_schema['totalTime']) : 0,
2107 'calories' => isset($rm_schema['calories']) ? intval($rm_schema['calories']) : 0
2108 ]
2109 ];
2110 }
2111
2112 return $metasync_schema;
2113 }
2114
2115 /**
2116 * Convert AIOSEO schema to Metasync format
2117 */
2118 private function convert_aioseo_schema_to_metasync($schema_type, $schema_options, $post_id)
2119 {
2120 $metasync_schema = [
2121 'enabled' => true,
2122 'types' => []
2123 ];
2124
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);
2130
2131 if ($type === 'article') {
2132 $metasync_schema['types'][] = [
2133 'type' => 'article',
2134 'fields' => [
2135 'title_override' => isset($schema_options['headline']) ? $schema_options['headline'] : '{{post_title}}',
2136 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
2137 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
2138 'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
2139 'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
2140 ]
2141 ];
2142 } elseif ($type === 'FAQPage') {
2143 $faq_items = [];
2144 if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
2145 foreach ($schema_options['questions'] as $question) {
2146 if (isset($question['question']) && isset($question['answer'])) {
2147 $faq_items[] = [
2148 'question' => $question['question'],
2149 'answer' => $question['answer']
2150 ];
2151 }
2152 }
2153 }
2154 if (!empty($faq_items)) {
2155 $metasync_schema['types'][] = [
2156 'type' => 'FAQPage',
2157 'fields' => [
2158 'faq_items' => $faq_items
2159 ]
2160 ];
2161 }
2162 } elseif ($type === 'product') {
2163 $metasync_schema['types'][] = [
2164 'type' => 'product',
2165 'fields' => [
2166 'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
2167 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
2168 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
2169 'sku' => isset($schema_options['sku']) ? $schema_options['sku'] : '',
2170 'brand' => isset($schema_options['brand']) ? $schema_options['brand'] : '',
2171 'price' => isset($schema_options['price']) ? floatval($schema_options['price']) : 0,
2172 'currency' => isset($schema_options['currency']) ? $schema_options['currency'] : 'USD',
2173 'availability' => isset($schema_options['availability']) ? $schema_options['availability'] : 'InStock',
2174 'condition' => isset($schema_options['condition']) ? $schema_options['condition'] : 'NewCondition'
2175 ]
2176 ];
2177 } elseif ($type === 'recipe') {
2178 $metasync_schema['types'][] = [
2179 'type' => 'recipe',
2180 'fields' => [
2181 'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
2182 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
2183 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
2184 'yield' => isset($schema_options['recipeYield']) ? $schema_options['recipeYield'] : '',
2185 'ingredients' => isset($schema_options['recipeIngredient']) ? $schema_options['recipeIngredient'] : [],
2186 'instructions' => isset($schema_options['recipeInstructions']) ? $schema_options['recipeInstructions'] : [],
2187 'prep_time' => isset($schema_options['prepTime']) ? intval($schema_options['prepTime']) : 0,
2188 'cook_time' => isset($schema_options['cookTime']) ? intval($schema_options['cookTime']) : 0,
2189 'total_time' => isset($schema_options['totalTime']) ? intval($schema_options['totalTime']) : 0,
2190 'calories' => isset($schema_options['calories']) ? intval($schema_options['calories']) : 0
2191 ]
2192 ];
2193 }
2194
2195 return $metasync_schema;
2196 }
2197
2198 /**
2199 * Parse ISO 8601 duration to minutes
2200 * e.g., "PT15M" = 15 minutes, "PT1H30M" = 90 minutes
2201 */
2202 private function parse_duration($duration)
2203 {
2204 if (empty($duration)) {
2205 return 0;
2206 }
2207
2208 // Simple parser for PT format
2209 $minutes = 0;
2210 if (preg_match('/PT(\d+)H/', $duration, $hours)) {
2211 $minutes += intval($hours[1]) * 60;
2212 }
2213 if (preg_match('/(\d+)M/', $duration, $mins)) {
2214 $minutes += intval($mins[1]);
2215 }
2216
2217 return $minutes;
2218 }
2219
2220 /**
2221 * Normalize image value to string URL
2222 * Handles arrays from Rank Math/Yoast and converts placeholders
2223 */
2224 private function normalize_image_value($image)
2225 {
2226 if (empty($image)) {
2227 return '{{featured_image}}';
2228 }
2229
2230 // If it's an array (from Rank Math/Yoast), extract the URL
2231 if (is_array($image)) {
2232 // Check for 'url' key first
2233 if (isset($image['url'])) {
2234 $image = $image['url'];
2235 }
2236 // Check for '@id' key (Yoast format)
2237 elseif (isset($image['@id'])) {
2238 $image = $image['@id'];
2239 }
2240 // If it's still an array, try to get first element
2241 elseif (isset($image[0])) {
2242 $image = is_string($image[0]) ? $image[0] : '{{featured_image}}';
2243 }
2244 else {
2245 $image = '{{featured_image}}';
2246 }
2247 }
2248
2249 // Convert common placeholder formats to Metasync format
2250 $placeholder_map = [
2251 '%post_thumbnail%' => '{{featured_image}}',
2252 '%featured_image%' => '{{featured_image}}',
2253 '%seo_title%' => '{{post_title}}',
2254 '%post_title%' => '{{post_title}}',
2255 '%seo_description%' => '{{post_description}}',
2256 '%post_excerpt%' => '{{post_description}}'
2257 ];
2258
2259 foreach ($placeholder_map as $old => $new) {
2260 if ($image === $old || strpos($image, $old) !== false) {
2261 $image = str_replace($old, $new, $image);
2262 }
2263 }
2264
2265 return is_string($image) ? $image : '{{featured_image}}';
2266 }
2267
2268 /**
2269 * Normalize text value to string
2270 * Converts placeholders to Metasync format
2271 */
2272 private function normalize_text_value($text, $default = '')
2273 {
2274 if (empty($text)) {
2275 return $default;
2276 }
2277
2278 // Convert common placeholder formats to Metasync format
2279 $placeholder_map = [
2280 '%seo_title%' => '{{post_title}}',
2281 '%post_title%' => '{{post_title}}',
2282 '%seo_description%' => '{{post_description}}',
2283 '%post_excerpt%' => '{{post_description}}'
2284 ];
2285
2286 foreach ($placeholder_map as $old => $new) {
2287 if (is_string($text) && (strpos($text, $old) !== false || $text === $old)) {
2288 $text = str_replace($old, $new, $text);
2289 }
2290 }
2291
2292 return is_string($text) ? $text : $default;
2293 }
2294
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 /**
2355 * Resolve SEO placeholder tokens to their actual values.
2356 *
2357 * Tries each source plugin's own replacement engine first so the result
2358 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
2359 * to manual replacement of the most common tokens.
2360 *
2361 * @param string $text Raw string that may contain placeholder tokens.
2362 * @param int $post_id Post whose context is used for replacement.
2363 * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
2364 * @return string Resolved string.
2365 */
2366 private function resolve_seo_placeholders($text, $post_id, $plugin) {
2367 if (empty($text) || !is_string($text)) {
2368 return (string) $text;
2369 }
2370
2371 $post = get_post($post_id);
2372 if (!$post) {
2373 return $text;
2374 }
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
2385 // Yoast SEO — %%var%% tokens
2386 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
2387 try {
2388 $replacer = new WPSEO_Replace_Vars();
2389 $resolved = $replacer->replace($text, $post);
2390 if (is_string($resolved) && $resolved !== '') {
2391 return $resolved;
2392 }
2393 } catch (Exception $e) {
2394 // fall through to manual replacement
2395 }
2396 }
2397
2398 // Rank Math — %var% tokens
2399 if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
2400 try {
2401 $resolved = rank_math_replace_vars($text, $post);
2402 if (is_string($resolved) && $resolved !== '') {
2403 return $resolved;
2404 }
2405 } catch (Exception $e) {
2406 // fall through to manual replacement
2407 }
2408 }
2409
2410 // All in One SEO — #var tokens
2411 if ($plugin === 'aioseo') {
2412 try {
2413 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
2414 $resolved = aioseo()->tags->replaceTags($text, $post_id);
2415 if (is_string($resolved) && $resolved !== '') {
2416 return $resolved;
2417 }
2418 }
2419 } catch (Exception $e) {
2420 // fall through to manual replacement
2421 }
2422 }
2423
2424 return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
2425 }
2426
2427 /**
2428 * Manual fallback: replace the most common SEO placeholder tokens from
2429 * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
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 *
2435 * @param string $text Text that may contain placeholder tokens.
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.
2440 * @return string Text with tokens replaced.
2441 */
2442 private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
2443 $post = get_post($post_id);
2444 if (!$post) {
2445 return $text;
2446 }
2447
2448 $site_name = get_bloginfo('name');
2449 $post_title = get_the_title($post_id);
2450 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
2451 $author = get_the_author_meta('display_name', (int) $post->post_author);
2452 $date = get_the_date('', $post_id);
2453 $modified = get_the_modified_date('', $post_id);
2454
2455 $categories = get_the_category($post_id);
2456 $primary_category = !empty($categories) ? $categories[0]->name : '';
2457
2458 $tags = get_the_tags($post_id);
2459 $first_tag = !empty($tags) ? $tags[0]->name : '';
2460
2461 $sep = $this->resolve_source_separator($plugin);
2462
2463 $map = [
2464 // ── Yoast (%%var%%) ──────────────────────────────────────────────
2465 '%%title%%' => $post_title,
2466 '%%sitename%%' => $site_name,
2467 '%%sep%%' => $sep,
2468 '%%excerpt%%' => $post_excerpt,
2469 '%%excerpt_only%%' => $post_excerpt,
2470 '%%author%%' => $author,
2471 '%%date%%' => $date,
2472 '%%modified%%' => $modified,
2473 '%%id%%' => (string) $post_id,
2474 '%%page%%' => '',
2475 '%%pagenumber%%' => '',
2476 '%%pagetotal%%' => '',
2477 '%%primary_category%%' => $primary_category,
2478 '%%category%%' => $primary_category,
2479 '%%tag%%' => $first_tag,
2480 '%%focuskw%%' => (string) get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
2481 // ── Rank Math (%var%) ────────────────────────────────────────────
2482 '%title%' => $post_title,
2483 '%sitename%' => $site_name,
2484 '%sep%' => $sep,
2485 '%excerpt%' => $post_excerpt,
2486 '%author%' => $author,
2487 '%date%' => $date,
2488 '%modified%' => $modified,
2489 '%id%' => (string) $post_id,
2490 '%page%' => '',
2491 '%category%' => $primary_category,
2492 '%tag%' => $first_tag,
2493 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
2494 ];
2495
2496 // Sort longest token first to avoid partial matches
2497 // e.g. %%primary_category%% must replace before %%category%%
2498 uksort($map, function ($a, $b) {
2499 return strlen($b) - strlen($a);
2500 });
2501
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;
2513 }
2514
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 /**
3541 * Import SEO Metadata (Titles and Descriptions)
3542 * Supports batch processing via AJAX
3543 *
3544 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
3545 * @param array $options Import options (import_titles, import_descriptions, overwrite_existing, batch_size, offset)
3546 * @return array Result with success status, progress info, and statistics
3547 */
3548 public function import_seo_metadata($plugin, $options = [])
3549 {
3550 // Default options
3551 $defaults = [
3552 'import_titles' => true,
3553 'import_descriptions' => true,
3554 'overwrite_existing' => false,
3555 'batch_size' => 50, // Process 50 posts per batch
3556 'offset' => 0
3557 ];
3558 $options = array_merge($defaults, $options);
3559
3560 // Validate plugin
3561 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
3562 return [
3563 'success' => false,
3564 'message' => 'Invalid plugin specified.'
3565 ];
3566 }
3567
3568 // Route to appropriate import method
3569 switch ($plugin) {
3570 case 'yoast':
3571 return $this->import_yoast_seo_metadata($options);
3572 case 'rankmath':
3573 return $this->import_rankmath_seo_metadata($options);
3574 case 'aioseo':
3575 return $this->import_aioseo_seo_metadata($options);
3576 }
3577
3578 return [
3579 'success' => false,
3580 'message' => 'Unknown error occurred.'
3581 ];
3582 }
3583
3584 /**
3585 * Import SEO metadata from Yoast SEO
3586 */
3587 private function import_yoast_seo_metadata($options)
3588 {
3589 global $wpdb;
3590
3591 $batch_size = intval($options['batch_size']);
3592 $offset = intval($options['offset']);
3593 $import_titles = (bool) $options['import_titles'];
3594 $import_descriptions = (bool) $options['import_descriptions'];
3595 $import_social_text = !empty($options['import_social_text']);
3596 $import_social_images = !empty($options['import_social_images']);
3597 $overwrite = (bool) $options['overwrite_existing'];
3598
3599 // Build WHERE clause for meta keys
3600 $meta_keys = [];
3601 if ($import_titles) {
3602 $meta_keys[] = '_yoast_wpseo_title';
3603 }
3604 if ($import_descriptions) {
3605 $meta_keys[] = '_yoast_wpseo_metadesc';
3606 }
3607 if ($import_social_text) {
3608 $meta_keys[] = '_yoast_wpseo_opengraph-title';
3609 $meta_keys[] = '_yoast_wpseo_opengraph-description';
3610 $meta_keys[] = '_yoast_wpseo_twitter-title';
3611 $meta_keys[] = '_yoast_wpseo_twitter-description';
3612 }
3613 if ($import_social_images) {
3614 $meta_keys[] = '_yoast_wpseo_opengraph-image';
3615 $meta_keys[] = '_yoast_wpseo_twitter-image';
3616 }
3617
3618 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
3619 return [
3620 'success' => false,
3621 'message' => 'No import options selected.'
3622 ];
3623 }
3624
3625 // Get total count (for progress tracking)
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('
3628 SELECT COUNT(DISTINCT post_id)
3629 FROM ' . $wpdb->postmeta . '
3630 WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3631 AND meta_value != ''
3632 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3633 ", $meta_keys));
3634
3635 // Get batch of posts with Yoast data
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('
3638 SELECT DISTINCT post_id
3639 FROM ' . $wpdb->postmeta . '
3640 WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3641 AND meta_value != ''
3642 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3643 ORDER BY post_id ASC
3644 LIMIT %d OFFSET %d
3645 ", array_merge($meta_keys, [$batch_size, $offset])));
3646
3647 $imported_count = 0;
3648 $skipped_count = 0;
3649 $unresolved = [];
3650
3651 foreach ($posts as $post_obj) {
3652 $post_id = $post_obj->post_id;
3653 $updated = false;
3654
3655 // Import title
3656 if ($import_titles) {
3657 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
3658 if (!empty($yoast_title)) {
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 );
3667 }
3668 }
3669
3670 // Import description
3671 if ($import_descriptions) {
3672 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
3673 if (!empty($yoast_desc)) {
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;
3682 }
3683 }
3684
3685 // Import social text
3686 if ($import_social_text) {
3687 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
3688 if (!empty($yoast_og_title)) {
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;
3697 }
3698
3699 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
3700 if (!empty($yoast_og_desc)) {
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;
3709 }
3710
3711 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
3712 if (!empty($yoast_tw_title)) {
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;
3721 }
3722
3723 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
3724 if (!empty($yoast_tw_desc)) {
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;
3733 }
3734 }
3735
3736 // Import social images
3737 if ($import_social_images) {
3738 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
3739 if (!empty($yoast_og_image)) {
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;
3748 }
3749
3750 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
3751 if (!empty($yoast_twitter_image)) {
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;
3760 }
3761 }
3762
3763 if ($updated) {
3764 $imported_count++;
3765 } else {
3766 $skipped_count++;
3767 }
3768 }
3769
3770 $processed = $offset + count($posts);
3771 $is_complete = $processed >= $total_posts;
3772
3773 return $this->attach_unresolved_report([
3774 'success' => true,
3775 'imported' => $imported_count,
3776 'skipped' => $skipped_count,
3777 'total' => $total_posts,
3778 'processed' => $processed,
3779 'is_complete' => $is_complete,
3780 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
3781 'message' => $is_complete
3782 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
3783 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
3784 ], $unresolved);
3785 }
3786
3787 /**
3788 * Import SEO metadata from Rank Math
3789 */
3790 private function import_rankmath_seo_metadata($options)
3791 {
3792 global $wpdb;
3793
3794 $batch_size = intval($options['batch_size']);
3795 $offset = intval($options['offset']);
3796 $import_titles = (bool) $options['import_titles'];
3797 $import_descriptions = (bool) $options['import_descriptions'];
3798 $import_social_text = !empty($options['import_social_text']);
3799 $import_social_images = !empty($options['import_social_images']);
3800 $overwrite = (bool) $options['overwrite_existing'];
3801
3802 // Build WHERE clause for meta keys
3803 $meta_keys = [];
3804 if ($import_titles) {
3805 $meta_keys[] = 'rank_math_title';
3806 }
3807 if ($import_descriptions) {
3808 $meta_keys[] = 'rank_math_description';
3809 }
3810 if ($import_social_text) {
3811 $meta_keys[] = 'rank_math_facebook_title';
3812 $meta_keys[] = 'rank_math_facebook_description';
3813 $meta_keys[] = 'rank_math_twitter_title';
3814 $meta_keys[] = 'rank_math_twitter_description';
3815 }
3816 if ($import_social_images) {
3817 $meta_keys[] = 'rank_math_facebook_image';
3818 $meta_keys[] = 'rank_math_twitter_image';
3819 }
3820
3821 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
3822 return [
3823 'success' => false,
3824 'message' => 'No import options selected.'
3825 ];
3826 }
3827
3828 // Get total count
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('
3831 SELECT COUNT(DISTINCT post_id)
3832 FROM ' . $wpdb->postmeta . '
3833 WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3834 AND meta_value != ''
3835 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3836 ", $meta_keys));
3837
3838 // Get batch of posts
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('
3841 SELECT DISTINCT post_id
3842 FROM ' . $wpdb->postmeta . '
3843 WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
3844 AND meta_value != ''
3845 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
3846 ORDER BY post_id ASC
3847 LIMIT %d OFFSET %d
3848 ", array_merge($meta_keys, [$batch_size, $offset])));
3849
3850 $imported_count = 0;
3851 $skipped_count = 0;
3852 $unresolved = [];
3853
3854 foreach ($posts as $post_obj) {
3855 $post_id = $post_obj->post_id;
3856 $updated = false;
3857
3858 // Import title
3859 if ($import_titles) {
3860 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
3861 if (!empty($rm_title)) {
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 );
3870 }
3871 }
3872
3873 // Import description
3874 if ($import_descriptions) {
3875 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
3876 if (!empty($rm_desc)) {
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;
3885 }
3886 }
3887
3888 // Import social text
3889 if ($import_social_text) {
3890 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
3891 if (!empty($rm_og_title)) {
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;
3900 }
3901
3902 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
3903 if (!empty($rm_og_desc)) {
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;
3912 }
3913
3914 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
3915 if (!empty($rm_tw_title)) {
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;
3924 }
3925
3926 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
3927 if (!empty($rm_tw_desc)) {
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;
3936 }
3937 }
3938
3939 // Import social images
3940 if ($import_social_images) {
3941 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
3942 if (!empty($rm_og_image)) {
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;
3951 }
3952
3953 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
3954 if (!empty($rm_twitter_image)) {
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;
3963 }
3964 }
3965
3966 if ($updated) {
3967 $imported_count++;
3968 } else {
3969 $skipped_count++;
3970 }
3971 }
3972
3973 $processed = $offset + count($posts);
3974 $is_complete = $processed >= $total_posts;
3975
3976 return $this->attach_unresolved_report([
3977 'success' => true,
3978 'imported' => $imported_count,
3979 'skipped' => $skipped_count,
3980 'total' => $total_posts,
3981 'processed' => $processed,
3982 'is_complete' => $is_complete,
3983 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
3984 'message' => $is_complete
3985 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
3986 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
3987 ], $unresolved);
3988 }
3989
3990 /**
3991 * Import SEO metadata from All in One SEO
3992 */
3993 private function import_aioseo_seo_metadata($options)
3994 {
3995 global $wpdb;
3996
3997 $batch_size = intval($options['batch_size']);
3998 $offset = intval($options['offset']);
3999 $import_titles = (bool) $options['import_titles'];
4000 $import_descriptions = (bool) $options['import_descriptions'];
4001 $import_social_text = !empty($options['import_social_text']);
4002 $import_social_images = !empty($options['import_social_images']);
4003 $overwrite = (bool) $options['overwrite_existing'];
4004
4005 // Check if AIOSEO table exists
4006 $table = $wpdb->prefix . 'aioseo_posts';
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
4008 return [
4009 'success' => false,
4010 'message' => 'AIOSEO database table not found.'
4011 ];
4012 }
4013
4014 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
4015 return [
4016 'success' => false,
4017 'message' => 'No import options selected.'
4018 ];
4019 }
4020
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 = [];
4026 if ($import_titles) {
4027 $value_columns[] = 'title';
4028 }
4029 if ($import_descriptions) {
4030 $value_columns[] = 'description';
4031 }
4032 if ($import_social_text) {
4033 $value_columns[] = 'og_title';
4034 $value_columns[] = 'og_description';
4035 $value_columns[] = 'twitter_title';
4036 $value_columns[] = 'twitter_description';
4037 }
4038 if ($import_social_images) {
4039 $value_columns[] = 'og_image_custom_url';
4040 $value_columns[] = 'twitter_image_custom_url';
4041 }
4042
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 }
4071 }
4072
4073 if (!empty($template_post_types)) {
4074 $type_placeholders = implode(', ', array_fill(0, count($template_post_types), '%s'));
4075
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);
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
4159 $imported_count = 0;
4160 $skipped_count = 0;
4161 $unresolved = [];
4162
4163 foreach ($posts as $aioseo_data) {
4164 $post_id = $aioseo_data->post_id;
4165 $updated = false;
4166
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);
4172
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;
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 );
4196 }
4197
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;
4202
4203 if ($raw_desc === '' && $desc_template !== '') {
4204 $raw_desc = $desc_template;
4205 $desc_from_template = true;
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;
4222 }
4223
4224 // Import social text
4225 if ($import_social_text) {
4226 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
4227 if (!empty($aioseo_og_title)) {
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;
4236 }
4237
4238 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
4239 if (!empty($aioseo_og_desc)) {
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;
4248 }
4249
4250 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
4251 if (!empty($aioseo_tw_title)) {
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;
4260 }
4261
4262 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
4263 if (!empty($aioseo_tw_desc)) {
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;
4272 }
4273 }
4274
4275 // Import social images
4276 if ($import_social_images) {
4277 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
4278 if (!empty($aioseo_og_image)) {
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;
4287 }
4288
4289 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
4290 if (!empty($aioseo_twitter_image)) {
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;
4299 }
4300 }
4301
4302 if ($updated) {
4303 $imported_count++;
4304 } else {
4305 $skipped_count++;
4306 }
4307 }
4308
4309 $processed = $offset + count($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);
4315
4316 return $this->attach_unresolved_report([
4317 'success' => true,
4318 'imported' => $imported_count,
4319 'skipped' => $skipped_count,
4320 'total' => $total_posts,
4321 'processed' => $processed,
4322 'is_complete' => $is_complete,
4323 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
4324 'message' => $is_complete
4325 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
4326 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
4327 ], $unresolved);
4328 }
4329
4330 /**
4331 * Import term-level SEO metadata from a third-party SEO plugin into
4332 * MetaSync term meta (`_metasync_*`).
4333 *
4334 * Mirrors import_seo_metadata() but operates on terms (wp_termmeta for
4335 * Yoast/Rank Math and the wp_aioseo_terms table for AIOSEO) and walks
4336 * every registered public taxonomy so category, post_tag, and custom
4337 * taxonomies are all covered.
4338 *
4339 * @param string $plugin One of 'yoast', 'rankmath', 'aioseo'.
4340 * @param array $options Batch options (overwrite_existing, batch_size, offset).
4341 * @return array ['success'=>bool,'imported'=>int,'skipped'=>int,'total'=>int,'message'=>string]
4342 */
4343 public function import_term_seo_metadata($plugin, $options = [])
4344 {
4345 $defaults = [
4346 'overwrite_existing' => false,
4347 'batch_size' => 100,
4348 'offset' => 0,
4349 ];
4350 $options = array_merge($defaults, $options);
4351
4352 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'], true)) {
4353 return [
4354 'success' => false,
4355 'message' => 'Invalid plugin specified.',
4356 ];
4357 }
4358
4359 switch ($plugin) {
4360 case 'yoast':
4361 return $this->import_yoast_term_meta($options);
4362 case 'rankmath':
4363 return $this->import_rankmath_term_meta($options);
4364 case 'aioseo':
4365 return $this->import_aioseo_term_meta($options);
4366 }
4367
4368 return [
4369 'success' => false,
4370 'message' => 'Unknown error occurred.',
4371 ];
4372 }
4373
4374 /**
4375 * Import Yoast term meta into MetaSync term meta.
4376 *
4377 * @param array $options
4378 * @return array
4379 */
4380 private function import_yoast_term_meta($options)
4381 {
4382 $batch_size = intval($options['batch_size']);
4383 $offset = intval($options['offset']);
4384 $overwrite = (bool) $options['overwrite_existing'];
4385
4386 // Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta`
4387 // option (wp_options), NOT in wp_termmeta. We must read from there.
4388 if (!class_exists('WPSEO_Taxonomy_Meta')) {
4389 return [
4390 'success' => false,
4391 'message' => 'Yoast SEO is not active or WPSEO_Taxonomy_Meta class not available.',
4392 ];
4393 }
4394
4395 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
4396
4397 $terms = get_terms([
4398 'taxonomy' => $taxonomies,
4399 'hide_empty' => false,
4400 'number' => $batch_size,
4401 'offset' => $offset,
4402 ]);
4403
4404 if (is_wp_error($terms)) {
4405 return [
4406 'success' => false,
4407 'message' => $terms->get_error_message(),
4408 ];
4409 }
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.
4414 $field_map = [
4415 'wpseo_opengraph-title' => '_metasync_og_title',
4416 'wpseo_opengraph-description' => '_metasync_og_description',
4417 'wpseo_canonical' => '_metasync_canonical_url',
4418 ];
4419
4420 $imported_map = [
4421 'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
4422 'wpseo_desc' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
4423 ];
4424
4425 $imported = 0;
4426 $skipped = 0;
4427
4428 foreach ($terms as $term) {
4429 $term_updated = false;
4430
4431 // Read from Yoast's wpseo_taxonomy_meta option via its API.
4432 $yoast_meta = WPSEO_Taxonomy_Meta::get_term_meta($term->term_id, $term->taxonomy);
4433 if (!is_array($yoast_meta)) {
4434 $yoast_meta = [];
4435 }
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
4447 foreach ($field_map as $src_key => $dest_key) {
4448 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
4449 if ($src_value === '' || $src_value === null) {
4450 continue;
4451 }
4452
4453 $existing = get_term_meta($term->term_id, $dest_key, true);
4454 if (!empty($existing) && !$overwrite) {
4455 continue;
4456 }
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
4471 update_term_meta($term->term_id, $dest_key, $src_value);
4472 $term_updated = true;
4473 }
4474
4475 $noindex = isset($yoast_meta['wpseo_noindex']) ? $yoast_meta['wpseo_noindex'] : '';
4476 if ($noindex === 'noindex') {
4477 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
4478 if (empty($existing) || $overwrite) {
4479 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
4480 $term_updated = true;
4481 }
4482 }
4483
4484 if ($term_updated) {
4485 $imported++;
4486 } else {
4487 $skipped++;
4488 }
4489 }
4490
4491 $processed = $offset + count($terms);
4492
4493 return [
4494 'success' => true,
4495 'imported' => $imported,
4496 'skipped' => $skipped,
4497 'total' => $processed,
4498 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
4499 ];
4500 }
4501
4502 /**
4503 * Import Rank Math term meta into MetaSync term meta.
4504 *
4505 * @param array $options
4506 * @return array
4507 */
4508 private function import_rankmath_term_meta($options)
4509 {
4510 $batch_size = intval($options['batch_size']);
4511 $offset = intval($options['offset']);
4512 $overwrite = (bool) $options['overwrite_existing'];
4513
4514 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
4515
4516 $terms = get_terms([
4517 'taxonomy' => $taxonomies,
4518 'hide_empty' => false,
4519 'number' => $batch_size,
4520 'offset' => $offset,
4521 ]);
4522
4523 if (is_wp_error($terms)) {
4524 return [
4525 'success' => false,
4526 'message' => $terms->get_error_message(),
4527 ];
4528 }
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.
4532 $field_map = [
4533 'rank_math_facebook_title' => '_metasync_og_title',
4534 'rank_math_facebook_description' => '_metasync_og_description',
4535 'rank_math_canonical_url' => '_metasync_canonical_url',
4536 ];
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
4543 $imported = 0;
4544 $skipped = 0;
4545
4546 foreach ($terms as $term) {
4547 $term_updated = false;
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
4559 foreach ($field_map as $src_key => $dest_key) {
4560 $src_value = get_term_meta($term->term_id, $src_key, true);
4561 if ($src_value === '' || $src_value === null) {
4562 continue;
4563 }
4564
4565 $existing = get_term_meta($term->term_id, $dest_key, true);
4566 if (!empty($existing) && !$overwrite) {
4567 continue;
4568 }
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
4583 update_term_meta($term->term_id, $dest_key, $src_value);
4584 $term_updated = true;
4585 }
4586
4587 $robots_raw = get_term_meta($term->term_id, 'rank_math_robots', true);
4588 $robots = maybe_unserialize($robots_raw);
4589 if (is_array($robots) && in_array('noindex', $robots, true)) {
4590 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
4591 if (empty($existing) || $overwrite) {
4592 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
4593 $term_updated = true;
4594 }
4595 }
4596
4597 if ($term_updated) {
4598 $imported++;
4599 } else {
4600 $skipped++;
4601 }
4602 }
4603
4604 $processed = $offset + count($terms);
4605
4606 return [
4607 'success' => true,
4608 'imported' => $imported,
4609 'skipped' => $skipped,
4610 'total' => $processed,
4611 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
4612 ];
4613 }
4614
4615 /**
4616 * Import AIOSEO term meta (from the wp_aioseo_terms custom table) into
4617 * MetaSync term meta.
4618 *
4619 * @param array $options
4620 * @return array
4621 */
4622 private function import_aioseo_term_meta($options)
4623 {
4624 global $wpdb;
4625
4626 $batch_size = intval($options['batch_size']);
4627 $offset = intval($options['offset']);
4628 $overwrite = (bool) $options['overwrite_existing'];
4629
4630 $table = $wpdb->prefix . 'aioseo_terms';
4631
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
4633 if ($table_exists !== $table) {
4634 return [
4635 'success' => false,
4636 'message' => 'AIOSEO terms table not found.',
4637 ];
4638 }
4639
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
4641 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
4642 FROM {$wpdb->prefix}aioseo_terms
4643 ORDER BY term_id ASC
4644 LIMIT %d OFFSET %d",
4645 $batch_size,
4646 $offset
4647 ));
4648
4649 $imported = 0;
4650 $skipped = 0;
4651
4652 foreach ($rows as $row) {
4653 $term_id = (int) $row->term_id;
4654 if ($term_id <= 0) {
4655 continue;
4656 }
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
4666 $term_updated = false;
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.
4670 $field_map = [
4671 'og_title' => '_metasync_og_title',
4672 'og_description' => '_metasync_og_description',
4673 'canonical_url' => '_metasync_canonical_url',
4674 ];
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
4691 foreach ($field_map as $column => $dest_key) {
4692 $value = isset($row->$column) ? $row->$column : '';
4693 if ($value === '' || $value === null) {
4694 continue;
4695 }
4696
4697 $existing = get_term_meta($term_id, $dest_key, true);
4698 if (!empty($existing) && !$overwrite) {
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 }
4713 }
4714
4715 update_term_meta($term_id, $dest_key, $value);
4716 $term_updated = true;
4717 }
4718
4719 if (!empty($row->robots_noindex) && (int) $row->robots_noindex === 1) {
4720 $existing = get_term_meta($term_id, '_metasync_robots_index', true);
4721 if (empty($existing) || $overwrite) {
4722 update_term_meta($term_id, '_metasync_robots_index', 'noindex');
4723 $term_updated = true;
4724 }
4725 }
4726
4727 if ($term_updated) {
4728 $imported++;
4729 } else {
4730 $skipped++;
4731 }
4732 }
4733
4734 $processed = $offset + count($rows);
4735
4736 return [
4737 'success' => true,
4738 'imported' => $imported,
4739 'skipped' => $skipped,
4740 'total' => $processed,
4741 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
4742 ];
4743 }
4744 }
4745