PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
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.22, at includes/class-metasync-seo-conflict-handler.php

1,756 lines 64.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.
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 * Whether an active third-party SEO plugin actually holds a
142 * non-empty meta description for this post in its OWN storage.
143 *
144 * The native-first output guards previously stood down whenever a
145 * sync timestamp (_metasync_plugin_sync_ts) existed, assuming the plugin
146 * would render the description. But a stale or partial sync leaves the
147 * plugin's field empty — MetaSync suppresses its own tag, the plugin has
148 * nothing to emit, and the description is dropped entirely. Callers use this
149 * to only defer when the plugin can genuinely output a description.
150 *
151 * @param int $post_id Post ID.
152 * @return bool True if the active/primary SEO plugin has a description.
153 */
154 public function active_plugin_has_description($post_id) {
155 $post_id = (int) $post_id;
156 if ($post_id <= 0) {
157 return false;
158 }
159
160 $this->ensure_plugin_api();
161
162 // Yoast (free or premium)
163 if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
164 if (!empty(get_post_meta($post_id, '_yoast_wpseo_metadesc', true))) {
165 return true;
166 }
167 }
168
169 // Rank Math
170 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
171 if (!empty(get_post_meta($post_id, 'rank_math_description', true))) {
172 return true;
173 }
174 }
175
176 // AIOSEO stores its description in a custom table, not post meta.
177 if ($this->is_aioseo_active()) {
178 global $wpdb;
179 // Defensive: $wpdb is always present on a booted frontend, but never
180 // assume — a method call on a null/!object $wpdb would be fatal.
181 if (isset($wpdb) && is_object($wpdb)) {
182 $table = $wpdb->prefix . 'aioseo_posts';
183 $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id));
184 if (!empty($desc)) {
185 return true;
186 }
187 }
188 }
189
190 return false;
191 }
192
193 // ------------------------------------------------------------------
194 // MetaSync description resolution
195 // ------------------------------------------------------------------
196
197 /**
198 * Determine whether MetaSync holds an intentional meta description
199 * for the current request.
200 *
201 * Only considers explicitly set values:
202 * 1. SEO sidebar custom value (_metasync_seo_desc)
203 * 2. OTTO persisted description (_metasync_otto_description)
204 *
205 * Auto-generated excerpts (legacy `meta_description` key) are NOT
206 * counted — they should not suppress a third-party plugin.
207 *
208 * @return bool
209 */
210 public function metasync_has_description() {
211 if ($this->has_description_cache !== null) {
212 return $this->has_description_cache;
213 }
214
215 if (!empty($this->get_metasync_description())) {
216 $this->has_description_cache = true;
217 return true;
218 }
219
220 // Term-level: on taxonomy archives MetaSync may have term meta
221 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
222 $term = $this->get_current_term();
223 if ($term) {
224 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
225 if (!empty($term_desc)) {
226 $this->has_description_cache = true;
227 return true;
228 }
229 }
230
231 $this->has_description_cache = false;
232 return false;
233 }
234
235 /**
236 * Return MetaSync's intentional meta description for the current request.
237 *
238 * Only returns values that were explicitly set (sidebar or OTTO), NOT
239 * auto-generated excerpts from the legacy `meta_description` key.
240 * This ensures we only suppress third-party plugins when MetaSync has
241 * a deliberate SEO value.
242 *
243 * @return string
244 */
245 public function get_metasync_description() {
246 $post_id = $this->get_current_object_id();
247
248 if (!$post_id) {
249 return '';
250 }
251
252 // 1. SEO sidebar (highest priority — user-edited)
253 $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
254 if (!empty($desc)) {
255 return $desc;
256 }
257
258 // 2. OTTO description
259 $desc = get_post_meta($post_id, '_metasync_otto_description', true);
260 if (!empty($desc)) {
261 return $desc;
262 }
263
264 return '';
265 }
266
267 /**
268 * Return MetaSync's intentional SEO title for the current request.
269 *
270 * Mirrors get_metasync_description(): only values that were explicitly set
271 * (sidebar or OTTO), never an auto-generated fallback such as the post title.
272 * This is the value the og:title / twitter:title suppression keys off, so the
273 * sidebar emitter can render the same title back as a replacement.
274 *
275 * @return string
276 */
277 public function get_metasync_title() {
278 $post_id = $this->get_current_object_id();
279
280 if (!$post_id) {
281 return '';
282 }
283
284 // 1. Social Media & Open Graph meta box (most specific — a social-only title)
285 $og_title = $this->get_customized_og_title($post_id);
286 if ($og_title !== '') {
287 return $og_title;
288 }
289
290 // 2. SEO sidebar (user-edited)
291 $title = get_post_meta($post_id, '_metasync_seo_title', true);
292 if (!empty($title)) {
293 return $title;
294 }
295
296 // 3. OTTO title
297 $title = get_post_meta($post_id, '_metasync_otto_title', true);
298 if (!empty($title)) {
299 return $title;
300 }
301
302 return '';
303 }
304
305 /**
306 * The per-post OG meta box title, but only when the user genuinely set it.
307 *
308 * The meta box pre-fills its Title from the post title and PERSISTS that
309 * default on save, so a non-empty `_metasync_og_title` alone does not prove
310 * intent — treating it as one would let an auto-filled post title override a
311 * deliberately-set SEO title on every ordinary edit. A value counts as the
312 * user's only when it differs from that default, the same comparison
313 * Otto_html_class::apply_metabox_og_precedence() makes.
314 *
315 * @param int $post_id
316 * @return string The customized OG title, or '' when unset or auto-filled.
317 */
318 private function get_customized_og_title($post_id) {
319 // Per-post OG meta box values only exist on singular views. On archives
320 // get_current_object_id() returns a TERM id, and reading post meta with it
321 // would consult an unrelated post — suppressing the third-party tag on a
322 // page where the singular-only replacement emitter never runs.
323 if (!is_singular()) {
324 return '';
325 }
326
327 $og_title = (string) get_post_meta($post_id, '_metasync_og_title', true);
328 if ($og_title === '') {
329 return '';
330 }
331
332 $post = get_post($post_id);
333 $default = ($post instanceof WP_Post) ? (string) $post->post_title : '';
334
335 return $og_title === $default ? '' : $og_title;
336 }
337
338 /**
339 * Whether this request suppressed a third-party plugin's og:title /
340 * twitter:title and therefore owes a replacement tag.
341 *
342 * Only true in the narrow case where the suppression leaves a gap:
343 * - Yoast or AIOSEO is active. Those are the only plugins whose og:title
344 * we filter; Rank Math / SEOPress / TSF render their own untouched, and
345 * on a MetaSync-only site Metasync_OpenGraph::output_opengraph_tags()
346 * still emits og:title itself — emitting here would duplicate it.
347 * - That plugin is not the primary output owner for the post (a synced
348 * post renders the plugin's own tags, so we must not double up).
349 * - The per-post OG toggle is not explicitly off (toggle off suppresses
350 * nothing, so nothing is owed).
351 * - MetaSync has an intentional title — the value that triggered the
352 * suppression in filter_yoast_og_title() / filter_aioseo_facebook_tags().
353 *
354 * Pages where OTTO owns og:title are deliberately excluded: OTTO injects its
355 * own tag through the output buffer, so there is no gap to fill.
356 *
357 * @return bool
358 */
359 public function og_title_needs_replacement() {
360 if (!$this->suppresses_third_party_og_title()) {
361 return false;
362 }
363
364 $post_id = $this->get_current_object_id();
365 if (!$post_id) {
366 return false;
367 }
368
369 if ($this->og_output_disabled($post_id)) {
370 return false;
371 }
372
373 foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
374 if ($this->is_primary_output_plugin($post_id, $slug)) {
375 return false;
376 }
377 }
378
379 return $this->metasync_has_title($post_id);
380 }
381
382 /**
383 * Whether an active third-party plugin is one whose og:title we filter
384 * (Yoast or AIOSEO). Rank Math, SEOPress and The SEO Framework emit their
385 * own og:title untouched, so they never leave a gap to fill.
386 *
387 * @return bool
388 */
389 private function suppresses_third_party_og_title() {
390 $this->ensure_plugin_api();
391
392 return is_plugin_active('wordpress-seo/wp-seo.php')
393 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
394 || $this->is_aioseo_active();
395 }
396
397 /**
398 * Whether an active third-party SEO plugin renders the og:description /
399 * twitter:description for this request, so MetaSync must not add its own.
400 *
401 * Mirrors the condition the suppression filters use: when that plugin is the
402 * primary output owner, filter_yoast_og_description() (and the AIOSEO/Rank
403 * Math equivalents) pass its tag through untouched, so a second tag from the
404 * sidebar emitter would duplicate it.
405 *
406 * Deliberately does NOT ask whether the plugin's OG description field is
407 * populated. Yoast falls back to its meta description, and then to the
408 * excerpt, so it emits an og:description either way — an "is the field set"
409 * test would miss those fallbacks and let the duplicate through.
410 *
411 * @return bool
412 */
413 public function third_party_owns_og_description() {
414 if (!$this->has_active_seo_plugin()) {
415 return false;
416 }
417
418 // Per-post ownership only means anything on singular views. On archives
419 // get_current_object_id() returns a TERM id, and the sync timestamp would
420 // be read from an unrelated post of the same numeric id.
421 if (!is_singular()) {
422 return false;
423 }
424
425 $post_id = $this->get_current_object_id();
426 if (!$post_id) {
427 return false;
428 }
429
430 foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
431 if ($this->is_primary_output_plugin($post_id, $slug)) {
432 return true;
433 }
434 }
435
436 return false;
437 }
438
439 /**
440 * Resolve the MetaSync-managed canonical URL for a post, if any.
441 *
442 * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta
443 * box value (meta_canonical). Restricted to singular views so term/archive
444 * queried-object ids are never misread as post ids. Mirrors the fallback in
445 * Metasync_Seo_Output::get_canonical_url() so both the no-SEO-plugin path and
446 * the third-party-plugin path honor the same value.
447 *
448 * @param int $post_id Current object id.
449 * @return string Escaped canonical URL, or '' when none is set.
450 */
451 private function get_metasync_canonical($post_id) {
452 if (!$post_id || !is_singular()) {
453 return '';
454 }
455
456 // Validate both sources: legacy rows corrupted to the literal "Array"
457 // (or stored as arrays) must never be emitted as a canonical.
458 $canonical = Metasync_Canonical_Sanitizer::sanitize(
459 get_post_meta($post_id, '_metasync_canonical_url', true)
460 );
461 if ($canonical === '') {
462 $canonical = Metasync_Canonical_Sanitizer::sanitize(
463 get_post_meta($post_id, 'meta_canonical', true)
464 );
465 }
466
467 return $canonical !== '' ? esc_url($canonical) : '';
468 }
469
470 /**
471 * Reset the cached description flag (useful when the queried object changes).
472 */
473 public function reset_cache() {
474 $this->has_description_cache = null;
475 $this->aioseo_has_description_cache = null;
476 $this->sync_cache = [];
477 $this->live_suggestions_cache = null;
478 }
479
480 /**
481 * Check whether a post has been synced to third-party plugins via.
482 *
483 * When native-first sync is active, each plugin reads MetaSync values from
484 * its own storage — no filter suppression needed. This method returns true
485 * when _metasync_plugin_sync_ts exists and contains a timestamp for the
486 * given plugin slug.
487 *
488 * @param int $post_id Post ID.
489 * @param string $plugin_slug Plugin slug: 'yoast', 'rankmath', or 'aioseo'.
490 * @return bool True if the post has been synced to this plugin.
491 */
492 private function is_post_synced($post_id, $plugin_slug) {
493 if ($post_id <= 0) {
494 return false;
495 }
496
497 if (!isset($this->sync_cache[$post_id])) {
498 $ts_raw = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
499 $this->sync_cache[$post_id] = !empty($ts_raw) ? json_decode($ts_raw, true) : [];
500 if (!is_array($this->sync_cache[$post_id])) {
501 $this->sync_cache[$post_id] = [];
502 }
503 }
504
505 return !empty($this->sync_cache[$post_id][$plugin_slug]);
506 }
507
508 /**
509 * For synced posts with multiple SEO plugins active, determine if a
510 * specific plugin is the designated output owner.
511 *
512 * Only the first active plugin in priority order (Yoast > Rank Math > AIOSEO)
513 * is allowed to output — the others are suppressed to prevent duplicate tags.
514 *
515 * @param int $post_id Post ID.
516 * @param string $plugin_slug Plugin slug to check.
517 * @return bool True if this plugin should output its tags.
518 */
519 private function is_primary_output_plugin($post_id, $plugin_slug) {
520 // For synced posts: only the primary synced plugin passes through.
521 // For unsynced posts with multiple plugins: only the highest-priority
522 // active plugin outputs to prevent duplicate tags.
523 $this->ensure_plugin_api();
524 $priority = ['yoast', 'rankmath', 'aioseo'];
525 $active_check = [
526 'yoast' => is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'),
527 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
528 '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'),
529 ];
530
531 $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'));
532
533 if ($has_any_sync) {
534 // Synced: primary = first active + synced plugin
535 foreach ($priority as $slug) {
536 if ($active_check[$slug] && $this->is_post_synced($post_id, $slug)) {
537 return $slug === $plugin_slug;
538 }
539 }
540 return false;
541 }
542
543 // Unsynced / multiple plugins active: pick the first active plugin
544 // as the sole outputter to prevent duplicate tags.
545 $active_count = count(array_filter($active_check));
546 if ($active_count > 1) {
547 foreach ($priority as $slug) {
548 if ($active_check[$slug]) {
549 return $slug === $plugin_slug;
550 }
551 }
552 }
553
554 // Single plugin active or no plugins — don't interfere
555 return false;
556 }
557
558 /**
559 * Ensure is_plugin_active() is loaded on the frontend.
560 */
561 private function ensure_plugin_api() {
562 if (!function_exists('is_plugin_active')) {
563 require_once ABSPATH . 'wp-admin/includes/plugin.php';
564 }
565 }
566
567 // ------------------------------------------------------------------
568 // AIOSEO integration
569 // ------------------------------------------------------------------
570
571 /**
572 * Register AIOSEO-specific filters to suppress its output
573 * when MetaSync/OTTO already provides the same tags.
574 */
575 private function register_aioseo_filters() {
576 // Suppress AIOSEO meta description
577 add_filter('aioseo_description', [$this, 'filter_aioseo_description'], 999);
578
579 // Suppress AIOSEO title
580 add_filter('aioseo_title', [$this, 'filter_aioseo_title'], 999);
581
582 // Suppress AIOSEO OG/Twitter tags that OTTO already provides
583 add_filter('aioseo_facebook_tags', [$this, 'filter_aioseo_facebook_tags'], 999);
584 add_filter('aioseo_twitter_tags', [$this, 'filter_aioseo_twitter_tags'], 999);
585
586 // Suppress AIOSEO robots when MetaSync has an intentional robots value
587 add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999);
588
589 // Suppress AIOSEO schema/JSON-LD when OTTO has structured data
590 add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999);
591
592 // Canonical — override AIOSEO's canonical with the MetaSync/OTTO value when set
593 add_filter('aioseo_canonical_url', [$this, 'filter_aioseo_canonical'], 999);
594 }
595
596 /**
597 * Filter AIOSEO's canonical URL.
598 *
599 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
600 * (OTTO or the Canonical meta box) when set, else pass AIOSEO's through.
601 */
602 public function filter_aioseo_canonical($canonical) {
603 $custom = $this->get_metasync_canonical($this->get_current_object_id());
604 return $custom !== '' ? $custom : $canonical;
605 }
606
607 /**
608 * Filter AIOSEO description output.
609 * Returns empty string when MetaSync has a description, letting MetaSync output it.
610 *
611 * @param string $description AIOSEO's computed description.
612 * @return string
613 */
614 public function filter_aioseo_description($description) {
615 // Cache whether AIOSEO actually has a description (before we suppress it).
616 // This is used later by should_output_legacy_description().
617 if ($this->aioseo_has_description_cache === null) {
618 $this->aioseo_has_description_cache = !empty($description);
619 }
620
621 // Primary plugin check — only the designated plugin outputs.
622 $post_id = $this->get_current_object_id();
623 if ($post_id && $this->has_active_seo_plugin()) {
624 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
625 return $description;
626 }
627 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
628 return '';
629 }
630 }
631
632 // Term archives: AIOSEO free doesn't read per-term custom descriptions
633 // from its `wp_aioseo_terms` table, so return the MetaSync value
634 // directly so AIOSEO renders it.
635 $term = $this->get_current_term();
636 if ($term) {
637 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
638 if (!empty($term_desc)) {
639 return $term_desc;
640 }
641 }
642
643 // Suppress when: OTTO active + has description, OR MetaSync sidebar has description
644 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
645 return '';
646 }
647 return $description;
648 }
649
650 /**
651 * Filter AIOSEO title output.
652 *
653 * On term archives: AIOSEO free doesn't read custom per-term titles from
654 * its `wp_aioseo_terms` table, so we replace AIOSEO's template-based title
655 * with the MetaSync term title directly.
656 *
657 * @param string $title AIOSEO's computed title.
658 * @return string
659 */
660 public function filter_aioseo_title($title) {
661 // Primary plugin check — only the designated plugin outputs.
662 $post_id = $this->get_current_object_id();
663 if ($post_id && $this->has_active_seo_plugin()) {
664 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
665 return $title;
666 }
667 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
668 return '';
669 }
670 }
671
672 // Term archives: return MetaSync term title directly.
673 $term = $this->get_current_term();
674 if ($term) {
675 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
676 if (!empty($term_title)) {
677 return $term_title;
678 }
679 }
680
681 if ($this->should_suppress_third_party_title()) {
682 return '';
683 }
684
685 return $title;
686 }
687
688 /**
689 * Filter AIOSEO Facebook/OG tags.
690 *
691 * Per-tag suppression: only remove a tag when OTTO is active AND has
692 * a persisted value for that specific tag, OR when MetaSync sidebar
693 * provides the equivalent value.
694 *
695 * @param array $meta AIOSEO's OG meta array.
696 * @return array
697 */
698 public function filter_aioseo_facebook_tags($meta) {
699 if (!is_array($meta)) {
700 return $meta;
701 }
702
703 $post_id = $this->get_current_object_id();
704
705 // Per-post OG toggle off — MetaSync emits no OG, so leave AIOSEO's tags intact.
706 if ($this->og_output_disabled($post_id)) {
707 return $meta;
708 }
709
710 // Post synced to AIOSEO — let AIOSEO read from its own storage.
711 if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
712 return $meta;
713 }
714
715 // og:title — suppress when OTTO has og:title OR MetaSync has title
716 if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
717 unset($meta['og:title']);
718 }
719
720 // og:description — suppress when OTTO has og:description OR MetaSync has description
721 if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
722 unset($meta['og:description']);
723 }
724
725 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
726 // (OTTO injects these structural OG tags alongside og:title in its block)
727 if ($this->otto_has_tag('og:title')) {
728 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
729 }
730
731 return $meta;
732 }
733
734 /**
735 * Filter AIOSEO Twitter tags.
736 *
737 * Per-tag suppression: only remove a tag when OTTO is active AND has
738 * a persisted value for that specific tag, OR when MetaSync sidebar
739 * provides the equivalent value.
740 *
741 * @param array $meta AIOSEO's Twitter meta array.
742 * @return array
743 */
744 public function filter_aioseo_twitter_tags($meta) {
745 if (!is_array($meta)) {
746 return $meta;
747 }
748
749 $post_id = $this->get_current_object_id();
750
751 // Per-post OG toggle off — MetaSync emits no Twitter tags, so leave AIOSEO's intact.
752 if ($this->og_output_disabled($post_id)) {
753 return $meta;
754 }
755
756 // Post synced to AIOSEO — let AIOSEO read from its own storage.
757 if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
758 return $meta;
759 }
760
761 if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
762 unset($meta['twitter:title']);
763 }
764
765 if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
766 unset($meta['twitter:description']);
767 }
768
769 // twitter:card — suppress when OTTO has any twitter tag
770 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
771 unset($meta['twitter:card']);
772 }
773
774 return $meta;
775 }
776
777 /**
778 * Filter AIOSEO robots meta output.
779 *
780 * When MetaSync has an intentional robots value (admin checkbox or REST API),
781 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
782 * hook_metasync_metatags() will output the MetaSync value instead.
783 *
784 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
785 * Returning an empty array suppresses AIOSEO's robots tag entirely.
786 *
787 * @param array $robots AIOSEO's computed robots attributes array.
788 * @return array
789 */
790 public function filter_aioseo_robots($robots) {
791 $post_id = $this->get_current_object_id();
792 if (!$post_id) {
793 return $robots;
794 }
795
796 // Post synced to AIOSEO — let AIOSEO read from its own storage.
797 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
798 return $robots;
799 }
800
801 if ($this->metasync_has_robots($post_id)) {
802 // MetaSync has robots — suppress AIOSEO's tag.
803 return [];
804 }
805
806 return $robots;
807 }
808
809 /**
810 * Filter AIOSEO schema/JSON-LD output.
811 * Suppress when OTTO has structured data for the current page.
812 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
813 * so MetaSync's own BreadcrumbList is the only one on the page.
814 *
815 * @param array $output AIOSEO's @graph array.
816 * @return array
817 */
818 public function filter_aioseo_schema($output) {
819 if ($this->otto_has_schema_for_current_page()) {
820 return [];
821 }
822
823 if ($this->metasync_breadcrumb_enabled() && is_array($output)) {
824 $output = $this->strip_breadcrumb_from_graph($output);
825 }
826
827 return $output;
828 }
829
830 /**
831 * Check whether MetaSync holds an intentional robots directive for a post.
832 *
833 * Checks both storage formats:
834 * - meta_robots (string from REST API)
835 * - metasync_common_robots (array from admin checkbox)
836 *
837 * @param int $post_id Post ID.
838 * @return bool
839 */
840 public function metasync_has_robots($post_id) {
841 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
842 if (!empty($meta_robots)) {
843 return true;
844 }
845
846 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
847 if (is_array($common_robots) && !empty(array_filter($common_robots))) {
848 return true;
849 }
850
851 return false;
852 }
853
854 /**
855 * Check whether MetaSync/OTTO has a title for a given post.
856 *
857 * @param int $post_id Post ID.
858 * @return bool
859 */
860 private function metasync_has_title($post_id) {
861 // Social Media & Open Graph meta box, when genuinely customized. Counted
862 // here so a post whose ONLY title is the OG one still suppresses the
863 // third-party tag — otherwise the plugin keeps rendering its own and the
864 // replacement below never gets the chance to emit.
865 if ($this->get_customized_og_title($post_id) !== '') {
866 return true;
867 }
868
869 $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
870 if (!empty($seo_title)) {
871 return true;
872 }
873
874 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
875 if (!empty($otto_title)) {
876 return true;
877 }
878
879 // Term-level fallback: on taxonomy archives the "object" is a term,
880 // so read `_metasync_metatitle` from term meta when we're rendering one.
881 $term = $this->get_current_term();
882 if ($term) {
883 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
884 if (!empty($term_title)) {
885 return true;
886 }
887 }
888
889 return false;
890 }
891
892 /**
893 * Determine whether a third-party SEO plugin's title should be suppressed.
894 *
895 * Suppress when either condition is met:
896 * 1. OTTO is active AND has a persisted title for this page
897 * 2. MetaSync sidebar has an explicit title for this page
898 *
899 * @return bool True if the third-party title should be suppressed.
900 */
901 private function should_suppress_third_party_title() {
902 // Condition 1: OTTO active + has title for this page
903 if ($this->otto_has_tag('title')) {
904 return true;
905 }
906
907 // Condition 2: MetaSync sidebar has explicit title
908 $post_id = $this->get_current_object_id();
909 if ($post_id) {
910 return $this->metasync_has_title($post_id);
911 }
912
913 return false;
914 }
915
916 /**
917 * Check whether the OTTO pixel is active.
918 *
919 * @return bool
920 */
921 private function is_otto_active() {
922 if (class_exists('Metasync_Otto_Config')) {
923 return Metasync_Otto_Config::is_otto_enabled();
924 }
925
926 return false;
927 }
928
929 /**
930 * Check whether the OTTO transient cache has live suggestions for the
931 * current request URL.
932 *
933 * Passive get_transient() lookup only — no OTTO API call. Mirrors the
934 * cache-key format used by Metasync_Otto_Transient_Cache and the URL
935 * construction from Otto_pixel_class::get_route().
936 *
937 * @return bool
938 */
939 public function otto_has_live_suggestions() {
940 if ($this->live_suggestions_cache !== null) {
941 return $this->live_suggestions_cache;
942 }
943
944 if (!$this->is_otto_active()) {
945 $this->live_suggestions_cache = false;
946 return false;
947 }
948
949 if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) {
950 $this->live_suggestions_cache = false;
951 return false;
952 }
953
954 $scheme = is_ssl() ? 'https' : 'http';
955 $host = $_SERVER['HTTP_HOST'];
956 $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
957 $url = $scheme . '://' . $host . $request_uri;
958
959 $hash = md5(rtrim(strtolower($url), '/'));
960 $site_id = is_multisite() ? get_current_blog_id() : 0;
961 $cached = get_transient('otto_suggestions_' . $site_id . '_' . $hash);
962
963 $this->live_suggestions_cache = ($cached !== false && !empty($cached));
964 return $this->live_suggestions_cache;
965 }
966
967 /**
968 * Check whether OTTO has a persisted value for a specific meta tag.
969 *
970 * Two conditions must be true to suppress a third-party tag:
971 * 1. OTTO is active (globally enabled)
972 * 2. OTTO has a value for this specific tag on the current page
973 *
974 * For OG/Twitter tags where OTTO's pixel injects dynamically (the
975 * specific _metasync_otto_og_* key may be empty), the buffer-level
976 * dedup in Otto_html_class::deduplicate_og_twitter_tags() handles
977 * removal after all sources have output. This method only does the
978 * direct per-tag check.
979 *
980 * @param string $tag Tag identifier (e.g. 'title', 'og:title', 'twitter:description').
981 * @return bool True when OTTO is active AND has a persisted value for this tag.
982 */
983 private function otto_has_tag($tag) {
984 if (!$this->is_otto_active()) {
985 return false;
986 }
987
988 $post_id = $this->get_current_object_id();
989 if (!$post_id) {
990 return false;
991 }
992
993 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
994 return false;
995 }
996
997 $meta_key_map = [
998 'title' => '_metasync_otto_title',
999 'description' => '_metasync_otto_description',
1000 'og:title' => '_metasync_otto_og_title',
1001 'og:description' => '_metasync_otto_og_description',
1002 'twitter:title' => '_metasync_otto_twitter_title',
1003 'twitter:description' => '_metasync_otto_twitter_description',
1004 ];
1005
1006 if (!isset($meta_key_map[$tag])) {
1007 return false;
1008 }
1009
1010 return !empty(get_post_meta($post_id, $meta_key_map[$tag], true));
1011 }
1012
1013 /**
1014 * Whether the per-post "Enable Open Graph & Social Media Tags" toggle is
1015 * explicitly turned off for the given object.
1016 *
1017 * Mirrors the guard in Metasync_OpenGraph::will_emit()/output_opengraph_tags():
1018 * only an explicit '0' opt-out disables MetaSync's OG/Twitter output; an
1019 * unset/empty value counts as enabled. When disabled, MetaSync emits no
1020 * OG/Twitter tags of its own, so it must NOT strip a third-party SEO
1021 * plugin's OG/Twitter tags either — otherwise the page is left with none.
1022 *
1023 * @param int $post_id
1024 * @return bool
1025 */
1026 private function og_output_disabled($post_id) {
1027 return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
1028 }
1029
1030 // ------------------------------------------------------------------
1031 // Yoast SEO integration
1032 // ------------------------------------------------------------------
1033
1034 /**
1035 * Register Yoast SEO-specific filters to suppress its title,
1036 * description, and OG/Twitter output when MetaSync/OTTO provides them.
1037 */
1038 private function register_yoast_filters() {
1039 add_filter('wpseo_title', [$this, 'filter_yoast_title'], 999);
1040 add_filter('wpseo_metadesc', [$this, 'filter_yoast_description'], 999);
1041
1042 // OG tags — per-tag suppression
1043 add_filter('wpseo_opengraph_title', [$this, 'filter_yoast_og_title'], 999);
1044 add_filter('wpseo_opengraph_desc', [$this, 'filter_yoast_og_description'], 999);
1045 add_filter('wpseo_opengraph_url', [$this, 'filter_yoast_og_structural'], 999);
1046 add_filter('wpseo_opengraph_type', [$this, 'filter_yoast_og_structural'], 999);
1047 add_filter('wpseo_opengraph_site_name', [$this, 'filter_yoast_og_structural'], 999);
1048 add_filter('wpseo_og_locale', [$this, 'filter_yoast_og_structural'], 999);
1049 add_filter('wpseo_opengraph_image', [$this, 'filter_yoast_og_structural'], 999);
1050
1051 // Twitter tags — per-tag suppression
1052 add_filter('wpseo_twitter_title', [$this, 'filter_yoast_twitter_title'], 999);
1053 add_filter('wpseo_twitter_description', [$this, 'filter_yoast_twitter_description'], 999);
1054 add_filter('wpseo_twitter_image', [$this, 'filter_yoast_twitter_structural'], 999);
1055 add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999);
1056
1057 // Suppress Yoast schema/JSON-LD when OTTO has structured data
1058 add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999);
1059
1060 // Canonical — override Yoast's canonical with the MetaSync/OTTO value when set
1061 add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 999);
1062 }
1063
1064 /**
1065 * Filter Yoast's canonical URL.
1066 *
1067 * When a MetaSync-managed canonical exists (OTTO or the Canonical meta box),
1068 * return it so Yoast emits our value instead of its own — avoiding a duplicate
1069 * <link rel="canonical"> while still honoring the per-post override.
1070 * Otherwise let Yoast's canonical through unchanged.
1071 */
1072 public function filter_yoast_canonical($canonical) {
1073 $custom = $this->get_metasync_canonical($this->get_current_object_id());
1074 return $custom !== '' ? $custom : $canonical;
1075 }
1076
1077 /**
1078 * Filter Yoast SEO title output.
1079 *
1080 * When the MetaSync sidebar has an explicit SEO title, return that title so
1081 * Yoast's Title_Presenter renders it inside the <title> tag it controls.
1082 * Returning '' would cause Title_Presenter to emit NO <title> tag at all,
1083 * because Yoast has already removed WordPress's native _wp_render_title_tag
1084 * action and is the sole renderer of the title element.
1085 *
1086 * When only OTTO has a title (no sidebar override), we let Yoast output its
1087 * own title normally — OTTO's buffer post-processing replaces it in the final
1088 * HTML. Returning '' here would again leave the page with no <title> tag.
1089 */
1090 public function filter_yoast_title($title) {
1091 $post_id = $this->get_current_object_id();
1092
1093 // When Yoast is the primary output plugin, let it through.
1094 // For synced posts, Yoast reads from its own storage (already has the value).
1095 // For unsynced posts as primary, still check for MetaSync sidebar override.
1096 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1097 // Even in passthrough, a sidebar title override takes precedence
1098 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1099 if (!empty($sidebar_title)) {
1100 return $sidebar_title;
1101 }
1102 return $title;
1103 }
1104
1105 // Another plugin is primary — suppress Yoast.
1106 if ($post_id && $this->has_active_seo_plugin()) {
1107 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1108 return '';
1109 }
1110 }
1111
1112 // Term-level archives
1113 $term = $this->get_current_term();
1114 if ($term) {
1115 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1116 if (!empty($term_title)) {
1117 return $term_title;
1118 }
1119 }
1120
1121 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
1122 if ($post_id) {
1123 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1124 if (!empty($sidebar_title)) {
1125 return $sidebar_title;
1126 }
1127 }
1128
1129 // Case 2: OTTO has a persisted title — do NOT suppress Yoast here.
1130 // OTTO's output-buffer post-processing (Otto_html_class) replaces the
1131 // <title> tag in the final HTML after WordPress renders. Returning '' would
1132 // remove the <title> tag entirely before OTTO can inject its replacement.
1133
1134 return $title;
1135 }
1136
1137 /**
1138 * Filter Yoast SEO description output.
1139 *
1140 * On term archives: the term-level sync writes MetaSync's description
1141 * into Yoast's native storage, so Yoast already computes the correct
1142 * value — let it through.
1143 *
1144 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1145 * the description (MetaSync outputs its own tag).
1146 */
1147 public function filter_yoast_description($description) {
1148 $post_id = $this->get_current_object_id();
1149 if ($post_id && $this->has_active_seo_plugin()) {
1150 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
1151 return $description;
1152 }
1153 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1154 return '';
1155 }
1156 }
1157
1158 // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
1159 $term = $this->get_current_term();
1160 if ($term) {
1161 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1162 if (!empty($term_desc)) {
1163 return $description;
1164 }
1165 }
1166
1167 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1168 return '';
1169 }
1170 return $description;
1171 }
1172
1173 /**
1174 * Filter Yoast og:title output.
1175 * Suppress when: OTTO active + has og:title, OR MetaSync has title.
1176 */
1177 public function filter_yoast_og_title($value) {
1178 $post_id = $this->get_current_object_id();
1179 if ($this->og_output_disabled($post_id)) {
1180 return $value;
1181 }
1182 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1183 return $value;
1184 }
1185 if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
1186 return '';
1187 }
1188 return $value;
1189 }
1190
1191 /**
1192 * Filter Yoast og:description output.
1193 * Suppress when: OTTO active + has og:description, OR MetaSync has description.
1194 */
1195 public function filter_yoast_og_description($value) {
1196 $post_id = $this->get_current_object_id();
1197 if ($this->og_output_disabled($post_id)) {
1198 return $value;
1199 }
1200 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1201 return $value;
1202 }
1203 if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
1204 return '';
1205 }
1206 return $value;
1207 }
1208
1209 /**
1210 * Filter Yoast OG structural tags (og:url, og:type, og:locale, og:site_name, og:image).
1211 * Suppress when: OTTO active + has og:title (OTTO provides these alongside og:title).
1212 */
1213 public function filter_yoast_og_structural($value) {
1214 $post_id = $this->get_current_object_id();
1215 if ($this->og_output_disabled($post_id)) {
1216 return $value;
1217 }
1218 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1219 return $value;
1220 }
1221 if ($this->otto_has_tag('og:title')) {
1222 return '';
1223 }
1224 return $value;
1225 }
1226
1227 /**
1228 * Filter Yoast twitter:title output.
1229 * Suppress when: OTTO active + has twitter:title, OR MetaSync has title.
1230 */
1231 public function filter_yoast_twitter_title($value) {
1232 $post_id = $this->get_current_object_id();
1233 if ($this->og_output_disabled($post_id)) {
1234 return $value;
1235 }
1236 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1237 return $value;
1238 }
1239 if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
1240 return '';
1241 }
1242 return $value;
1243 }
1244
1245 /**
1246 * Filter Yoast twitter:description output.
1247 * Suppress when: OTTO active + has twitter:description, OR MetaSync has description.
1248 */
1249 public function filter_yoast_twitter_description($value) {
1250 $post_id = $this->get_current_object_id();
1251 if ($this->og_output_disabled($post_id)) {
1252 return $value;
1253 }
1254 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1255 return $value;
1256 }
1257 if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
1258 return '';
1259 }
1260 return $value;
1261 }
1262
1263 /**
1264 * Filter Yoast Twitter structural tags (twitter:image, twitter:card).
1265 * Suppress when: OTTO active + has any twitter tag.
1266 */
1267 public function filter_yoast_twitter_structural($value) {
1268 $post_id = $this->get_current_object_id();
1269 if ($this->og_output_disabled($post_id)) {
1270 return $value;
1271 }
1272 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1273 return $value;
1274 }
1275 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
1276 return '';
1277 }
1278 return $value;
1279 }
1280
1281 /**
1282 * Filter Yoast SEO schema/JSON-LD output.
1283 * Suppress when OTTO has structured data for the current page.
1284 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1285 * so MetaSync's own BreadcrumbList is the only one on the page.
1286 *
1287 * @param array|false $data Yoast's JSON-LD data.
1288 * @return array|false
1289 */
1290 public function filter_yoast_schema($data) {
1291 if ($this->otto_has_schema_for_current_page()) {
1292 return false;
1293 }
1294
1295 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1296 $data = $this->strip_breadcrumb_from_graph($data);
1297 }
1298
1299 return $data;
1300 }
1301
1302 // ------------------------------------------------------------------
1303 // RankMath integration
1304 // ------------------------------------------------------------------
1305
1306 /**
1307 * Register RankMath-specific filters to suppress its title and
1308 * description output when MetaSync/OTTO already provides them.
1309 */
1310 private function register_rankmath_filters() {
1311 add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
1312 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
1313
1314 // OG/Twitter description — per-tag suppression, mirroring the Yoast filters.
1315 // Without these Rank Math renders its own og:description alongside the one
1316 // MetaSync emits, leaving two conflicting tags on the page.
1317 add_filter('rank_math/opengraph/facebook/og_description', [$this, 'filter_rankmath_og_description'], 999);
1318 add_filter('rank_math/opengraph/twitter/twitter_description', [$this, 'filter_rankmath_twitter_description'], 999);
1319
1320 // Suppress RankMath schema/JSON-LD when OTTO has structured data
1321 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1322
1323 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1324 add_filter('rank_math/frontend/canonical', [$this, 'filter_rankmath_canonical'], 999);
1325 }
1326
1327 /**
1328 * Filter RankMath's canonical URL.
1329 *
1330 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
1331 * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
1332 */
1333 public function filter_rankmath_canonical($canonical) {
1334 $custom = $this->get_metasync_canonical($this->get_current_object_id());
1335 return $custom !== '' ? $custom : $canonical;
1336 }
1337
1338 /**
1339 * Filter RankMath title output.
1340 *
1341 * On taxonomy archive pages: when MetaSync has an explicit `_metasync_metatitle`
1342 * term meta value, return it so Rank Math renders the MetaSync-managed archive
1343 * title inside <title>. This mirrors the Yoast term-level title override in
1344 * filter_yoast_title().
1345 *
1346 * On singular pages: return empty string when MetaSync/OTTO has a title (Rank
1347 * Math controls the sole <title> renderer on classic themes, so OTTO's buffer
1348 * post-processing will replace it — returning '' would leave no <title> at all).
1349 *
1350 * @param string $title RankMath's computed title.
1351 * @return string
1352 */
1353 public function filter_rankmath_title($title) {
1354 $post_id = $this->get_current_object_id();
1355 // If another plugin is the primary output owner, suppress Rank Math.
1356 if ($post_id && $this->has_active_seo_plugin()) {
1357 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1358 return $title;
1359 }
1360 // Another plugin is primary — suppress this one
1361 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1362 return '';
1363 }
1364 }
1365
1366 // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1367 // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1368 // MetaSync-managed archive title inside <title>.
1369 $term = $this->get_current_term();
1370 if ($term) {
1371 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1372 if (!empty($term_title)) {
1373 return $term_title;
1374 }
1375 }
1376
1377 $post_id = $this->get_current_object_id();
1378
1379 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1380 if ($post_id) {
1381 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1382 if (!empty($sidebar_title)) {
1383 return $sidebar_title;
1384 }
1385 }
1386
1387 // OTTO has a persisted title — do NOT suppress Rank Math here.
1388 // OTTO's output-buffer post-processing replaces the <title> tag in the
1389 // final HTML. Returning '' would remove the tag before OTTO can inject.
1390
1391 return $title;
1392 }
1393
1394 /**
1395 * Filter RankMath description output.
1396 *
1397 * On term archives: the term-level sync writes MetaSync's description
1398 * into Rank Math's native term meta, so Rank Math already computes the
1399 * correct value — let it through.
1400 *
1401 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1402 * the description.
1403 *
1404 * @param string $description RankMath's computed description.
1405 * @return string
1406 */
1407 public function filter_rankmath_description($description) {
1408 $post_id = $this->get_current_object_id();
1409 if ($post_id && $this->has_active_seo_plugin()) {
1410 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1411 return $description;
1412 }
1413 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1414 return '';
1415 }
1416 }
1417
1418 // Term archives: MetaSync syncs to Rank Math storage — let it render.
1419 $term = $this->get_current_term();
1420 if ($term) {
1421 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1422 if (!empty($term_desc)) {
1423 return $description;
1424 }
1425 }
1426
1427 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1428 return '';
1429 }
1430
1431 return $description;
1432 }
1433
1434 /**
1435 * Filter Rank Math's og:description / twitter:description output.
1436 *
1437 * Mirrors filter_yoast_og_description(): suppress when OTTO has the tag or
1438 * MetaSync has an intentional description, so exactly one description tag
1439 * reaches the page. Returning '' removes Rank Math's.
1440 *
1441 * @param string $value Rank Math's computed description.
1442 * @return string
1443 */
1444 public function filter_rankmath_og_description($value) {
1445 return $this->filter_rankmath_social_description($value, 'og:description');
1446 }
1447
1448 /**
1449 * Filter Rank Math's twitter:description output.
1450 *
1451 * Kept separate from the og:description filter so each consults its OWN
1452 * OTTO key — twitter:description resolves to _metasync_otto_twitter_description,
1453 * not the og one. Sharing a callback would suppress Rank Math's tag on the
1454 * strength of the wrong key, either leaving a duplicate or a blank.
1455 *
1456 * @param string $value Rank Math's computed description.
1457 * @return string
1458 */
1459 public function filter_rankmath_twitter_description($value) {
1460 return $this->filter_rankmath_social_description($value, 'twitter:description');
1461 }
1462
1463 /**
1464 * Shared body for the two Rank Math social-description filters.
1465 *
1466 * @param string $value Rank Math's computed description.
1467 * @param string $otto_tag OTTO tag identifier to test for this specific tag.
1468 * @return string
1469 */
1470 private function filter_rankmath_social_description($value, $otto_tag) {
1471 $post_id = $this->get_current_object_id();
1472 if ($post_id && $this->og_output_disabled($post_id)) {
1473 return $value;
1474 }
1475 if ($post_id && $this->is_primary_output_plugin($post_id, 'rankmath')) {
1476 return $value;
1477 }
1478 if ($this->otto_has_tag($otto_tag) || $this->metasync_has_description()) {
1479 return '';
1480 }
1481 return $value;
1482 }
1483
1484 /**
1485 * Filter RankMath schema/JSON-LD output.
1486 * Suppress when OTTO has structured data for the current page.
1487 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1488 * so MetaSync's own BreadcrumbList is the only one on the page.
1489 *
1490 * @param array $data RankMath's JSON-LD data array.
1491 * @return array
1492 */
1493 public function filter_rankmath_schema($data) {
1494 if ($this->otto_has_schema_for_current_page()) {
1495 return [];
1496 }
1497
1498 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1499 $data = $this->strip_breadcrumb_from_graph($data);
1500 }
1501
1502 return $data;
1503 }
1504
1505 // ------------------------------------------------------------------
1506 // MetaSync output gating
1507 // ------------------------------------------------------------------
1508
1509 /**
1510 * Whether the legacy `hook_metasync_metatags()` should output a description tag.
1511 *
1512 * Decision matrix (when a third-party SEO plugin is active):
1513 * MetaSync has value → true (MetaSync outputs, AIOSEO suppressed via filter)
1514 * AIOSEO has value → false (let AIOSEO handle it)
1515 * Neither has value → true (fallback: legacy auto-generated description)
1516 *
1517 * When no third-party SEO plugin is active → always true.
1518 *
1519 * @return bool True if the legacy output should include a description tag.
1520 */
1521 public function should_output_legacy_description() {
1522 // Post synced to any active plugin — that plugin now owns the
1523 // description output from its native storage. Suppress MetaSync's own tag.
1524 $post_id = $this->get_current_object_id();
1525 if ($post_id && $this->has_active_seo_plugin()) {
1526 // If synced to any plugin, a primary output plugin exists — suppress MetaSync's own tag.
1527 if ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo')) {
1528 return false;
1529 }
1530 }
1531
1532 // OTTO active + has description → suppress legacy auto-generated description.
1533 if ($this->otto_has_tag('description')) {
1534 return false;
1535 }
1536
1537 if (!$this->has_active_seo_plugin()) {
1538 return true;
1539 }
1540
1541 // MetaSync has an intentional value — always output it
1542 if ($this->metasync_has_description()) {
1543 return true;
1544 }
1545
1546 // Check if AIOSEO actually provides a description for this page.
1547 // If it does, suppress our legacy output to avoid duplicates.
1548 // If it doesn't, let our legacy auto-generated description through
1549 // so the page isn't left with zero descriptions.
1550 if ($this->is_aioseo_active() && $this->aioseo_provides_description()) {
1551 return false;
1552 }
1553
1554 // For Yoast/RankMath: they always auto-generate a description,
1555 // so suppress our legacy output when they're active.
1556 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
1557 return false;
1558 }
1559 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1560 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1561 return false;
1562 }
1563
1564 // No third-party plugin will provide a description — output ours
1565 return true;
1566 }
1567
1568 /**
1569 * Check whether AIOSEO will actually output a description for the current page.
1570 *
1571 * Uses the cached value captured in filter_aioseo_description() if available.
1572 * Falls back to calling AIOSEO's API directly if the filter hasn't fired yet.
1573 *
1574 * @return bool
1575 */
1576 private function aioseo_provides_description() {
1577 // Use cached value if available (set when our filter fires)
1578 if ($this->aioseo_has_description_cache !== null) {
1579 return $this->aioseo_has_description_cache;
1580 }
1581
1582 // Filter hasn't fired yet — query AIOSEO directly
1583 if (function_exists('aioseo') && isset(aioseo()->meta->description)) {
1584 $desc = aioseo()->meta->description->getDescription();
1585 $this->aioseo_has_description_cache = !empty($desc);
1586 return $this->aioseo_has_description_cache;
1587 }
1588
1589 // Can't determine — assume AIOSEO has one to avoid duplicates
1590 $this->aioseo_has_description_cache = true;
1591 return true;
1592 }
1593
1594 // ------------------------------------------------------------------
1595 // Helpers
1596 // ------------------------------------------------------------------
1597
1598 /**
1599 * Strip BreadcrumbList nodes from a JSON-LD @graph array and remove
1600 * dangling breadcrumb references from WebPage-type nodes.
1601 *
1602 * Previously we only removed the BreadcrumbList entry but left
1603 * the WebPage's `breadcrumb: { @id: "...#breadcrumb" }` property intact.
1604 * Google follows that dangling @id, finds no matching node, and reports
1605 * "Missing field itemListElement".
1606 *
1607 * @param array $graph The @graph array from a third-party SEO plugin.
1608 * @return array
1609 */
1610 private function strip_breadcrumb_from_graph($graph) {
1611 // Collect @ids of BreadcrumbList nodes being removed.
1612 $removed_ids = [];
1613
1614 foreach ($graph as $key => $entry) {
1615 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1616 if (!empty($entry['@id'])) {
1617 $removed_ids[] = $entry['@id'];
1618 }
1619 unset($graph[$key]);
1620 }
1621 }
1622
1623 // Remove dangling breadcrumb references from WebPage-type nodes.
1624 foreach ($graph as $key => &$entry) {
1625 if (!is_array($entry) || !isset($entry['@type'])) {
1626 continue;
1627 }
1628
1629 $type = $entry['@type'];
1630 $is_page_type = $type === 'WebPage'
1631 || (is_array($type) && in_array('WebPage', $type, true));
1632
1633 if ($is_page_type && isset($entry['breadcrumb'])) {
1634 // Remove if the reference points to a stripped node, or if
1635 // no BreadcrumbList remains in this graph at all.
1636 $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1637 if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1638 unset($entry['breadcrumb']);
1639 }
1640 }
1641 }
1642 unset($entry);
1643
1644 return array_values($graph);
1645 }
1646
1647 /**
1648 * Determine whether MetaSync's own BreadcrumbList output is enabled.
1649 *
1650 * Mirrors the gate logic in Metasync_Breadcrumbs_Schema::output_breadcrumb_schema():
1651 * enabled by default, disabled only when the `enabled` setting is explicitly falsy.
1652 * Used by the Yoast / RankMath / AIOSEO schema filters so we only strip their
1653 * BreadcrumbList entries when MetaSync will emit one itself.
1654 *
1655 * @return bool
1656 */
1657 private function metasync_breadcrumb_enabled() {
1658 $settings = Metasync::get_option('breadcrumbs', array());
1659 if (!is_array($settings)) {
1660 return true;
1661 }
1662
1663 if (array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
1664 return false;
1665 }
1666
1667 // When schema output is explicitly disabled, don't strip
1668 // third-party breadcrumbs — MetaSync won't emit its own.
1669 if (!empty($settings['disable_schema'])) {
1670 return false;
1671 }
1672
1673 return true;
1674 }
1675
1676 /**
1677 * Check whether OTTO has structured data (schema/JSON-LD) for the current page.
1678 *
1679 * @return bool
1680 */
1681 private function otto_has_schema_for_current_page() {
1682 if (!$this->is_otto_active()) {
1683 return false;
1684 }
1685
1686 $post_id = $this->get_current_object_id();
1687 if (!$post_id) {
1688 return false;
1689 }
1690
1691 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
1692 return false;
1693 }
1694
1695 return !empty(get_post_meta($post_id, '_metasync_otto_structured_data', true));
1696 }
1697
1698 /**
1699 * Get the current queried object ID.
1700 *
1701 * Uses get_queried_object_id() as the universal fallback so every
1702 * public page type (singular, front page, static blog page, CPT
1703 * archives, WooCommerce shop, etc.) is covered without enumerating
1704 * each one individually.
1705 *
1706 * For blog-style homepages (show_on_front=posts) there is no backing
1707 * page, so this returns 0.
1708 *
1709 * @return int 0 when unknown.
1710 */
1711 private function get_current_object_id() {
1712 // Singular pages (posts, pages, CPTs, attachments)
1713 if (is_singular()) {
1714 return (int) get_the_ID();
1715 }
1716
1717 // WooCommerce shop page (virtual archive backed by a real page)
1718 if (function_exists('is_shop') && is_shop()) {
1719 return function_exists('wc_get_page_id') ? (int) wc_get_page_id('shop') : 0;
1720 }
1721
1722 // Universal fallback: static front page, static posts page,
1723 // or any other page type WordPress assigns a queried object to.
1724 $id = get_queried_object_id();
1725 if ($id > 0) {
1726 return (int) $id;
1727 }
1728
1729 return 0;
1730 }
1731
1732 /**
1733 * Return the WP_Term being rendered on taxonomy archive pages.
1734 *
1735 * Only returns a term when the current query is a category, tag, or
1736 * custom taxonomy archive — i.e. when MetaSync term meta could be
1737 * driving the rendered output. Returns null in every other context
1738 * (singular, blog home, search, 404, etc.) so callers don't have to
1739 * double-check the page type.
1740 *
1741 * @return \WP_Term|null
1742 */
1743 private function get_current_term() {
1744 if (!(is_category() || is_tag() || is_tax())) {
1745 return null;
1746 }
1747
1748 $queried = get_queried_object();
1749 if ($queried instanceof \WP_Term) {
1750 return $queried;
1751 }
1752
1753 return null;
1754 }
1755 }
1756