PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.15
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.15
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 2.5.23 All 138 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.15, at includes/class-metasync-external-importer.php

2,833 lines 113.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Import data from other SEO plugins.
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 * @package Metasync
9 * @subpackage Metasync/includes
10 * @author Engineering Team <support@searchatlas.com>
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 // WP-197: 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 update_post_meta($post_id, 'meta_canonical', sanitize_url($yoast_canonical));
821 $has_changes = true;
822 }
823 }
824
825 // Save Metasync robots meta if changes were made
826 if ($has_changes) {
827 if (!empty($metasync_robots)) {
828 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
829 }
830 $imported_count++;
831 }
832
833 // Flush per-post object cache to prevent unbounded memory growth across batches
834 clean_post_cache($post_id);
835 }
836
837 $processed = $offset + count($posts);
838 $is_complete = $processed >= $total;
839
840 return [
841 'success' => true,
842 'imported' => $imported_count,
843 'skipped' => count($posts) - $imported_count,
844 'total' => $total,
845 'processed' => $processed,
846 'is_complete' => $is_complete,
847 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
848 'message' => $is_complete
849 ? "Import complete! Imported {$imported_count} posts."
850 : "Processing... {$imported_count} imported."
851 ];
852 }
853
854 /**
855 * Import per-post indexation settings from Rank Math
856 */
857 private function import_rankmath_indexation($options = [])
858 {
859 global $wpdb;
860 $imported_count = 0;
861
862 $batch_size = intval($options['batch_size']);
863 $offset = intval($options['offset']);
864
865 // Get total count (for progress tracking)
866 $total = (int) $wpdb->get_var("
867 SELECT COUNT(DISTINCT post_id)
868 FROM {$wpdb->postmeta}
869 WHERE meta_key LIKE 'rank_math_%'
870 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
871 ");
872
873 // Get batch of posts with Rank Math robots meta
874 $posts = $wpdb->get_results($wpdb->prepare("
875 SELECT DISTINCT post_id
876 FROM {$wpdb->postmeta}
877 WHERE meta_key LIKE 'rank_math_%'
878 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
879 ORDER BY post_id ASC
880 LIMIT %d OFFSET %d
881 ", $batch_size, $offset));
882
883 foreach ($posts as $post_obj) {
884 $post_id = $post_obj->post_id;
885 $has_changes = false;
886
887 // Get existing Metasync robots meta
888 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
889 if (!is_array($metasync_robots)) {
890 $metasync_robots = [];
891 }
892
893 // Import robots array
894 $rm_robots = get_post_meta($post_id, 'rank_math_robots', true);
895 if (is_array($rm_robots)) {
896 // Rank Math stores as array like ['noindex', 'nofollow']
897 foreach ($rm_robots as $directive) {
898 $directive = strtolower(trim($directive));
899 if (in_array($directive, ['index', 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
900 $metasync_robots[$directive] = $directive;
901 $has_changes = true;
902 }
903 }
904 }
905
906 // Import advanced robots
907 $rm_adv_robots = get_post_meta($post_id, 'rank_math_advanced_robots', true);
908 if (is_array($rm_adv_robots)) {
909 foreach ($rm_adv_robots as $directive) {
910 $directive = strtolower(trim($directive));
911 if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex', 'max-snippet', 'max-video-preview', 'max-image-preview']) && !isset($metasync_robots[$directive])) {
912 $metasync_robots[$directive] = $directive;
913 $has_changes = true;
914 }
915 }
916 }
917
918 // WP-197: Parse max-* values and write _metasync_robots_advanced JSON
919 $robots_advanced = [];
920 // Boolean directives from rank_math_robots
921 if (is_array($rm_robots)) {
922 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
923 if (in_array($dir, $rm_robots, true)) {
924 $robots_advanced[$dir] = true;
925 }
926 }
927 }
928 // max-* directives from rank_math_advanced_robots
929 if (is_array($rm_adv_robots)) {
930 foreach ($rm_adv_robots as $key => $value) {
931 // Values are formatted like "max-snippet:-1"
932 if (strpos($key, 'max-snippet') !== false && strpos($value, ':') !== false) {
933 $robots_advanced['max_snippet'] = (int) explode(':', $value)[1];
934 }
935 if (strpos($key, 'max-image-preview') !== false && strpos($value, ':') !== false) {
936 $robots_advanced['max_image_preview'] = explode(':', $value)[1];
937 }
938 if (strpos($key, 'max-video-preview') !== false && strpos($value, ':') !== false) {
939 $robots_advanced['max_video_preview'] = (int) explode(':', $value)[1];
940 }
941 }
942 }
943 if (!empty($robots_advanced)) {
944 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
945 }
946
947 // Import canonical URL
948 $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
949 if (!empty($rm_canonical)) {
950 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
951 if (empty($existing_canonical)) {
952 update_post_meta($post_id, 'meta_canonical', sanitize_url($rm_canonical));
953 $has_changes = true;
954 }
955 }
956
957 // Save Metasync robots meta if changes were made
958 if ($has_changes) {
959 if (!empty($metasync_robots)) {
960 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
961 }
962 $imported_count++;
963 }
964
965 // Flush per-post object cache to prevent unbounded memory growth across batches
966 clean_post_cache($post_id);
967 }
968
969 $processed = $offset + count($posts);
970 $is_complete = $processed >= $total;
971
972 return [
973 'success' => true,
974 'imported' => $imported_count,
975 'skipped' => count($posts) - $imported_count,
976 'total' => $total,
977 'processed' => $processed,
978 'is_complete' => $is_complete,
979 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
980 'message' => $is_complete
981 ? "Import complete! Imported {$imported_count} posts."
982 : "Processing... {$imported_count} imported."
983 ];
984 }
985
986 /**
987 * Import per-post indexation settings from AIOSEO
988 */
989 private function import_aioseo_indexation($options = [])
990 {
991 global $wpdb;
992 $imported_count = 0;
993
994 $batch_size = intval($options['batch_size']);
995 $offset = intval($options['offset']);
996
997 // AIOSEO stores data in a custom table
998 $aioseo_table = $wpdb->prefix . 'aioseo_posts';
999
1000 // Check if table exists
1001 $table_exists = $wpdb->get_var("SHOW TABLES LIKE '$aioseo_table'") === $aioseo_table;
1002
1003 if (!$table_exists) {
1004 return ['success' => false, 'message' => 'AIOSEO table not found.'];
1005 }
1006
1007 // Get total count (for progress tracking)
1008 $total = (int) $wpdb->get_var("
1009 SELECT COUNT(*)
1010 FROM {$aioseo_table}
1011 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1012 ");
1013
1014 // Get batch of posts with AIOSEO settings
1015 $posts = $wpdb->get_results($wpdb->prepare("
1016 SELECT post_id, robots_default, robots_noindex, robots_nofollow,
1017 robots_noarchive, robots_nosnippet, robots_noimageindex,
1018 robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
1019 canonical_url
1020 FROM {$aioseo_table}
1021 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1022 ORDER BY post_id ASC
1023 LIMIT %d OFFSET %d
1024 ", $batch_size, $offset));
1025
1026 foreach ($posts as $aioseo_data) {
1027 $post_id = $aioseo_data->post_id;
1028 $has_changes = false;
1029
1030 // Get existing Metasync robots meta
1031 $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1032 if (!is_array($metasync_robots)) {
1033 $metasync_robots = [];
1034 }
1035
1036 // Only import if not using default (robots_default = 0)
1037 if ($aioseo_data->robots_default == 0) {
1038 // Import noindex
1039 if ($aioseo_data->robots_noindex == 1 && !isset($metasync_robots['noindex'])) {
1040 $metasync_robots['noindex'] = 'noindex';
1041 $has_changes = true;
1042 }
1043
1044 // Import nofollow
1045 if ($aioseo_data->robots_nofollow == 1 && !isset($metasync_robots['nofollow'])) {
1046 $metasync_robots['nofollow'] = 'nofollow';
1047 $has_changes = true;
1048 }
1049
1050 // Import noarchive
1051 if ($aioseo_data->robots_noarchive == 1 && !isset($metasync_robots['noarchive'])) {
1052 $metasync_robots['noarchive'] = 'noarchive';
1053 $has_changes = true;
1054 }
1055
1056 // Import nosnippet
1057 if ($aioseo_data->robots_nosnippet == 1 && !isset($metasync_robots['nosnippet'])) {
1058 $metasync_robots['nosnippet'] = 'nosnippet';
1059 $has_changes = true;
1060 }
1061
1062 // Import noimageindex
1063 if ($aioseo_data->robots_noimageindex == 1 && !isset($metasync_robots['noimageindex'])) {
1064 $metasync_robots['noimageindex'] = 'noimageindex';
1065 $has_changes = true;
1066 }
1067 }
1068
1069 // WP-197: Write _metasync_robots_advanced JSON
1070 $robots_advanced = [];
1071 if ($aioseo_data->robots_default == 0) {
1072 if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
1073 if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
1074 if ($aioseo_data->robots_nosnippet == 1) $robots_advanced['nosnippet'] = true;
1075 if ($aioseo_data->robots_noimageindex == 1) $robots_advanced['noimageindex'] = true;
1076 }
1077 if (isset($aioseo_data->robots_max_snippet) && is_numeric($aioseo_data->robots_max_snippet)) {
1078 $robots_advanced['max_snippet'] = (int) $aioseo_data->robots_max_snippet;
1079 }
1080 if (!empty($aioseo_data->robots_max_imagepreview)) {
1081 $robots_advanced['max_image_preview'] = $aioseo_data->robots_max_imagepreview;
1082 }
1083 if (isset($aioseo_data->robots_max_videopreview) && is_numeric($aioseo_data->robots_max_videopreview)) {
1084 $robots_advanced['max_video_preview'] = (int) $aioseo_data->robots_max_videopreview;
1085 }
1086 if (!empty($robots_advanced)) {
1087 update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
1088 $has_changes = true;
1089 }
1090
1091 // Import canonical URL
1092 if (!empty($aioseo_data->canonical_url)) {
1093 $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
1094 if (empty($existing_canonical)) {
1095 update_post_meta($post_id, 'meta_canonical', sanitize_url($aioseo_data->canonical_url));
1096 $has_changes = true;
1097 }
1098 }
1099
1100 // Save Metasync robots meta if changes were made
1101 if ($has_changes) {
1102 if (!empty($metasync_robots)) {
1103 update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
1104 }
1105 $imported_count++;
1106 }
1107
1108 // Flush per-post object cache to prevent unbounded memory growth across batches
1109 clean_post_cache($post_id);
1110 }
1111
1112 $processed = $offset + count($posts);
1113 $is_complete = $processed >= $total;
1114
1115 return [
1116 'success' => true,
1117 'imported' => $imported_count,
1118 'skipped' => count($posts) - $imported_count,
1119 'total' => $total,
1120 'processed' => $processed,
1121 'is_complete' => $is_complete,
1122 'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
1123 'message' => $is_complete
1124 ? "Import complete! Imported {$imported_count} posts."
1125 : "Processing... {$imported_count} imported."
1126 ];
1127 }
1128
1129 /**
1130 * Import Schema Settings (Per-Post Schema)
1131 */
1132 public function import_schema($plugin)
1133 {
1134 $imported_count = 0;
1135
1136 switch ($plugin) {
1137 case 'yoast':
1138 $imported_count = $this->import_yoast_schema();
1139 break;
1140
1141 case 'rankmath':
1142 $imported_count = $this->import_rankmath_schema();
1143 break;
1144
1145 case 'aioseo':
1146 $imported_count = $this->import_aioseo_schema();
1147 break;
1148
1149 default:
1150 return ['success' => false, 'message' => 'Invalid plugin specified.'];
1151 }
1152
1153 if ($imported_count > 0) {
1154 return ['success' => true, 'message' => "Successfully imported schema settings for $imported_count posts."];
1155 }
1156
1157 return ['success' => false, 'message' => 'No post-level schema settings found to import.'];
1158 }
1159
1160 /**
1161 * Import per-post schema from Yoast SEO
1162 */
1163 private function import_yoast_schema()
1164 {
1165 global $wpdb;
1166 $imported_count = 0;
1167
1168 // First, try to import from full schema JSON (free version or old approach)
1169 $posts = $wpdb->get_results("
1170 SELECT post_id, meta_value
1171 FROM {$wpdb->postmeta}
1172 WHERE meta_key = '_yoast_wpseo_schema'
1173 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1174 ");
1175
1176 foreach ($posts as $post_obj) {
1177 $post_id = $post_obj->post_id;
1178
1179 // Check if Metasync schema already exists
1180 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1181 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1182 continue; // Skip if already has Metasync schema
1183 }
1184
1185 // Decode Yoast schema JSON
1186 $yoast_schema = json_decode((string)($post_obj->meta_value ?? ''), true);
1187 if (empty($yoast_schema) || !is_array($yoast_schema)) {
1188 continue;
1189 }
1190
1191 // Convert Yoast schema to Metasync format
1192 $metasync_schema = $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id);
1193
1194 if (!empty($metasync_schema['types'])) {
1195 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1196 $imported_count++;
1197 }
1198 }
1199
1200 // Second, try to import from schema type (Premium version)
1201 $posts = $wpdb->get_results("
1202 SELECT post_id, meta_value
1203 FROM {$wpdb->postmeta}
1204 WHERE meta_key = '_yoast_wpseo_schema_article_type'
1205 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1206 ");
1207
1208 foreach ($posts as $post_obj) {
1209 $post_id = $post_obj->post_id;
1210
1211 // Check if Metasync schema already exists
1212 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1213 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1214 continue; // Skip if already has Metasync schema
1215 }
1216
1217 $schema_type = strtolower($post_obj->meta_value);
1218
1219 // Create basic article schema with placeholders
1220 // Yoast Premium generates schema dynamically, so we create a minimal version
1221 if ($schema_type === 'article' || $schema_type === 'newsarticle' || $schema_type === 'blogposting') {
1222 $metasync_schema = [
1223 'enabled' => true,
1224 'types' => [
1225 [
1226 'type' => 'article',
1227 'fields' => [
1228 'title_override' => '{{post_title}}',
1229 'description_override' => '{{post_description}}',
1230 'image_override' => '{{featured_image}}',
1231 'organization_name' => '',
1232 'organization_logo' => ''
1233 ]
1234 ]
1235 ]
1236 ];
1237
1238 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1239 $imported_count++;
1240 }
1241 }
1242
1243 return $imported_count;
1244 }
1245
1246 /**
1247 * Import per-post schema from Rank Math
1248 */
1249 private function import_rankmath_schema()
1250 {
1251 global $wpdb;
1252 $imported_count = 0;
1253
1254 // Get all posts with any Rank Math schema (dynamically detect schema types)
1255 // Exclude shortcode schemas
1256 $posts = $wpdb->get_results("
1257 SELECT DISTINCT pm.post_id, pm.meta_key, pm.meta_value
1258 FROM {$wpdb->postmeta} pm
1259 WHERE pm.meta_key LIKE 'rank_math_schema_%'
1260 AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
1261 AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1262 ORDER BY pm.post_id
1263 ");
1264
1265 $processed_posts = [];
1266
1267 foreach ($posts as $post_obj) {
1268 $post_id = $post_obj->post_id;
1269
1270 // Skip if we already processed this post
1271 if (in_array($post_id, $processed_posts)) {
1272 continue;
1273 }
1274
1275 // Check if Metasync schema already exists for this post
1276 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1277 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1278 continue; // Skip if already has Metasync schema
1279 }
1280
1281 // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
1282 $schema_type = str_replace('rank_math_schema_', '', $post_obj->meta_key);
1283
1284 // Decode Rank Math schema
1285 $rm_schema = maybe_unserialize($post_obj->meta_value);
1286 if (empty($rm_schema) || !is_array($rm_schema)) {
1287 continue;
1288 }
1289
1290 // Convert Rank Math schema to Metasync format
1291 $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);
1292
1293 if (!empty($metasync_schema['types'])) {
1294 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1295 $imported_count++;
1296 $processed_posts[] = $post_id; // Mark post as processed
1297 }
1298 }
1299
1300 return $imported_count;
1301 }
1302
1303 /**
1304 * Import per-post schema from AIOSEO
1305 */
1306 private function import_aioseo_schema()
1307 {
1308 global $wpdb;
1309 $imported_count = 0;
1310
1311 // Check if AIOSEO table exists
1312 $table = $wpdb->prefix . 'aioseo_posts';
1313 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
1314 return 0;
1315 }
1316
1317 // Get all posts with AIOSEO schema
1318 $posts = $wpdb->get_results("
1319 SELECT post_id, schema_type, schema_type_options
1320 FROM {$table}
1321 WHERE (schema_type IS NOT NULL AND schema_type != '')
1322 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1323 ");
1324
1325 foreach ($posts as $aioseo_data) {
1326 $post_id = $aioseo_data->post_id;
1327
1328 // Check if Metasync schema already exists
1329 $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
1330 if (!empty($existing_schema) && !empty($existing_schema['types'])) {
1331 continue; // Skip if already has Metasync schema
1332 }
1333
1334 // Decode AIOSEO schema options
1335 $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
1336 if (!is_array($schema_options)) {
1337 $schema_options = [];
1338 }
1339
1340 // Convert AIOSEO schema to Metasync format
1341 $metasync_schema = $this->convert_aioseo_schema_to_metasync(
1342 $aioseo_data->schema_type,
1343 $schema_options,
1344 $post_id
1345 );
1346
1347 if (!empty($metasync_schema['types'])) {
1348 update_post_meta($post_id, 'metasync_schema_markup', $metasync_schema);
1349 $imported_count++;
1350 }
1351 }
1352
1353 return $imported_count;
1354 }
1355
1356 /**
1357 * Convert Yoast schema to Metasync format
1358 */
1359 private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
1360 {
1361 $metasync_schema = [
1362 'enabled' => true,
1363 'types' => []
1364 ];
1365
1366 // Yoast stores schema as a graph array
1367 if (isset($yoast_schema['@graph']) && is_array($yoast_schema['@graph'])) {
1368 foreach ($yoast_schema['@graph'] as $item) {
1369 if (!isset($item['@type'])) {
1370 continue;
1371 }
1372
1373 $raw_type = $item['@type'];
1374 if ( is_array( $raw_type ) ) {
1375 $raw_type = reset( $raw_type );
1376 }
1377 if ( ! is_string( $raw_type ) || '' === $raw_type ) {
1378 continue;
1379 }
1380 $type = strtolower( $raw_type );
1381
1382 // Map Yoast types to Metasync types
1383 if ($type === 'article' || $type === 'newsarticle' || $type === 'blogposting') {
1384 $metasync_schema['types'][] = [
1385 'type' => 'article',
1386 'fields' => [
1387 'title_override' => isset($item['headline']) ? $item['headline'] : '{{post_title}}',
1388 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1389 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1390 'organization_name' => isset($item['publisher']['name']) ? $item['publisher']['name'] : '',
1391 'organization_logo' => isset($item['publisher']['logo']['url']) ? $item['publisher']['logo']['url'] : ''
1392 ]
1393 ];
1394 } elseif ($type === 'faqpage') {
1395 $faq_items = [];
1396 if (isset($item['mainEntity']) && is_array($item['mainEntity'])) {
1397 foreach ($item['mainEntity'] as $question) {
1398 if (isset($question['name']) && isset($question['acceptedAnswer']['text'])) {
1399 $faq_items[] = [
1400 'question' => $question['name'],
1401 'answer' => $question['acceptedAnswer']['text']
1402 ];
1403 }
1404 }
1405 }
1406 if (!empty($faq_items)) {
1407 $metasync_schema['types'][] = [
1408 'type' => 'FAQPage',
1409 'fields' => [
1410 'faq_items' => $faq_items
1411 ]
1412 ];
1413 }
1414 } elseif ($type === 'product') {
1415 $metasync_schema['types'][] = [
1416 'type' => 'product',
1417 'fields' => [
1418 'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
1419 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1420 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1421 'sku' => isset($item['sku']) ? $item['sku'] : '',
1422 'brand' => isset($item['brand']['name']) ? $item['brand']['name'] : '',
1423 'price' => isset($item['offers']['price']) ? floatval($item['offers']['price']) : 0,
1424 'currency' => isset($item['offers']['priceCurrency']) ? $item['offers']['priceCurrency'] : 'USD',
1425 'availability' => isset($item['offers']['availability']) ? basename($item['offers']['availability']) : 'InStock',
1426 'condition' => isset($item['offers']['itemCondition']) ? basename($item['offers']['itemCondition']) : 'NewCondition'
1427 ]
1428 ];
1429 } elseif ($type === 'recipe') {
1430 $ingredients = [];
1431 if (isset($item['recipeIngredient']) && is_array($item['recipeIngredient'])) {
1432 $ingredients = $item['recipeIngredient'];
1433 }
1434
1435 $instructions = [];
1436 if (isset($item['recipeInstructions']) && is_array($item['recipeInstructions'])) {
1437 foreach ($item['recipeInstructions'] as $step) {
1438 if (is_string($step)) {
1439 $instructions[] = $step;
1440 } elseif (isset($step['text'])) {
1441 $instructions[] = $step['text'];
1442 }
1443 }
1444 }
1445
1446 $metasync_schema['types'][] = [
1447 'type' => 'recipe',
1448 'fields' => [
1449 'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
1450 'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
1451 'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
1452 'yield' => isset($item['recipeYield']) ? $item['recipeYield'] : '',
1453 'ingredients' => $ingredients,
1454 'instructions' => $instructions,
1455 'prep_time' => isset($item['prepTime']) ? $this->parse_duration($item['prepTime']) : 0,
1456 'cook_time' => isset($item['cookTime']) ? $this->parse_duration($item['cookTime']) : 0,
1457 'total_time' => isset($item['totalTime']) ? $this->parse_duration($item['totalTime']) : 0,
1458 'calories' => isset($item['nutrition']['calories']) ? intval($item['nutrition']['calories']) : 0
1459 ]
1460 ];
1461 }
1462 }
1463 }
1464
1465 return $metasync_schema;
1466 }
1467
1468 /**
1469 * Convert Rank Math schema to Metasync format
1470 */
1471 private function convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id)
1472 {
1473 $metasync_schema = [
1474 'enabled' => true,
1475 'types' => []
1476 ];
1477
1478 $type = strtolower($schema_type);
1479
1480 // Handle article-like schema types (Article, BlogPosting, NewsArticle, etc.)
1481 if ($type === 'article' || $type === 'blogposting' || $type === 'newsarticle') {
1482 $metasync_schema['types'][] = [
1483 'type' => 'article',
1484 'fields' => [
1485 'title_override' => $this->normalize_text_value($rm_schema['headline'] ?? null, '{{post_title}}'),
1486 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
1487 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
1488 'organization_name' => isset($rm_schema['publisher']) ? $rm_schema['publisher'] : '',
1489 'organization_logo' => isset($rm_schema['publisher_logo']) ? $rm_schema['publisher_logo'] : ''
1490 ]
1491 ];
1492 } elseif ($type === 'faqpage') {
1493 $faq_items = [];
1494 if (isset($rm_schema['questions']) && is_array($rm_schema['questions'])) {
1495 foreach ($rm_schema['questions'] as $question) {
1496 if (isset($question['name']) && isset($question['text'])) {
1497 $faq_items[] = [
1498 'question' => $question['name'],
1499 'answer' => $question['text']
1500 ];
1501 }
1502 }
1503 }
1504 if (!empty($faq_items)) {
1505 $metasync_schema['types'][] = [
1506 'type' => 'FAQPage',
1507 'fields' => [
1508 'faq_items' => $faq_items
1509 ]
1510 ];
1511 }
1512 } elseif ($type === 'product') {
1513 $metasync_schema['types'][] = [
1514 'type' => 'product',
1515 'fields' => [
1516 'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
1517 'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
1518 'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
1519 'sku' => isset($rm_schema['sku']) ? $rm_schema['sku'] : '',
1520 'brand' => isset($rm_schema['brand']) ? $rm_schema['brand'] : '',
1521 'price' => isset($rm_schema['price']) ? floatval($rm_schema['price']) : 0,
1522 'currency' => isset($rm_schema['currency']) ? $rm_schema['currency'] : 'USD',
1523 'availability' => isset($rm_schema['inStock']) ? ($rm_schema['inStock'] ? 'InStock' : 'OutOfStock') : 'InStock',
1524 'condition' => 'NewCondition'
1525 ]
1526 ];
1527 } elseif ($type === 'recipe') {
1528 $metasync_schema['types'][] = [
1529 'type' => 'recipe',
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 'yield' => isset($rm_schema['recipeYield']) ? $rm_schema['recipeYield'] : '',
1535 'ingredients' => isset($rm_schema['recipeIngredient']) ? $rm_schema['recipeIngredient'] : [],
1536 'instructions' => isset($rm_schema['recipeInstructions']) ? $rm_schema['recipeInstructions'] : [],
1537 'prep_time' => isset($rm_schema['prepTime']) ? intval($rm_schema['prepTime']) : 0,
1538 'cook_time' => isset($rm_schema['cookTime']) ? intval($rm_schema['cookTime']) : 0,
1539 'total_time' => isset($rm_schema['totalTime']) ? intval($rm_schema['totalTime']) : 0,
1540 'calories' => isset($rm_schema['calories']) ? intval($rm_schema['calories']) : 0
1541 ]
1542 ];
1543 }
1544
1545 return $metasync_schema;
1546 }
1547
1548 /**
1549 * Convert AIOSEO schema to Metasync format
1550 */
1551 private function convert_aioseo_schema_to_metasync($schema_type, $schema_options, $post_id)
1552 {
1553 $metasync_schema = [
1554 'enabled' => true,
1555 'types' => []
1556 ];
1557
1558 $type = strtolower($schema_type);
1559
1560 if ($type === 'article') {
1561 $metasync_schema['types'][] = [
1562 'type' => 'article',
1563 'fields' => [
1564 'title_override' => isset($schema_options['headline']) ? $schema_options['headline'] : '{{post_title}}',
1565 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
1566 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
1567 'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
1568 'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
1569 ]
1570 ];
1571 } elseif ($type === 'faqpage') {
1572 $faq_items = [];
1573 if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
1574 foreach ($schema_options['questions'] as $question) {
1575 if (isset($question['question']) && isset($question['answer'])) {
1576 $faq_items[] = [
1577 'question' => $question['question'],
1578 'answer' => $question['answer']
1579 ];
1580 }
1581 }
1582 }
1583 if (!empty($faq_items)) {
1584 $metasync_schema['types'][] = [
1585 'type' => 'FAQPage',
1586 'fields' => [
1587 'faq_items' => $faq_items
1588 ]
1589 ];
1590 }
1591 } elseif ($type === 'product') {
1592 $metasync_schema['types'][] = [
1593 'type' => 'product',
1594 'fields' => [
1595 'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
1596 'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
1597 'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
1598 'sku' => isset($schema_options['sku']) ? $schema_options['sku'] : '',
1599 'brand' => isset($schema_options['brand']) ? $schema_options['brand'] : '',
1600 'price' => isset($schema_options['price']) ? floatval($schema_options['price']) : 0,
1601 'currency' => isset($schema_options['currency']) ? $schema_options['currency'] : 'USD',
1602 'availability' => isset($schema_options['availability']) ? $schema_options['availability'] : 'InStock',
1603 'condition' => isset($schema_options['condition']) ? $schema_options['condition'] : 'NewCondition'
1604 ]
1605 ];
1606 } elseif ($type === 'recipe') {
1607 $metasync_schema['types'][] = [
1608 'type' => 'recipe',
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 'yield' => isset($schema_options['recipeYield']) ? $schema_options['recipeYield'] : '',
1614 'ingredients' => isset($schema_options['recipeIngredient']) ? $schema_options['recipeIngredient'] : [],
1615 'instructions' => isset($schema_options['recipeInstructions']) ? $schema_options['recipeInstructions'] : [],
1616 'prep_time' => isset($schema_options['prepTime']) ? intval($schema_options['prepTime']) : 0,
1617 'cook_time' => isset($schema_options['cookTime']) ? intval($schema_options['cookTime']) : 0,
1618 'total_time' => isset($schema_options['totalTime']) ? intval($schema_options['totalTime']) : 0,
1619 'calories' => isset($schema_options['calories']) ? intval($schema_options['calories']) : 0
1620 ]
1621 ];
1622 }
1623
1624 return $metasync_schema;
1625 }
1626
1627 /**
1628 * Parse ISO 8601 duration to minutes
1629 * e.g., "PT15M" = 15 minutes, "PT1H30M" = 90 minutes
1630 */
1631 private function parse_duration($duration)
1632 {
1633 if (empty($duration)) {
1634 return 0;
1635 }
1636
1637 // Simple parser for PT format
1638 $minutes = 0;
1639 if (preg_match('/PT(\d+)H/', $duration, $hours)) {
1640 $minutes += intval($hours[1]) * 60;
1641 }
1642 if (preg_match('/(\d+)M/', $duration, $mins)) {
1643 $minutes += intval($mins[1]);
1644 }
1645
1646 return $minutes;
1647 }
1648
1649 /**
1650 * Normalize image value to string URL
1651 * Handles arrays from Rank Math/Yoast and converts placeholders
1652 */
1653 private function normalize_image_value($image)
1654 {
1655 if (empty($image)) {
1656 return '{{featured_image}}';
1657 }
1658
1659 // If it's an array (from Rank Math/Yoast), extract the URL
1660 if (is_array($image)) {
1661 // Check for 'url' key first
1662 if (isset($image['url'])) {
1663 $image = $image['url'];
1664 }
1665 // Check for '@id' key (Yoast format)
1666 elseif (isset($image['@id'])) {
1667 $image = $image['@id'];
1668 }
1669 // If it's still an array, try to get first element
1670 elseif (isset($image[0])) {
1671 $image = is_string($image[0]) ? $image[0] : '{{featured_image}}';
1672 }
1673 else {
1674 $image = '{{featured_image}}';
1675 }
1676 }
1677
1678 // Convert common placeholder formats to Metasync format
1679 $placeholder_map = [
1680 '%post_thumbnail%' => '{{featured_image}}',
1681 '%featured_image%' => '{{featured_image}}',
1682 '%seo_title%' => '{{post_title}}',
1683 '%post_title%' => '{{post_title}}',
1684 '%seo_description%' => '{{post_description}}',
1685 '%post_excerpt%' => '{{post_description}}'
1686 ];
1687
1688 foreach ($placeholder_map as $old => $new) {
1689 if ($image === $old || strpos($image, $old) !== false) {
1690 $image = str_replace($old, $new, $image);
1691 }
1692 }
1693
1694 return is_string($image) ? $image : '{{featured_image}}';
1695 }
1696
1697 /**
1698 * Normalize text value to string
1699 * Converts placeholders to Metasync format
1700 */
1701 private function normalize_text_value($text, $default = '')
1702 {
1703 if (empty($text)) {
1704 return $default;
1705 }
1706
1707 // Convert common placeholder formats to Metasync format
1708 $placeholder_map = [
1709 '%seo_title%' => '{{post_title}}',
1710 '%post_title%' => '{{post_title}}',
1711 '%seo_description%' => '{{post_description}}',
1712 '%post_excerpt%' => '{{post_description}}'
1713 ];
1714
1715 foreach ($placeholder_map as $old => $new) {
1716 if (is_string($text) && (strpos($text, $old) !== false || $text === $old)) {
1717 $text = str_replace($old, $new, $text);
1718 }
1719 }
1720
1721 return is_string($text) ? $text : $default;
1722 }
1723
1724 /**
1725 * Resolve SEO placeholder tokens to their actual values.
1726 *
1727 * Tries each source plugin's own replacement engine first so the result
1728 * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
1729 * to manual replacement of the most common tokens.
1730 *
1731 * @param string $text Raw string that may contain placeholder tokens.
1732 * @param int $post_id Post whose context is used for replacement.
1733 * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
1734 * @return string Resolved string.
1735 */
1736 private function resolve_seo_placeholders($text, $post_id, $plugin) {
1737 if (empty($text) || !is_string($text)) {
1738 return (string) $text;
1739 }
1740
1741 $post = get_post($post_id);
1742 if (!$post) {
1743 return $text;
1744 }
1745
1746 // Yoast SEO — %%var%% tokens
1747 if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
1748 try {
1749 $replacer = new WPSEO_Replace_Vars();
1750 $resolved = $replacer->replace($text, $post);
1751 if (is_string($resolved) && $resolved !== '') {
1752 return $resolved;
1753 }
1754 } catch (Exception $e) {
1755 // fall through to manual replacement
1756 }
1757 }
1758
1759 // Rank Math — %var% tokens
1760 if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
1761 try {
1762 $resolved = rank_math_replace_vars($text, $post);
1763 if (is_string($resolved) && $resolved !== '') {
1764 return $resolved;
1765 }
1766 } catch (Exception $e) {
1767 // fall through to manual replacement
1768 }
1769 }
1770
1771 // All in One SEO — #var# tokens
1772 if ($plugin === 'aioseo') {
1773 try {
1774 if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
1775 $resolved = aioseo()->tags->replaceTags($text, $post_id);
1776 if (is_string($resolved) && $resolved !== '') {
1777 return $resolved;
1778 }
1779 }
1780 } catch (Exception $e) {
1781 // fall through to manual replacement
1782 }
1783 }
1784
1785 return $this->manually_replace_seo_placeholders($text, $post_id);
1786 }
1787
1788 /**
1789 * Manual fallback: replace the most common SEO placeholder tokens from
1790 * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var#) formats.
1791 *
1792 * @param string $text Text that may contain placeholder tokens.
1793 * @param int $post_id Post ID used for context.
1794 * @return string Text with tokens replaced.
1795 */
1796 private function manually_replace_seo_placeholders($text, $post_id) {
1797 $post = get_post($post_id);
1798 if (!$post) {
1799 return $text;
1800 }
1801
1802 $site_name = get_bloginfo('name');
1803 $post_title = get_the_title($post_id);
1804 $post_excerpt = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
1805 $author = get_the_author_meta('display_name', $post->post_author);
1806 $date = get_the_date('', $post_id);
1807 $modified = get_the_modified_date('', $post_id);
1808
1809 $categories = get_the_category($post_id);
1810 $primary_category = !empty($categories) ? $categories[0]->name : '';
1811
1812 $tags = get_the_tags($post_id);
1813 $first_tag = !empty($tags) ? $tags[0]->name : '';
1814
1815 // Try to read the separator from whichever plugin is active
1816 $sep = '-';
1817 if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
1818 try {
1819 $raw = WPSEO_Options::get('separator', 'sc-dash');
1820 // Yoast stores separator keys like 'sc-dash'; convert to glyph
1821 // via the public get_separator_options() lookup table.
1822 if (class_exists('WPSEO_Option_Titles')) {
1823 $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
1824 if (isset($options[$raw])) {
1825 $sep = html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
1826 }
1827 }
1828 } catch (Exception $e) {
1829 $sep = '-';
1830 }
1831 } elseif (defined('RANK_MATH_VERSION')) {
1832 $rm_settings = get_option('rank_math_general_settings', []);
1833 if (!empty($rm_settings['title_separator'])) {
1834 $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1835 }
1836 }
1837
1838 $map = [
1839 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1840 '%%title%%' => $post_title,
1841 '%%sitename%%' => $site_name,
1842 '%%sep%%' => $sep,
1843 '%%excerpt%%' => $post_excerpt,
1844 '%%excerpt_only%%' => $post_excerpt,
1845 '%%author%%' => $author,
1846 '%%date%%' => $date,
1847 '%%modified%%' => $modified,
1848 '%%id%%' => (string) $post_id,
1849 '%%page%%' => '',
1850 '%%pagenumber%%' => '',
1851 '%%pagetotal%%' => '',
1852 '%%primary_category%%' => $primary_category,
1853 '%%category%%' => $primary_category,
1854 '%%tag%%' => $first_tag,
1855 '%%focuskw%%' => (string) get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
1856 // ── Rank Math (%var%) ────────────────────────────────────────────
1857 '%title%' => $post_title,
1858 '%sitename%' => $site_name,
1859 '%sep%' => $sep,
1860 '%excerpt%' => $post_excerpt,
1861 '%author%' => $author,
1862 '%date%' => $date,
1863 '%modified%' => $modified,
1864 '%id%' => (string) $post_id,
1865 '%page%' => '',
1866 '%category%' => $primary_category,
1867 '%tag%' => $first_tag,
1868 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1869 // ── AIOSEO (#var#) ───────────────────────────────────────────────
1870 '#site_title#' => $site_name,
1871 '#post_title#' => $post_title,
1872 '#post_excerpt#' => $post_excerpt,
1873 '#separator_sa#' => $sep,
1874 '#author_name#' => $author,
1875 '#current_date#' => $date,
1876 '#post_date#' => $date,
1877 '#category_title#' => $primary_category,
1878 '#tag_title#' => $first_tag,
1879 '#id#' => (string) $post_id,
1880 ];
1881
1882 // Sort longest token first to avoid partial matches
1883 // e.g. %%primary_category%% must replace before %%category%%
1884 uksort($map, function ($a, $b) {
1885 return strlen($b) - strlen($a);
1886 });
1887
1888 return str_replace(array_keys($map), array_values($map), $text);
1889 }
1890
1891 /**
1892 * Import SEO Metadata (Titles and Descriptions)
1893 * Supports batch processing via AJAX
1894 *
1895 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
1896 * @param array $options Import options (import_titles, import_descriptions, overwrite_existing, batch_size, offset)
1897 * @return array Result with success status, progress info, and statistics
1898 */
1899 public function import_seo_metadata($plugin, $options = [])
1900 {
1901 // Default options
1902 $defaults = [
1903 'import_titles' => true,
1904 'import_descriptions' => true,
1905 'overwrite_existing' => false,
1906 'batch_size' => 50, // Process 50 posts per batch
1907 'offset' => 0
1908 ];
1909 $options = array_merge($defaults, $options);
1910
1911 // Validate plugin
1912 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
1913 return [
1914 'success' => false,
1915 'message' => 'Invalid plugin specified.'
1916 ];
1917 }
1918
1919 // Route to appropriate import method
1920 switch ($plugin) {
1921 case 'yoast':
1922 return $this->import_yoast_seo_metadata($options);
1923 case 'rankmath':
1924 return $this->import_rankmath_seo_metadata($options);
1925 case 'aioseo':
1926 return $this->import_aioseo_seo_metadata($options);
1927 }
1928
1929 return [
1930 'success' => false,
1931 'message' => 'Unknown error occurred.'
1932 ];
1933 }
1934
1935 /**
1936 * Import SEO metadata from Yoast SEO
1937 */
1938 private function import_yoast_seo_metadata($options)
1939 {
1940 global $wpdb;
1941
1942 $batch_size = intval($options['batch_size']);
1943 $offset = intval($options['offset']);
1944 $import_titles = (bool) $options['import_titles'];
1945 $import_descriptions = (bool) $options['import_descriptions'];
1946 $import_social_text = !empty($options['import_social_text']);
1947 $import_social_images = !empty($options['import_social_images']);
1948 $overwrite = (bool) $options['overwrite_existing'];
1949
1950 // Build WHERE clause for meta keys
1951 $meta_keys = [];
1952 if ($import_titles) {
1953 $meta_keys[] = '_yoast_wpseo_title';
1954 }
1955 if ($import_descriptions) {
1956 $meta_keys[] = '_yoast_wpseo_metadesc';
1957 }
1958 if ($import_social_text) {
1959 $meta_keys[] = '_yoast_wpseo_opengraph-title';
1960 $meta_keys[] = '_yoast_wpseo_opengraph-description';
1961 $meta_keys[] = '_yoast_wpseo_twitter-title';
1962 $meta_keys[] = '_yoast_wpseo_twitter-description';
1963 }
1964 if ($import_social_images) {
1965 $meta_keys[] = '_yoast_wpseo_opengraph-image';
1966 $meta_keys[] = '_yoast_wpseo_twitter-image';
1967 }
1968
1969 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1970 return [
1971 'success' => false,
1972 'message' => 'No import options selected.'
1973 ];
1974 }
1975
1976 // Get total count (for progress tracking)
1977 $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1978 $total_posts = (int) $wpdb->get_var($wpdb->prepare("
1979 SELECT COUNT(DISTINCT post_id)
1980 FROM {$wpdb->postmeta}
1981 WHERE meta_key IN ($meta_keys_placeholders)
1982 AND meta_value != ''
1983 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1984 ", $meta_keys));
1985
1986 // Get batch of posts with Yoast data
1987 $posts = $wpdb->get_results($wpdb->prepare("
1988 SELECT DISTINCT post_id
1989 FROM {$wpdb->postmeta}
1990 WHERE meta_key IN ($meta_keys_placeholders)
1991 AND meta_value != ''
1992 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1993 ORDER BY post_id ASC
1994 LIMIT %d OFFSET %d
1995 ", array_merge($meta_keys, [$batch_size, $offset])));
1996
1997 $imported_count = 0;
1998 $skipped_count = 0;
1999
2000 foreach ($posts as $post_obj) {
2001 $post_id = $post_obj->post_id;
2002 $updated = false;
2003
2004 // Import title
2005 if ($import_titles) {
2006 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
2007 if (!empty($yoast_title)) {
2008 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2009
2010 if (empty($existing_title) || $overwrite) {
2011 $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
2012 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
2013 $updated = true;
2014 }
2015 }
2016 }
2017
2018 // Import description
2019 if ($import_descriptions) {
2020 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
2021 if (!empty($yoast_desc)) {
2022 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2023
2024 if (empty($existing_desc) || $overwrite) {
2025 $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
2026 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
2027 $updated = true;
2028 }
2029 }
2030 }
2031
2032 // Import social text
2033 if ($import_social_text) {
2034 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
2035 if (!empty($yoast_og_title)) {
2036 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2037 if (empty($existing) || $overwrite) {
2038 $yoast_og_title = $this->resolve_seo_placeholders($yoast_og_title, $post_id, 'yoast');
2039 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($yoast_og_title));
2040 $updated = true;
2041 }
2042 }
2043
2044 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
2045 if (!empty($yoast_og_desc)) {
2046 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2047 if (empty($existing) || $overwrite) {
2048 $yoast_og_desc = $this->resolve_seo_placeholders($yoast_og_desc, $post_id, 'yoast');
2049 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($yoast_og_desc));
2050 $updated = true;
2051 }
2052 }
2053
2054 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
2055 if (!empty($yoast_tw_title)) {
2056 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2057 if (empty($existing) || $overwrite) {
2058 $yoast_tw_title = $this->resolve_seo_placeholders($yoast_tw_title, $post_id, 'yoast');
2059 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($yoast_tw_title));
2060 $updated = true;
2061 }
2062 }
2063
2064 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
2065 if (!empty($yoast_tw_desc)) {
2066 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2067 if (empty($existing) || $overwrite) {
2068 $yoast_tw_desc = $this->resolve_seo_placeholders($yoast_tw_desc, $post_id, 'yoast');
2069 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($yoast_tw_desc));
2070 $updated = true;
2071 }
2072 }
2073 }
2074
2075 // Import social images
2076 if ($import_social_images) {
2077 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
2078 if (!empty($yoast_og_image)) {
2079 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2080 if (empty($existing_og) || $overwrite) {
2081 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($yoast_og_image));
2082 $updated = true;
2083 }
2084 }
2085
2086 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
2087 if (!empty($yoast_twitter_image)) {
2088 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2089 if (empty($existing_twitter) || $overwrite) {
2090 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($yoast_twitter_image));
2091 $updated = true;
2092 }
2093 }
2094 }
2095
2096 if ($updated) {
2097 $imported_count++;
2098 } else {
2099 $skipped_count++;
2100 }
2101 }
2102
2103 $processed = $offset + count($posts);
2104 $is_complete = $processed >= $total_posts;
2105
2106 return [
2107 'success' => true,
2108 'imported' => $imported_count,
2109 'skipped' => $skipped_count,
2110 'total' => $total_posts,
2111 'processed' => $processed,
2112 'is_complete' => $is_complete,
2113 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2114 'message' => $is_complete
2115 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2116 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2117 ];
2118 }
2119
2120 /**
2121 * Import SEO metadata from Rank Math
2122 */
2123 private function import_rankmath_seo_metadata($options)
2124 {
2125 global $wpdb;
2126
2127 $batch_size = intval($options['batch_size']);
2128 $offset = intval($options['offset']);
2129 $import_titles = (bool) $options['import_titles'];
2130 $import_descriptions = (bool) $options['import_descriptions'];
2131 $import_social_text = !empty($options['import_social_text']);
2132 $import_social_images = !empty($options['import_social_images']);
2133 $overwrite = (bool) $options['overwrite_existing'];
2134
2135 // Build WHERE clause for meta keys
2136 $meta_keys = [];
2137 if ($import_titles) {
2138 $meta_keys[] = 'rank_math_title';
2139 }
2140 if ($import_descriptions) {
2141 $meta_keys[] = 'rank_math_description';
2142 }
2143 if ($import_social_text) {
2144 $meta_keys[] = 'rank_math_facebook_title';
2145 $meta_keys[] = 'rank_math_facebook_description';
2146 $meta_keys[] = 'rank_math_twitter_title';
2147 $meta_keys[] = 'rank_math_twitter_description';
2148 }
2149 if ($import_social_images) {
2150 $meta_keys[] = 'rank_math_facebook_image';
2151 $meta_keys[] = 'rank_math_twitter_image';
2152 }
2153
2154 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2155 return [
2156 'success' => false,
2157 'message' => 'No import options selected.'
2158 ];
2159 }
2160
2161 // Get total count
2162 $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
2163 $total_posts = (int) $wpdb->get_var($wpdb->prepare("
2164 SELECT COUNT(DISTINCT post_id)
2165 FROM {$wpdb->postmeta}
2166 WHERE meta_key IN ($meta_keys_placeholders)
2167 AND meta_value != ''
2168 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2169 ", $meta_keys));
2170
2171 // Get batch of posts
2172 $posts = $wpdb->get_results($wpdb->prepare("
2173 SELECT DISTINCT post_id
2174 FROM {$wpdb->postmeta}
2175 WHERE meta_key IN ($meta_keys_placeholders)
2176 AND meta_value != ''
2177 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2178 ORDER BY post_id ASC
2179 LIMIT %d OFFSET %d
2180 ", array_merge($meta_keys, [$batch_size, $offset])));
2181
2182 $imported_count = 0;
2183 $skipped_count = 0;
2184
2185 foreach ($posts as $post_obj) {
2186 $post_id = $post_obj->post_id;
2187 $updated = false;
2188
2189 // Import title
2190 if ($import_titles) {
2191 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2192 if (!empty($rm_title)) {
2193 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2194
2195 if (empty($existing_title) || $overwrite) {
2196 $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2197 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2198 $updated = true;
2199 }
2200 }
2201 }
2202
2203 // Import description
2204 if ($import_descriptions) {
2205 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2206 if (!empty($rm_desc)) {
2207 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2208
2209 if (empty($existing_desc) || $overwrite) {
2210 $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2211 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2212 $updated = true;
2213 }
2214 }
2215 }
2216
2217 // Import social text
2218 if ($import_social_text) {
2219 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
2220 if (!empty($rm_og_title)) {
2221 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2222 if (empty($existing) || $overwrite) {
2223 $rm_og_title = $this->resolve_seo_placeholders($rm_og_title, $post_id, 'rankmath');
2224 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($rm_og_title));
2225 $updated = true;
2226 }
2227 }
2228
2229 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
2230 if (!empty($rm_og_desc)) {
2231 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2232 if (empty($existing) || $overwrite) {
2233 $rm_og_desc = $this->resolve_seo_placeholders($rm_og_desc, $post_id, 'rankmath');
2234 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($rm_og_desc));
2235 $updated = true;
2236 }
2237 }
2238
2239 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
2240 if (!empty($rm_tw_title)) {
2241 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2242 if (empty($existing) || $overwrite) {
2243 $rm_tw_title = $this->resolve_seo_placeholders($rm_tw_title, $post_id, 'rankmath');
2244 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($rm_tw_title));
2245 $updated = true;
2246 }
2247 }
2248
2249 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
2250 if (!empty($rm_tw_desc)) {
2251 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2252 if (empty($existing) || $overwrite) {
2253 $rm_tw_desc = $this->resolve_seo_placeholders($rm_tw_desc, $post_id, 'rankmath');
2254 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($rm_tw_desc));
2255 $updated = true;
2256 }
2257 }
2258 }
2259
2260 // Import social images
2261 if ($import_social_images) {
2262 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
2263 if (!empty($rm_og_image)) {
2264 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2265 if (empty($existing_og) || $overwrite) {
2266 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($rm_og_image));
2267 $updated = true;
2268 }
2269 }
2270
2271 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
2272 if (!empty($rm_twitter_image)) {
2273 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2274 if (empty($existing_twitter) || $overwrite) {
2275 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($rm_twitter_image));
2276 $updated = true;
2277 }
2278 }
2279 }
2280
2281 if ($updated) {
2282 $imported_count++;
2283 } else {
2284 $skipped_count++;
2285 }
2286 }
2287
2288 $processed = $offset + count($posts);
2289 $is_complete = $processed >= $total_posts;
2290
2291 return [
2292 'success' => true,
2293 'imported' => $imported_count,
2294 'skipped' => $skipped_count,
2295 'total' => $total_posts,
2296 'processed' => $processed,
2297 'is_complete' => $is_complete,
2298 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2299 'message' => $is_complete
2300 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2301 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2302 ];
2303 }
2304
2305 /**
2306 * Import SEO metadata from All in One SEO
2307 */
2308 private function import_aioseo_seo_metadata($options)
2309 {
2310 global $wpdb;
2311
2312 $batch_size = intval($options['batch_size']);
2313 $offset = intval($options['offset']);
2314 $import_titles = (bool) $options['import_titles'];
2315 $import_descriptions = (bool) $options['import_descriptions'];
2316 $import_social_text = !empty($options['import_social_text']);
2317 $import_social_images = !empty($options['import_social_images']);
2318 $overwrite = (bool) $options['overwrite_existing'];
2319
2320 // Check if AIOSEO table exists
2321 $table = $wpdb->prefix . 'aioseo_posts';
2322 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
2323 return [
2324 'success' => false,
2325 'message' => 'AIOSEO database table not found.'
2326 ];
2327 }
2328
2329 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2330 return [
2331 'success' => false,
2332 'message' => 'No import options selected.'
2333 ];
2334 }
2335
2336 // Build WHERE clause
2337 $where_conditions = [];
2338 if ($import_titles) {
2339 $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
2340 }
2341 if ($import_descriptions) {
2342 $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
2343 }
2344 if ($import_social_text) {
2345 $where_conditions[] = '(og_title IS NOT NULL AND og_title != \'\')';
2346 $where_conditions[] = '(og_description IS NOT NULL AND og_description != \'\')';
2347 $where_conditions[] = '(twitter_title IS NOT NULL AND twitter_title != \'\')';
2348 $where_conditions[] = '(twitter_description IS NOT NULL AND twitter_description != \'\')';
2349 }
2350 if ($import_social_images) {
2351 $where_conditions[] = '(og_image_custom_url IS NOT NULL AND og_image_custom_url != \'\')';
2352 $where_conditions[] = '(twitter_image_custom_url IS NOT NULL AND twitter_image_custom_url != \'\')';
2353 }
2354 $where_clause = implode(' OR ', $where_conditions);
2355
2356 // Build SELECT columns
2357 $select_columns = ['post_id'];
2358 if ($import_titles) {
2359 $select_columns[] = 'title';
2360 }
2361 if ($import_descriptions) {
2362 $select_columns[] = 'description';
2363 }
2364 if ($import_social_text) {
2365 $select_columns[] = 'og_title';
2366 $select_columns[] = 'og_description';
2367 $select_columns[] = 'twitter_title';
2368 $select_columns[] = 'twitter_description';
2369 }
2370 if ($import_social_images) {
2371 $select_columns[] = 'og_image_custom_url';
2372 $select_columns[] = 'twitter_image_custom_url';
2373 }
2374 $select_clause = implode(', ', $select_columns);
2375
2376 // Get total count
2377 $total_posts = (int) $wpdb->get_var("
2378 SELECT COUNT(post_id)
2379 FROM {$table}
2380 WHERE ({$where_clause})
2381 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2382 ");
2383
2384 // Get batch of posts
2385 $posts = $wpdb->get_results($wpdb->prepare("
2386 SELECT {$select_clause}
2387 FROM {$table}
2388 WHERE ({$where_clause})
2389 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2390 ORDER BY post_id ASC
2391 LIMIT %d OFFSET %d
2392 ", $batch_size, $offset));
2393
2394 $imported_count = 0;
2395 $skipped_count = 0;
2396
2397 foreach ($posts as $aioseo_data) {
2398 $post_id = $aioseo_data->post_id;
2399 $updated = false;
2400
2401 // Import title
2402 if ($import_titles && !empty($aioseo_data->title)) {
2403 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2404
2405 if (empty($existing_title) || $overwrite) {
2406 $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2407 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2408 $updated = true;
2409 }
2410 }
2411
2412 // Import description
2413 if ($import_descriptions && !empty($aioseo_data->description)) {
2414 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2415
2416 if (empty($existing_desc) || $overwrite) {
2417 $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2418 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2419 $updated = true;
2420 }
2421 }
2422
2423 // Import social text
2424 if ($import_social_text) {
2425 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
2426 if (!empty($aioseo_og_title)) {
2427 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2428 if (empty($existing) || $overwrite) {
2429 $aioseo_og_title = $this->resolve_seo_placeholders($aioseo_og_title, $post_id, 'aioseo');
2430 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($aioseo_og_title));
2431 $updated = true;
2432 }
2433 }
2434
2435 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
2436 if (!empty($aioseo_og_desc)) {
2437 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2438 if (empty($existing) || $overwrite) {
2439 $aioseo_og_desc = $this->resolve_seo_placeholders($aioseo_og_desc, $post_id, 'aioseo');
2440 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($aioseo_og_desc));
2441 $updated = true;
2442 }
2443 }
2444
2445 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
2446 if (!empty($aioseo_tw_title)) {
2447 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2448 if (empty($existing) || $overwrite) {
2449 $aioseo_tw_title = $this->resolve_seo_placeholders($aioseo_tw_title, $post_id, 'aioseo');
2450 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($aioseo_tw_title));
2451 $updated = true;
2452 }
2453 }
2454
2455 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
2456 if (!empty($aioseo_tw_desc)) {
2457 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2458 if (empty($existing) || $overwrite) {
2459 $aioseo_tw_desc = $this->resolve_seo_placeholders($aioseo_tw_desc, $post_id, 'aioseo');
2460 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($aioseo_tw_desc));
2461 $updated = true;
2462 }
2463 }
2464 }
2465
2466 // Import social images
2467 if ($import_social_images) {
2468 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
2469 if (!empty($aioseo_og_image)) {
2470 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2471 if (empty($existing_og) || $overwrite) {
2472 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($aioseo_og_image));
2473 $updated = true;
2474 }
2475 }
2476
2477 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
2478 if (!empty($aioseo_twitter_image)) {
2479 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2480 if (empty($existing_twitter) || $overwrite) {
2481 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($aioseo_twitter_image));
2482 $updated = true;
2483 }
2484 }
2485 }
2486
2487 if ($updated) {
2488 $imported_count++;
2489 } else {
2490 $skipped_count++;
2491 }
2492 }
2493
2494 $processed = $offset + count($posts);
2495 $is_complete = $processed >= $total_posts;
2496
2497 return [
2498 'success' => true,
2499 'imported' => $imported_count,
2500 'skipped' => $skipped_count,
2501 'total' => $total_posts,
2502 'processed' => $processed,
2503 'is_complete' => $is_complete,
2504 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2505 'message' => $is_complete
2506 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2507 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2508 ];
2509 }
2510
2511 /**
2512 * Import term-level SEO metadata from a third-party SEO plugin into
2513 * MetaSync term meta (`_metasync_*`).
2514 *
2515 * Mirrors import_seo_metadata() but operates on terms (wp_termmeta for
2516 * Yoast/Rank Math and the wp_aioseo_terms table for AIOSEO) and walks
2517 * every registered public taxonomy so category, post_tag, and custom
2518 * taxonomies are all covered.
2519 *
2520 * @param string $plugin One of 'yoast', 'rankmath', 'aioseo'.
2521 * @param array $options Batch options (overwrite_existing, batch_size, offset).
2522 * @return array ['success'=>bool,'imported'=>int,'skipped'=>int,'total'=>int,'message'=>string]
2523 */
2524 public function import_term_seo_metadata($plugin, $options = [])
2525 {
2526 $defaults = [
2527 'overwrite_existing' => false,
2528 'batch_size' => 100,
2529 'offset' => 0,
2530 ];
2531 $options = array_merge($defaults, $options);
2532
2533 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'], true)) {
2534 return [
2535 'success' => false,
2536 'message' => 'Invalid plugin specified.',
2537 ];
2538 }
2539
2540 switch ($plugin) {
2541 case 'yoast':
2542 return $this->import_yoast_term_meta($options);
2543 case 'rankmath':
2544 return $this->import_rankmath_term_meta($options);
2545 case 'aioseo':
2546 return $this->import_aioseo_term_meta($options);
2547 }
2548
2549 return [
2550 'success' => false,
2551 'message' => 'Unknown error occurred.',
2552 ];
2553 }
2554
2555 /**
2556 * Import Yoast term meta into MetaSync term meta.
2557 *
2558 * @param array $options
2559 * @return array
2560 */
2561 private function import_yoast_term_meta($options)
2562 {
2563 $batch_size = intval($options['batch_size']);
2564 $offset = intval($options['offset']);
2565 $overwrite = (bool) $options['overwrite_existing'];
2566
2567 // Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta`
2568 // option (wp_options), NOT in wp_termmeta. We must read from there.
2569 if (!class_exists('WPSEO_Taxonomy_Meta')) {
2570 return [
2571 'success' => false,
2572 'message' => 'Yoast SEO is not active or WPSEO_Taxonomy_Meta class not available.',
2573 ];
2574 }
2575
2576 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
2577
2578 $terms = get_terms([
2579 'taxonomy' => $taxonomies,
2580 'hide_empty' => false,
2581 'number' => $batch_size,
2582 'offset' => $offset,
2583 ]);
2584
2585 if (is_wp_error($terms)) {
2586 return [
2587 'success' => false,
2588 'message' => $terms->get_error_message(),
2589 ];
2590 }
2591
2592 $field_map = [
2593 'wpseo_title' => '_metasync_metatitle',
2594 'wpseo_desc' => '_metasync_metadesc',
2595 'wpseo_opengraph-title' => '_metasync_og_title',
2596 'wpseo_opengraph-description' => '_metasync_og_description',
2597 'wpseo_canonical' => '_metasync_canonical_url',
2598 ];
2599
2600 $imported = 0;
2601 $skipped = 0;
2602
2603 foreach ($terms as $term) {
2604 $term_updated = false;
2605
2606 // Read from Yoast's wpseo_taxonomy_meta option via its API.
2607 $yoast_meta = WPSEO_Taxonomy_Meta::get_term_meta($term->term_id, $term->taxonomy);
2608 if (!is_array($yoast_meta)) {
2609 $yoast_meta = [];
2610 }
2611
2612 foreach ($field_map as $src_key => $dest_key) {
2613 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2614 if ($src_value === '' || $src_value === null) {
2615 continue;
2616 }
2617
2618 $existing = get_term_meta($term->term_id, $dest_key, true);
2619 if (!empty($existing) && !$overwrite) {
2620 continue;
2621 }
2622
2623 update_term_meta($term->term_id, $dest_key, $src_value);
2624 $term_updated = true;
2625 }
2626
2627 $noindex = isset($yoast_meta['wpseo_noindex']) ? $yoast_meta['wpseo_noindex'] : '';
2628 if ($noindex === 'noindex') {
2629 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
2630 if (empty($existing) || $overwrite) {
2631 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
2632 $term_updated = true;
2633 }
2634 }
2635
2636 if ($term_updated) {
2637 $imported++;
2638 } else {
2639 $skipped++;
2640 }
2641 }
2642
2643 $processed = $offset + count($terms);
2644
2645 return [
2646 'success' => true,
2647 'imported' => $imported,
2648 'skipped' => $skipped,
2649 'total' => $processed,
2650 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2651 ];
2652 }
2653
2654 /**
2655 * Import Rank Math term meta into MetaSync term meta.
2656 *
2657 * @param array $options
2658 * @return array
2659 */
2660 private function import_rankmath_term_meta($options)
2661 {
2662 $batch_size = intval($options['batch_size']);
2663 $offset = intval($options['offset']);
2664 $overwrite = (bool) $options['overwrite_existing'];
2665
2666 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
2667
2668 $terms = get_terms([
2669 'taxonomy' => $taxonomies,
2670 'hide_empty' => false,
2671 'number' => $batch_size,
2672 'offset' => $offset,
2673 ]);
2674
2675 if (is_wp_error($terms)) {
2676 return [
2677 'success' => false,
2678 'message' => $terms->get_error_message(),
2679 ];
2680 }
2681
2682 $field_map = [
2683 'rank_math_title' => '_metasync_metatitle',
2684 'rank_math_description' => '_metasync_metadesc',
2685 'rank_math_facebook_title' => '_metasync_og_title',
2686 'rank_math_facebook_description' => '_metasync_og_description',
2687 'rank_math_canonical_url' => '_metasync_canonical_url',
2688 ];
2689
2690 $imported = 0;
2691 $skipped = 0;
2692
2693 foreach ($terms as $term) {
2694 $term_updated = false;
2695
2696 foreach ($field_map as $src_key => $dest_key) {
2697 $src_value = get_term_meta($term->term_id, $src_key, true);
2698 if ($src_value === '' || $src_value === null) {
2699 continue;
2700 }
2701
2702 $existing = get_term_meta($term->term_id, $dest_key, true);
2703 if (!empty($existing) && !$overwrite) {
2704 continue;
2705 }
2706
2707 update_term_meta($term->term_id, $dest_key, $src_value);
2708 $term_updated = true;
2709 }
2710
2711 $robots_raw = get_term_meta($term->term_id, 'rank_math_robots', true);
2712 $robots = maybe_unserialize($robots_raw);
2713 if (is_array($robots) && in_array('noindex', $robots, true)) {
2714 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
2715 if (empty($existing) || $overwrite) {
2716 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
2717 $term_updated = true;
2718 }
2719 }
2720
2721 if ($term_updated) {
2722 $imported++;
2723 } else {
2724 $skipped++;
2725 }
2726 }
2727
2728 $processed = $offset + count($terms);
2729
2730 return [
2731 'success' => true,
2732 'imported' => $imported,
2733 'skipped' => $skipped,
2734 'total' => $processed,
2735 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2736 ];
2737 }
2738
2739 /**
2740 * Import AIOSEO term meta (from the wp_aioseo_terms custom table) into
2741 * MetaSync term meta.
2742 *
2743 * @param array $options
2744 * @return array
2745 */
2746 private function import_aioseo_term_meta($options)
2747 {
2748 global $wpdb;
2749
2750 $batch_size = intval($options['batch_size']);
2751 $offset = intval($options['offset']);
2752 $overwrite = (bool) $options['overwrite_existing'];
2753
2754 $table = $wpdb->prefix . 'aioseo_terms';
2755
2756 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
2757 if ($table_exists !== $table) {
2758 return [
2759 'success' => false,
2760 'message' => 'AIOSEO terms table not found.',
2761 ];
2762 }
2763
2764 $rows = $wpdb->get_results($wpdb->prepare(
2765 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2766 FROM {$table}
2767 ORDER BY term_id ASC
2768 LIMIT %d OFFSET %d",
2769 $batch_size,
2770 $offset
2771 ));
2772
2773 $imported = 0;
2774 $skipped = 0;
2775
2776 foreach ($rows as $row) {
2777 $term_id = (int) $row->term_id;
2778 if ($term_id <= 0) {
2779 continue;
2780 }
2781
2782 $term_updated = false;
2783
2784 $field_map = [
2785 'title' => '_metasync_metatitle',
2786 'description' => '_metasync_metadesc',
2787 'og_title' => '_metasync_og_title',
2788 'og_description' => '_metasync_og_description',
2789 'canonical_url' => '_metasync_canonical_url',
2790 ];
2791
2792 foreach ($field_map as $column => $dest_key) {
2793 $value = isset($row->$column) ? $row->$column : '';
2794 if ($value === '' || $value === null) {
2795 continue;
2796 }
2797
2798 $existing = get_term_meta($term_id, $dest_key, true);
2799 if (!empty($existing) && !$overwrite) {
2800 continue;
2801 }
2802
2803 update_term_meta($term_id, $dest_key, $value);
2804 $term_updated = true;
2805 }
2806
2807 if (!empty($row->robots_noindex) && (int) $row->robots_noindex === 1) {
2808 $existing = get_term_meta($term_id, '_metasync_robots_index', true);
2809 if (empty($existing) || $overwrite) {
2810 update_term_meta($term_id, '_metasync_robots_index', 'noindex');
2811 $term_updated = true;
2812 }
2813 }
2814
2815 if ($term_updated) {
2816 $imported++;
2817 } else {
2818 $skipped++;
2819 }
2820 }
2821
2822 $processed = $offset + count($rows);
2823
2824 return [
2825 'success' => true,
2826 'imported' => $imported,
2827 'skipped' => $skipped,
2828 'total' => $processed,
2829 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2830 ];
2831 }
2832 }
2833