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

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