PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.17
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.17
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 2.6.4 2.6.3 All 139 releases
metasync / includes / class-metasync-external-importer.php

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

2,872 lines 115.2 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 $count = (int) $wpdb->get_var("
181 SELECT COUNT(DISTINCT post_id)
182 FROM {$wpdb->postmeta}
183 WHERE meta_key LIKE '_yoast_wpseo_%'
184 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
185 ");
186 break;
187
188 case 'rankmath':
189 $count = (int) $wpdb->get_var("
190 SELECT COUNT(DISTINCT post_id)
191 FROM {$wpdb->postmeta}
192 WHERE meta_key LIKE 'rank_math_%'
193 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
194 ");
195 break;
196
197 case 'aioseo':
198 $table = $wpdb->prefix . 'aioseo_posts';
199 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
200 $count = (int) $wpdb->get_var("
201 SELECT COUNT(post_id)
202 FROM {$table}
203 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
204 ");
205 }
206 break;
207 }
208
209 return $count > 0;
210 }
211
212 /**
213 * Check if schema data exists for a plugin
214 */
215 private function check_schema_data($plugin, &$count)
216 {
217 global $wpdb;
218
219 $count = 0;
220
221 switch ($plugin) {
222 case 'yoast':
223 // Check for Yoast schema meta
224 // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
225 // Free version may use _yoast_wpseo_schema (JSON)
226 $count = (int) $wpdb->get_var("
227 SELECT COUNT(DISTINCT post_id)
228 FROM {$wpdb->postmeta}
229 WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
230 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
231 ");
232 break;
233
234 case 'rankmath':
235 // Check for Rank Math schema meta (any schema type)
236 // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
237 // Exclude shortcode schemas (rank_math_shortcode_schema_*)
238 $count = (int) $wpdb->get_var("
239 SELECT COUNT(DISTINCT post_id)
240 FROM {$wpdb->postmeta}
241 WHERE meta_key LIKE 'rank_math_schema_%'
242 AND meta_key NOT LIKE 'rank_math_shortcode_schema_%'
243 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
244 ");
245 break;
246
247 case 'aioseo':
248 // Check for AIOSEO schema in table
249 $table = $wpdb->prefix . 'aioseo_posts';
250 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
251 $count = (int) $wpdb->get_var("
252 SELECT COUNT(post_id)
253 FROM {$table}
254 WHERE (schema_type IS NOT NULL AND schema_type != '')
255 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
256 ");
257 }
258 break;
259 }
260
261 return $count > 0;
262 }
263
264 /**
265 * Check if SEO metadata (titles and descriptions) exists for a plugin
266 */
267 private function check_seo_metadata($plugin, &$count)
268 {
269 global $wpdb;
270
271 $count = 0;
272
273 switch ($plugin) {
274 case 'yoast':
275 // Check for Yoast SEO title or description
276 $count = (int) $wpdb->get_var("
277 SELECT COUNT(DISTINCT post_id)
278 FROM {$wpdb->postmeta}
279 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
280 AND meta_value != ''
281 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
282 ");
283 break;
284
285 case 'rankmath':
286 // Check for Rank Math title or description
287 $count = (int) $wpdb->get_var("
288 SELECT COUNT(DISTINCT post_id)
289 FROM {$wpdb->postmeta}
290 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
291 AND meta_value != ''
292 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
293 ");
294 break;
295
296 case 'aioseo':
297 // Check for AIOSEO title or description in their custom table
298 $table = $wpdb->prefix . 'aioseo_posts';
299 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
300 $count = (int) $wpdb->get_var("
301 SELECT COUNT(post_id)
302 FROM {$table}
303 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
304 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
305 ");
306 }
307 break;
308 }
309
310 return $count > 0;
311 }
312
313 /**
314 * Check if primary category data exists for a plugin
315 */
316 private function check_primary_category_data($plugin, &$count)
317 {
318 global $wpdb;
319
320 $count = 0;
321
322 switch ($plugin) {
323 case 'yoast':
324 $count = (int) $wpdb->get_var("
325 SELECT COUNT(DISTINCT post_id)
326 FROM {$wpdb->postmeta}
327 WHERE meta_key = '_yoast_wpseo_primary_category'
328 AND meta_value != ''
329 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
330 ");
331 break;
332
333 case 'rankmath':
334 $count = (int) $wpdb->get_var("
335 SELECT COUNT(DISTINCT post_id)
336 FROM {$wpdb->postmeta}
337 WHERE meta_key = 'rank_math_primary_category'
338 AND meta_value != ''
339 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
340 ");
341 break;
342
343 case 'aioseo':
344 $count = (int) $wpdb->get_var("
345 SELECT COUNT(DISTINCT post_id)
346 FROM {$wpdb->postmeta}
347 WHERE meta_key = '_aioseo_primary_category'
348 AND meta_value != ''
349 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
350 ");
351 break;
352 }
353
354 return $count > 0;
355 }
356
357 /**
358 * Import primary category from another SEO plugin
359 *
360 * @param string $plugin Plugin slug (yoast, rankmath, aioseo)
361 * @param array $options Import options
362 * @return array Result array with success, imported, skipped, total, etc.
363 */
364 public function import_primary_category($plugin, $options = [])
365 {
366 $defaults = [
367 'overwrite_existing' => false,
368 'batch_size' => 50,
369 'offset' => 0,
370 ];
371 $options = array_merge($defaults, $options);
372
373 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
374 return [
375 'success' => false,
376 'message' => 'Invalid plugin specified.',
377 ];
378 }
379
380 switch ($plugin) {
381 case 'yoast':
382 return $this->import_yoast_primary_category($options);
383 case 'rankmath':
384 return $this->import_rankmath_primary_category($options);
385 case 'aioseo':
386 return $this->import_aioseo_primary_category($options);
387 }
388
389 return [
390 'success' => false,
391 'message' => 'Unknown error occurred.',
392 ];
393 }
394
395 /**
396 * Import primary category from Yoast SEO
397 */
398 private function import_yoast_primary_category($options)
399 {
400 global $wpdb;
401
402 $batch_size = intval($options['batch_size']);
403 $offset = intval($options['offset']);
404 $overwrite = (bool) $options['overwrite_existing'];
405
406 $total_posts = (int) $wpdb->get_var("
407 SELECT COUNT(DISTINCT post_id)
408 FROM {$wpdb->postmeta}
409 WHERE meta_key = '_yoast_wpseo_primary_category'
410 AND meta_value != ''
411 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
412 ");
413
414 $posts = $wpdb->get_results($wpdb->prepare("
415 SELECT DISTINCT post_id
416 FROM {$wpdb->postmeta}
417 WHERE meta_key = '_yoast_wpseo_primary_category'
418 AND meta_value != ''
419 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
420 ORDER BY post_id ASC
421 LIMIT %d OFFSET %d
422 ", $batch_size, $offset));
423
424 $imported_count = 0;
425 $skipped_count = 0;
426
427 foreach ($posts as $post_obj) {
428 $post_id = $post_obj->post_id;
429 $term_id = absint(get_post_meta($post_id, '_yoast_wpseo_primary_category', true));
430
431 if ($term_id <= 0 || !get_term($term_id, 'category')) {
432 $skipped_count++;
433 continue;
434 }
435
436 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
437 if ($existing > 0 && !$overwrite) {
438 $skipped_count++;
439 continue;
440 }
441
442 update_post_meta($post_id, '_metasync_primary_category', $term_id);
443 $imported_count++;
444 }
445
446 $processed = $offset + count($posts);
447 $has_more = $processed < $total_posts;
448
449 return [
450 'success' => true,
451 'imported' => $imported_count,
452 'skipped' => $skipped_count,
453 'total' => $total_posts,
454 'has_more' => $has_more,
455 ];
456 }
457
458 /**
459 * Import primary category from Rank Math
460 */
461 private function import_rankmath_primary_category($options)
462 {
463 global $wpdb;
464
465 $batch_size = intval($options['batch_size']);
466 $offset = intval($options['offset']);
467 $overwrite = (bool) $options['overwrite_existing'];
468
469 $total_posts = (int) $wpdb->get_var("
470 SELECT COUNT(DISTINCT post_id)
471 FROM {$wpdb->postmeta}
472 WHERE meta_key = 'rank_math_primary_category'
473 AND meta_value != ''
474 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
475 ");
476
477 $posts = $wpdb->get_results($wpdb->prepare("
478 SELECT DISTINCT post_id
479 FROM {$wpdb->postmeta}
480 WHERE meta_key = 'rank_math_primary_category'
481 AND meta_value != ''
482 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
483 ORDER BY post_id ASC
484 LIMIT %d OFFSET %d
485 ", $batch_size, $offset));
486
487 $imported_count = 0;
488 $skipped_count = 0;
489
490 foreach ($posts as $post_obj) {
491 $post_id = $post_obj->post_id;
492 $term_id = absint(get_post_meta($post_id, 'rank_math_primary_category', true));
493
494 if ($term_id <= 0 || !get_term($term_id, 'category')) {
495 $skipped_count++;
496 continue;
497 }
498
499 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
500 if ($existing > 0 && !$overwrite) {
501 $skipped_count++;
502 continue;
503 }
504
505 update_post_meta($post_id, '_metasync_primary_category', $term_id);
506 $imported_count++;
507 }
508
509 $processed = $offset + count($posts);
510 $has_more = $processed < $total_posts;
511
512 return [
513 'success' => true,
514 'imported' => $imported_count,
515 'skipped' => $skipped_count,
516 'total' => $total_posts,
517 'has_more' => $has_more,
518 ];
519 }
520
521 /**
522 * Import primary category from AIOSEO
523 */
524 private function import_aioseo_primary_category($options)
525 {
526 global $wpdb;
527
528 $batch_size = intval($options['batch_size']);
529 $offset = intval($options['offset']);
530 $overwrite = (bool) $options['overwrite_existing'];
531
532 $total_posts = (int) $wpdb->get_var("
533 SELECT COUNT(DISTINCT post_id)
534 FROM {$wpdb->postmeta}
535 WHERE meta_key = '_aioseo_primary_category'
536 AND meta_value != ''
537 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
538 ");
539
540 $posts = $wpdb->get_results($wpdb->prepare("
541 SELECT DISTINCT post_id
542 FROM {$wpdb->postmeta}
543 WHERE meta_key = '_aioseo_primary_category'
544 AND meta_value != ''
545 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
546 ORDER BY post_id ASC
547 LIMIT %d OFFSET %d
548 ", $batch_size, $offset));
549
550 $imported_count = 0;
551 $skipped_count = 0;
552
553 foreach ($posts as $post_obj) {
554 $post_id = $post_obj->post_id;
555 $term_id = absint(get_post_meta($post_id, '_aioseo_primary_category', true));
556
557 if ($term_id <= 0 || !get_term($term_id, 'category')) {
558 $skipped_count++;
559 continue;
560 }
561
562 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
563 if ($existing > 0 && !$overwrite) {
564 $skipped_count++;
565 continue;
566 }
567
568 update_post_meta($post_id, '_metasync_primary_category', $term_id);
569 $imported_count++;
570 }
571
572 $processed = $offset + count($posts);
573 $has_more = $processed < $total_posts;
574
575 return [
576 'success' => true,
577 'imported' => $imported_count,
578 'skipped' => $skipped_count,
579 'total' => $total_posts,
580 'has_more' => $has_more,
581 ];
582 }
583
584 /**
585 * Import Redirections
586 */
587 public function import_redirections($plugin)
588 {
589 if (!$this->redirection_importer) {
590 return ['success' => false, 'message' => 'Redirection database not initialized.'];
591 }
592 return $this->redirection_importer->import_from_plugin($plugin);
593 }
594
595 /**
596 * Import Sitemap Settings
597 */
598 public function import_sitemap($plugin)
599 {
600 $imported = false;
601 $message = '';
602
603 switch ($plugin) {
604 case 'yoast':
605 $options = get_option('wpseo_xml');
606 if ($options && isset($options['enablexmlsitemap'])) {
607 // Yoast stores sitemap settings in wpseo_xml option
608 // Metasync sitemap is auto-generated, so we just acknowledge the import
609 $message = 'Sitemap settings imported from Yoast.';
610 $imported = true;
611 }
612 break;
613
614 case 'rankmath':
615 $options = get_option('rank-math-options-sitemap');
616 if ($options) {
617 // Import logic here
618 $message = 'Sitemap settings imported from Rank Math.';
619 $imported = true;
620 }
621 break;
622
623 case 'aioseo':
624 $options = get_option('aioseo_options');
625 if ($options && isset($options['sitemap'])) {
626 // Import logic here
627 $message = 'Sitemap settings imported from AIOSEO.';
628 $imported = true;
629 }
630 break;
631 }
632
633 if (!$imported) {
634 return ['success' => false, 'message' => 'No sitemap settings found or plugin not active.'];
635 }
636
637 return ['success' => true, 'message' => $message];
638 }
639
640 /**
641 * Import Robots.txt
642 */
643 public function import_robots($plugin)
644 {
645 $content = '';
646
647 switch ($plugin) {
648 case 'yoast':
649 // Yoast doesn't store robots.txt in DB, it edits the file.
650 // But it might have settings for it.
651 // If we are "importing", we might just want to read the current file if managed by them?
652 // Actually, if they have a custom robots.txt editor, they might store it.
653 // Yoast uses the file system directly.
654 $content = $this->get_robots_content_from_file();
655 break;
656
657 case 'rankmath':
658 $options = get_option('rank-math-options-general');
659 if (isset($options['robots_txt_content'])) {
660 $content = $options['robots_txt_content'];
661 } else {
662 $content = $this->get_robots_content_from_file();
663 }
664 break;
665
666 case 'aioseo':
667 $options = get_option('aioseo_options');
668 if (isset($options['tools']['robots']['rules'])) {
669 // AIOSEO stores rules as array, need to reconstruct
670 // For simplicity, let's try reading the file first as it's the source of truth
671 $content = $this->get_robots_content_from_file();
672 }
673 break;
674 }
675
676 if (empty($content)) {
677 return ['success' => false, 'message' => 'No robots.txt content found.'];
678 }
679
680 // Save to Metasync Robots.txt
681 // Load the class if not already loaded
682 if (!class_exists('Metasync_Robots_Txt')) {
683 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
684 }
685
686 $robots_class = Metasync_Robots_Txt::get_instance();
687 $result = $robots_class->write_robots_file($content);
688
689 if (is_wp_error($result)) {
690 return ['success' => false, 'message' => $result->get_error_message()];
691 }
692
693 return ['success' => true, 'message' => 'Robots.txt content imported successfully.'];
694 }
695
696 private function get_robots_content_from_file() {
697 $robots_file = ABSPATH . 'robots.txt';
698 if (file_exists($robots_file)) {
699 return file_get_contents($robots_file);
700 }
701 return '';
702 }
703
704 /**
705 * Import Indexation Options (Per-Post Robots Meta)
706 */
707 public function import_indexation($plugin, $options = [])
708 {
709 $defaults = ['batch_size' => 50, 'offset' => 0];
710 $options = array_merge($defaults, $options);
711
712 switch ($plugin) {
713 case 'yoast':
714 return $this->import_yoast_indexation($options);
715
716 case 'rankmath':
717 return $this->import_rankmath_indexation($options);
718
719 case 'aioseo':
720 return $this->import_aioseo_indexation($options);
721
722 default:
723 return ['success' => false, 'message' => 'Invalid plugin specified.'];
724 }
725 }
726
727 /**
728 * Import per-post indexation settings from Yoast SEO
729 */
730 private function import_yoast_indexation($options = [])
731 {
732 global $wpdb;
733 $imported_count = 0;
734
735 $batch_size = intval($options['batch_size']);
736 $offset = intval($options['offset']);
737
738 // Get total count (for progress tracking)
739 $total = (int) $wpdb->get_var("
740 SELECT COUNT(DISTINCT post_id)
741 FROM {$wpdb->postmeta}
742 WHERE meta_key LIKE '_yoast_wpseo_%'
743 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
744 ");
745
746 // Get batch of posts with Yoast robots meta
747 $posts = $wpdb->get_results($wpdb->prepare("
748 SELECT DISTINCT post_id
749 FROM {$wpdb->postmeta}
750 WHERE meta_key LIKE '_yoast_wpseo_%'
751 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
752 ORDER BY post_id ASC
753 LIMIT %d OFFSET %d
754 ", $batch_size, $offset));
755
756 foreach ($posts as $post_obj) {
757 $post_id = $post_obj->post_id;
758 $has_changes = false;
759
760 // Get existing Metasync robots meta
761 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
762 if (!is_array($metasync_robots)) {
763 $metasync_robots = [];
764 }
765
766 // Import noindex
767 $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
768 if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
769 $metasync_robots['noindex'] = 'noindex';
770 $has_changes = true;
771 } elseif ($yoast_noindex === '2' && !isset($metasync_robots['index'])) {
772 // '2' means 'index' in Yoast
773 $metasync_robots['index'] = 'index';
774 $has_changes = true;
775 }
776
777 // Import nofollow
778 $yoast_nofollow = get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true);
779 if ($yoast_nofollow === '1' && !isset($metasync_robots['nofollow'])) {
780 $metasync_robots['nofollow'] = 'nofollow';
781 $has_changes = true;
782 }
783
784 // Import advanced robots (noarchive, nosnippet, noimageindex)
785 $yoast_adv = get_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', true);
786 if (!empty($yoast_adv)) {
787 $adv_directives = explode(',', $yoast_adv);
788 foreach ($adv_directives as $directive) {
789 $directive = trim($directive);
790 if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
791 $metasync_robots[$directive] = $directive;
792 $has_changes = true;
793 }
794 }
795 }
796
797 // Also write to _metasync_robots_advanced JSON
798 $robots_advanced = [];
799 if (!empty($yoast_adv)) {
800 $adv_directives = array_map('trim', explode(',', $yoast_adv));
801 foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
802 if (in_array($dir, $adv_directives, true)) {
803 $robots_advanced[$dir] = true;
804 }
805 }
806 }
807 // nofollow from Yoast
808 if ($yoast_nofollow === '1') {
809 $robots_advanced['nofollow'] = true;
810 }
811 if (!empty($robots_advanced)) {
812 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
813 }
814
815 // Import canonical URL
816 $yoast_canonical = get_post_meta($post_id, '_yoast_wpseo_canonical', true);
817 if (!empty($yoast_canonical)) {
818 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
819 if (empty($existing_canonical)) {
820 // Validate: never import a corrupted value such as
821 // "http://Array" from third-party storage.
822 $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($yoast_canonical);
823 if ($clean_canonical !== '') {
824 update_post_meta($post_id, 'meta_canonical', $clean_canonical);
825 $has_changes = true;
826 }
827 }
828 }
829
830 // Save Metasync robots meta if changes were made
831 if ($has_changes) {
832 if (!empty($metasync_robots)) {
833 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
834 }
835 $imported_count++;
836 }
837
838 // Flush per-post object cache to prevent unbounded memory growth across batches
839 clean_post_cache($post_id);
840 }
841
842 $processed = $offset + count($posts);
843 $is_complete = $processed >= $total;
844
845 return [
846 'success' => true,
847 'imported' => $imported_count,
848 'skipped' => count($posts) - $imported_count,
849 'total' => $total,
850 'processed' => $processed,
851 'is_complete' => $is_complete,
852 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
853 'message' => $is_complete
854 ? "Import complete! Imported {$imported_count} posts."
855 : "Processing... {$imported_count} imported."
856 ];
857 }
858
859 /**
860 * Import per-post indexation settings from Rank Math
861 */
862 private function import_rankmath_indexation($options = [])
863 {
864 global $wpdb;
865 $imported_count = 0;
866
867 $batch_size = intval($options['batch_size']);
868 $offset = intval($options['offset']);
869
870 // Get total count (for progress tracking)
871 $total = (int) $wpdb->get_var("
872 SELECT COUNT(DISTINCT post_id)
873 FROM {$wpdb->postmeta}
874 WHERE meta_key LIKE 'rank_math_%'
875 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
876 ");
877
878 // Get batch of posts with Rank Math robots meta
879 $posts = $wpdb->get_results($wpdb->prepare("
880 SELECT DISTINCT post_id
881 FROM {$wpdb->postmeta}
882 WHERE meta_key LIKE 'rank_math_%'
883 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
884 ORDER BY post_id ASC
885 LIMIT %d OFFSET %d
886 ", $batch_size, $offset));
887
888 foreach ($posts as $post_obj) {
889 $post_id = $post_obj->post_id;
890 $has_changes = false;
891
892 // Get existing Metasync robots meta
893 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
894 if (!is_array($metasync_robots)) {
895 $metasync_robots = [];
896 }
897
898 // Import robots array
899 $rm_robots = get_post_meta($post_id, 'rank_math_robots', true);
900 if (is_array($rm_robots)) {
901 // Rank Math stores as array like ['noindex', 'nofollow']
902 foreach ($rm_robots as $directive) {
903 $directive = strtolower(trim($directive));
904 if (in_array($directive, ['index', 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
905 $metasync_robots[$directive] = $directive;
906 $has_changes = true;
907 }
908 }
909 }
910
911 // Import advanced robots
912 $rm_adv_robots = get_post_meta($post_id, 'rank_math_advanced_robots', true);
913 if (is_array($rm_adv_robots)) {
914 foreach ($rm_adv_robots as $directive) {
915 $directive = strtolower(trim($directive));
916 if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex', 'max-snippet', 'max-video-preview', 'max-image-preview']) && !isset($metasync_robots[$directive])) {
917 $metasync_robots[$directive] = $directive;
918 $has_changes = true;
919 }
920 }
921 }
922
923 // Parse max-* values and write _metasync_robots_advanced JSON
924 $robots_advanced = [];
925 // Boolean directives from rank_math_robots
926 if (is_array($rm_robots)) {
927 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
928 if (in_array($dir, $rm_robots, true)) {
929 $robots_advanced[$dir] = true;
930 }
931 }
932 }
933 // max-* directives from rank_math_advanced_robots
934 if (is_array($rm_adv_robots)) {
935 foreach ($rm_adv_robots as $key => $value) {
936 // Values are formatted like "max-snippet:-1"
937 if (strpos($key, 'max-snippet') !== false && strpos($value, ':') !== false) {
938 $robots_advanced['max_snippet'] = (int) explode(':', $value)[1];
939 }
940 if (strpos($key, 'max-image-preview') !== false && strpos($value, ':') !== false) {
941 $robots_advanced['max_image_preview'] = explode(':', $value)[1];
942 }
943 if (strpos($key, 'max-video-preview') !== false && strpos($value, ':') !== false) {
944 $robots_advanced['max_video_preview'] = (int) explode(':', $value)[1];
945 }
946 }
947 }
948 if (!empty($robots_advanced)) {
949 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
950 }
951
952 // Import canonical URL
953 $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
954 if (!empty($rm_canonical)) {
955 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
956 if (empty($existing_canonical)) {
957 // Validate: never import a corrupted value such as
958 // "http://Array" from third-party storage.
959 $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($rm_canonical);
960 if ($clean_canonical !== '') {
961 update_post_meta($post_id, 'meta_canonical', $clean_canonical);
962 $has_changes = true;
963 }
964 }
965 }
966
967 // Save Metasync robots meta if changes were made
968 if ($has_changes) {
969 if (!empty($metasync_robots)) {
970 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
971 }
972 $imported_count++;
973 }
974
975 // Flush per-post object cache to prevent unbounded memory growth across batches
976 clean_post_cache($post_id);
977 }
978
979 $processed = $offset + count($posts);
980 $is_complete = $processed >= $total;
981
982 return [
983 'success' => true,
984 'imported' => $imported_count,
985 'skipped' => count($posts) - $imported_count,
986 'total' => $total,
987 'processed' => $processed,
988 'is_complete' => $is_complete,
989 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
990 'message' => $is_complete
991 ? "Import complete! Imported {$imported_count} posts."
992 : "Processing... {$imported_count} imported."
993 ];
994 }
995
996 /**
997 * Import per-post indexation settings from AIOSEO
998 */
999 private function import_aioseo_indexation($options = [])
1000 {
1001 global $wpdb;
1002 $imported_count = 0;
1003
1004 $batch_size = intval($options['batch_size']);
1005 $offset = intval($options['offset']);
1006
1007 // AIOSEO stores data in a custom table
1008 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
1009
1010 // Check if table exists
1011 $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$aioseo_table'") === $aioseo_table;
1012
1013 if (!$table_exists) {
1014 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1015 }
1016
1017 // Get total count (for progress tracking)
1018 $total = (int) $wpdb->get_var("
1019 SELECT COUNT(*)
1020 FROM {$aioseo_table}
1021 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1022 ");
1023
1024 // Get batch of posts with AIOSEO settings
1025 $posts = $wpdb->get_results($wpdb->prepare("
1026 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
1027 robots_noarchive, robots_nosnippet, robots_noimageindex,
1028 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
1029 canonical_url
1030 FROM {$aioseo_table}
1031 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1032 ORDER BY post_id ASC
1033 LIMIT %d OFFSET %d
1034 ", $batch_size, $offset));
1035
1036 foreach ($posts as $aioseo_data) {
1037 $post_id = $aioseo_data->post_id;
1038 $has_changes = false;
1039
1040 // Get existing Metasync robots meta
1041 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1042 if (!is_array($metasync_robots)) {
1043 $metasync_robots = [];
1044 }
1045
1046 // Only import if not using default (robots_default = 0)
1047 if ($aioseo_data->robots_default == 0) {
1048 // Import noindex
1049 if ($aioseo_data->robots_noindex == 1 && !isset($metasync_robots['noindex'])) {
1050 $metasync_robots['noindex'] = 'noindex';
1051 $has_changes = true;
1052 }
1053
1054 // Import nofollow
1055 if ($aioseo_data->robots_nofollow == 1 && !isset($metasync_robots['nofollow'])) {
1056 $metasync_robots['nofollow'] = 'nofollow';
1057 $has_changes = true;
1058 }
1059
1060 // Import noarchive
1061 if ($aioseo_data->robots_noarchive == 1 && !isset($metasync_robots['noarchive'])) {
1062 $metasync_robots['noarchive'] = 'noarchive';
1063 $has_changes = true;
1064 }
1065
1066 // Import nosnippet
1067 if ($aioseo_data->robots_nosnippet == 1 && !isset($metasync_robots['nosnippet'])) {
1068 $metasync_robots['nosnippet'] = 'nosnippet';
1069 $has_changes = true;
1070 }
1071
1072 // Import noimageindex
1073 if ($aioseo_data->robots_noimageindex == 1 && !isset($metasync_robots['noimageindex'])) {
1074 $metasync_robots['noimageindex'] = 'noimageindex';
1075 $has_changes = true;
1076 }
1077 }
1078
1079 // Write _metasync_robots_advanced JSON
1080 $robots_advanced = [];
1081 if ($aioseo_data->robots_default == 0) {
1082 if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
1083 if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
1084 if ($aioseo_data->robots_nosnippet == 1) $robots_advanced['nosnippet'] = true;
1085 if ($aioseo_data->robots_noimageindex == 1) $robots_advanced['noimageindex'] = true;
1086 }
1087 if (isset($aioseo_data->robots_max_snippet) && is_numeric($aioseo_data->robots_max_snippet)) {
1088 $robots_advanced['max_snippet'] = (int) $aioseo_data->robots_max_snippet;
1089 }
1090 if (!empty($aioseo_data->robots_max_imagepreview)) {
1091 $robots_advanced['max_image_preview'] = $aioseo_data->robots_max_imagepreview;
1092 }
1093 if (isset($aioseo_data->robots_max_videopreview) && is_numeric($aioseo_data->robots_max_videopreview)) {
1094 $robots_advanced['max_video_preview'] = (int) $aioseo_data->robots_max_videopreview;
1095 }
1096 if (!empty($robots_advanced)) {
1097 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
1098 $has_changes = true;
1099 }
1100
1101 // Import canonical URL
1102 if (!empty($aioseo_data->canonical_url)) {
1103 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1104 if (empty($existing_canonical)) {
1105 // Validate: never import a corrupted value such as
1106 // "http://Array" from third-party storage.
1107 $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($aioseo_data->canonical_url);
1108 if ($clean_canonical !== '') {
1109 update_post_meta($post_id, 'meta_canonical', $clean_canonical);
1110 $has_changes = true;
1111 }
1112 }
1113 }
1114
1115 // Save Metasync robots meta if changes were made
1116 if ($has_changes) {
1117 if (!empty($metasync_robots)) {
1118 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1119 }
1120 $imported_count++;
1121 }
1122
1123 // Flush per-post object cache to prevent unbounded memory growth across batches
1124 clean_post_cache($post_id);
1125 }
1126
1127 $processed = $offset + count($posts);
1128 $is_complete = $processed >= $total;
1129
1130 return [
1131 'success' => true,
1132 'imported' => $imported_count,
1133 'skipped' => count($posts) - $imported_count,
1134 'total' => $total,
1135 'processed' => $processed,
1136 'is_complete' => $is_complete,
1137 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1138 'message' => $is_complete
1139 ? "Import complete! Imported {$imported_count} posts."
1140 : "Processing... {$imported_count} imported."
1141 ];
1142 }
1143
1144 /**
1145 * Import Schema Settings (Per-Post Schema)
1146 */
1147 public function import_schema($plugin)
1148 {
1149 $imported_count = 0;
1150
1151 switch ($plugin) {
1152 case 'yoast':
1153 $imported_count = $this->import_yoast_schema();
1154 break;
1155
1156 case 'rankmath':
1157 $imported_count = $this->import_rankmath_schema();
1158 break;
1159
1160 case 'aioseo':
1161 $imported_count = $this->import_aioseo_schema();
1162 break;
1163
1164 default:
1165 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1166 }
1167
1168 if ($imported_count > 0) {
1169 return ['success' => true, 'message' => "Successfully imported schema settings for $imported_count posts."];
1170 }
1171
1172 return ['success' => false, 'message' => 'No post-level schema settings found to import.'];
1173 }
1174
1175 /**
1176 * Import per-post schema from Yoast SEO
1177 */
1178 private function import_yoast_schema()
1179 {
1180 global $wpdb;
1181 $imported_count = 0;
1182
1183 // First, try to import from full schema JSON (free version or old approach)
1184 $posts = $wpdb->get_results("
1185 SELECT post_id, meta_value
1186 FROM {$wpdb->postmeta}
1187 WHERE meta_key = '_yoast_wpseo_schema'
1188 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1189 ");
1190
1191 foreach ($posts as $post_obj) {
1192 $post_id = $post_obj->post_id;
1193
1194 // Check if Metasync schema already exists
1195 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1196 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1197 continue; // Skip if already has Metasync schema
1198 }
1199
1200 // Decode Yoast schema JSON
1201 $yoast_schema = json_decode((string)($post_obj->meta_value ?? ''), true);
1202 if (empty($yoast_schema) || !is_array($yoast_schema)) {
1203 continue;
1204 }
1205
1206 // Convert Yoast schema to Metasync format
1207 $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1208
1209 if (!empty($metasync_schema['types'])) {
1210 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1211 $imported_count++;
1212 }
1213 }
1214
1215 // Second, try to import from schema type (Premium version)
1216 $posts = $wpdb->get_results("
1217 SELECT post_id, meta_value
1218 FROM {$wpdb->postmeta}
1219 WHERE meta_key = '_yoast_wpseo_schema_article_type'
1220 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1221 ");
1222
1223 foreach ($posts as $post_obj) {
1224 $post_id = $post_obj->post_id;
1225
1226 // Check if Metasync schema already exists
1227 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1228 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1229 continue; // Skip if already has Metasync schema
1230 }
1231
1232 $schema_type = strtolower($post_obj->meta_value);
1233
1234 // Create basic article schema with placeholders
1235 // Yoast Premium generates schema dynamically, so we create a minimal version
1236 if ($schema_type === 'article' || $schema_type === 'newsarticle' || $schema_type === 'blogposting') {
1237 $metasync_schema = [
1238 'enabled' => true,
1239 'types' => [
1240 [
1241 'type' => 'article',
1242 'fields' => [
1243 'title_override' => '{{post_title}}',
1244 'description_override' => '{{post_description}}',
1245 'image_override' => '{{featured_image}}',
1246 'organization_name' => '',
1247 'organization_logo' => ''
1248 ]
1249 ]
1250 ]
1251 ];
1252
1253 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1254 $imported_count++;
1255 }
1256 }
1257
1258 return $imported_count;
1259 }
1260
1261 /**
1262 * Import per-post schema from Rank Math
1263 */
1264 private function import_rankmath_schema()
1265 {
1266 global $wpdb;
1267 $imported_count = 0;
1268
1269 // Get all posts with any Rank Math schema (dynamically detect schema types)
1270 // Exclude shortcode schemas
1271 $posts = $wpdb->get_results("
1272 SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1273 FROM {$wpdb->postmeta} pm
1274 WHERE pm.meta_key LIKE 'rank_math_schema_%'
1275 AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
1276 AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1277 ORDER BY pm.post_id
1278 ");
1279
1280 $processed_posts = [];
1281
1282 foreach ($posts as $post_obj) {
1283 $post_id = $post_obj->post_id;
1284
1285 // Skip if we already processed this post
1286 if (in_array($post_id, $processed_posts)) {
1287 continue;
1288 }
1289
1290 // Check if Metasync schema already exists for this post
1291 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1292 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1293 continue; // Skip if already has Metasync schema
1294 }
1295
1296 // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1297 $schema_type = str_replace('rank_math_schema_', '', $post_obj->meta_key);
1298
1299 // Decode Rank Math schema
1300 $rm_schema = maybe_unserialize($post_obj->meta_value);
1301 if (empty($rm_schema) || !is_array($rm_schema)) {
1302 continue;
1303 }
1304
1305 // Convert Rank Math schema to Metasync format
1306 $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1307
1308 if (!empty($metasync_schema['types'])) {
1309 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1310 $imported_count++;
1311 $processed_posts[] = $post_id; // Mark post as processed
1312 }
1313 }
1314
1315 return $imported_count;
1316 }
1317
1318 /**
1319 * Import per-post schema from AIOSEO
1320 */
1321 private function import_aioseo_schema()
1322 {
1323 global $wpdb;
1324 $imported_count = 0;
1325
1326 // Check if AIOSEO table exists
1327 $table = $wpdb->prefix . 'aioseo_posts';
1328 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
1329 return 0;
1330 }
1331
1332 // Get all posts with AIOSEO schema
1333 $posts = $wpdb->get_results("
1334 SELECT post_id, schema_type, schema_type_options
1335 FROM {$table}
1336 WHERE (schema_type IS NOT NULL AND schema_type != '')
1337 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1338 ");
1339
1340 foreach ($posts as $aioseo_data) {
1341 $post_id = $aioseo_data->post_id;
1342
1343 // Check if Metasync schema already exists
1344 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1345 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1346 continue; // Skip if already has Metasync schema
1347 }
1348
1349 // Decode AIOSEO schema options
1350 $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
1351 if (!is_array($schema_options)) {
1352 $schema_options = [];
1353 }
1354
1355 // Convert AIOSEO schema to Metasync format
1356 $metasync_schema = $this->convert_aioseo_schema_to_metasync(
1357 $aioseo_data->schema_type,
1358 $schema_options,
1359 $post_id
1360 );
1361
1362 if (!empty($metasync_schema['types'])) {
1363 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1364 $imported_count++;
1365 }
1366 }
1367
1368 return $imported_count;
1369 }
1370
1371 /**
1372 * Convert Yoast schema to Metasync format
1373 */
1374 private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1375 {
1376 $metasync_schema = [
1377 'enabled' => true,
1378 'types' => []
1379 ];
1380
1381 // Yoast stores schema as a graph array
1382 if (isset($yoast_schema['@graph']) && is_array($yoast_schema['@graph'])) {
1383 foreach ($yoast_schema['@graph'] as $item) {
1384 if (!isset($item['@type'])) {
1385 continue;
1386 }
1387
1388 $raw_type = $item['@type'];
1389 if ( is_array( $raw_type ) ) {
1390 $raw_type = reset( $raw_type );
1391 }
1392 if ( ! is_string( $raw_type ) || '' === $raw_type ) {
1393 continue;
1394 }
1395 $type = strtolower( $raw_type );
1396
1397 // Map Yoast types to Metasync types
1398 if ($type === 'article' || $type === 'newsarticle' || $type === 'blogposting') {
1399 $metasync_schema['types'][] = [
1400 'type' => 'article',
1401 'fields' => [
1402 'title_override' => isset($item['headline']) ? $item['headline'] : '{{post_title}}',
1403 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1404 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1405 'organization_name' => isset($item['publisher']['name']) ? $item['publisher']['name'] : '',
1406 'organization_logo' => isset($item['publisher']['logo']['url']) ? $item['publisher']['logo']['url'] : ''
1407 ]
1408 ];
1409 } elseif ($type === 'faqpage') {
1410 $faq_items = [];
1411 if (isset($item['mainEntity']) && is_array($item['mainEntity'])) {
1412 foreach ($item['mainEntity'] as $question) {
1413 if (isset($question['name']) && isset($question['acceptedAnswer']['text'])) {
1414 $faq_items[] = [
1415 'question' => $question['name'],
1416 'answer' => $question['acceptedAnswer']['text']
1417 ];
1418 }
1419 }
1420 }
1421 if (!empty($faq_items)) {
1422 $metasync_schema['types'][] = [
1423 'type' => 'FAQPage',
1424 'fields' => [
1425 'faq_items' => $faq_items
1426 ]
1427 ];
1428 }
1429 } elseif ($type === 'product') {
1430 $metasync_schema['types'][] = [
1431 'type' => 'product',
1432 'fields' => [
1433 'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
1434 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1435 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1436 'sku' => isset($item['sku']) ? $item['sku'] : '',
1437 'brand' => isset($item['brand']['name']) ? $item['brand']['name'] : '',
1438 'price' => isset($item['offers']['price']) ? floatval($item['offers']['price']) : 0,
1439 'currency' => isset($item['offers']['priceCurrency']) ? $item['offers']['priceCurrency'] : 'USD',
1440 'availability' => isset($item['offers']['availability']) ? basename($item['offers']['availability']) : 'InStock',
1441 'condition' => isset($item['offers']['itemCondition']) ? basename($item['offers']['itemCondition']) : 'NewCondition'
1442 ]
1443 ];
1444 } elseif ($type === 'recipe') {
1445 $ingredients = [];
1446 if (isset($item['recipeIngredient']) && is_array($item['recipeIngredient'])) {
1447 $ingredients = $item['recipeIngredient'];
1448 }
1449
1450 $instructions = [];
1451 if (isset($item['recipeInstructions']) && is_array($item['recipeInstructions'])) {
1452 foreach ($item['recipeInstructions'] as $step) {
1453 if (is_string($step)) {
1454 $instructions[] = $step;
1455 } elseif (isset($step['text'])) {
1456 $instructions[] = $step['text'];
1457 }
1458 }
1459 }
1460
1461 $metasync_schema['types'][] = [
1462 'type' => 'recipe',
1463 'fields' => [
1464 'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
1465 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1466 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1467 'yield' => isset($item['recipeYield']) ? $item['recipeYield'] : '',
1468 'ingredients' => $ingredients,
1469 'instructions' => $instructions,
1470 'prep_time' => isset($item['prepTime']) ? $this->parse_duration($item['prepTime']) : 0,
1471 'cook_time' => isset($item['cookTime']) ? $this->parse_duration($item['cookTime']) : 0,
1472 'total_time' => isset($item['totalTime']) ? $this->parse_duration($item['totalTime']) : 0,
1473 'calories' => isset($item['nutrition']['calories']) ? intval($item['nutrition']['calories']) : 0
1474 ]
1475 ];
1476 }
1477 }
1478 }
1479
1480 return $metasync_schema;
1481 }
1482
1483 /**
1484 * Convert Rank Math schema to Metasync format
1485 */
1486 private function convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id)
1487 {
1488 $metasync_schema = [
1489 'enabled' => true,
1490 'types' => []
1491 ];
1492
1493 $type = strtolower($schema_type);
1494
1495 // Handle article-like schema types (Article, BlogPosting, NewsArticle, etc.)
1496 if ($type === 'article' || $type === 'blogposting' || $type === 'newsarticle') {
1497 $metasync_schema['types'][] = [
1498 'type' => 'article',
1499 'fields' => [
1500 'title_override' => $this->normalize_text_value($rm_schema['headline'] ?? null, '{{post_title}}'),
1501 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
1502 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
1503 'organization_name' => isset($rm_schema['publisher']) ? $rm_schema['publisher'] : '',
1504 'organization_logo' => isset($rm_schema['publisher_logo']) ? $rm_schema['publisher_logo'] : ''
1505 ]
1506 ];
1507 } elseif ($type === 'faqpage') {
1508 $faq_items = [];
1509 if (isset($rm_schema['questions']) && is_array($rm_schema['questions'])) {
1510 foreach ($rm_schema['questions'] as $question) {
1511 if (isset($question['name']) && isset($question['text'])) {
1512 $faq_items[] = [
1513 'question' => $question['name'],
1514 'answer' => $question['text']
1515 ];
1516 }
1517 }
1518 }
1519 if (!empty($faq_items)) {
1520 $metasync_schema['types'][] = [
1521 'type' => 'FAQPage',
1522 'fields' => [
1523 'faq_items' => $faq_items
1524 ]
1525 ];
1526 }
1527 } elseif ($type === 'product') {
1528 $metasync_schema['types'][] = [
1529 'type' => 'product',
1530 'fields' => [
1531 'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
1532 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
1533 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
1534 'sku' => isset($rm_schema['sku']) ? $rm_schema['sku'] : '',
1535 'brand' => isset($rm_schema['brand']) ? $rm_schema['brand'] : '',
1536 'price' => isset($rm_schema['price']) ? floatval($rm_schema['price']) : 0,
1537 'currency' => isset($rm_schema['currency']) ? $rm_schema['currency'] : 'USD',
1538 'availability' => isset($rm_schema['inStock']) ? ($rm_schema['inStock'] ? 'InStock' : 'OutOfStock') : 'InStock',
1539 'condition' => 'NewCondition'
1540 ]
1541 ];
1542 } elseif ($type === 'recipe') {
1543 $metasync_schema['types'][] = [
1544 'type' => 'recipe',
1545 'fields' => [
1546 'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
1547 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
1548 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
1549 'yield' => isset($rm_schema['recipeYield']) ? $rm_schema['recipeYield'] : '',
1550 'ingredients' => isset($rm_schema['recipeIngredient']) ? $rm_schema['recipeIngredient'] : [],
1551 'instructions' => isset($rm_schema['recipeInstructions']) ? $rm_schema['recipeInstructions'] : [],
1552 'prep_time' => isset($rm_schema['prepTime']) ? intval($rm_schema['prepTime']) : 0,
1553 'cook_time' => isset($rm_schema['cookTime']) ? intval($rm_schema['cookTime']) : 0,
1554 'total_time' => isset($rm_schema['totalTime']) ? intval($rm_schema['totalTime']) : 0,
1555 'calories' => isset($rm_schema['calories']) ? intval($rm_schema['calories']) : 0
1556 ]
1557 ];
1558 }
1559
1560 return $metasync_schema;
1561 }
1562
1563 /**
1564 * Convert AIOSEO schema to Metasync format
1565 */
1566 private function convert_aioseo_schema_to_metasync($schema_type, $schema_options, $post_id)
1567 {
1568 $metasync_schema = [
1569 'enabled' => true,
1570 'types' => []
1571 ];
1572
1573 $type = strtolower($schema_type);
1574
1575 if ($type === 'article') {
1576 $metasync_schema['types'][] = [
1577 'type' => 'article',
1578 'fields' => [
1579 'title_override' => isset($schema_options['headline']) ? $schema_options['headline'] : '{{post_title}}',
1580 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
1581 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
1582 'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
1583 'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
1584 ]
1585 ];
1586 } elseif ($type === 'faqpage') {
1587 $faq_items = [];
1588 if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
1589 foreach ($schema_options['questions'] as $question) {
1590 if (isset($question['question']) && isset($question['answer'])) {
1591 $faq_items[] = [
1592 'question' => $question['question'],
1593 'answer' => $question['answer']
1594 ];
1595 }
1596 }
1597 }
1598 if (!empty($faq_items)) {
1599 $metasync_schema['types'][] = [
1600 'type' => 'FAQPage',
1601 'fields' => [
1602 'faq_items' => $faq_items
1603 ]
1604 ];
1605 }
1606 } elseif ($type === 'product') {
1607 $metasync_schema['types'][] = [
1608 'type' => 'product',
1609 'fields' => [
1610 'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
1611 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
1612 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
1613 'sku' => isset($schema_options['sku']) ? $schema_options['sku'] : '',
1614 'brand' => isset($schema_options['brand']) ? $schema_options['brand'] : '',
1615 'price' => isset($schema_options['price']) ? floatval($schema_options['price']) : 0,
1616 'currency' => isset($schema_options['currency']) ? $schema_options['currency'] : 'USD',
1617 'availability' => isset($schema_options['availability']) ? $schema_options['availability'] : 'InStock',
1618 'condition' => isset($schema_options['condition']) ? $schema_options['condition'] : 'NewCondition'
1619 ]
1620 ];
1621 } elseif ($type === 'recipe') {
1622 $metasync_schema['types'][] = [
1623 'type' => 'recipe',
1624 'fields' => [
1625 'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
1626 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
1627 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
1628 'yield' => isset($schema_options['recipeYield']) ? $schema_options['recipeYield'] : '',
1629 'ingredients' => isset($schema_options['recipeIngredient']) ? $schema_options['recipeIngredient'] : [],
1630 'instructions' => isset($schema_options['recipeInstructions']) ? $schema_options['recipeInstructions'] : [],
1631 'prep_time' => isset($schema_options['prepTime']) ? intval($schema_options['prepTime']) : 0,
1632 'cook_time' => isset($schema_options['cookTime']) ? intval($schema_options['cookTime']) : 0,
1633 'total_time' => isset($schema_options['totalTime']) ? intval($schema_options['totalTime']) : 0,
1634 'calories' => isset($schema_options['calories']) ? intval($schema_options['calories']) : 0
1635 ]
1636 ];
1637 }
1638
1639 return $metasync_schema;
1640 }
1641
1642 /**
1643 * Parse ISO 8601 duration to minutes
1644 * e.g., "PT15M" = 15 minutes, "PT1H30M" = 90 minutes
1645 */
1646 private function parse_duration($duration)
1647 {
1648 if (empty($duration)) {
1649 return 0;
1650 }
1651
1652 // Simple parser for PT format
1653 $minutes = 0;
1654 if (preg_match('/PT(\d+)H/', $duration, $hours)) {
1655 $minutes += intval($hours[1]) * 60;
1656 }
1657 if (preg_match('/(\d+)M/', $duration, $mins)) {
1658 $minutes += intval($mins[1]);
1659 }
1660
1661 return $minutes;
1662 }
1663
1664 /**
1665 * Normalize image value to string URL
1666 * Handles arrays from Rank Math/Yoast and converts placeholders
1667 */
1668 private function normalize_image_value($image)
1669 {
1670 if (empty($image)) {
1671 return '{{featured_image}}';
1672 }
1673
1674 // If it's an array (from Rank Math/Yoast), extract the URL
1675 if (is_array($image)) {
1676 // Check for 'url' key first
1677 if (isset($image['url'])) {
1678 $image = $image['url'];
1679 }
1680 // Check for '@id' key (Yoast format)
1681 elseif (isset($image['@id'])) {
1682 $image = $image['@id'];
1683 }
1684 // If it's still an array, try to get first element
1685 elseif (isset($image[0])) {
1686 $image = is_string($image[0]) ? $image[0] : '{{featured_image}}';
1687 }
1688 else {
1689 $image = '{{featured_image}}';
1690 }
1691 }
1692
1693 // Convert common placeholder formats to Metasync format
1694 $placeholder_map = [
1695 '%post_thumbnail%' => '{{featured_image}}',
1696 '%featured_image%' => '{{featured_image}}',
1697 '%seo_title%' => '{{post_title}}',
1698 '%post_title%' => '{{post_title}}',
1699 '%seo_description%' => '{{post_description}}',
1700 '%post_excerpt%' => '{{post_description}}'
1701 ];
1702
1703 foreach ($placeholder_map as $old => $new) {
1704 if ($image === $old || strpos($image, $old) !== false) {
1705 $image = str_replace($old, $new, $image);
1706 }
1707 }
1708
1709 return is_string($image) ? $image : '{{featured_image}}';
1710 }
1711
1712 /**
1713 * Normalize text value to string
1714 * Converts placeholders to Metasync format
1715 */
1716 private function normalize_text_value($text, $default = '')
1717 {
1718 if (empty($text)) {
1719 return $default;
1720 }
1721
1722 // Convert common placeholder formats to Metasync format
1723 $placeholder_map = [
1724 '%seo_title%' => '{{post_title}}',
1725 '%post_title%' => '{{post_title}}',
1726 '%seo_description%' => '{{post_description}}',
1727 '%post_excerpt%' => '{{post_description}}'
1728 ];
1729
1730 foreach ($placeholder_map as $old => $new) {
1731 if (is_string($text) && (strpos($text, $old) !== false || $text === $old)) {
1732 $text = str_replace($old, $new, $text);
1733 }
1734 }
1735
1736 return is_string($text) ? $text : $default;
1737 }
1738
1739 /**
1740 * Resolve SEO placeholder tokens to their actual values.
1741 *
1742 * Tries each source plugin's own replacement engine first so the result
1743 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
1744 * to manual replacement of the most common tokens.
1745 *
1746 * @param string $text Raw string that may contain placeholder tokens.
1747 * @param int $post_id Post whose context is used for replacement.
1748 * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
1749 * @return string Resolved string.
1750 */
1751 private function resolve_seo_placeholders($text, $post_id, $plugin) {
1752 if (empty($text) || !is_string($text)) {
1753 return (string) $text;
1754 }
1755
1756 $post = get_post($post_id);
1757 if (!$post) {
1758 return $text;
1759 }
1760
1761 // Yoast SEO — %%var%% tokens
1762 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1763 try {
1764 $replacer = new WPSEO_Replace_Vars();
1765 $resolved = $replacer->replace($text, $post);
1766 if (is_string($resolved) && $resolved !== '') {
1767 return $resolved;
1768 }
1769 } catch (Exception $e) {
1770 // fall through to manual replacement
1771 }
1772 }
1773
1774 // Rank Math — %var% tokens
1775 if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
1776 try {
1777 $resolved = rank_math_replace_vars($text, $post);
1778 if (is_string($resolved) && $resolved !== '') {
1779 return $resolved;
1780 }
1781 } catch (Exception $e) {
1782 // fall through to manual replacement
1783 }
1784 }
1785
1786 // All in One SEO — #var# tokens
1787 if ($plugin === 'aioseo') {
1788 try {
1789 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1790 $resolved = aioseo()->tags->replaceTags($text, $post_id);
1791 if (is_string($resolved) && $resolved !== '') {
1792 return $resolved;
1793 }
1794 }
1795 } catch (Exception $e) {
1796 // fall through to manual replacement
1797 }
1798 }
1799
1800 return $this->manually_replace_seo_placeholders($text, $post_id);
1801 }
1802
1803 /**
1804 * Manual fallback: replace the most common SEO placeholder tokens from
1805 * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
1806 *
1807 * @param string $text Text that may contain placeholder tokens.
1808 * @param int $post_id Post ID used for context.
1809 * @return string Text with tokens replaced.
1810 */
1811 private function manually_replace_seo_placeholders($text, $post_id) {
1812 $post = get_post($post_id);
1813 if (!$post) {
1814 return $text;
1815 }
1816
1817 $site_name = get_bloginfo('name');
1818 $post_title = get_the_title($post_id);
1819 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1820 $author = get_the_author_meta('display_name', $post->post_author);
1821 $date = get_the_date('', $post_id);
1822 $modified = get_the_modified_date('', $post_id);
1823
1824 $categories = get_the_category($post_id);
1825 $primary_category = !empty($categories) ? $categories[0]->name : '';
1826
1827 $tags = get_the_tags($post_id);
1828 $first_tag = !empty($tags) ? $tags[0]->name : '';
1829
1830 // Try to read the separator from whichever plugin is active
1831 $sep = '-';
1832 if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1833 try {
1834 $raw = WPSEO_Options::get('separator', 'sc-dash');
1835 // Yoast stores separator keys like 'sc-dash'; convert to glyph
1836 // via the public get_separator_options() lookup table.
1837 if (class_exists('WPSEO_Option_Titles')) {
1838 $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
1839 if (isset($options[$raw])) {
1840 $sep = html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
1841 }
1842 }
1843 } catch (Exception $e) {
1844 $sep = '-';
1845 }
1846 } elseif (defined('RANK_MATH_VERSION')) {
1847 $rm_settings = get_option('rank_math_general_settings', []);
1848 if (!empty($rm_settings['title_separator'])) {
1849 $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1850 }
1851 }
1852
1853 $map = [
1854 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1855 '%%title%%' => $post_title,
1856 '%%sitename%%' => $site_name,
1857 '%%sep%%' => $sep,
1858 '%%excerpt%%' => $post_excerpt,
1859 '%%excerpt_only%%' => $post_excerpt,
1860 '%%author%%' => $author,
1861 '%%date%%' => $date,
1862 '%%modified%%' => $modified,
1863 '%%id%%' => (string) $post_id,
1864 '%%page%%' => '',
1865 '%%pagenumber%%' => '',
1866 '%%pagetotal%%' => '',
1867 '%%primary_category%%' => $primary_category,
1868 '%%category%%' => $primary_category,
1869 '%%tag%%' => $first_tag,
1870 '%%focuskw%%' => (string) get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
1871 // ── Rank Math (%var%) ────────────────────────────────────────────
1872 '%title%' => $post_title,
1873 '%sitename%' => $site_name,
1874 '%sep%' => $sep,
1875 '%excerpt%' => $post_excerpt,
1876 '%author%' => $author,
1877 '%date%' => $date,
1878 '%modified%' => $modified,
1879 '%id%' => (string) $post_id,
1880 '%page%' => '',
1881 '%category%' => $primary_category,
1882 '%tag%' => $first_tag,
1883 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1884 // ── AIOSEO (#var#) ───────────────────────────────────────────────
1885 '#site_title#' => $site_name,
1886 '#post_title#' => $post_title,
1887 '#post_excerpt#' => $post_excerpt,
1888 '#separator_sa#' => $sep,
1889 '#author_name#' => $author,
1890 '#current_date#' => $date,
1891 '#post_date#' => $date,
1892 '#category_title#' => $primary_category,
1893 '#tag_title#' => $first_tag,
1894 '#id#' => (string) $post_id,
1895 ];
1896
1897 // Sort longest token first to avoid partial matches
1898 // e.g. %%primary_category%% must replace before %%category%%
1899 uksort($map, function ($a, $b) {
1900 return strlen($b) - strlen($a);
1901 });
1902
1903 return str_replace(array_keys($map), array_values($map), $text);
1904 }
1905
1906 /**
1907 * Import SEO Metadata (Titles and Descriptions)
1908 * Supports batch processing via AJAX
1909 *
1910 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
1911 * @param array $options Import options (import_titles, import_descriptions, overwrite_existing, batch_size, offset)
1912 * @return array Result with success status, progress info, and statistics
1913 */
1914 public function import_seo_metadata($plugin, $options = [])
1915 {
1916 // Default options
1917 $defaults = [
1918 'import_titles' => true,
1919 'import_descriptions' => true,
1920 'overwrite_existing' => false,
1921 'batch_size' => 50, // Process 50 posts per batch
1922 'offset' => 0
1923 ];
1924 $options = array_merge($defaults, $options);
1925
1926 // Validate plugin
1927 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
1928 return [
1929 'success' => false,
1930 'message' => 'Invalid plugin specified.'
1931 ];
1932 }
1933
1934 // Route to appropriate import method
1935 switch ($plugin) {
1936 case 'yoast':
1937 return $this->import_yoast_seo_metadata($options);
1938 case 'rankmath':
1939 return $this->import_rankmath_seo_metadata($options);
1940 case 'aioseo':
1941 return $this->import_aioseo_seo_metadata($options);
1942 }
1943
1944 return [
1945 'success' => false,
1946 'message' => 'Unknown error occurred.'
1947 ];
1948 }
1949
1950 /**
1951 * Import SEO metadata from Yoast SEO
1952 */
1953 private function import_yoast_seo_metadata($options)
1954 {
1955 global $wpdb;
1956
1957 $batch_size = intval($options['batch_size']);
1958 $offset = intval($options['offset']);
1959 $import_titles = (bool) $options['import_titles'];
1960 $import_descriptions = (bool) $options['import_descriptions'];
1961 $import_social_text = !empty($options['import_social_text']);
1962 $import_social_images = !empty($options['import_social_images']);
1963 $overwrite = (bool) $options['overwrite_existing'];
1964
1965 // Build WHERE clause for meta keys
1966 $meta_keys = [];
1967 if ($import_titles) {
1968 $meta_keys[] = '_yoast_wpseo_title';
1969 }
1970 if ($import_descriptions) {
1971 $meta_keys[] = '_yoast_wpseo_metadesc';
1972 }
1973 if ($import_social_text) {
1974 $meta_keys[] = '_yoast_wpseo_opengraph-title';
1975 $meta_keys[] = '_yoast_wpseo_opengraph-description';
1976 $meta_keys[] = '_yoast_wpseo_twitter-title';
1977 $meta_keys[] = '_yoast_wpseo_twitter-description';
1978 }
1979 if ($import_social_images) {
1980 $meta_keys[] = '_yoast_wpseo_opengraph-image';
1981 $meta_keys[] = '_yoast_wpseo_twitter-image';
1982 }
1983
1984 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1985 return [
1986 'success' => false,
1987 'message' => 'No import options selected.'
1988 ];
1989 }
1990
1991 // Get total count (for progress tracking)
1992 $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1993 $total_posts = (int) $wpdb->get_var($wpdb->prepare("
1994 SELECT COUNT(DISTINCT post_id)
1995 FROM {$wpdb->postmeta}
1996 WHERE meta_key IN ($meta_keys_placeholders)
1997 AND meta_value != ''
1998 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1999 ", $meta_keys));
2000
2001 // Get batch of posts with Yoast data
2002 $posts = $wpdb->get_results($wpdb->prepare("
2003 SELECT DISTINCT post_id
2004 FROM {$wpdb->postmeta}
2005 WHERE meta_key IN ($meta_keys_placeholders)
2006 AND meta_value != ''
2007 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2008 ORDER BY post_id ASC
2009 LIMIT %d OFFSET %d
2010 ", array_merge($meta_keys, [$batch_size, $offset])));
2011
2012 $imported_count = 0;
2013 $skipped_count = 0;
2014
2015 foreach ($posts as $post_obj) {
2016 $post_id = $post_obj->post_id;
2017 $updated = false;
2018
2019 // Import title
2020 if ($import_titles) {
2021 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
2022 if (!empty($yoast_title)) {
2023 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2024
2025 if (empty($existing_title) || $overwrite) {
2026 $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
2027 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
2028 $updated = true;
2029 }
2030 }
2031 }
2032
2033 // Import description
2034 if ($import_descriptions) {
2035 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
2036 if (!empty($yoast_desc)) {
2037 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2038
2039 if (empty($existing_desc) || $overwrite) {
2040 $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
2041 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
2042 $updated = true;
2043 }
2044 }
2045 }
2046
2047 // Import social text
2048 if ($import_social_text) {
2049 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
2050 if (!empty($yoast_og_title)) {
2051 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2052 if (empty($existing) || $overwrite) {
2053 $yoast_og_title = $this->resolve_seo_placeholders($yoast_og_title, $post_id, 'yoast');
2054 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($yoast_og_title));
2055 $updated = true;
2056 }
2057 }
2058
2059 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
2060 if (!empty($yoast_og_desc)) {
2061 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2062 if (empty($existing) || $overwrite) {
2063 $yoast_og_desc = $this->resolve_seo_placeholders($yoast_og_desc, $post_id, 'yoast');
2064 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($yoast_og_desc));
2065 $updated = true;
2066 }
2067 }
2068
2069 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
2070 if (!empty($yoast_tw_title)) {
2071 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2072 if (empty($existing) || $overwrite) {
2073 $yoast_tw_title = $this->resolve_seo_placeholders($yoast_tw_title, $post_id, 'yoast');
2074 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($yoast_tw_title));
2075 $updated = true;
2076 }
2077 }
2078
2079 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
2080 if (!empty($yoast_tw_desc)) {
2081 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2082 if (empty($existing) || $overwrite) {
2083 $yoast_tw_desc = $this->resolve_seo_placeholders($yoast_tw_desc, $post_id, 'yoast');
2084 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($yoast_tw_desc));
2085 $updated = true;
2086 }
2087 }
2088 }
2089
2090 // Import social images
2091 if ($import_social_images) {
2092 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
2093 if (!empty($yoast_og_image)) {
2094 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2095 if (empty($existing_og) || $overwrite) {
2096 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($yoast_og_image));
2097 $updated = true;
2098 }
2099 }
2100
2101 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
2102 if (!empty($yoast_twitter_image)) {
2103 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2104 if (empty($existing_twitter) || $overwrite) {
2105 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($yoast_twitter_image));
2106 $updated = true;
2107 }
2108 }
2109 }
2110
2111 if ($updated) {
2112 $imported_count++;
2113 } else {
2114 $skipped_count++;
2115 }
2116 }
2117
2118 $processed = $offset + count($posts);
2119 $is_complete = $processed >= $total_posts;
2120
2121 return [
2122 'success' => true,
2123 'imported' => $imported_count,
2124 'skipped' => $skipped_count,
2125 'total' => $total_posts,
2126 'processed' => $processed,
2127 'is_complete' => $is_complete,
2128 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2129 'message' => $is_complete
2130 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2131 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2132 ];
2133 }
2134
2135 /**
2136 * Import SEO metadata from Rank Math
2137 */
2138 private function import_rankmath_seo_metadata($options)
2139 {
2140 global $wpdb;
2141
2142 $batch_size = intval($options['batch_size']);
2143 $offset = intval($options['offset']);
2144 $import_titles = (bool) $options['import_titles'];
2145 $import_descriptions = (bool) $options['import_descriptions'];
2146 $import_social_text = !empty($options['import_social_text']);
2147 $import_social_images = !empty($options['import_social_images']);
2148 $overwrite = (bool) $options['overwrite_existing'];
2149
2150 // Build WHERE clause for meta keys
2151 $meta_keys = [];
2152 if ($import_titles) {
2153 $meta_keys[] = 'rank_math_title';
2154 }
2155 if ($import_descriptions) {
2156 $meta_keys[] = 'rank_math_description';
2157 }
2158 if ($import_social_text) {
2159 $meta_keys[] = 'rank_math_facebook_title';
2160 $meta_keys[] = 'rank_math_facebook_description';
2161 $meta_keys[] = 'rank_math_twitter_title';
2162 $meta_keys[] = 'rank_math_twitter_description';
2163 }
2164 if ($import_social_images) {
2165 $meta_keys[] = 'rank_math_facebook_image';
2166 $meta_keys[] = 'rank_math_twitter_image';
2167 }
2168
2169 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2170 return [
2171 'success' => false,
2172 'message' => 'No import options selected.'
2173 ];
2174 }
2175
2176 // Get total count
2177 $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
2178 $total_posts = (int) $wpdb->get_var($wpdb->prepare("
2179 SELECT COUNT(DISTINCT post_id)
2180 FROM {$wpdb->postmeta}
2181 WHERE meta_key IN ($meta_keys_placeholders)
2182 AND meta_value != ''
2183 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2184 ", $meta_keys));
2185
2186 // Get batch of posts
2187 $posts = $wpdb->get_results($wpdb->prepare("
2188 SELECT DISTINCT post_id
2189 FROM {$wpdb->postmeta}
2190 WHERE meta_key IN ($meta_keys_placeholders)
2191 AND meta_value != ''
2192 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2193 ORDER BY post_id ASC
2194 LIMIT %d OFFSET %d
2195 ", array_merge($meta_keys, [$batch_size, $offset])));
2196
2197 $imported_count = 0;
2198 $skipped_count = 0;
2199
2200 foreach ($posts as $post_obj) {
2201 $post_id = $post_obj->post_id;
2202 $updated = false;
2203
2204 // Import title
2205 if ($import_titles) {
2206 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2207 if (!empty($rm_title)) {
2208 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2209
2210 if (empty($existing_title) || $overwrite) {
2211 $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2212 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2213 $updated = true;
2214 }
2215 }
2216 }
2217
2218 // Import description
2219 if ($import_descriptions) {
2220 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2221 if (!empty($rm_desc)) {
2222 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2223
2224 if (empty($existing_desc) || $overwrite) {
2225 $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2226 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2227 $updated = true;
2228 }
2229 }
2230 }
2231
2232 // Import social text
2233 if ($import_social_text) {
2234 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
2235 if (!empty($rm_og_title)) {
2236 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2237 if (empty($existing) || $overwrite) {
2238 $rm_og_title = $this->resolve_seo_placeholders($rm_og_title, $post_id, 'rankmath');
2239 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($rm_og_title));
2240 $updated = true;
2241 }
2242 }
2243
2244 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
2245 if (!empty($rm_og_desc)) {
2246 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2247 if (empty($existing) || $overwrite) {
2248 $rm_og_desc = $this->resolve_seo_placeholders($rm_og_desc, $post_id, 'rankmath');
2249 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($rm_og_desc));
2250 $updated = true;
2251 }
2252 }
2253
2254 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
2255 if (!empty($rm_tw_title)) {
2256 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2257 if (empty($existing) || $overwrite) {
2258 $rm_tw_title = $this->resolve_seo_placeholders($rm_tw_title, $post_id, 'rankmath');
2259 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($rm_tw_title));
2260 $updated = true;
2261 }
2262 }
2263
2264 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
2265 if (!empty($rm_tw_desc)) {
2266 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2267 if (empty($existing) || $overwrite) {
2268 $rm_tw_desc = $this->resolve_seo_placeholders($rm_tw_desc, $post_id, 'rankmath');
2269 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($rm_tw_desc));
2270 $updated = true;
2271 }
2272 }
2273 }
2274
2275 // Import social images
2276 if ($import_social_images) {
2277 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
2278 if (!empty($rm_og_image)) {
2279 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2280 if (empty($existing_og) || $overwrite) {
2281 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($rm_og_image));
2282 $updated = true;
2283 }
2284 }
2285
2286 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
2287 if (!empty($rm_twitter_image)) {
2288 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2289 if (empty($existing_twitter) || $overwrite) {
2290 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($rm_twitter_image));
2291 $updated = true;
2292 }
2293 }
2294 }
2295
2296 if ($updated) {
2297 $imported_count++;
2298 } else {
2299 $skipped_count++;
2300 }
2301 }
2302
2303 $processed = $offset + count($posts);
2304 $is_complete = $processed >= $total_posts;
2305
2306 return [
2307 'success' => true,
2308 'imported' => $imported_count,
2309 'skipped' => $skipped_count,
2310 'total' => $total_posts,
2311 'processed' => $processed,
2312 'is_complete' => $is_complete,
2313 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2314 'message' => $is_complete
2315 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2316 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2317 ];
2318 }
2319
2320 /**
2321 * Import SEO metadata from All in One SEO
2322 */
2323 private function import_aioseo_seo_metadata($options)
2324 {
2325 global $wpdb;
2326
2327 $batch_size = intval($options['batch_size']);
2328 $offset = intval($options['offset']);
2329 $import_titles = (bool) $options['import_titles'];
2330 $import_descriptions = (bool) $options['import_descriptions'];
2331 $import_social_text = !empty($options['import_social_text']);
2332 $import_social_images = !empty($options['import_social_images']);
2333 $overwrite = (bool) $options['overwrite_existing'];
2334
2335 // Check if AIOSEO table exists
2336 $table = $wpdb->prefix . 'aioseo_posts';
2337 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
2338 return [
2339 'success' => false,
2340 'message' => 'AIOSEO database table not found.'
2341 ];
2342 }
2343
2344 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2345 return [
2346 'success' => false,
2347 'message' => 'No import options selected.'
2348 ];
2349 }
2350
2351 // Build WHERE clause
2352 $where_conditions = [];
2353 if ($import_titles) {
2354 $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
2355 }
2356 if ($import_descriptions) {
2357 $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
2358 }
2359 if ($import_social_text) {
2360 $where_conditions[] = '(og_title IS NOT NULL AND og_title != \'\')';
2361 $where_conditions[] = '(og_description IS NOT NULL AND og_description != \'\')';
2362 $where_conditions[] = '(twitter_title IS NOT NULL AND twitter_title != \'\')';
2363 $where_conditions[] = '(twitter_description IS NOT NULL AND twitter_description != \'\')';
2364 }
2365 if ($import_social_images) {
2366 $where_conditions[] = '(og_image_custom_url IS NOT NULL AND og_image_custom_url != \'\')';
2367 $where_conditions[] = '(twitter_image_custom_url IS NOT NULL AND twitter_image_custom_url != \'\')';
2368 }
2369 $where_clause = implode(' OR ', $where_conditions);
2370
2371 // Build SELECT columns
2372 $select_columns = ['post_id'];
2373 if ($import_titles) {
2374 $select_columns[] = 'title';
2375 }
2376 if ($import_descriptions) {
2377 $select_columns[] = 'description';
2378 }
2379 if ($import_social_text) {
2380 $select_columns[] = 'og_title';
2381 $select_columns[] = 'og_description';
2382 $select_columns[] = 'twitter_title';
2383 $select_columns[] = 'twitter_description';
2384 }
2385 if ($import_social_images) {
2386 $select_columns[] = 'og_image_custom_url';
2387 $select_columns[] = 'twitter_image_custom_url';
2388 }
2389 $select_clause = implode(', ', $select_columns);
2390
2391 // Get total count
2392 $total_posts = (int) $wpdb->get_var("
2393 SELECT COUNT(post_id)
2394 FROM {$table}
2395 WHERE ({$where_clause})
2396 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2397 ");
2398
2399 // Get batch of posts
2400 $posts = $wpdb->get_results($wpdb->prepare("
2401 SELECT {$select_clause}
2402 FROM {$table}
2403 WHERE ({$where_clause})
2404 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2405 ORDER BY post_id ASC
2406 LIMIT %d OFFSET %d
2407 ", $batch_size, $offset));
2408
2409 $imported_count = 0;
2410 $skipped_count = 0;
2411
2412 foreach ($posts as $aioseo_data) {
2413 $post_id = $aioseo_data->post_id;
2414 $updated = false;
2415
2416 // Import title
2417 if ($import_titles && !empty($aioseo_data->title)) {
2418 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2419
2420 if (empty($existing_title) || $overwrite) {
2421 $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2422 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2423 $updated = true;
2424 }
2425 }
2426
2427 // Import description
2428 if ($import_descriptions && !empty($aioseo_data->description)) {
2429 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2430
2431 if (empty($existing_desc) || $overwrite) {
2432 $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2433 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2434 $updated = true;
2435 }
2436 }
2437
2438 // Import social text
2439 if ($import_social_text) {
2440 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
2441 if (!empty($aioseo_og_title)) {
2442 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2443 if (empty($existing) || $overwrite) {
2444 $aioseo_og_title = $this->resolve_seo_placeholders($aioseo_og_title, $post_id, 'aioseo');
2445 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($aioseo_og_title));
2446 $updated = true;
2447 }
2448 }
2449
2450 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
2451 if (!empty($aioseo_og_desc)) {
2452 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2453 if (empty($existing) || $overwrite) {
2454 $aioseo_og_desc = $this->resolve_seo_placeholders($aioseo_og_desc, $post_id, 'aioseo');
2455 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($aioseo_og_desc));
2456 $updated = true;
2457 }
2458 }
2459
2460 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
2461 if (!empty($aioseo_tw_title)) {
2462 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2463 if (empty($existing) || $overwrite) {
2464 $aioseo_tw_title = $this->resolve_seo_placeholders($aioseo_tw_title, $post_id, 'aioseo');
2465 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($aioseo_tw_title));
2466 $updated = true;
2467 }
2468 }
2469
2470 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
2471 if (!empty($aioseo_tw_desc)) {
2472 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2473 if (empty($existing) || $overwrite) {
2474 $aioseo_tw_desc = $this->resolve_seo_placeholders($aioseo_tw_desc, $post_id, 'aioseo');
2475 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($aioseo_tw_desc));
2476 $updated = true;
2477 }
2478 }
2479 }
2480
2481 // Import social images
2482 if ($import_social_images) {
2483 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
2484 if (!empty($aioseo_og_image)) {
2485 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2486 if (empty($existing_og) || $overwrite) {
2487 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($aioseo_og_image));
2488 $updated = true;
2489 }
2490 }
2491
2492 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
2493 if (!empty($aioseo_twitter_image)) {
2494 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2495 if (empty($existing_twitter) || $overwrite) {
2496 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($aioseo_twitter_image));
2497 $updated = true;
2498 }
2499 }
2500 }
2501
2502 if ($updated) {
2503 $imported_count++;
2504 } else {
2505 $skipped_count++;
2506 }
2507 }
2508
2509 $processed = $offset + count($posts);
2510 $is_complete = $processed >= $total_posts;
2511
2512 return [
2513 'success' => true,
2514 'imported' => $imported_count,
2515 'skipped' => $skipped_count,
2516 'total' => $total_posts,
2517 'processed' => $processed,
2518 'is_complete' => $is_complete,
2519 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2520 'message' => $is_complete
2521 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2522 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2523 ];
2524 }
2525
2526 /**
2527 * Import term-level SEO metadata from a third-party SEO plugin into
2528 * MetaSync term meta (`_metasync_*`).
2529 *
2530 * Mirrors import_seo_metadata() but operates on terms (wp_termmeta for
2531 * Yoast/Rank Math and the wp_aioseo_terms table for AIOSEO) and walks
2532 * every registered public taxonomy so category, post_tag, and custom
2533 * taxonomies are all covered.
2534 *
2535 * @param string $plugin One of 'yoast', 'rankmath', 'aioseo'.
2536 * @param array $options Batch options (overwrite_existing, batch_size, offset).
2537 * @return array ['success'=>bool,'imported'=>int,'skipped'=>int,'total'=>int,'message'=>string]
2538 */
2539 public function import_term_seo_metadata($plugin, $options = [])
2540 {
2541 $defaults = [
2542 'overwrite_existing' => false,
2543 'batch_size' => 100,
2544 'offset' => 0,
2545 ];
2546 $options = array_merge($defaults, $options);
2547
2548 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'], true)) {
2549 return [
2550 'success' => false,
2551 'message' => 'Invalid plugin specified.',
2552 ];
2553 }
2554
2555 switch ($plugin) {
2556 case 'yoast':
2557 return $this->import_yoast_term_meta($options);
2558 case 'rankmath':
2559 return $this->import_rankmath_term_meta($options);
2560 case 'aioseo':
2561 return $this->import_aioseo_term_meta($options);
2562 }
2563
2564 return [
2565 'success' => false,
2566 'message' => 'Unknown error occurred.',
2567 ];
2568 }
2569
2570 /**
2571 * Import Yoast term meta into MetaSync term meta.
2572 *
2573 * @param array $options
2574 * @return array
2575 */
2576 private function import_yoast_term_meta($options)
2577 {
2578 $batch_size = intval($options['batch_size']);
2579 $offset = intval($options['offset']);
2580 $overwrite = (bool) $options['overwrite_existing'];
2581
2582 // Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta`
2583 // option (wp_options), NOT in wp_termmeta. We must read from there.
2584 if (!class_exists('WPSEO_Taxonomy_Meta')) {
2585 return [
2586 'success' => false,
2587 'message' => 'Yoast SEO is not active or WPSEO_Taxonomy_Meta class not available.',
2588 ];
2589 }
2590
2591 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
2592
2593 $terms = get_terms([
2594 'taxonomy' => $taxonomies,
2595 'hide_empty' => false,
2596 'number' => $batch_size,
2597 'offset' => $offset,
2598 ]);
2599
2600 if (is_wp_error($terms)) {
2601 return [
2602 'success' => false,
2603 'message' => $terms->get_error_message(),
2604 ];
2605 }
2606
2607 $field_map = [
2608 'wpseo_title' => '_metasync_metatitle',
2609 'wpseo_desc' => '_metasync_metadesc',
2610 'wpseo_opengraph-title' => '_metasync_og_title',
2611 'wpseo_opengraph-description' => '_metasync_og_description',
2612 'wpseo_canonical' => '_metasync_canonical_url',
2613 ];
2614
2615 $imported = 0;
2616 $skipped = 0;
2617
2618 foreach ($terms as $term) {
2619 $term_updated = false;
2620
2621 // Read from Yoast's wpseo_taxonomy_meta option via its API.
2622 $yoast_meta = WPSEO_Taxonomy_Meta::get_term_meta($term->term_id, $term->taxonomy);
2623 if (!is_array($yoast_meta)) {
2624 $yoast_meta = [];
2625 }
2626
2627 foreach ($field_map as $src_key => $dest_key) {
2628 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2629 if ($src_value === '' || $src_value === null) {
2630 continue;
2631 }
2632
2633 $existing = get_term_meta($term->term_id, $dest_key, true);
2634 if (!empty($existing) && !$overwrite) {
2635 continue;
2636 }
2637
2638 // Validate canonical: never import a corrupted value.
2639 if ($dest_key === '_metasync_canonical_url') {
2640 $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
2641 if ($src_value === '') {
2642 continue;
2643 }
2644 }
2645
2646 update_term_meta($term->term_id, $dest_key, $src_value);
2647 $term_updated = true;
2648 }
2649
2650 $noindex = isset($yoast_meta['wpseo_noindex']) ? $yoast_meta['wpseo_noindex'] : '';
2651 if ($noindex === 'noindex') {
2652 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
2653 if (empty($existing) || $overwrite) {
2654 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
2655 $term_updated = true;
2656 }
2657 }
2658
2659 if ($term_updated) {
2660 $imported++;
2661 } else {
2662 $skipped++;
2663 }
2664 }
2665
2666 $processed = $offset + count($terms);
2667
2668 return [
2669 'success' => true,
2670 'imported' => $imported,
2671 'skipped' => $skipped,
2672 'total' => $processed,
2673 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2674 ];
2675 }
2676
2677 /**
2678 * Import Rank Math term meta into MetaSync term meta.
2679 *
2680 * @param array $options
2681 * @return array
2682 */
2683 private function import_rankmath_term_meta($options)
2684 {
2685 $batch_size = intval($options['batch_size']);
2686 $offset = intval($options['offset']);
2687 $overwrite = (bool) $options['overwrite_existing'];
2688
2689 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
2690
2691 $terms = get_terms([
2692 'taxonomy' => $taxonomies,
2693 'hide_empty' => false,
2694 'number' => $batch_size,
2695 'offset' => $offset,
2696 ]);
2697
2698 if (is_wp_error($terms)) {
2699 return [
2700 'success' => false,
2701 'message' => $terms->get_error_message(),
2702 ];
2703 }
2704
2705 $field_map = [
2706 'rank_math_title' => '_metasync_metatitle',
2707 'rank_math_description' => '_metasync_metadesc',
2708 'rank_math_facebook_title' => '_metasync_og_title',
2709 'rank_math_facebook_description' => '_metasync_og_description',
2710 'rank_math_canonical_url' => '_metasync_canonical_url',
2711 ];
2712
2713 $imported = 0;
2714 $skipped = 0;
2715
2716 foreach ($terms as $term) {
2717 $term_updated = false;
2718
2719 foreach ($field_map as $src_key => $dest_key) {
2720 $src_value = get_term_meta($term->term_id, $src_key, true);
2721 if ($src_value === '' || $src_value === null) {
2722 continue;
2723 }
2724
2725 $existing = get_term_meta($term->term_id, $dest_key, true);
2726 if (!empty($existing) && !$overwrite) {
2727 continue;
2728 }
2729
2730 // Validate canonical: never import a corrupted value.
2731 if ($dest_key === '_metasync_canonical_url') {
2732 $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
2733 if ($src_value === '') {
2734 continue;
2735 }
2736 }
2737
2738 update_term_meta($term->term_id, $dest_key, $src_value);
2739 $term_updated = true;
2740 }
2741
2742 $robots_raw = get_term_meta($term->term_id, 'rank_math_robots', true);
2743 $robots = maybe_unserialize($robots_raw);
2744 if (is_array($robots) && in_array('noindex', $robots, true)) {
2745 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
2746 if (empty($existing) || $overwrite) {
2747 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
2748 $term_updated = true;
2749 }
2750 }
2751
2752 if ($term_updated) {
2753 $imported++;
2754 } else {
2755 $skipped++;
2756 }
2757 }
2758
2759 $processed = $offset + count($terms);
2760
2761 return [
2762 'success' => true,
2763 'imported' => $imported,
2764 'skipped' => $skipped,
2765 'total' => $processed,
2766 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2767 ];
2768 }
2769
2770 /**
2771 * Import AIOSEO term meta (from the wp_aioseo_terms custom table) into
2772 * MetaSync term meta.
2773 *
2774 * @param array $options
2775 * @return array
2776 */
2777 private function import_aioseo_term_meta($options)
2778 {
2779 global $wpdb;
2780
2781 $batch_size = intval($options['batch_size']);
2782 $offset = intval($options['offset']);
2783 $overwrite = (bool) $options['overwrite_existing'];
2784
2785 $table = $wpdb->prefix . 'aioseo_terms';
2786
2787 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
2788 if ($table_exists !== $table) {
2789 return [
2790 'success' => false,
2791 'message' => 'AIOSEO terms table not found.',
2792 ];
2793 }
2794
2795 $rows = $wpdb->get_results($wpdb->prepare(
2796 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2797 FROM {$table}
2798 ORDER BY term_id ASC
2799 LIMIT %d OFFSET %d",
2800 $batch_size,
2801 $offset
2802 ));
2803
2804 $imported = 0;
2805 $skipped = 0;
2806
2807 foreach ($rows as $row) {
2808 $term_id = (int) $row->term_id;
2809 if ($term_id <= 0) {
2810 continue;
2811 }
2812
2813 $term_updated = false;
2814
2815 $field_map = [
2816 'title' => '_metasync_metatitle',
2817 'description' => '_metasync_metadesc',
2818 'og_title' => '_metasync_og_title',
2819 'og_description' => '_metasync_og_description',
2820 'canonical_url' => '_metasync_canonical_url',
2821 ];
2822
2823 foreach ($field_map as $column => $dest_key) {
2824 $value = isset($row->$column) ? $row->$column : '';
2825 if ($value === '' || $value === null) {
2826 continue;
2827 }
2828
2829 $existing = get_term_meta($term_id, $dest_key, true);
2830 if (!empty($existing) && !$overwrite) {
2831 continue;
2832 }
2833
2834 // Validate canonical: never import a corrupted value.
2835 if ($dest_key === '_metasync_canonical_url') {
2836 $value = Metasync_Canonical_Sanitizer::sanitize_for_save($value);
2837 if ($value === '') {
2838 continue;
2839 }
2840 }
2841
2842 update_term_meta($term_id, $dest_key, $value);
2843 $term_updated = true;
2844 }
2845
2846 if (!empty($row->robots_noindex) && (int) $row->robots_noindex === 1) {
2847 $existing = get_term_meta($term_id, '_metasync_robots_index', true);
2848 if (empty($existing) || $overwrite) {
2849 update_term_meta($term_id, '_metasync_robots_index', 'noindex');
2850 $term_updated = true;
2851 }
2852 }
2853
2854 if ($term_updated) {
2855 $imported++;
2856 } else {
2857 $skipped++;
2858 }
2859 }
2860
2861 $processed = $offset + count($rows);
2862
2863 return [
2864 'success' => true,
2865 'imported' => $imported,
2866 'skipped' => $skipped,
2867 'total' => $processed,
2868 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2869 ];
2870 }
2871 }
2872