PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.10
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.10
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-seo-conflict-handler.php

class-metasync-seo-conflict-handler.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.10, at includes/class-metasync-seo-conflict-handler.php

1,347 lines 48.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Centralized SEO Plugin Conflict Handler
4 *
5 * Prevents duplicate meta descriptions when MetaSync coexists with
6 * third-party SEO plugins (AIOSEO, Yoast, RankMath, etc.).
7 *
8 * Strategy:
9 * - When MetaSync (OTTO or sidebar) has a value → suppress the third-party plugin.
10 * - When MetaSync has NO value → let the third-party plugin output its own.
11 * - When NEITHER has a value → let MetaSync's legacy auto-generated description through.
12 *
13 * @package MetaSync
14 * @subpackage MetaSync/includes
15 * @since 2.8.23
16 */
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 class Metasync_SEO_Conflict_Handler {
23
24 /**
25 * Singleton instance.
26 *
27 * @var self|null
28 */
29 private static $instance = null;
30
31 /**
32 * Cached result for whether MetaSync has a description for the current page.
33 *
34 * @var bool|null
35 */
36 private $has_description_cache = null;
37
38 /**
39 * Cached result for whether AIOSEO provides a description for the current page.
40 *
41 * @var bool|null
42 */
43 private $aioseo_has_description_cache = null;
44
45 /**
46 * Cached result: whether the current post has been synced via WP-196.
47 *
48 * @var array Keyed by post_id => bool
49 */
50 private $sync_cache = [];
51
52 /**
53 * Cached result for whether OTTO has live transient-cached suggestions
54 * for the current request URL.
55 *
56 * @var bool|null
57 */
58 private $live_suggestions_cache = null;
59
60 /**
61 * Get singleton instance.
62 *
63 * @return self
64 */
65 public static function get_instance() {
66 if (self::$instance === null) {
67 self::$instance = new self();
68 }
69 return self::$instance;
70 }
71
72 /**
73 * Private constructor — use get_instance().
74 */
75 private function __construct() {
76 // Only hook on the frontend
77 if (is_admin()) {
78 return;
79 }
80
81 add_action('wp', [$this, 'register_filters'], 0);
82 }
83
84 /**
85 * Register filters after the query is parsed (so is_singular() etc. work).
86 */
87 public function register_filters() {
88 if ($this->is_aioseo_active()) {
89 $this->register_aioseo_filters();
90 }
91
92 // Ensure is_plugin_active() is available
93 if (!function_exists('is_plugin_active')) {
94 require_once ABSPATH . 'wp-admin/includes/plugin.php';
95 }
96
97 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
98 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
99 $this->register_yoast_filters();
100 }
101
102 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
103 $this->register_rankmath_filters();
104 }
105 }
106
107 // ------------------------------------------------------------------
108 // Third-party SEO plugin detection
109 // ------------------------------------------------------------------
110
111 /**
112 * Check whether AIOSEO (free or pro) is active.
113 *
114 * @return bool
115 */
116 public function is_aioseo_active() {
117 // Ensure is_plugin_active() is available on the frontend
118 if (!function_exists('is_plugin_active')) {
119 require_once ABSPATH . 'wp-admin/includes/plugin.php';
120 }
121
122 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
123 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
124 }
125
126 /**
127 * Check whether any supported third-party SEO plugin is active.
128 *
129 * @return bool
130 */
131 public function has_active_seo_plugin() {
132 // is_plugin_active() availability ensured by is_aioseo_active() call
133 return $this->is_aioseo_active()
134 || is_plugin_active('wordpress-seo/wp-seo.php')
135 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
136 || is_plugin_active('seo-by-rank-math/rank-math.php')
137 || is_plugin_active('seo-by-rankmath/rank-math.php');
138 }
139
140 // ------------------------------------------------------------------
141 // MetaSync description resolution
142 // ------------------------------------------------------------------
143
144 /**
145 * Determine whether MetaSync holds an intentional meta description
146 * for the current request.
147 *
148 * Only considers explicitly set values:
149 * 1. SEO sidebar custom value (_metasync_seo_desc)
150 * 2. OTTO persisted description (_metasync_otto_description)
151 *
152 * Auto-generated excerpts (legacy `meta_description` key) are NOT
153 * counted — they should not suppress a third-party plugin.
154 *
155 * @return bool
156 */
157 public function metasync_has_description() {
158 if ($this->has_description_cache !== null) {
159 return $this->has_description_cache;
160 }
161
162 if (!empty($this->get_metasync_description())) {
163 $this->has_description_cache = true;
164 return true;
165 }
166
167 // Term-level: on taxonomy archives MetaSync may have term meta
168 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
169 $term = $this->get_current_term();
170 if ($term) {
171 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
172 if (!empty($term_desc)) {
173 $this->has_description_cache = true;
174 return true;
175 }
176 }
177
178 $this->has_description_cache = false;
179 return false;
180 }
181
182 /**
183 * Return MetaSync's intentional meta description for the current request.
184 *
185 * Only returns values that were explicitly set (sidebar or OTTO), NOT
186 * auto-generated excerpts from the legacy `meta_description` key.
187 * This ensures we only suppress third-party plugins when MetaSync has
188 * a deliberate SEO value.
189 *
190 * @return string
191 */
192 public function get_metasync_description() {
193 $post_id = $this->get_current_object_id();
194
195 if (!$post_id) {
196 return '';
197 }
198
199 // 1. SEO sidebar (highest priority — user-edited)
200 $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
201 if (!empty($desc)) {
202 return $desc;
203 }
204
205 // 2. OTTO description
206 $desc = get_post_meta($post_id, '_metasync_otto_description', true);
207 if (!empty($desc)) {
208 return $desc;
209 }
210
211 return '';
212 }
213
214 /**
215 * Reset the cached description flag (useful when the queried object changes).
216 */
217 public function reset_cache() {
218 $this->has_description_cache = null;
219 $this->aioseo_has_description_cache = null;
220 $this->sync_cache = [];
221 $this->live_suggestions_cache = null;
222 }
223
224 /**
225 * Check whether a post has been synced to third-party plugins via WP-196.
226 *
227 * When native-first sync is active, each plugin reads MetaSync values from
228 * its own storage — no filter suppression needed. This method returns true
229 * when _metasync_plugin_sync_ts exists and contains a timestamp for the
230 * given plugin slug.
231 *
232 * @param int $post_id Post ID.
233 * @param string $plugin_slug Plugin slug: 'yoast', 'rankmath', or 'aioseo'.
234 * @return bool True if the post has been synced to this plugin.
235 */
236 private function is_post_synced($post_id, $plugin_slug) {
237 if ($post_id <= 0) {
238 return false;
239 }
240
241 if (!isset($this->sync_cache[$post_id])) {
242 $ts_raw = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
243 $this->sync_cache[$post_id] = !empty($ts_raw) ? json_decode($ts_raw, true) : [];
244 if (!is_array($this->sync_cache[$post_id])) {
245 $this->sync_cache[$post_id] = [];
246 }
247 }
248
249 return !empty($this->sync_cache[$post_id][$plugin_slug]);
250 }
251
252 /**
253 * For synced posts with multiple SEO plugins active, determine if a
254 * specific plugin is the designated output owner.
255 *
256 * Only the first active plugin in priority order (Yoast > Rank Math > AIOSEO)
257 * is allowed to output — the others are suppressed to prevent duplicate tags.
258 *
259 * @param int $post_id Post ID.
260 * @param string $plugin_slug Plugin slug to check.
261 * @return bool True if this plugin should output its tags.
262 */
263 private function is_primary_output_plugin($post_id, $plugin_slug) {
264 // For synced posts: only the primary synced plugin passes through.
265 // For unsynced posts with multiple plugins: only the highest-priority
266 // active plugin outputs to prevent duplicate tags.
267 $this->ensure_plugin_api();
268 $priority = ['yoast', 'rankmath', 'aioseo'];
269 $active_check = [
270 'yoast' => is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'),
271 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
272 'aioseo' => is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'),
273 ];
274
275 $has_any_sync = $post_id > 0 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));
276
277 if ($has_any_sync) {
278 // Synced: primary = first active + synced plugin
279 foreach ($priority as $slug) {
280 if ($active_check[$slug] && $this->is_post_synced($post_id, $slug)) {
281 return $slug === $plugin_slug;
282 }
283 }
284 return false;
285 }
286
287 // Unsynced / multiple plugins active: pick the first active plugin
288 // as the sole outputter to prevent duplicate tags.
289 $active_count = count(array_filter($active_check));
290 if ($active_count > 1) {
291 foreach ($priority as $slug) {
292 if ($active_check[$slug]) {
293 return $slug === $plugin_slug;
294 }
295 }
296 }
297
298 // Single plugin active or no plugins — don't interfere
299 return false;
300 }
301
302 /**
303 * Ensure is_plugin_active() is loaded on the frontend.
304 */
305 private function ensure_plugin_api() {
306 if (!function_exists('is_plugin_active')) {
307 require_once ABSPATH . 'wp-admin/includes/plugin.php';
308 }
309 }
310
311 // ------------------------------------------------------------------
312 // AIOSEO integration
313 // ------------------------------------------------------------------
314
315 /**
316 * Register AIOSEO-specific filters to suppress its output
317 * when MetaSync/OTTO already provides the same tags.
318 */
319 private function register_aioseo_filters() {
320 // Suppress AIOSEO meta description
321 add_filter('aioseo_description', [$this, 'filter_aioseo_description'], 999);
322
323 // Suppress AIOSEO title
324 add_filter('aioseo_title', [$this, 'filter_aioseo_title'], 999);
325
326 // Suppress AIOSEO OG/Twitter tags that OTTO already provides
327 add_filter('aioseo_facebook_tags', [$this, 'filter_aioseo_facebook_tags'], 999);
328 add_filter('aioseo_twitter_tags', [$this, 'filter_aioseo_twitter_tags'], 999);
329
330 // Suppress AIOSEO robots when MetaSync has an intentional robots value
331 add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999);
332
333 // Suppress AIOSEO schema/JSON-LD when OTTO has structured data
334 add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999);
335 }
336
337 /**
338 * Filter AIOSEO description output.
339 * Returns empty string when MetaSync has a description, letting MetaSync output it.
340 *
341 * @param string $description AIOSEO's computed description.
342 * @return string
343 */
344 public function filter_aioseo_description($description) {
345 // Cache whether AIOSEO actually has a description (before we suppress it).
346 // This is used later by should_output_legacy_description().
347 if ($this->aioseo_has_description_cache === null) {
348 $this->aioseo_has_description_cache = !empty($description);
349 }
350
351 // WP-196: Primary plugin check — only the designated plugin outputs.
352 $post_id = $this->get_current_object_id();
353 if ($post_id && $this->has_active_seo_plugin()) {
354 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
355 return $description;
356 }
357 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
358 return '';
359 }
360 }
361
362 // Term archives: AIOSEO free doesn't read per-term custom descriptions
363 // from its `wp_aioseo_terms` table, so return the MetaSync value
364 // directly so AIOSEO renders it.
365 $term = $this->get_current_term();
366 if ($term) {
367 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
368 if (!empty($term_desc)) {
369 return $term_desc;
370 }
371 }
372
373 // Suppress when: OTTO active + has description, OR MetaSync sidebar has description
374 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
375 return '';
376 }
377 return $description;
378 }
379
380 /**
381 * Filter AIOSEO title output.
382 *
383 * On term archives: AIOSEO free doesn't read custom per-term titles from
384 * its `wp_aioseo_terms` table, so we replace AIOSEO's template-based title
385 * with the MetaSync term title directly.
386 *
387 * @param string $title AIOSEO's computed title.
388 * @return string
389 */
390 public function filter_aioseo_title($title) {
391 // WP-196: Primary plugin check — only the designated plugin outputs.
392 $post_id = $this->get_current_object_id();
393 if ($post_id && $this->has_active_seo_plugin()) {
394 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
395 return $title;
396 }
397 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
398 return '';
399 }
400 }
401
402 // Term archives: return MetaSync term title directly.
403 $term = $this->get_current_term();
404 if ($term) {
405 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
406 if (!empty($term_title)) {
407 return $term_title;
408 }
409 }
410
411 if ($this->should_suppress_third_party_title()) {
412 return '';
413 }
414
415 return $title;
416 }
417
418 /**
419 * Filter AIOSEO Facebook/OG tags.
420 *
421 * Per-tag suppression: only remove a tag when OTTO is active AND has
422 * a persisted value for that specific tag, OR when MetaSync sidebar
423 * provides the equivalent value.
424 *
425 * @param array $meta AIOSEO's OG meta array.
426 * @return array
427 */
428 public function filter_aioseo_facebook_tags($meta) {
429 if (!is_array($meta)) {
430 return $meta;
431 }
432
433 $post_id = $this->get_current_object_id();
434
435 // WP-196: Post synced to AIOSEO — let AIOSEO read from its own storage.
436 if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
437 return $meta;
438 }
439
440 // og:title — suppress when OTTO has og:title OR MetaSync has title
441 if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
442 unset($meta['og:title']);
443 }
444
445 // og:description — suppress when OTTO has og:description OR MetaSync has description
446 if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
447 unset($meta['og:description']);
448 }
449
450 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
451 // (OTTO injects these structural OG tags alongside og:title in its block)
452 if ($this->otto_has_tag('og:title')) {
453 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
454 }
455
456 return $meta;
457 }
458
459 /**
460 * Filter AIOSEO Twitter tags.
461 *
462 * Per-tag suppression: only remove a tag when OTTO is active AND has
463 * a persisted value for that specific tag, OR when MetaSync sidebar
464 * provides the equivalent value.
465 *
466 * @param array $meta AIOSEO's Twitter meta array.
467 * @return array
468 */
469 public function filter_aioseo_twitter_tags($meta) {
470 if (!is_array($meta)) {
471 return $meta;
472 }
473
474 $post_id = $this->get_current_object_id();
475
476 // WP-196: Post synced to AIOSEO — let AIOSEO read from its own storage.
477 if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
478 return $meta;
479 }
480
481 if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
482 unset($meta['twitter:title']);
483 }
484
485 if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
486 unset($meta['twitter:description']);
487 }
488
489 // twitter:card — suppress when OTTO has any twitter tag
490 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
491 unset($meta['twitter:card']);
492 }
493
494 return $meta;
495 }
496
497 /**
498 * Filter AIOSEO robots meta output.
499 *
500 * When MetaSync has an intentional robots value (admin checkbox or REST API),
501 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
502 * hook_metasync_metatags() will output the MetaSync value instead.
503 *
504 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
505 * Returning an empty array suppresses AIOSEO's robots tag entirely.
506 *
507 * @param array $robots AIOSEO's computed robots attributes array.
508 * @return array
509 */
510 public function filter_aioseo_robots($robots) {
511 $post_id = $this->get_current_object_id();
512 if (!$post_id) {
513 return $robots;
514 }
515
516 // WP-196: Post synced to AIOSEO — let AIOSEO read from its own storage.
517 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
518 return $robots;
519 }
520
521 if ($this->metasync_has_robots($post_id)) {
522 // MetaSync has robots — suppress AIOSEO's tag.
523 return [];
524 }
525
526 return $robots;
527 }
528
529 /**
530 * Filter AIOSEO schema/JSON-LD output.
531 * Suppress when OTTO has structured data for the current page.
532 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
533 * so MetaSync's own BreadcrumbList is the only one on the page.
534 *
535 * @param array $output AIOSEO's @graph array.
536 * @return array
537 */
538 public function filter_aioseo_schema($output) {
539 if ($this->otto_has_schema_for_current_page()) {
540 return [];
541 }
542
543 if ($this->metasync_breadcrumb_enabled() && is_array($output)) {
544 $output = $this->strip_breadcrumb_from_graph($output);
545 }
546
547 return $output;
548 }
549
550 /**
551 * Check whether MetaSync holds an intentional robots directive for a post.
552 *
553 * Checks both storage formats:
554 * - meta_robots (string from REST API)
555 * - metasync_common_robots (array from admin checkbox)
556 *
557 * @param int $post_id Post ID.
558 * @return bool
559 */
560 public function metasync_has_robots($post_id) {
561 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
562 if (!empty($meta_robots)) {
563 return true;
564 }
565
566 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
567 if (is_array($common_robots) && !empty(array_filter($common_robots))) {
568 return true;
569 }
570
571 return false;
572 }
573
574 /**
575 * Check whether MetaSync/OTTO has a title for a given post.
576 *
577 * @param int $post_id Post ID.
578 * @return bool
579 */
580 private function metasync_has_title($post_id) {
581 $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
582 if (!empty($seo_title)) {
583 return true;
584 }
585
586 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
587 if (!empty($otto_title)) {
588 return true;
589 }
590
591 // Term-level fallback: on taxonomy archives the "object" is a term,
592 // so read `_metasync_metatitle` from term meta when we're rendering one.
593 $term = $this->get_current_term();
594 if ($term) {
595 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
596 if (!empty($term_title)) {
597 return true;
598 }
599 }
600
601 return false;
602 }
603
604 /**
605 * Determine whether a third-party SEO plugin's title should be suppressed.
606 *
607 * Suppress when either condition is met:
608 * 1. OTTO is active AND has a persisted title for this page
609 * 2. MetaSync sidebar has an explicit title for this page
610 *
611 * @return bool True if the third-party title should be suppressed.
612 */
613 private function should_suppress_third_party_title() {
614 // Condition 1: OTTO active + has title for this page
615 if ($this->otto_has_tag('title')) {
616 return true;
617 }
618
619 // Condition 2: MetaSync sidebar has explicit title
620 $post_id = $this->get_current_object_id();
621 if ($post_id) {
622 return $this->metasync_has_title($post_id);
623 }
624
625 return false;
626 }
627
628 /**
629 * Check whether the OTTO pixel is active.
630 *
631 * @return bool
632 */
633 private function is_otto_active() {
634 if (class_exists('Metasync_Otto_Config')) {
635 return Metasync_Otto_Config::is_otto_enabled();
636 }
637
638 return false;
639 }
640
641 /**
642 * Check whether the OTTO transient cache has live suggestions for the
643 * current request URL.
644 *
645 * Passive get_transient() lookup only — no OTTO API call. Mirrors the
646 * cache-key format used by Metasync_Otto_Transient_Cache and the URL
647 * construction from Otto_pixel_class::get_route().
648 *
649 * @return bool
650 */
651 public function otto_has_live_suggestions() {
652 if ($this->live_suggestions_cache !== null) {
653 return $this->live_suggestions_cache;
654 }
655
656 if (!$this->is_otto_active()) {
657 $this->live_suggestions_cache = false;
658 return false;
659 }
660
661 if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) {
662 $this->live_suggestions_cache = false;
663 return false;
664 }
665
666 $scheme = is_ssl() ? 'https' : 'http';
667 $host = $_SERVER['HTTP_HOST'];
668 $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
669 $url = $scheme . '://' . $host . $request_uri;
670
671 $hash = md5(rtrim(strtolower($url), '/'));
672 $site_id = is_multisite() ? get_current_blog_id() : 0;
673 $cached = get_transient('otto_suggestions_' . $site_id . '_' . $hash);
674
675 $this->live_suggestions_cache = ($cached !== false && !empty($cached));
676 return $this->live_suggestions_cache;
677 }
678
679 /**
680 * Check whether OTTO has a persisted value for a specific meta tag.
681 *
682 * Two conditions must be true to suppress a third-party tag:
683 * 1. OTTO is active (globally enabled)
684 * 2. OTTO has a value for this specific tag on the current page
685 *
686 * For OG/Twitter tags where OTTO's pixel injects dynamically (the
687 * specific _metasync_otto_og_* key may be empty), the buffer-level
688 * dedup in Otto_html_class::deduplicate_og_twitter_tags() handles
689 * removal after all sources have output. This method only does the
690 * direct per-tag check.
691 *
692 * @param string $tag Tag identifier (e.g. 'title', 'og:title', 'twitter:description').
693 * @return bool True when OTTO is active AND has a persisted value for this tag.
694 */
695 private function otto_has_tag($tag) {
696 if (!$this->is_otto_active()) {
697 return false;
698 }
699
700 $post_id = $this->get_current_object_id();
701 if (!$post_id) {
702 return false;
703 }
704
705 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
706 return false;
707 }
708
709 $meta_key_map = [
710 'title' => '_metasync_otto_title',
711 'description' => '_metasync_otto_description',
712 'og:title' => '_metasync_otto_og_title',
713 'og:description' => '_metasync_otto_og_description',
714 'twitter:title' => '_metasync_otto_twitter_title',
715 'twitter:description' => '_metasync_otto_twitter_description',
716 ];
717
718 if (!isset($meta_key_map[$tag])) {
719 return false;
720 }
721
722 return !empty(get_post_meta($post_id, $meta_key_map[$tag], true));
723 }
724
725 // ------------------------------------------------------------------
726 // Yoast SEO integration
727 // ------------------------------------------------------------------
728
729 /**
730 * Register Yoast SEO-specific filters to suppress its title,
731 * description, and OG/Twitter output when MetaSync/OTTO provides them.
732 */
733 private function register_yoast_filters() {
734 add_filter('wpseo_title', [$this, 'filter_yoast_title'], 999);
735 add_filter('wpseo_metadesc', [$this, 'filter_yoast_description'], 999);
736
737 // OG tags — per-tag suppression
738 add_filter('wpseo_opengraph_title', [$this, 'filter_yoast_og_title'], 999);
739 add_filter('wpseo_opengraph_desc', [$this, 'filter_yoast_og_description'], 999);
740 add_filter('wpseo_opengraph_url', [$this, 'filter_yoast_og_structural'], 999);
741 add_filter('wpseo_opengraph_type', [$this, 'filter_yoast_og_structural'], 999);
742 add_filter('wpseo_opengraph_site_name', [$this, 'filter_yoast_og_structural'], 999);
743 add_filter('wpseo_og_locale', [$this, 'filter_yoast_og_structural'], 999);
744 add_filter('wpseo_opengraph_image', [$this, 'filter_yoast_og_structural'], 999);
745
746 // Twitter tags — per-tag suppression
747 add_filter('wpseo_twitter_title', [$this, 'filter_yoast_twitter_title'], 999);
748 add_filter('wpseo_twitter_description', [$this, 'filter_yoast_twitter_description'], 999);
749 add_filter('wpseo_twitter_image', [$this, 'filter_yoast_twitter_structural'], 999);
750 add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999);
751
752 // Suppress Yoast schema/JSON-LD when OTTO has structured data
753 add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999);
754 }
755
756 /**
757 * Filter Yoast SEO title output.
758 *
759 * When the MetaSync sidebar has an explicit SEO title, return that title so
760 * Yoast's Title_Presenter renders it inside the <title> tag it controls.
761 * Returning '' would cause Title_Presenter to emit NO <title> tag at all,
762 * because Yoast has already removed WordPress's native _wp_render_title_tag
763 * action and is the sole renderer of the title element.
764 *
765 * When only OTTO has a title (no sidebar override), we let Yoast output its
766 * own title normally — OTTO's buffer post-processing replaces it in the final
767 * HTML. Returning '' here would again leave the page with no <title> tag.
768 */
769 public function filter_yoast_title($title) {
770 $post_id = $this->get_current_object_id();
771
772 // WP-196: When Yoast is the primary output plugin, let it through.
773 // For synced posts, Yoast reads from its own storage (already has the value).
774 // For unsynced posts as primary, still check for MetaSync sidebar override.
775 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
776 // Even in passthrough, a sidebar title override takes precedence
777 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
778 if (!empty($sidebar_title)) {
779 return $sidebar_title;
780 }
781 return $title;
782 }
783
784 // WP-196: Another plugin is primary — suppress Yoast.
785 if ($post_id && $this->has_active_seo_plugin()) {
786 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
787 return '';
788 }
789 }
790
791 // Term-level archives
792 $term = $this->get_current_term();
793 if ($term) {
794 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
795 if (!empty($term_title)) {
796 return $term_title;
797 }
798 }
799
800 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
801 if ($post_id) {
802 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
803 if (!empty($sidebar_title)) {
804 return $sidebar_title;
805 }
806 }
807
808 // Case 2: OTTO has a persisted title — do NOT suppress Yoast here.
809 // OTTO's output-buffer post-processing (Otto_html_class) replaces the
810 // <title> tag in the final HTML after WordPress renders. Returning '' would
811 // remove the <title> tag entirely before OTTO can inject its replacement.
812
813 return $title;
814 }
815
816 /**
817 * Filter Yoast SEO description output.
818 *
819 * On term archives: the term-level sync writes MetaSync's description
820 * into Yoast's native storage, so Yoast already computes the correct
821 * value — let it through.
822 *
823 * On singular pages: suppress when OTTO or MetaSync sidebar provides
824 * the description (MetaSync outputs its own tag).
825 */
826 public function filter_yoast_description($description) {
827 $post_id = $this->get_current_object_id();
828 if ($post_id && $this->has_active_seo_plugin()) {
829 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
830 return $description;
831 }
832 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
833 return '';
834 }
835 }
836
837 // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
838 $term = $this->get_current_term();
839 if ($term) {
840 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
841 if (!empty($term_desc)) {
842 return $description;
843 }
844 }
845
846 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
847 return '';
848 }
849 return $description;
850 }
851
852 /**
853 * Filter Yoast og:title output.
854 * Suppress when: OTTO active + has og:title, OR MetaSync has title.
855 */
856 public function filter_yoast_og_title($value) {
857 $post_id = $this->get_current_object_id();
858 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
859 return $value;
860 }
861 if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
862 return '';
863 }
864 return $value;
865 }
866
867 /**
868 * Filter Yoast og:description output.
869 * Suppress when: OTTO active + has og:description, OR MetaSync has description.
870 */
871 public function filter_yoast_og_description($value) {
872 $post_id = $this->get_current_object_id();
873 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
874 return $value;
875 }
876 if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
877 return '';
878 }
879 return $value;
880 }
881
882 /**
883 * Filter Yoast OG structural tags (og:url, og:type, og:locale, og:site_name, og:image).
884 * Suppress when: OTTO active + has og:title (OTTO provides these alongside og:title).
885 */
886 public function filter_yoast_og_structural($value) {
887 $post_id = $this->get_current_object_id();
888 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
889 return $value;
890 }
891 if ($this->otto_has_tag('og:title')) {
892 return '';
893 }
894 return $value;
895 }
896
897 /**
898 * Filter Yoast twitter:title output.
899 * Suppress when: OTTO active + has twitter:title, OR MetaSync has title.
900 */
901 public function filter_yoast_twitter_title($value) {
902 $post_id = $this->get_current_object_id();
903 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
904 return $value;
905 }
906 if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
907 return '';
908 }
909 return $value;
910 }
911
912 /**
913 * Filter Yoast twitter:description output.
914 * Suppress when: OTTO active + has twitter:description, OR MetaSync has description.
915 */
916 public function filter_yoast_twitter_description($value) {
917 $post_id = $this->get_current_object_id();
918 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
919 return $value;
920 }
921 if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
922 return '';
923 }
924 return $value;
925 }
926
927 /**
928 * Filter Yoast Twitter structural tags (twitter:image, twitter:card).
929 * Suppress when: OTTO active + has any twitter tag.
930 */
931 public function filter_yoast_twitter_structural($value) {
932 $post_id = $this->get_current_object_id();
933 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
934 return $value;
935 }
936 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
937 return '';
938 }
939 return $value;
940 }
941
942 /**
943 * Filter Yoast SEO schema/JSON-LD output.
944 * Suppress when OTTO has structured data for the current page.
945 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
946 * so MetaSync's own BreadcrumbList is the only one on the page.
947 *
948 * @param array|false $data Yoast's JSON-LD data.
949 * @return array|false
950 */
951 public function filter_yoast_schema($data) {
952 if ($this->otto_has_schema_for_current_page()) {
953 return false;
954 }
955
956 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
957 $data = $this->strip_breadcrumb_from_graph($data);
958 }
959
960 return $data;
961 }
962
963 // ------------------------------------------------------------------
964 // RankMath integration
965 // ------------------------------------------------------------------
966
967 /**
968 * Register RankMath-specific filters to suppress its title and
969 * description output when MetaSync/OTTO already provides them.
970 */
971 private function register_rankmath_filters() {
972 add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
973 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
974
975 // Suppress RankMath schema/JSON-LD when OTTO has structured data
976 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
977 }
978
979 /**
980 * Filter RankMath title output.
981 *
982 * On taxonomy archive pages: when MetaSync has an explicit `_metasync_metatitle`
983 * term meta value, return it so Rank Math renders the MetaSync-managed archive
984 * title inside <title>. This mirrors the Yoast term-level title override in
985 * filter_yoast_title().
986 *
987 * On singular pages: return empty string when MetaSync/OTTO has a title (Rank
988 * Math controls the sole <title> renderer on classic themes, so OTTO's buffer
989 * post-processing will replace it — returning '' would leave no <title> at all).
990 *
991 * @param string $title RankMath's computed title.
992 * @return string
993 */
994 public function filter_rankmath_title($title) {
995 $post_id = $this->get_current_object_id();
996 // WP-196: If another plugin is the primary output owner, suppress Rank Math.
997 if ($post_id && $this->has_active_seo_plugin()) {
998 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
999 return $title;
1000 }
1001 // Another plugin is primary — suppress this one
1002 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1003 return '';
1004 }
1005 }
1006
1007 // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1008 // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1009 // MetaSync-managed archive title inside <title>.
1010 $term = $this->get_current_term();
1011 if ($term) {
1012 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1013 if (!empty($term_title)) {
1014 return $term_title;
1015 }
1016 }
1017
1018 $post_id = $this->get_current_object_id();
1019
1020 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1021 if ($post_id) {
1022 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1023 if (!empty($sidebar_title)) {
1024 return $sidebar_title;
1025 }
1026 }
1027
1028 // OTTO has a persisted title — do NOT suppress Rank Math here.
1029 // OTTO's output-buffer post-processing replaces the <title> tag in the
1030 // final HTML. Returning '' would remove the tag before OTTO can inject.
1031
1032 return $title;
1033 }
1034
1035 /**
1036 * Filter RankMath description output.
1037 *
1038 * On term archives: the term-level sync writes MetaSync's description
1039 * into Rank Math's native term meta, so Rank Math already computes the
1040 * correct value — let it through.
1041 *
1042 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1043 * the description.
1044 *
1045 * @param string $description RankMath's computed description.
1046 * @return string
1047 */
1048 public function filter_rankmath_description($description) {
1049 $post_id = $this->get_current_object_id();
1050 if ($post_id && $this->has_active_seo_plugin()) {
1051 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1052 return $description;
1053 }
1054 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1055 return '';
1056 }
1057 }
1058
1059 // Term archives: MetaSync syncs to Rank Math storage — let it render.
1060 $term = $this->get_current_term();
1061 if ($term) {
1062 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1063 if (!empty($term_desc)) {
1064 return $description;
1065 }
1066 }
1067
1068 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1069 return '';
1070 }
1071
1072 return $description;
1073 }
1074
1075 /**
1076 * Filter RankMath schema/JSON-LD output.
1077 * Suppress when OTTO has structured data for the current page.
1078 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1079 * so MetaSync's own BreadcrumbList is the only one on the page.
1080 *
1081 * @param array $data RankMath's JSON-LD data array.
1082 * @return array
1083 */
1084 public function filter_rankmath_schema($data) {
1085 if ($this->otto_has_schema_for_current_page()) {
1086 return [];
1087 }
1088
1089 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1090 $data = $this->strip_breadcrumb_from_graph($data);
1091 }
1092
1093 return $data;
1094 }
1095
1096 // ------------------------------------------------------------------
1097 // MetaSync output gating
1098 // ------------------------------------------------------------------
1099
1100 /**
1101 * Whether the legacy `hook_metasync_metatags()` should output a description tag.
1102 *
1103 * Decision matrix (when a third-party SEO plugin is active):
1104 * MetaSync has value → true (MetaSync outputs, AIOSEO suppressed via filter)
1105 * AIOSEO has value → false (let AIOSEO handle it)
1106 * Neither has value → true (fallback: legacy auto-generated description)
1107 *
1108 * When no third-party SEO plugin is active → always true.
1109 *
1110 * @return bool True if the legacy output should include a description tag.
1111 */
1112 public function should_output_legacy_description() {
1113 // WP-196: Post synced to any active plugin — that plugin now owns the
1114 // description output from its native storage. Suppress MetaSync's own tag.
1115 $post_id = $this->get_current_object_id();
1116 if ($post_id && $this->has_active_seo_plugin()) {
1117 // If synced to any plugin, a primary output plugin exists — suppress MetaSync's own tag.
1118 if ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo')) {
1119 return false;
1120 }
1121 }
1122
1123 // OTTO active + has description → suppress legacy auto-generated description.
1124 if ($this->otto_has_tag('description')) {
1125 return false;
1126 }
1127
1128 if (!$this->has_active_seo_plugin()) {
1129 return true;
1130 }
1131
1132 // MetaSync has an intentional value — always output it
1133 if ($this->metasync_has_description()) {
1134 return true;
1135 }
1136
1137 // Check if AIOSEO actually provides a description for this page.
1138 // If it does, suppress our legacy output to avoid duplicates.
1139 // If it doesn't, let our legacy auto-generated description through
1140 // so the page isn't left with zero descriptions.
1141 if ($this->is_aioseo_active() && $this->aioseo_provides_description()) {
1142 return false;
1143 }
1144
1145 // For Yoast/RankMath: they always auto-generate a description,
1146 // so suppress our legacy output when they're active.
1147 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
1148 return false;
1149 }
1150 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1151 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1152 return false;
1153 }
1154
1155 // No third-party plugin will provide a description — output ours
1156 return true;
1157 }
1158
1159 /**
1160 * Check whether AIOSEO will actually output a description for the current page.
1161 *
1162 * Uses the cached value captured in filter_aioseo_description() if available.
1163 * Falls back to calling AIOSEO's API directly if the filter hasn't fired yet.
1164 *
1165 * @return bool
1166 */
1167 private function aioseo_provides_description() {
1168 // Use cached value if available (set when our filter fires)
1169 if ($this->aioseo_has_description_cache !== null) {
1170 return $this->aioseo_has_description_cache;
1171 }
1172
1173 // Filter hasn't fired yet — query AIOSEO directly
1174 if (function_exists('aioseo') && isset(aioseo()->meta->description)) {
1175 $desc = aioseo()->meta->description->getDescription();
1176 $this->aioseo_has_description_cache = !empty($desc);
1177 return $this->aioseo_has_description_cache;
1178 }
1179
1180 // Can't determine — assume AIOSEO has one to avoid duplicates
1181 $this->aioseo_has_description_cache = true;
1182 return true;
1183 }
1184
1185 // ------------------------------------------------------------------
1186 // Helpers
1187 // ------------------------------------------------------------------
1188
1189 /**
1190 * Strip BreadcrumbList nodes from a JSON-LD @graph array and remove
1191 * dangling breadcrumb references from WebPage-type nodes.
1192 *
1193 * WP-369: Previously we only removed the BreadcrumbList entry but left
1194 * the WebPage's `breadcrumb: { @id: "...#breadcrumb" }` property intact.
1195 * Google follows that dangling @id, finds no matching node, and reports
1196 * "Missing field itemListElement".
1197 *
1198 * @param array $graph The @graph array from a third-party SEO plugin.
1199 * @return array
1200 */
1201 private function strip_breadcrumb_from_graph($graph) {
1202 // Collect @ids of BreadcrumbList nodes being removed.
1203 $removed_ids = [];
1204
1205 foreach ($graph as $key => $entry) {
1206 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1207 if (!empty($entry['@id'])) {
1208 $removed_ids[] = $entry['@id'];
1209 }
1210 unset($graph[$key]);
1211 }
1212 }
1213
1214 // Remove dangling breadcrumb references from WebPage-type nodes.
1215 foreach ($graph as $key => &$entry) {
1216 if (!is_array($entry) || !isset($entry['@type'])) {
1217 continue;
1218 }
1219
1220 $type = $entry['@type'];
1221 $is_page_type = $type === 'WebPage'
1222 || (is_array($type) && in_array('WebPage', $type, true));
1223
1224 if ($is_page_type && isset($entry['breadcrumb'])) {
1225 // Remove if the reference points to a stripped node, or if
1226 // no BreadcrumbList remains in this graph at all.
1227 $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1228 if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1229 unset($entry['breadcrumb']);
1230 }
1231 }
1232 }
1233 unset($entry);
1234
1235 return array_values($graph);
1236 }
1237
1238 /**
1239 * Determine whether MetaSync's own BreadcrumbList output is enabled.
1240 *
1241 * Mirrors the gate logic in Metasync_Breadcrumbs_Schema::output_breadcrumb_schema():
1242 * enabled by default, disabled only when the `enabled` setting is explicitly falsy.
1243 * Used by the Yoast / RankMath / AIOSEO schema filters so we only strip their
1244 * BreadcrumbList entries when MetaSync will emit one itself.
1245 *
1246 * @return bool
1247 */
1248 private function metasync_breadcrumb_enabled() {
1249 $settings = Metasync::get_option('breadcrumbs', array());
1250 if (!is_array($settings)) {
1251 return true;
1252 }
1253
1254 if (array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
1255 return false;
1256 }
1257
1258 // WP-369: When schema output is explicitly disabled, don't strip
1259 // third-party breadcrumbs — MetaSync won't emit its own.
1260 if (!empty($settings['disable_schema'])) {
1261 return false;
1262 }
1263
1264 return true;
1265 }
1266
1267 /**
1268 * Check whether OTTO has structured data (schema/JSON-LD) for the current page.
1269 *
1270 * @return bool
1271 */
1272 private function otto_has_schema_for_current_page() {
1273 if (!$this->is_otto_active()) {
1274 return false;
1275 }
1276
1277 $post_id = $this->get_current_object_id();
1278 if (!$post_id) {
1279 return false;
1280 }
1281
1282 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
1283 return false;
1284 }
1285
1286 return !empty(get_post_meta($post_id, '_metasync_otto_structured_data', true));
1287 }
1288
1289 /**
1290 * Get the current queried object ID.
1291 *
1292 * Uses get_queried_object_id() as the universal fallback so every
1293 * public page type (singular, front page, static blog page, CPT
1294 * archives, WooCommerce shop, etc.) is covered without enumerating
1295 * each one individually.
1296 *
1297 * For blog-style homepages (show_on_front=posts) there is no backing
1298 * page, so this returns 0.
1299 *
1300 * @return int 0 when unknown.
1301 */
1302 private function get_current_object_id() {
1303 // Singular pages (posts, pages, CPTs, attachments)
1304 if (is_singular()) {
1305 return (int) get_the_ID();
1306 }
1307
1308 // WooCommerce shop page (virtual archive backed by a real page)
1309 if (function_exists('is_shop') && is_shop()) {
1310 return function_exists('wc_get_page_id') ? (int) wc_get_page_id('shop') : 0;
1311 }
1312
1313 // Universal fallback: static front page, static posts page,
1314 // or any other page type WordPress assigns a queried object to.
1315 $id = get_queried_object_id();
1316 if ($id > 0) {
1317 return (int) $id;
1318 }
1319
1320 return 0;
1321 }
1322
1323 /**
1324 * Return the WP_Term being rendered on taxonomy archive pages.
1325 *
1326 * Only returns a term when the current query is a category, tag, or
1327 * custom taxonomy archive — i.e. when MetaSync term meta could be
1328 * driving the rendered output. Returns null in every other context
1329 * (singular, blog home, search, 404, etc.) so callers don't have to
1330 * double-check the page type.
1331 *
1332 * @return \WP_Term|null
1333 */
1334 private function get_current_term() {
1335 if (!(is_category() || is_tag() || is_tax())) {
1336 return null;
1337 }
1338
1339 $queried = get_queried_object();
1340 if ($queried instanceof \WP_Term) {
1341 return $queried;
1342 }
1343
1344 return null;
1345 }
1346 }
1347