PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-external-importer.php

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

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