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

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

2,752 lines 109.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Import data from other SEO plugins.
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 * @package Metasync
9 * @subpackage Metasync/includes
10 * @author Engineering Team <[email protected]>
11 */
12
13 // Abort if this file is accessed directly.
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 class Metasync_External_Importer
19 {
20 private $db_redirection;
21 private $redirection_importer;
22
23 public function __construct($db_redirection = null)
24 {
25 $this->db_redirection = $db_redirection;
26
27 // Initialize redirection importer if DB resource is provided
28 if ($this->db_redirection) {
29 require_once plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-importer.php';
30 $this->redirection_importer = new Metasync_Redirection_Importer($this->db_redirection);
31 }
32 }
33
34 /**
35 * Get available plugins for a specific import type
36 */
37 public function get_plugins_for_type($type)
38 {
39 $plugins = [
40 'yoast' => ['name' => 'Yoast SEO', 'constant' => 'WPSEO_VERSION'],
41 'rankmath' => ['name' => 'Rank Math', 'constant' => 'RANK_MATH_VERSION'],
42 'aioseo' => ['name' => 'All in One SEO', 'constant' => 'AIOSEO_VERSION'],
43 'redirection' => ['name' => 'Redirection', 'constant' => 'REDIRECTION_VERSION'],
44 'simple301' => ['name' => 'Simple 301 Redirects', 'constant' => 'SIMPLE_301_REDIRECTS_VERSION'],
45 ];
46
47 // Filter plugins based on type support
48 $supported_plugins = [];
49 switch ($type) {
50 case 'redirections':
51 if ($this->redirection_importer) {
52 return $this->redirection_importer->get_available_plugins();
53 }
54 return [];
55 case 'sitemap':
56 case 'robots':
57 // These types are generally supported by the main SEO plugins
58 $supported = ['yoast', 'rankmath', 'aioseo'];
59 foreach ($supported as $slug) {
60 $plugin = $plugins[$slug];
61 $is_active = defined($plugin['constant']);
62
63 // Basic data structure similar to redirection importer
64 $supported_plugins[$slug] = [
65 'name' => $plugin['name'],
66 'key' => $slug,
67 'installed' => $is_active,
68 'has_data' => $is_active, // Assume data exists if active for now
69 'count' => 0, // Count not applicable/calculated yet
70 'version' => $is_active ? constant($plugin['constant']) : ''
71 ];
72 }
73 break;
74
75 case 'schema':
76 // Check for actual per-post schema data
77 // Even if plugin is deactivated, we can still import the data
78 $supported = ['yoast', 'rankmath', 'aioseo'];
79 foreach ($supported as $slug) {
80 $plugin = $plugins[$slug];
81 $is_active = defined($plugin['constant']);
82 $count = 0;
83
84 // Always check for data, regardless of plugin activation status
85 $has_data = $this->check_schema_data($slug, $count);
86
87 $supported_plugins[$slug] = [
88 'name' => $plugin['name'],
89 'key' => $slug,
90 'installed' => $is_active,
91 'has_data' => $has_data,
92 'count' => $count,
93 'version' => $is_active ? constant($plugin['constant']) : ''
94 ];
95 }
96 break;
97
98 case 'indexation':
99 // Check for actual per-post SEO data
100 // Even if plugin is deactivated, we can still import the data
101 $supported = ['yoast', 'rankmath', 'aioseo'];
102 foreach ($supported as $slug) {
103 $plugin = $plugins[$slug];
104 $is_active = defined($plugin['constant']);
105 $count = 0;
106
107 // Always check for data, regardless of plugin activation status
108 $has_data = $this->check_indexation_data($slug, $count);
109
110 $supported_plugins[$slug] = [
111 'name' => $plugin['name'],
112 'key' => $slug,
113 'installed' => $is_active,
114 'has_data' => $has_data,
115 'count' => $count,
116 'version' => $is_active ? constant($plugin['constant']) : ''
117 ];
118 }
119 break;
120
121 case 'primary_category':
122 // Check for primary category data from other SEO plugins
123 $supported = ['yoast', 'rankmath', 'aioseo'];
124 foreach ($supported as $slug) {
125 $plugin = $plugins[$slug];
126 $is_active = defined($plugin['constant']);
127 $count = 0;
128
129 $has_data = $this->check_primary_category_data($slug, $count);
130
131 $supported_plugins[$slug] = [
132 'name' => $plugin['name'],
133 'key' => $slug,
134 'installed' => $is_active,
135 'has_data' => $has_data,
136 'count' => $count,
137 'version' => $is_active ? constant($plugin['constant']) : ''
138 ];
139 }
140 break;
141
142 case 'seo_metadata':
143 // Check for actual SEO metadata (titles and descriptions)
144 // Even if plugin is deactivated, we can still import the data
145 $supported = ['yoast', 'rankmath', 'aioseo'];
146 foreach ($supported as $slug) {
147 $plugin = $plugins[$slug];
148 $is_active = defined($plugin['constant']);
149 $count = 0;
150
151 // Always check for data, regardless of plugin activation status
152 $has_data = $this->check_seo_metadata($slug, $count);
153
154 $supported_plugins[$slug] = [
155 'name' => $plugin['name'],
156 'key' => $slug,
157 'installed' => $is_active,
158 'has_data' => $has_data,
159 'count' => $count,
160 'version' => $is_active ? constant($plugin['constant']) : ''
161 ];
162 }
163 break;
164 }
165
166 return $supported_plugins;
167 }
168
169 /**
170 * Check if indexation data exists for a plugin
171 */
172 private function check_indexation_data($plugin, &$count)
173 {
174 global $wpdb;
175
176 $count = 0;
177
178 switch ($plugin) {
179 case 'yoast':
180 $count = (int) $wpdb->get_var("
181 SELECT COUNT(DISTINCT post_id)
182 FROM {$wpdb->postmeta}
183 WHERE meta_key LIKE '_yoast_wpseo_%'
184 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
185 ");
186 break;
187
188 case 'rankmath':
189 $count = (int) $wpdb->get_var("
190 SELECT COUNT(DISTINCT post_id)
191 FROM {$wpdb->postmeta}
192 WHERE meta_key LIKE 'rank_math_%'
193 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
194 ");
195 break;
196
197 case 'aioseo':
198 $table = $wpdb->prefix . 'aioseo_posts';
199 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
200 $count = (int) $wpdb->get_var("
201 SELECT COUNT(post_id)
202 FROM {$table}
203 WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
204 ");
205 }
206 break;
207 }
208
209 return $count > 0;
210 }
211
212 /**
213 * Check if schema data exists for a plugin
214 */
215 private function check_schema_data($plugin, &$count)
216 {
217 global $wpdb;
218
219 $count = 0;
220
221 switch ($plugin) {
222 case 'yoast':
223 // Check for Yoast schema meta
224 // Yoast Premium stores schema type in _yoast_wpseo_schema_article_type
225 // Free version may use _yoast_wpseo_schema (JSON)
226 $count = (int) $wpdb->get_var("
227 SELECT COUNT(DISTINCT post_id)
228 FROM {$wpdb->postmeta}
229 WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
230 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
231 ");
232 break;
233
234 case 'rankmath':
235 // Check for Rank Math schema meta (any schema type)
236 // Rank Math uses meta keys like: rank_math_schema_Article, rank_math_schema_BlogPosting, rank_math_schema_Product, etc.
237 // Exclude shortcode schemas (rank_math_shortcode_schema_*)
238 $count = (int) $wpdb->get_var("
239 SELECT COUNT(DISTINCT post_id)
240 FROM {$wpdb->postmeta}
241 WHERE meta_key LIKE 'rank_math_schema_%'
242 AND meta_key NOT LIKE 'rank_math_shortcode_schema_%'
243 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
244 ");
245 break;
246
247 case 'aioseo':
248 // Check for AIOSEO schema in table
249 $table = $wpdb->prefix . 'aioseo_posts';
250 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
251 $count = (int) $wpdb->get_var("
252 SELECT COUNT(post_id)
253 FROM {$table}
254 WHERE (schema_type IS NOT NULL AND schema_type != '')
255 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
256 ");
257 }
258 break;
259 }
260
261 return $count > 0;
262 }
263
264 /**
265 * Check if SEO metadata (titles and descriptions) exists for a plugin
266 */
267 private function check_seo_metadata($plugin, &$count)
268 {
269 global $wpdb;
270
271 $count = 0;
272
273 switch ($plugin) {
274 case 'yoast':
275 // Check for Yoast SEO title or description
276 $count = (int) $wpdb->get_var("
277 SELECT COUNT(DISTINCT post_id)
278 FROM {$wpdb->postmeta}
279 WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
280 AND meta_value != ''
281 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
282 ");
283 break;
284
285 case 'rankmath':
286 // Check for Rank Math title or description
287 $count = (int) $wpdb->get_var("
288 SELECT COUNT(DISTINCT post_id)
289 FROM {$wpdb->postmeta}
290 WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
291 AND meta_value != ''
292 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
293 ");
294 break;
295
296 case 'aioseo':
297 // Check for AIOSEO title or description in their custom table
298 $table = $wpdb->prefix . 'aioseo_posts';
299 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") === $table) {
300 $count = (int) $wpdb->get_var("
301 SELECT COUNT(post_id)
302 FROM {$table}
303 WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
304 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
305 ");
306 }
307 break;
308 }
309
310 return $count > 0;
311 }
312
313 /**
314 * Check if primary category data exists for a plugin
315 */
316 private function check_primary_category_data($plugin, &$count)
317 {
318 global $wpdb;
319
320 $count = 0;
321
322 switch ($plugin) {
323 case 'yoast':
324 $count = (int) $wpdb->get_var("
325 SELECT COUNT(DISTINCT post_id)
326 FROM {$wpdb->postmeta}
327 WHERE meta_key = '_yoast_wpseo_primary_category'
328 AND meta_value != ''
329 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
330 ");
331 break;
332
333 case 'rankmath':
334 $count = (int) $wpdb->get_var("
335 SELECT COUNT(DISTINCT post_id)
336 FROM {$wpdb->postmeta}
337 WHERE meta_key = 'rank_math_primary_category'
338 AND meta_value != ''
339 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
340 ");
341 break;
342
343 case 'aioseo':
344 $count = (int) $wpdb->get_var("
345 SELECT COUNT(DISTINCT post_id)
346 FROM {$wpdb->postmeta}
347 WHERE meta_key = '_aioseo_primary_category'
348 AND meta_value != ''
349 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
350 ");
351 break;
352 }
353
354 return $count > 0;
355 }
356
357 /**
358 * Import primary category from another SEO plugin
359 *
360 * @param string $plugin Plugin slug (yoast, rankmath, aioseo)
361 * @param array $options Import options
362 * @return array Result array with success, imported, skipped, total, etc.
363 */
364 public function import_primary_category($plugin, $options = [])
365 {
366 $defaults = [
367 'overwrite_existing' => false,
368 'batch_size' => 50,
369 'offset' => 0,
370 ];
371 $options = array_merge($defaults, $options);
372
373 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
374 return [
375 'success' => false,
376 'message' => 'Invalid plugin specified.',
377 ];
378 }
379
380 switch ($plugin) {
381 case 'yoast':
382 return $this->import_yoast_primary_category($options);
383 case 'rankmath':
384 return $this->import_rankmath_primary_category($options);
385 case 'aioseo':
386 return $this->import_aioseo_primary_category($options);
387 }
388
389 return [
390 'success' => false,
391 'message' => 'Unknown error occurred.',
392 ];
393 }
394
395 /**
396 * Import primary category from Yoast SEO
397 */
398 private function import_yoast_primary_category($options)
399 {
400 global $wpdb;
401
402 $batch_size = intval($options['batch_size']);
403 $offset = intval($options['offset']);
404 $overwrite = (bool) $options['overwrite_existing'];
405
406 $total_posts = (int) $wpdb->get_var("
407 SELECT COUNT(DISTINCT post_id)
408 FROM {$wpdb->postmeta}
409 WHERE meta_key = '_yoast_wpseo_primary_category'
410 AND meta_value != ''
411 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
412 ");
413
414 $posts = $wpdb->get_results($wpdb->prepare("
415 SELECT DISTINCT post_id
416 FROM {$wpdb->postmeta}
417 WHERE meta_key = '_yoast_wpseo_primary_category'
418 AND meta_value != ''
419 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
420 ORDER BY post_id ASC
421 LIMIT %d OFFSET %d
422 ", $batch_size, $offset));
423
424 $imported_count = 0;
425 $skipped_count = 0;
426
427 foreach ($posts as $post_obj) {
428 $post_id = $post_obj->post_id;
429 $term_id = absint(get_post_meta($post_id, '_yoast_wpseo_primary_category', true));
430
431 if ($term_id <= 0 || !get_term($term_id, 'category')) {
432 $skipped_count++;
433 continue;
434 }
435
436 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
437 if ($existing > 0 && !$overwrite) {
438 $skipped_count++;
439 continue;
440 }
441
442 update_post_meta($post_id, '_metasync_primary_category', $term_id);
443 $imported_count++;
444 }
445
446 $processed = $offset + count($posts);
447 $has_more = $processed < $total_posts;
448
449 return [
450 'success' => true,
451 'imported' => $imported_count,
452 'skipped' => $skipped_count,
453 'total' => $total_posts,
454 'has_more' => $has_more,
455 ];
456 }
457
458 /**
459 * Import primary category from Rank Math
460 */
461 private function import_rankmath_primary_category($options)
462 {
463 global $wpdb;
464
465 $batch_size = intval($options['batch_size']);
466 $offset = intval($options['offset']);
467 $overwrite = (bool) $options['overwrite_existing'];
468
469 $total_posts = (int) $wpdb->get_var("
470 SELECT COUNT(DISTINCT post_id)
471 FROM {$wpdb->postmeta}
472 WHERE meta_key = 'rank_math_primary_category'
473 AND meta_value != ''
474 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
475 ");
476
477 $posts = $wpdb->get_results($wpdb->prepare("
478 SELECT DISTINCT post_id
479 FROM {$wpdb->postmeta}
480 WHERE meta_key = 'rank_math_primary_category'
481 AND meta_value != ''
482 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
483 ORDER BY post_id ASC
484 LIMIT %d OFFSET %d
485 ", $batch_size, $offset));
486
487 $imported_count = 0;
488 $skipped_count = 0;
489
490 foreach ($posts as $post_obj) {
491 $post_id = $post_obj->post_id;
492 $term_id = absint(get_post_meta($post_id, 'rank_math_primary_category', true));
493
494 if ($term_id <= 0 || !get_term($term_id, 'category')) {
495 $skipped_count++;
496 continue;
497 }
498
499 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
500 if ($existing > 0 && !$overwrite) {
501 $skipped_count++;
502 continue;
503 }
504
505 update_post_meta($post_id, '_metasync_primary_category', $term_id);
506 $imported_count++;
507 }
508
509 $processed = $offset + count($posts);
510 $has_more = $processed < $total_posts;
511
512 return [
513 'success' => true,
514 'imported' => $imported_count,
515 'skipped' => $skipped_count,
516 'total' => $total_posts,
517 'has_more' => $has_more,
518 ];
519 }
520
521 /**
522 * Import primary category from AIOSEO
523 */
524 private function import_aioseo_primary_category($options)
525 {
526 global $wpdb;
527
528 $batch_size = intval($options['batch_size']);
529 $offset = intval($options['offset']);
530 $overwrite = (bool) $options['overwrite_existing'];
531
532 $total_posts = (int) $wpdb->get_var("
533 SELECT COUNT(DISTINCT post_id)
534 FROM {$wpdb->postmeta}
535 WHERE meta_key = '_aioseo_primary_category'
536 AND meta_value != ''
537 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
538 ");
539
540 $posts = $wpdb->get_results($wpdb->prepare("
541 SELECT DISTINCT post_id
542 FROM {$wpdb->postmeta}
543 WHERE meta_key = '_aioseo_primary_category'
544 AND meta_value != ''
545 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
546 ORDER BY post_id ASC
547 LIMIT %d OFFSET %d
548 ", $batch_size, $offset));
549
550 $imported_count = 0;
551 $skipped_count = 0;
552
553 foreach ($posts as $post_obj) {
554 $post_id = $post_obj->post_id;
555 $term_id = absint(get_post_meta($post_id, '_aioseo_primary_category', true));
556
557 if ($term_id <= 0 || !get_term($term_id, 'category')) {
558 $skipped_count++;
559 continue;
560 }
561
562 $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
563 if ($existing > 0 && !$overwrite) {
564 $skipped_count++;
565 continue;
566 }
567
568 update_post_meta($post_id, '_metasync_primary_category', $term_id);
569 $imported_count++;
570 }
571
572 $processed = $offset + count($posts);
573 $has_more = $processed < $total_posts;
574
575 return [
576 'success' => true,
577 'imported' => $imported_count,
578 'skipped' => $skipped_count,
579 'total' => $total_posts,
580 'has_more' => $has_more,
581 ];
582 }
583
584 /**
585 * Import Redirections
586 */
587 public function import_redirections($plugin)
588 {
589 if (!$this->redirection_importer) {
590 return ['success' => false, 'message' => 'Redirection database not initialized.'];
591 }
592 return $this->redirection_importer->import_from_plugin($plugin);
593 }
594
595 /**
596 * Import Sitemap Settings
597 */
598 public function import_sitemap($plugin)
599 {
600 $imported = false;
601 $message = '';
602
603 switch ($plugin) {
604 case 'yoast':
605 $options = get_option('wpseo_xml');
606 if ($options && isset($options['enablexmlsitemap'])) {
607 // Yoast stores sitemap settings in wpseo_xml option
608 // Metasync sitemap is auto-generated, so we just acknowledge the import
609 $message = 'Sitemap settings imported from Yoast.';
610 $imported = true;
611 }
612 break;
613
614 case 'rankmath':
615 $options = get_option('rank-math-options-sitemap');
616 if ($options) {
617 // Import logic here
618 $message = 'Sitemap settings imported from Rank Math.';
619 $imported = true;
620 }
621 break;
622
623 case 'aioseo':
624 $options = get_option('aioseo_options');
625 if ($options && isset($options['sitemap'])) {
626 // Import logic here
627 $message = 'Sitemap settings imported from AIOSEO.';
628 $imported = true;
629 }
630 break;
631 }
632
633 if (!$imported) {
634 return ['success' => false, 'message' => 'No sitemap settings found or plugin not active.'];
635 }
636
637 return ['success' => true, 'message' => $message];
638 }
639
640 /**
641 * Import Robots.txt
642 */
643 public function import_robots($plugin)
644 {
645 $content = '';
646
647 switch ($plugin) {
648 case 'yoast':
649 // Yoast doesn't store robots.txt in DB, it edits the file.
650 // But it might have settings for it.
651 // If we are "importing", we might just want to read the current file if managed by them?
652 // Actually, if they have a custom robots.txt editor, they might store it.
653 // Yoast uses the file system directly.
654 $content = $this->get_robots_content_from_file();
655 break;
656
657 case 'rankmath':
658 $options = get_option('rank-math-options-general');
659 if (isset($options['robots_txt_content'])) {
660 $content = $options['robots_txt_content'];
661 } else {
662 $content = $this->get_robots_content_from_file();
663 }
664 break;
665
666 case 'aioseo':
667 $options = get_option('aioseo_options');
668 if (isset($options['tools']['robots']['rules'])) {
669 // AIOSEO stores rules as array, need to reconstruct
670 // For simplicity, let's try reading the file first as it's the source of truth
671 $content = $this->get_robots_content_from_file();
672 }
673 break;
674 }
675
676 if (empty($content)) {
677 return ['success' => false, 'message' => 'No robots.txt content found.'];
678 }
679
680 // Save to Metasync Robots.txt
681 // Load the class if not already loaded
682 if (!class_exists('Metasync_Robots_Txt')) {
683 require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
684 }
685
686 $robots_class = Metasync_Robots_Txt::get_instance();
687 $result = $robots_class->write_robots_file($content);
688
689 if (is_wp_error($result)) {
690 return ['success' => false, 'message' => $result->get_error_message()];
691 }
692
693 return ['success' => true, 'message' => 'Robots.txt content imported successfully.'];
694 }
695
696 private function get_robots_content_from_file() {
697 $robots_file = ABSPATH . 'robots.txt';
698 if (file_exists($robots_file)) {
699 return file_get_contents($robots_file);
700 }
701 return '';
702 }
703
704 /**
705 * Import Indexation Options (Per-Post Robots Meta)
706 */
707 public function import_indexation($plugin)
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', 'sc-dash');
1739 // Yoast stores separator keys like 'sc-dash'; convert to glyph
1740 // via the public get_separator_options() lookup table.
1741 if (class_exists('WPSEO_Option_Titles')) {
1742 $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
1743 if (isset($options[$raw])) {
1744 $sep = html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
1745 }
1746 }
1747 } catch (Exception $e) {
1748 $sep = '-';
1749 }
1750 } elseif (defined('RANK_MATH_VERSION')) {
1751 $rm_settings = get_option('rank_math_general_settings', []);
1752 if (!empty($rm_settings['title_separator'])) {
1753 $sep = html_entity_decode($rm_settings['title_separator'], ENT_QUOTES, 'UTF-8');
1754 }
1755 }
1756
1757 $map = [
1758 // ── Yoast (%%var%%) ──────────────────────────────────────────────
1759 '%%title%%' => $post_title,
1760 '%%sitename%%' => $site_name,
1761 '%%sep%%' => $sep,
1762 '%%excerpt%%' => $post_excerpt,
1763 '%%excerpt_only%%' => $post_excerpt,
1764 '%%author%%' => $author,
1765 '%%date%%' => $date,
1766 '%%modified%%' => $modified,
1767 '%%id%%' => (string) $post_id,
1768 '%%page%%' => '',
1769 '%%pagenumber%%' => '',
1770 '%%pagetotal%%' => '',
1771 '%%primary_category%%' => $primary_category,
1772 '%%category%%' => $primary_category,
1773 '%%tag%%' => $first_tag,
1774 '%%focuskw%%' => (string) get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
1775 // ── Rank Math (%var%) ────────────────────────────────────────────
1776 '%title%' => $post_title,
1777 '%sitename%' => $site_name,
1778 '%sep%' => $sep,
1779 '%excerpt%' => $post_excerpt,
1780 '%author%' => $author,
1781 '%date%' => $date,
1782 '%modified%' => $modified,
1783 '%id%' => (string) $post_id,
1784 '%page%' => '',
1785 '%category%' => $primary_category,
1786 '%tag%' => $first_tag,
1787 '%focus_keyword%' => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
1788 // ── AIOSEO (#var#) ───────────────────────────────────────────────
1789 '#site_title#' => $site_name,
1790 '#post_title#' => $post_title,
1791 '#post_excerpt#' => $post_excerpt,
1792 '#separator_sa#' => $sep,
1793 '#author_name#' => $author,
1794 '#current_date#' => $date,
1795 '#post_date#' => $date,
1796 '#category_title#' => $primary_category,
1797 '#tag_title#' => $first_tag,
1798 '#id#' => (string) $post_id,
1799 ];
1800
1801 // Sort longest token first to avoid partial matches
1802 // e.g. %%primary_category%% must replace before %%category%%
1803 uksort($map, function ($a, $b) {
1804 return strlen($b) - strlen($a);
1805 });
1806
1807 return str_replace(array_keys($map), array_values($map), $text);
1808 }
1809
1810 /**
1811 * Import SEO Metadata (Titles and Descriptions)
1812 * Supports batch processing via AJAX
1813 *
1814 * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
1815 * @param array $options Import options (import_titles, import_descriptions, overwrite_existing, batch_size, offset)
1816 * @return array Result with success status, progress info, and statistics
1817 */
1818 public function import_seo_metadata($plugin, $options = [])
1819 {
1820 // Default options
1821 $defaults = [
1822 'import_titles' => true,
1823 'import_descriptions' => true,
1824 'overwrite_existing' => false,
1825 'batch_size' => 50, // Process 50 posts per batch
1826 'offset' => 0
1827 ];
1828 $options = array_merge($defaults, $options);
1829
1830 // Validate plugin
1831 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
1832 return [
1833 'success' => false,
1834 'message' => 'Invalid plugin specified.'
1835 ];
1836 }
1837
1838 // Route to appropriate import method
1839 switch ($plugin) {
1840 case 'yoast':
1841 return $this->import_yoast_seo_metadata($options);
1842 case 'rankmath':
1843 return $this->import_rankmath_seo_metadata($options);
1844 case 'aioseo':
1845 return $this->import_aioseo_seo_metadata($options);
1846 }
1847
1848 return [
1849 'success' => false,
1850 'message' => 'Unknown error occurred.'
1851 ];
1852 }
1853
1854 /**
1855 * Import SEO metadata from Yoast SEO
1856 */
1857 private function import_yoast_seo_metadata($options)
1858 {
1859 global $wpdb;
1860
1861 $batch_size = intval($options['batch_size']);
1862 $offset = intval($options['offset']);
1863 $import_titles = (bool) $options['import_titles'];
1864 $import_descriptions = (bool) $options['import_descriptions'];
1865 $import_social_text = !empty($options['import_social_text']);
1866 $import_social_images = !empty($options['import_social_images']);
1867 $overwrite = (bool) $options['overwrite_existing'];
1868
1869 // Build WHERE clause for meta keys
1870 $meta_keys = [];
1871 if ($import_titles) {
1872 $meta_keys[] = '_yoast_wpseo_title';
1873 }
1874 if ($import_descriptions) {
1875 $meta_keys[] = '_yoast_wpseo_metadesc';
1876 }
1877 if ($import_social_text) {
1878 $meta_keys[] = '_yoast_wpseo_opengraph-title';
1879 $meta_keys[] = '_yoast_wpseo_opengraph-description';
1880 $meta_keys[] = '_yoast_wpseo_twitter-title';
1881 $meta_keys[] = '_yoast_wpseo_twitter-description';
1882 }
1883 if ($import_social_images) {
1884 $meta_keys[] = '_yoast_wpseo_opengraph-image';
1885 $meta_keys[] = '_yoast_wpseo_twitter-image';
1886 }
1887
1888 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
1889 return [
1890 'success' => false,
1891 'message' => 'No import options selected.'
1892 ];
1893 }
1894
1895 // Get total count (for progress tracking)
1896 $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
1897 $total_posts = (int) $wpdb->get_var($wpdb->prepare("
1898 SELECT COUNT(DISTINCT post_id)
1899 FROM {$wpdb->postmeta}
1900 WHERE meta_key IN ($meta_keys_placeholders)
1901 AND meta_value != ''
1902 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1903 ", $meta_keys));
1904
1905 // Get batch of posts with Yoast data
1906 $posts = $wpdb->get_results($wpdb->prepare("
1907 SELECT DISTINCT post_id
1908 FROM {$wpdb->postmeta}
1909 WHERE meta_key IN ($meta_keys_placeholders)
1910 AND meta_value != ''
1911 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
1912 ORDER BY post_id ASC
1913 LIMIT %d OFFSET %d
1914 ", array_merge($meta_keys, [$batch_size, $offset])));
1915
1916 $imported_count = 0;
1917 $skipped_count = 0;
1918
1919 foreach ($posts as $post_obj) {
1920 $post_id = $post_obj->post_id;
1921 $updated = false;
1922
1923 // Import title
1924 if ($import_titles) {
1925 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
1926 if (!empty($yoast_title)) {
1927 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
1928
1929 if (empty($existing_title) || $overwrite) {
1930 $yoast_title = $this->resolve_seo_placeholders($yoast_title, $post_id, 'yoast');
1931 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($yoast_title));
1932 $updated = true;
1933 }
1934 }
1935 }
1936
1937 // Import description
1938 if ($import_descriptions) {
1939 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
1940 if (!empty($yoast_desc)) {
1941 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
1942
1943 if (empty($existing_desc) || $overwrite) {
1944 $yoast_desc = $this->resolve_seo_placeholders($yoast_desc, $post_id, 'yoast');
1945 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($yoast_desc));
1946 $updated = true;
1947 }
1948 }
1949 }
1950
1951 // Import social text
1952 if ($import_social_text) {
1953 $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
1954 if (!empty($yoast_og_title)) {
1955 $existing = get_post_meta($post_id, '_metasync_og_title', true);
1956 if (empty($existing) || $overwrite) {
1957 $yoast_og_title = $this->resolve_seo_placeholders($yoast_og_title, $post_id, 'yoast');
1958 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($yoast_og_title));
1959 $updated = true;
1960 }
1961 }
1962
1963 $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
1964 if (!empty($yoast_og_desc)) {
1965 $existing = get_post_meta($post_id, '_metasync_og_description', true);
1966 if (empty($existing) || $overwrite) {
1967 $yoast_og_desc = $this->resolve_seo_placeholders($yoast_og_desc, $post_id, 'yoast');
1968 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($yoast_og_desc));
1969 $updated = true;
1970 }
1971 }
1972
1973 $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
1974 if (!empty($yoast_tw_title)) {
1975 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
1976 if (empty($existing) || $overwrite) {
1977 $yoast_tw_title = $this->resolve_seo_placeholders($yoast_tw_title, $post_id, 'yoast');
1978 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($yoast_tw_title));
1979 $updated = true;
1980 }
1981 }
1982
1983 $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
1984 if (!empty($yoast_tw_desc)) {
1985 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
1986 if (empty($existing) || $overwrite) {
1987 $yoast_tw_desc = $this->resolve_seo_placeholders($yoast_tw_desc, $post_id, 'yoast');
1988 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($yoast_tw_desc));
1989 $updated = true;
1990 }
1991 }
1992 }
1993
1994 // Import social images
1995 if ($import_social_images) {
1996 $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
1997 if (!empty($yoast_og_image)) {
1998 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
1999 if (empty($existing_og) || $overwrite) {
2000 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($yoast_og_image));
2001 $updated = true;
2002 }
2003 }
2004
2005 $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
2006 if (!empty($yoast_twitter_image)) {
2007 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2008 if (empty($existing_twitter) || $overwrite) {
2009 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($yoast_twitter_image));
2010 $updated = true;
2011 }
2012 }
2013 }
2014
2015 if ($updated) {
2016 $imported_count++;
2017 } else {
2018 $skipped_count++;
2019 }
2020 }
2021
2022 $processed = $offset + count($posts);
2023 $is_complete = $processed >= $total_posts;
2024
2025 return [
2026 'success' => true,
2027 'imported' => $imported_count,
2028 'skipped' => $skipped_count,
2029 'total' => $total_posts,
2030 'processed' => $processed,
2031 'is_complete' => $is_complete,
2032 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2033 'message' => $is_complete
2034 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2035 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2036 ];
2037 }
2038
2039 /**
2040 * Import SEO metadata from Rank Math
2041 */
2042 private function import_rankmath_seo_metadata($options)
2043 {
2044 global $wpdb;
2045
2046 $batch_size = intval($options['batch_size']);
2047 $offset = intval($options['offset']);
2048 $import_titles = (bool) $options['import_titles'];
2049 $import_descriptions = (bool) $options['import_descriptions'];
2050 $import_social_text = !empty($options['import_social_text']);
2051 $import_social_images = !empty($options['import_social_images']);
2052 $overwrite = (bool) $options['overwrite_existing'];
2053
2054 // Build WHERE clause for meta keys
2055 $meta_keys = [];
2056 if ($import_titles) {
2057 $meta_keys[] = 'rank_math_title';
2058 }
2059 if ($import_descriptions) {
2060 $meta_keys[] = 'rank_math_description';
2061 }
2062 if ($import_social_text) {
2063 $meta_keys[] = 'rank_math_facebook_title';
2064 $meta_keys[] = 'rank_math_facebook_description';
2065 $meta_keys[] = 'rank_math_twitter_title';
2066 $meta_keys[] = 'rank_math_twitter_description';
2067 }
2068 if ($import_social_images) {
2069 $meta_keys[] = 'rank_math_facebook_image';
2070 $meta_keys[] = 'rank_math_twitter_image';
2071 }
2072
2073 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2074 return [
2075 'success' => false,
2076 'message' => 'No import options selected.'
2077 ];
2078 }
2079
2080 // Get total count
2081 $meta_keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
2082 $total_posts = (int) $wpdb->get_var($wpdb->prepare("
2083 SELECT COUNT(DISTINCT post_id)
2084 FROM {$wpdb->postmeta}
2085 WHERE meta_key IN ($meta_keys_placeholders)
2086 AND meta_value != ''
2087 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2088 ", $meta_keys));
2089
2090 // Get batch of posts
2091 $posts = $wpdb->get_results($wpdb->prepare("
2092 SELECT DISTINCT post_id
2093 FROM {$wpdb->postmeta}
2094 WHERE meta_key IN ($meta_keys_placeholders)
2095 AND meta_value != ''
2096 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2097 ORDER BY post_id ASC
2098 LIMIT %d OFFSET %d
2099 ", array_merge($meta_keys, [$batch_size, $offset])));
2100
2101 $imported_count = 0;
2102 $skipped_count = 0;
2103
2104 foreach ($posts as $post_obj) {
2105 $post_id = $post_obj->post_id;
2106 $updated = false;
2107
2108 // Import title
2109 if ($import_titles) {
2110 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2111 if (!empty($rm_title)) {
2112 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2113
2114 if (empty($existing_title) || $overwrite) {
2115 $rm_title = $this->resolve_seo_placeholders($rm_title, $post_id, 'rankmath');
2116 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($rm_title));
2117 $updated = true;
2118 }
2119 }
2120 }
2121
2122 // Import description
2123 if ($import_descriptions) {
2124 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2125 if (!empty($rm_desc)) {
2126 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2127
2128 if (empty($existing_desc) || $overwrite) {
2129 $rm_desc = $this->resolve_seo_placeholders($rm_desc, $post_id, 'rankmath');
2130 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($rm_desc));
2131 $updated = true;
2132 }
2133 }
2134 }
2135
2136 // Import social text
2137 if ($import_social_text) {
2138 $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
2139 if (!empty($rm_og_title)) {
2140 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2141 if (empty($existing) || $overwrite) {
2142 $rm_og_title = $this->resolve_seo_placeholders($rm_og_title, $post_id, 'rankmath');
2143 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($rm_og_title));
2144 $updated = true;
2145 }
2146 }
2147
2148 $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
2149 if (!empty($rm_og_desc)) {
2150 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2151 if (empty($existing) || $overwrite) {
2152 $rm_og_desc = $this->resolve_seo_placeholders($rm_og_desc, $post_id, 'rankmath');
2153 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($rm_og_desc));
2154 $updated = true;
2155 }
2156 }
2157
2158 $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
2159 if (!empty($rm_tw_title)) {
2160 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2161 if (empty($existing) || $overwrite) {
2162 $rm_tw_title = $this->resolve_seo_placeholders($rm_tw_title, $post_id, 'rankmath');
2163 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($rm_tw_title));
2164 $updated = true;
2165 }
2166 }
2167
2168 $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
2169 if (!empty($rm_tw_desc)) {
2170 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2171 if (empty($existing) || $overwrite) {
2172 $rm_tw_desc = $this->resolve_seo_placeholders($rm_tw_desc, $post_id, 'rankmath');
2173 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($rm_tw_desc));
2174 $updated = true;
2175 }
2176 }
2177 }
2178
2179 // Import social images
2180 if ($import_social_images) {
2181 $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
2182 if (!empty($rm_og_image)) {
2183 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2184 if (empty($existing_og) || $overwrite) {
2185 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($rm_og_image));
2186 $updated = true;
2187 }
2188 }
2189
2190 $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
2191 if (!empty($rm_twitter_image)) {
2192 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2193 if (empty($existing_twitter) || $overwrite) {
2194 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($rm_twitter_image));
2195 $updated = true;
2196 }
2197 }
2198 }
2199
2200 if ($updated) {
2201 $imported_count++;
2202 } else {
2203 $skipped_count++;
2204 }
2205 }
2206
2207 $processed = $offset + count($posts);
2208 $is_complete = $processed >= $total_posts;
2209
2210 return [
2211 'success' => true,
2212 'imported' => $imported_count,
2213 'skipped' => $skipped_count,
2214 'total' => $total_posts,
2215 'processed' => $processed,
2216 'is_complete' => $is_complete,
2217 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2218 'message' => $is_complete
2219 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2220 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2221 ];
2222 }
2223
2224 /**
2225 * Import SEO metadata from All in One SEO
2226 */
2227 private function import_aioseo_seo_metadata($options)
2228 {
2229 global $wpdb;
2230
2231 $batch_size = intval($options['batch_size']);
2232 $offset = intval($options['offset']);
2233 $import_titles = (bool) $options['import_titles'];
2234 $import_descriptions = (bool) $options['import_descriptions'];
2235 $import_social_text = !empty($options['import_social_text']);
2236 $import_social_images = !empty($options['import_social_images']);
2237 $overwrite = (bool) $options['overwrite_existing'];
2238
2239 // Check if AIOSEO table exists
2240 $table = $wpdb->prefix . 'aioseo_posts';
2241 if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
2242 return [
2243 'success' => false,
2244 'message' => 'AIOSEO database table not found.'
2245 ];
2246 }
2247
2248 if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
2249 return [
2250 'success' => false,
2251 'message' => 'No import options selected.'
2252 ];
2253 }
2254
2255 // Build WHERE clause
2256 $where_conditions = [];
2257 if ($import_titles) {
2258 $where_conditions[] = '(title IS NOT NULL AND title != \'\')';
2259 }
2260 if ($import_descriptions) {
2261 $where_conditions[] = '(description IS NOT NULL AND description != \'\')';
2262 }
2263 if ($import_social_text) {
2264 $where_conditions[] = '(og_title IS NOT NULL AND og_title != \'\')';
2265 $where_conditions[] = '(og_description IS NOT NULL AND og_description != \'\')';
2266 $where_conditions[] = '(twitter_title IS NOT NULL AND twitter_title != \'\')';
2267 $where_conditions[] = '(twitter_description IS NOT NULL AND twitter_description != \'\')';
2268 }
2269 if ($import_social_images) {
2270 $where_conditions[] = '(og_image_custom_url IS NOT NULL AND og_image_custom_url != \'\')';
2271 $where_conditions[] = '(twitter_image_custom_url IS NOT NULL AND twitter_image_custom_url != \'\')';
2272 }
2273 $where_clause = implode(' OR ', $where_conditions);
2274
2275 // Build SELECT columns
2276 $select_columns = ['post_id'];
2277 if ($import_titles) {
2278 $select_columns[] = 'title';
2279 }
2280 if ($import_descriptions) {
2281 $select_columns[] = 'description';
2282 }
2283 if ($import_social_text) {
2284 $select_columns[] = 'og_title';
2285 $select_columns[] = 'og_description';
2286 $select_columns[] = 'twitter_title';
2287 $select_columns[] = 'twitter_description';
2288 }
2289 if ($import_social_images) {
2290 $select_columns[] = 'og_image_custom_url';
2291 $select_columns[] = 'twitter_image_custom_url';
2292 }
2293 $select_clause = implode(', ', $select_columns);
2294
2295 // Get total count
2296 $total_posts = (int) $wpdb->get_var("
2297 SELECT COUNT(post_id)
2298 FROM {$table}
2299 WHERE ({$where_clause})
2300 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2301 ");
2302
2303 // Get batch of posts
2304 $posts = $wpdb->get_results($wpdb->prepare("
2305 SELECT {$select_clause}
2306 FROM {$table}
2307 WHERE ({$where_clause})
2308 AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
2309 ORDER BY post_id ASC
2310 LIMIT %d OFFSET %d
2311 ", $batch_size, $offset));
2312
2313 $imported_count = 0;
2314 $skipped_count = 0;
2315
2316 foreach ($posts as $aioseo_data) {
2317 $post_id = $aioseo_data->post_id;
2318 $updated = false;
2319
2320 // Import title
2321 if ($import_titles && !empty($aioseo_data->title)) {
2322 $existing_title = get_post_meta($post_id, '_metasync_seo_title', true);
2323
2324 if (empty($existing_title) || $overwrite) {
2325 $aioseo_title = $this->resolve_seo_placeholders($aioseo_data->title, $post_id, 'aioseo');
2326 update_post_meta($post_id, '_metasync_seo_title', sanitize_text_field($aioseo_title));
2327 $updated = true;
2328 }
2329 }
2330
2331 // Import description
2332 if ($import_descriptions && !empty($aioseo_data->description)) {
2333 $existing_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
2334
2335 if (empty($existing_desc) || $overwrite) {
2336 $aioseo_desc = $this->resolve_seo_placeholders($aioseo_data->description, $post_id, 'aioseo');
2337 update_post_meta($post_id, '_metasync_seo_desc', sanitize_textarea_field($aioseo_desc));
2338 $updated = true;
2339 }
2340 }
2341
2342 // Import social text
2343 if ($import_social_text) {
2344 $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
2345 if (!empty($aioseo_og_title)) {
2346 $existing = get_post_meta($post_id, '_metasync_og_title', true);
2347 if (empty($existing) || $overwrite) {
2348 $aioseo_og_title = $this->resolve_seo_placeholders($aioseo_og_title, $post_id, 'aioseo');
2349 update_post_meta($post_id, '_metasync_og_title', sanitize_text_field($aioseo_og_title));
2350 $updated = true;
2351 }
2352 }
2353
2354 $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
2355 if (!empty($aioseo_og_desc)) {
2356 $existing = get_post_meta($post_id, '_metasync_og_description', true);
2357 if (empty($existing) || $overwrite) {
2358 $aioseo_og_desc = $this->resolve_seo_placeholders($aioseo_og_desc, $post_id, 'aioseo');
2359 update_post_meta($post_id, '_metasync_og_description', sanitize_textarea_field($aioseo_og_desc));
2360 $updated = true;
2361 }
2362 }
2363
2364 $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
2365 if (!empty($aioseo_tw_title)) {
2366 $existing = get_post_meta($post_id, '_metasync_twitter_title', true);
2367 if (empty($existing) || $overwrite) {
2368 $aioseo_tw_title = $this->resolve_seo_placeholders($aioseo_tw_title, $post_id, 'aioseo');
2369 update_post_meta($post_id, '_metasync_twitter_title', sanitize_text_field($aioseo_tw_title));
2370 $updated = true;
2371 }
2372 }
2373
2374 $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
2375 if (!empty($aioseo_tw_desc)) {
2376 $existing = get_post_meta($post_id, '_metasync_twitter_description', true);
2377 if (empty($existing) || $overwrite) {
2378 $aioseo_tw_desc = $this->resolve_seo_placeholders($aioseo_tw_desc, $post_id, 'aioseo');
2379 update_post_meta($post_id, '_metasync_twitter_description', sanitize_textarea_field($aioseo_tw_desc));
2380 $updated = true;
2381 }
2382 }
2383 }
2384
2385 // Import social images
2386 if ($import_social_images) {
2387 $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
2388 if (!empty($aioseo_og_image)) {
2389 $existing_og = get_post_meta($post_id, '_metasync_og_image', true);
2390 if (empty($existing_og) || $overwrite) {
2391 update_post_meta($post_id, '_metasync_og_image', esc_url_raw($aioseo_og_image));
2392 $updated = true;
2393 }
2394 }
2395
2396 $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
2397 if (!empty($aioseo_twitter_image)) {
2398 $existing_twitter = get_post_meta($post_id, '_metasync_twitter_image', true);
2399 if (empty($existing_twitter) || $overwrite) {
2400 update_post_meta($post_id, '_metasync_twitter_image', esc_url_raw($aioseo_twitter_image));
2401 $updated = true;
2402 }
2403 }
2404 }
2405
2406 if ($updated) {
2407 $imported_count++;
2408 } else {
2409 $skipped_count++;
2410 }
2411 }
2412
2413 $processed = $offset + count($posts);
2414 $is_complete = $processed >= $total_posts;
2415
2416 return [
2417 'success' => true,
2418 'imported' => $imported_count,
2419 'skipped' => $skipped_count,
2420 'total' => $total_posts,
2421 'processed' => $processed,
2422 'is_complete' => $is_complete,
2423 'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
2424 'message' => $is_complete
2425 ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
2426 : "Processing... {$imported_count} imported, {$skipped_count} skipped."
2427 ];
2428 }
2429
2430 /**
2431 * Import term-level SEO metadata from a third-party SEO plugin into
2432 * MetaSync term meta (`_metasync_*`).
2433 *
2434 * Mirrors import_seo_metadata() but operates on terms (wp_termmeta for
2435 * Yoast/Rank Math and the wp_aioseo_terms table for AIOSEO) and walks
2436 * every registered public taxonomy so category, post_tag, and custom
2437 * taxonomies are all covered.
2438 *
2439 * @param string $plugin One of 'yoast', 'rankmath', 'aioseo'.
2440 * @param array $options Batch options (overwrite_existing, batch_size, offset).
2441 * @return array ['success'=>bool,'imported'=>int,'skipped'=>int,'total'=>int,'message'=>string]
2442 */
2443 public function import_term_seo_metadata($plugin, $options = [])
2444 {
2445 $defaults = [
2446 'overwrite_existing' => false,
2447 'batch_size' => 100,
2448 'offset' => 0,
2449 ];
2450 $options = array_merge($defaults, $options);
2451
2452 if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'], true)) {
2453 return [
2454 'success' => false,
2455 'message' => 'Invalid plugin specified.',
2456 ];
2457 }
2458
2459 switch ($plugin) {
2460 case 'yoast':
2461 return $this->import_yoast_term_meta($options);
2462 case 'rankmath':
2463 return $this->import_rankmath_term_meta($options);
2464 case 'aioseo':
2465 return $this->import_aioseo_term_meta($options);
2466 }
2467
2468 return [
2469 'success' => false,
2470 'message' => 'Unknown error occurred.',
2471 ];
2472 }
2473
2474 /**
2475 * Import Yoast term meta into MetaSync term meta.
2476 *
2477 * @param array $options
2478 * @return array
2479 */
2480 private function import_yoast_term_meta($options)
2481 {
2482 $batch_size = intval($options['batch_size']);
2483 $offset = intval($options['offset']);
2484 $overwrite = (bool) $options['overwrite_existing'];
2485
2486 // Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta`
2487 // option (wp_options), NOT in wp_termmeta. We must read from there.
2488 if (!class_exists('WPSEO_Taxonomy_Meta')) {
2489 return [
2490 'success' => false,
2491 'message' => 'Yoast SEO is not active or WPSEO_Taxonomy_Meta class not available.',
2492 ];
2493 }
2494
2495 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
2496
2497 $terms = get_terms([
2498 'taxonomy' => $taxonomies,
2499 'hide_empty' => false,
2500 'number' => $batch_size,
2501 'offset' => $offset,
2502 ]);
2503
2504 if (is_wp_error($terms)) {
2505 return [
2506 'success' => false,
2507 'message' => $terms->get_error_message(),
2508 ];
2509 }
2510
2511 $field_map = [
2512 'wpseo_title' => '_metasync_metatitle',
2513 'wpseo_desc' => '_metasync_metadesc',
2514 'wpseo_opengraph-title' => '_metasync_og_title',
2515 'wpseo_opengraph-description' => '_metasync_og_description',
2516 'wpseo_canonical' => '_metasync_canonical_url',
2517 ];
2518
2519 $imported = 0;
2520 $skipped = 0;
2521
2522 foreach ($terms as $term) {
2523 $term_updated = false;
2524
2525 // Read from Yoast's wpseo_taxonomy_meta option via its API.
2526 $yoast_meta = WPSEO_Taxonomy_Meta::get_term_meta($term->term_id, $term->taxonomy);
2527 if (!is_array($yoast_meta)) {
2528 $yoast_meta = [];
2529 }
2530
2531 foreach ($field_map as $src_key => $dest_key) {
2532 $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
2533 if ($src_value === '' || $src_value === null) {
2534 continue;
2535 }
2536
2537 $existing = get_term_meta($term->term_id, $dest_key, true);
2538 if (!empty($existing) && !$overwrite) {
2539 continue;
2540 }
2541
2542 update_term_meta($term->term_id, $dest_key, $src_value);
2543 $term_updated = true;
2544 }
2545
2546 $noindex = isset($yoast_meta['wpseo_noindex']) ? $yoast_meta['wpseo_noindex'] : '';
2547 if ($noindex === 'noindex') {
2548 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
2549 if (empty($existing) || $overwrite) {
2550 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
2551 $term_updated = true;
2552 }
2553 }
2554
2555 if ($term_updated) {
2556 $imported++;
2557 } else {
2558 $skipped++;
2559 }
2560 }
2561
2562 $processed = $offset + count($terms);
2563
2564 return [
2565 'success' => true,
2566 'imported' => $imported,
2567 'skipped' => $skipped,
2568 'total' => $processed,
2569 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2570 ];
2571 }
2572
2573 /**
2574 * Import Rank Math term meta into MetaSync term meta.
2575 *
2576 * @param array $options
2577 * @return array
2578 */
2579 private function import_rankmath_term_meta($options)
2580 {
2581 $batch_size = intval($options['batch_size']);
2582 $offset = intval($options['offset']);
2583 $overwrite = (bool) $options['overwrite_existing'];
2584
2585 $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));
2586
2587 $terms = get_terms([
2588 'taxonomy' => $taxonomies,
2589 'hide_empty' => false,
2590 'number' => $batch_size,
2591 'offset' => $offset,
2592 ]);
2593
2594 if (is_wp_error($terms)) {
2595 return [
2596 'success' => false,
2597 'message' => $terms->get_error_message(),
2598 ];
2599 }
2600
2601 $field_map = [
2602 'rank_math_title' => '_metasync_metatitle',
2603 'rank_math_description' => '_metasync_metadesc',
2604 'rank_math_facebook_title' => '_metasync_og_title',
2605 'rank_math_facebook_description' => '_metasync_og_description',
2606 'rank_math_canonical_url' => '_metasync_canonical_url',
2607 ];
2608
2609 $imported = 0;
2610 $skipped = 0;
2611
2612 foreach ($terms as $term) {
2613 $term_updated = false;
2614
2615 foreach ($field_map as $src_key => $dest_key) {
2616 $src_value = get_term_meta($term->term_id, $src_key, true);
2617 if ($src_value === '' || $src_value === null) {
2618 continue;
2619 }
2620
2621 $existing = get_term_meta($term->term_id, $dest_key, true);
2622 if (!empty($existing) && !$overwrite) {
2623 continue;
2624 }
2625
2626 update_term_meta($term->term_id, $dest_key, $src_value);
2627 $term_updated = true;
2628 }
2629
2630 $robots_raw = get_term_meta($term->term_id, 'rank_math_robots', true);
2631 $robots = maybe_unserialize($robots_raw);
2632 if (is_array($robots) && in_array('noindex', $robots, true)) {
2633 $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
2634 if (empty($existing) || $overwrite) {
2635 update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
2636 $term_updated = true;
2637 }
2638 }
2639
2640 if ($term_updated) {
2641 $imported++;
2642 } else {
2643 $skipped++;
2644 }
2645 }
2646
2647 $processed = $offset + count($terms);
2648
2649 return [
2650 'success' => true,
2651 'imported' => $imported,
2652 'skipped' => $skipped,
2653 'total' => $processed,
2654 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2655 ];
2656 }
2657
2658 /**
2659 * Import AIOSEO term meta (from the wp_aioseo_terms custom table) into
2660 * MetaSync term meta.
2661 *
2662 * @param array $options
2663 * @return array
2664 */
2665 private function import_aioseo_term_meta($options)
2666 {
2667 global $wpdb;
2668
2669 $batch_size = intval($options['batch_size']);
2670 $offset = intval($options['offset']);
2671 $overwrite = (bool) $options['overwrite_existing'];
2672
2673 $table = $wpdb->prefix . 'aioseo_terms';
2674
2675 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
2676 if ($table_exists !== $table) {
2677 return [
2678 'success' => false,
2679 'message' => 'AIOSEO terms table not found.',
2680 ];
2681 }
2682
2683 $rows = $wpdb->get_results($wpdb->prepare(
2684 "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
2685 FROM {$table}
2686 ORDER BY term_id ASC
2687 LIMIT %d OFFSET %d",
2688 $batch_size,
2689 $offset
2690 ));
2691
2692 $imported = 0;
2693 $skipped = 0;
2694
2695 foreach ($rows as $row) {
2696 $term_id = (int) $row->term_id;
2697 if ($term_id <= 0) {
2698 continue;
2699 }
2700
2701 $term_updated = false;
2702
2703 $field_map = [
2704 'title' => '_metasync_metatitle',
2705 'description' => '_metasync_metadesc',
2706 'og_title' => '_metasync_og_title',
2707 'og_description' => '_metasync_og_description',
2708 'canonical_url' => '_metasync_canonical_url',
2709 ];
2710
2711 foreach ($field_map as $column => $dest_key) {
2712 $value = isset($row->$column) ? $row->$column : '';
2713 if ($value === '' || $value === null) {
2714 continue;
2715 }
2716
2717 $existing = get_term_meta($term_id, $dest_key, true);
2718 if (!empty($existing) && !$overwrite) {
2719 continue;
2720 }
2721
2722 update_term_meta($term_id, $dest_key, $value);
2723 $term_updated = true;
2724 }
2725
2726 if (!empty($row->robots_noindex) && (int) $row->robots_noindex === 1) {
2727 $existing = get_term_meta($term_id, '_metasync_robots_index', true);
2728 if (empty($existing) || $overwrite) {
2729 update_term_meta($term_id, '_metasync_robots_index', 'noindex');
2730 $term_updated = true;
2731 }
2732 }
2733
2734 if ($term_updated) {
2735 $imported++;
2736 } else {
2737 $skipped++;
2738 }
2739 }
2740
2741 $processed = $offset + count($rows);
2742
2743 return [
2744 'success' => true,
2745 'imported' => $imported,
2746 'skipped' => $skipped,
2747 'total' => $processed,
2748 'message' => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
2749 ];
2750 }
2751 }
2752