PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.6
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.6
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-seo-conflict-handler.php

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

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