PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
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.10, at includes/class-metasync-external-importer.php

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