PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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-opengraph.php

class-metasync-opengraph.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.0, at includes/class-metasync-opengraph.php

2,422 lines 105.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The Open Graph functionality of the plugin.
5 *
6 * @package MetaSync
7 * @subpackage MetaSync/includes
8 * @since 1.0.0
9 */
10
11 # Prevent direct access
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * Open Graph Tags Generator Class
18 *
19 * This class handles the generation and management of Open Graph and Twitter Card tags
20 * for WordPress posts and pages.
21 */
22 class Metasync_OpenGraph {
23
24 /**
25 * The ID of this plugin.
26 */
27 private $plugin_name;
28
29 /**
30 * The version of this plugin.
31 */
32 private $version;
33
34 /**
35 * Most recently constructed instance, for cross-class reuse of the value
36 * resolvers (e.g. the OTTO buffer needs the same default OG values the meta
37 * box pre-fills, to tell an auto-filled default from a user-customized value).
38 *
39 * @var Metasync_OpenGraph|null
40 */
41 private static $instance;
42
43 /**
44 * Per-request memo for get_default_og_values(), keyed on post ID. See that
45 * method for why the defaults are worth memoizing and where the memo is
46 * dropped.
47 *
48 * @var array<int, array{title:string,description:string,image:string}>
49 */
50 private static $default_og_values_memo = [];
51
52 /**
53 * Meta box ID
54 */
55 const META_BOX_ID = 'metasync_opengraph_meta_box';
56
57 /**
58 * The social title/description keys the meta box pre-fills from the post
59 * title/excerpt and persists on save. These are the keys that can capture the
60 * "Auto Draft" placeholder WordPress gives a brand-new, still-untitled post.
61 */
62 const AUTO_DRAFT_PRONE_KEYS = [
63 '_metasync_og_title',
64 '_metasync_og_description',
65 '_metasync_twitter_title',
66 '_metasync_twitter_description',
67 ];
68
69 /**
70 * The social title keys whose meta box default is the post title itself.
71 * Unlike the description keys — whose pre-fill (the excerpt) is content the
72 * editor curates separately — these mirror the title verbatim, so a stored
73 * value equal to it carries no information of its own: it is the pre-fill
74 * snapshot, not a customization. Reads collapse such rows to '' so the live
75 * title (which a rename keeps fresh) applies instead.
76 */
77 const TITLE_DEFAULTED_KEYS = [
78 '_metasync_og_title',
79 '_metasync_twitter_title',
80 ];
81
82 /**
83 * The social description keys whose meta box default is the resolved
84 * description (the excerpt the emitter would derive). The pre-fill persisted
85 * that default verbatim on save, so rows exist where these keys hold a copy
86 * of the excerpt as it was on save day — stale the moment the excerpt or the
87 * content it is generated from changes. Reads collapse such rows to '' so
88 * the live default applies instead. A value that differs from the default is
89 * untouched: only text the editor genuinely typed survives.
90 */
91 const DESCRIPTION_DEFAULTED_KEYS = [
92 '_metasync_og_description',
93 '_metasync_twitter_description',
94 ];
95
96 /**
97 * Site option holding every "Auto Draft" placeholder variant this install
98 * has actually seen, keyed by nothing, de-duplicated, capped. Core fills a
99 * new post's title with __( 'Auto Draft' ) in the *creating admin user's*
100 * locale, and that translation is not loaded on front-end requests, so no
101 * render-time literal/translation list can be complete. The registry is the
102 * locale-free source of truth: captured verbatim at creation time in admin,
103 * where the translation does resolve, and re-read anywhere.
104 */
105 const AUTO_DRAFT_PLACEHOLDER_OPTION = 'metasync_auto_draft_placeholders';
106
107 /**
108 * Upper bound on registered placeholder variants. Locales change rarely;
109 * the cap keeps the site option tiny no matter how many editors come and
110 * go with different locales.
111 */
112 const AUTO_DRAFT_PLACEHOLDER_CAP = 20;
113
114 /**
115 * Upper bound on posts held in the get_default_og_values() memo. One
116 * front-end render touches one or two posts; a back-end loop (an importer,
117 * a bulk action, a resync walking every post) would otherwise grow the
118 * memo — and the excerpt-derivation results it caches — without end for
119 * the life of the request. When full the oldest entry is dropped first:
120 * insertion order is the only order the memo has, and a dropped post
121 * simply re-derives on its next read.
122 */
123 const DEFAULT_OG_VALUES_MEMO_CAP = 10;
124
125 /**
126 * Per-post flag marking "this post was saved while its title was still the
127 * untitled placeholder". Stores the exact placeholder string that was
128 * recognized at save time, so the render path can suppress by state (flag
129 * present + title still equals it) rather than by string matching alone —
130 * a genuinely-titled post never carries the flag.
131 */
132 const UNTITLED_FLAG_META = '_metasync_untitled_placeholder';
133
134 /**
135 * Initialize the class and set its properties.
136 */
137 public function __construct($plugin_name, $version) {
138 $this->plugin_name = $plugin_name;
139 $this->version = $version;
140 self::$instance = $this;
141 }
142
143 /**
144 * Get the most recently constructed instance (null if none yet).
145 *
146 * @return Metasync_OpenGraph|null
147 */
148 public static function get_instance() {
149 return self::$instance;
150 }
151
152 /**
153 * Whether a value is the placeholder title WordPress assigns to a brand-new,
154 * still-untitled post.
155 *
156 * Core creates the row with a *translated* title —
157 * wp-admin/includes/post.php: 'post_title' => __( 'Auto Draft' ) — so matching
158 * only the English literal misses every localized site. Compare against the
159 * literal and the current locale's translation, so both an English install and
160 * a translated one are covered, as is a value stored before a locale switch.
161 * A third arm checks the captured-variant registry, because the translation
162 * only resolves in admin: on front-end requests __( 'Auto Draft' ) stays
163 * English even on a localized site, and a stored de_DE placeholder would
164 * otherwise pass every check above.
165 *
166 * Exact match after trim, never fuzzy: an editor who legitimately titles a post
167 * "Auto Draft" on purpose is a caller-side concern, and only the four social
168 * keys in AUTO_DRAFT_PRONE_KEYS are ever routed through here.
169 *
170 * @param mixed $value
171 * @return bool
172 */
173 public static function is_auto_draft_title($value) {
174 if (!is_string($value)) {
175 return false;
176 }
177 $value = trim($value);
178 if ($value === '') {
179 return false;
180 }
181 # 'default' text domain, matching the __() call core itself uses.
182 if ($value === 'Auto Draft' || $value === trim(__('Auto Draft'))) {
183 return true;
184 }
185 # Registry arm: a variant captured in another locale's admin request.
186 # Without it the guard above is English-only on every non-admin request,
187 # because core never loads the 'default' MO files out there.
188 return in_array($value, self::known_auto_draft_placeholders(), true);
189 }
190
191 /**
192 * Every placeholder variant known to this install: the English literal plus
193 * whatever has been captured into the site option so far.
194 *
195 * @return string[]
196 */
197 public static function known_auto_draft_placeholders() {
198 $stored = get_option(self::AUTO_DRAFT_PLACEHOLDER_OPTION, []);
199 if (!is_array($stored)) {
200 $stored = [];
201 }
202 $variants = ['Auto Draft'];
203 foreach ($stored as $variant) {
204 if (is_string($variant) && trim($variant) !== '' && !in_array(trim($variant), $variants, true)) {
205 $variants[] = trim($variant);
206 }
207 }
208 return $variants;
209 }
210
211 /**
212 * Capture a placeholder variant into the site option. Called from the
213 * admin save path, where the creating request's own __( 'Auto Draft' )
214 * has already verified the title *is* core's placeholder — so no
215 * translation is needed here, only de-duplication and capping.
216 *
217 * @param mixed $title Verbatim post title recognized as the placeholder.
218 * @return void
219 */
220 public static function remember_auto_draft_placeholder($title) {
221 $title = is_string($title) ? trim($title) : '';
222 if ($title === '' || $title === 'Auto Draft') {
223 return;
224 }
225 $known = self::known_auto_draft_placeholders();
226 if (in_array($title, $known, true)) {
227 return;
228 }
229 # Persist captured variants only: the English literal is seeded
230 # code-side by known_auto_draft_placeholders(), so storing it would be
231 # redundant state.
232 $captured = array_values(array_diff($known, ['Auto Draft']));
233 $captured[] = $title;
234 update_option(self::AUTO_DRAFT_PLACEHOLDER_OPTION, array_slice($captured, -self::AUTO_DRAFT_PLACEHOLDER_CAP), false);
235 }
236
237 /**
238 * admin_init callback: register the placeholder variant the current admin
239 * request's translation resolves to. remember_auto_draft_placeholder()
240 * de-duplicates, so repeated requests only cost one cached option read.
241 *
242 * @return void
243 */
244 public static function seed_auto_draft_placeholder() {
245 self::remember_auto_draft_placeholder(__('Auto Draft'));
246 }
247
248 /**
249 * Read a persisted social meta value, collapsing the "Auto Draft" placeholder
250 * to an empty string.
251 *
252 * Every consumer resolves these fields through a "first non-empty wins" chain
253 * (persisted key -> OTTO staging key -> real post title). A stored placeholder
254 * is truthy, so the chain would keep it; returning '' lets the chain fall
255 * through to the real title. That fixes rows already polluted before this fix
256 * shipped, at render time, without a DB migration.
257 *
258 * Public and static because the three consumers that actually emit or forward
259 * these values live in different classes: this emitter,
260 * Otto_html_class::apply_metabox_og_precedence(), and Metasync_Plugin_Sync's
261 * mirror into Yoast/RankMath/AIOSEO storage. Follows the same
262 * sanitize-at-the-source pattern as Metasync_Canonical_Sanitizer::sanitize().
263 *
264 * @param mixed $value Raw stored meta value.
265 * @return string
266 */
267 public static function strip_auto_draft_title($value) {
268 if (self::is_auto_draft_title($value)) {
269 return '';
270 }
271 return is_scalar($value) ? (string) $value : '';
272 }
273
274 /**
275 * Read one of the AUTO_DRAFT_PRONE_KEYS for a post with the placeholder
276 * collapsed to '' — and, for the title keys, a stored snapshot of the
277 * title default the pre-fill showed (the post title, or the og title for
278 * the twitter twin), and for the description keys a stored snapshot of
279 * the resolved description, collapsed too.
280 *
281 * @param int $post_id
282 * @param string $key
283 * @return string
284 */
285 public static function get_social_meta($post_id, $key) {
286 return self::strip_description_snapshot(
287 $post_id,
288 $key,
289 self::strip_title_snapshot(
290 $post_id,
291 $key,
292 self::strip_auto_draft_title(get_post_meta($post_id, $key, true))
293 )
294 );
295 }
296
297 /**
298 * Collapse a stored social title that merely mirrors the post's own title.
299 *
300 * The meta box used to pre-fill Title from the post title as a real value
301 * and persist it on save, so rows exist where `_metasync_og_title` (and the
302 * twitter twin) hold a verbatim copy of the title as it was on save day.
303 * Renaming the post then left that snapshot stale with nothing to tell it
304 * apart from a typed title. Collapsing it here — at the read, the same
305 * place the "Auto Draft" placeholder collapses — makes such a row read as
306 * unset, and the render-time fallback to the *live* title keeps the social
307 * title in step with every rename. A value that differs from the title is
308 * untouched: only text the editor genuinely typed survives.
309 *
310 * The twitter twin compares against what the og title field showed when
311 * the pre-fill was rendered — a stored og title if one is set, otherwise
312 * the same live post title — since that is exactly what the pre-fill
313 * echoed into the twitter field. Without that chain, a twitter row echoed
314 * from a typed og title would read as a deliberate override and stop
315 * following the og title it always rendered.
316 *
317 * Compared after trim on both sides: stored values pass through
318 * sanitize_text_field, which trims, so only the post title side can carry
319 * surrounding whitespace.
320 *
321 * Public and static for the same reason strip_auto_draft_title() is: the
322 * consumers live in different classes (this emitter, the precedence
323 * resolver, the plugin sync mirror, the SEO suite).
324 *
325 * @param int $post_id
326 * @param string $key One of TITLE_DEFAULTED_KEYS; others pass through.
327 * @param mixed $value Stored meta value, already placeholder-collapsed.
328 * @return string
329 */
330 public static function strip_title_snapshot($post_id, $key, $value) {
331 $value = is_scalar($value) ? (string) $value : '';
332 if ($value === '' || !in_array($key, self::TITLE_DEFAULTED_KEYS, true)) {
333 return $value;
334 }
335
336 $default = self::default_social_title($post_id, $key);
337 if ($default === '') {
338 return $value;
339 }
340
341 return trim($value) === trim($default) ? '' : $value;
342 }
343
344 /**
345 * The default a title key renders when its own field is blank: the live
346 * post title for the og key, and for the twitter key whatever the og field
347 * would show (a stored og title if one is set, otherwise the same live
348 * title) — the exact chain the meta box fallback used to pre-fill as a
349 * value and still shows as the twitter field's placeholder.
350 *
351 * social_post_title() — not the raw post_title — is what the pre-fill ever
352 * stored, and it returns '' for a still-untitled post, where there is no
353 * meaningful default to compare against anyway (such rows are the
354 * placeholder case strip_auto_draft_title() already handles).
355 *
356 * @param int $post_id
357 * @param string $key
358 * @return string
359 */
360 private static function default_social_title($post_id, $key) {
361 $title = self::social_post_title(get_post($post_id));
362 if ($key === '_metasync_og_title') {
363 return $title;
364 }
365
366 $og = self::get_social_meta($post_id, '_metasync_og_title');
367 return $og !== '' ? $og : $title;
368 }
369
370 /**
371 * Collapse a stored social description that merely mirrors the default the
372 * meta box pre-filled.
373 *
374 * The meta box pre-filled Description from the resolved description (the
375 * manual excerpt, or one generated from the content) as a real value and
376 * persisted it on save, so rows exist where `_metasync_og_description` (and
377 * the twitter twin) hold a copy of the excerpt as it was on save day.
378 * Changing the excerpt — or the content the excerpt is generated from — then
379 * left that snapshot stale with nothing to tell it apart from a typed
380 * description. Collapsing it here — at the read, the same place the "Auto
381 * Draft" placeholder and title snapshots collapse — makes such a row read as
382 * unset, and the render-time fallback to the *live* excerpt keeps the social
383 * description in step. A value that differs from the default is untouched:
384 * only text the editor genuinely typed survives.
385 *
386 * The twitter twin compares against what the og description field showed
387 * when the pre-fill was rendered — a stored og description if one is set,
388 * otherwise the same resolved default — since that is exactly what the
389 * pre-fill echoed into the twitter field.
390 *
391 * Compared after whitespace normalization on both sides: descriptions pass
392 * through sanitize_textarea_field, which preserves line breaks, and a manual
393 * excerpt can carry them while the generated default cannot, so a snapshot
394 * and its default must be compared with runs of whitespace treated as one
395 * space rather than byte-for-byte.
396 *
397 * Public and static for the same reason strip_title_snapshot() is: the
398 * consumers live in different classes (this emitter, the precedence
399 * resolver, the plugin sync mirror, the SEO suite).
400 *
401 * @param int $post_id
402 * @param string $key One of DESCRIPTION_DEFAULTED_KEYS; others pass through.
403 * @param mixed $value Stored meta value, already placeholder-collapsed.
404 * @return string
405 */
406 public static function strip_description_snapshot($post_id, $key, $value) {
407 $value = is_scalar($value) ? (string) $value : '';
408 if ($value === '' || !in_array($key, self::DESCRIPTION_DEFAULTED_KEYS, true)) {
409 return $value;
410 }
411
412 $default = self::default_social_description($post_id, $key);
413 if ($default === '') {
414 return $value;
415 }
416
417 return self::normalize_description_compare($value) === self::normalize_description_compare($default)
418 ? ''
419 : $value;
420 }
421
422 /**
423 * The default a description key renders when its own field is blank: the
424 * resolved description for the og key, and for the twitter key whatever the
425 * og field would show (a stored og description if one is set, otherwise the
426 * same resolved default — even when that default is empty) — the exact
427 * chain the meta box fallback used to pre-fill as a value.
428 *
429 * Resolved through the emitter's own instance so it matches what actually
430 * renders; when no instance is available the default cannot be proven, so ''
431 * is returned and the caller keeps the stored value rather than risk
432 * discarding text the editor typed.
433 *
434 * @param int $post_id
435 * @param string $key
436 * @return string
437 */
438 private static function default_social_description($post_id, $key) {
439 $instance = self::get_instance();
440 if (!$instance) {
441 return '';
442 }
443
444 $default = (string) $instance->get_default_og_values($post_id)['description'];
445 if ($key === '_metasync_og_description') {
446 return $default;
447 }
448
449 # The og chain runs for the twitter twin even when the resolved default
450 # is empty: the pre-fill echoed the og description into the twitter
451 # field whenever the og field carried a value, so that — not the empty
452 # excerpt — is the default a stored twitter echo must match to collapse.
453 $og = self::get_social_meta($post_id, '_metasync_og_description');
454 return $og !== '' ? $og : $default;
455 }
456
457 /**
458 * Normalize a description for equality comparison: trimmed, with runs of
459 * whitespace collapsed to a single space. Falls back to a plain trim when
460 * the value is not valid UTF-8 and the /u pattern therefore fails.
461 *
462 * @param string $value
463 * @return string
464 */
465 private static function normalize_description_compare($value) {
466 $normalized = preg_replace('/\s+/u', ' ', trim((string) $value));
467 return is_string($normalized) ? $normalized : trim((string) $value);
468 }
469
470 /**
471 * A post's title for social use, suppressing only WordPress's own placeholder.
472 *
473 * Distinct from strip_auto_draft_title(), which is for *stored* meta values:
474 * there the string is all we have, since a legacy row carries no clue about the
475 * status it was written under. A live post gives us both signals, so require
476 * both — the post is still an auto-draft AND its title is the placeholder. An
477 * editor who genuinely titles a published post "Auto Draft" (an article about
478 * the placeholder itself, say) keeps it as their og:title.
479 *
480 * @param WP_Post|mixed $post
481 * @return string
482 */
483 public static function social_post_title($post) {
484 if (!$post instanceof WP_Post) {
485 return '';
486 }
487 if ($post->post_status === 'auto-draft' && self::is_auto_draft_title($post->post_title)) {
488 return '';
489 }
490 # State-based arm: the post was saved while still untitled (flag set at
491 # save time, where translations resolve) and its title is still that
492 # flagged placeholder. This is what catches a placeholder-titled post
493 # that went on to be published — the status check above no longer
494 # matches it, and on a localized site the string checks cannot either.
495 $flagged = get_post_meta($post->ID, self::UNTITLED_FLAG_META, true);
496 if (is_string($flagged) && trim($flagged) !== '' && trim((string) $post->post_title) === trim($flagged)) {
497 return '';
498 }
499 return (string) $post->post_title;
500 }
501
502 /**
503 * Whether MetaSync social output is disabled for a post.
504 *
505 * The site-wide switch and the per-post switch both disable only MetaSync's
506 * social output; callers must leave third-party SEO output untouched.
507 *
508 * @param int $post_id Post ID.
509 * @return bool
510 */
511 public static function is_social_output_disabled($post_id = 0) {
512 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
513 return true;
514 }
515
516 return $post_id > 0 && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
517 }
518
519 /**
520 * The default OG values the meta box pre-fills for a post: post title,
521 * generated excerpt, and featured image. Computed with the same helpers the
522 * meta box/emitter use, so a caller can compare a stored _metasync_og_* value
523 * against the default and tell whether the user genuinely customized it (the
524 * meta box persists these defaults on save, so a non-empty value alone does
525 * not prove user intent).
526 *
527 * The title is returned empty on a still-untitled post rather than as the
528 * "Auto Draft" placeholder, so the meta box pre-fills nothing and callers
529 * comparing a stored value against this default don't read the placeholder as
530 * a deliberate override.
531 *
532 * Memoized per request: the values are pure functions of the post, and one
533 * front-end render resolves them several times (every collapsed description
534 * read asks for the default, and the OTTO precedence walk asks again) — on
535 * page-builder content the excerpt pass alone re-runs the whole
536 * shortcode-strip pipeline each time. The save handler drops the memo
537 * before its comparisons, since save_post fires after the post row write
538 * and the comparison must see the new title/excerpt. Capped at
539 * DEFAULT_OG_VALUES_MEMO_CAP posts for loops that walk many posts in one
540 * request.
541 *
542 * @param int $post_id
543 * @return array{title:string,description:string,image:string}
544 */
545 public function get_default_og_values($post_id) {
546 $post_id = (int) $post_id;
547 // No real post: get_post(0) answers with the global post, which would
548 // then be memoized under key 0 and served for every later bogus id.
549 if ($post_id <= 0) {
550 return ['title' => '', 'description' => '', 'image' => ''];
551 }
552 if (isset(self::$default_og_values_memo[$post_id])) {
553 return self::$default_og_values_memo[$post_id];
554 }
555
556 $post = get_post($post_id);
557 if (!$post instanceof WP_Post) {
558 return ['title' => '', 'description' => '', 'image' => ''];
559 }
560
561 if (count(self::$default_og_values_memo) >= self::DEFAULT_OG_VALUES_MEMO_CAP) {
562 // unset() on the first key, NOT array_shift(): the memo is keyed by
563 // post id, and array_shift() reindexes integer keys from zero —
564 // after one eviction the survivors would live under 0..8 and any
565 // post with a low id would be served another post's defaults.
566 unset(self::$default_og_values_memo[array_key_first(self::$default_og_values_memo)]);
567 }
568 return self::$default_og_values_memo[$post_id] = [
569 'title' => self::social_post_title($post),
570 'description' => (string) $this->get_post_excerpt($post),
571 'image' => (string) $this->get_featured_image_url($post->ID),
572 ];
573 }
574
575 /**
576 * Drop the get_default_og_values() memo. Called on the save path, where the
577 * just-written post row must be re-read rather than served from a memo
578 * populated earlier in the request; also the seam long-lived processes
579 * (tests, CLI) use to simulate a fresh request. Mirrors
580 * Metasync_Otto_Config::clear_cache().
581 */
582 public static function clear_default_og_values_memo() {
583 self::$default_og_values_memo = [];
584 }
585
586 /**
587 * Register all hooks for this class
588 */
589 public function init() {
590 # Admin hooks
591 add_action('add_meta_boxes', [$this, 'add_meta_box']);
592 # Runs before save_meta_box_data so the untitled flag it maintains is
593 # already current when the meta box save guard consults it.
594 add_action('save_post', [$this, 'track_untitled_post'], 5, 2);
595 add_action('save_post', [$this, 'save_meta_box_data']);
596 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
597
598 # Alternative script loading for post edit screens
599 add_action('admin_print_scripts-post.php', [$this, 'force_enqueue_scripts']);
600 add_action('admin_print_scripts-post-new.php', [$this, 'force_enqueue_scripts']);
601
602 # Frontend hooks
603 add_action('wp_head', [$this, 'output_opengraph_tags'], 5);
604 add_action('wp_head', [$this, 'output_article_tags'], 6);
605
606 # Update OpenGraph URL when post is published/updated
607 add_action('save_post', [$this, 'update_opengraph_url'], 20);
608
609 # Update OpenGraph URL when post permalink changes
610 add_action('post_updated', [$this, 'check_permalink_change'], 10, 3);
611
612 # Also check on transition_post_status for status changes
613 add_action('transition_post_status', [$this, 'check_status_change'], 10, 3);
614
615 # Check when post slug is updated via edit slug functionality
616 add_action('wp_ajax_sample-permalink', [$this, 'check_slug_change'], 5);
617
618 # AJAX hooks for preview
619 add_action('wp_ajax_metasync_og_preview', [$this, 'ajax_generate_preview']);
620
621 # Register cross-plugin dedup filters (Yoast / Rank Math) when their plugins are active
622 $this->register_dedup_filters();
623
624 # Keep the localized "Auto Draft" placeholder out of SEO-plugin titles
625 # for posts published while still untitled.
626 $this->register_untitled_title_filters();
627
628 # Seed the placeholder registry with this admin user's locale variant,
629 # so localized values are recognized everywhere even before any new
630 # post is created on a localized install.
631 if (is_admin()) {
632 add_action('admin_init', [self::class, 'seed_auto_draft_placeholder']);
633 }
634
635 # Shared predicate so the legacy emitter (Metasync_Seo_Output::hook_metasync_metatags)
636 # can suppress its own OG/Twitter blocks whenever this class will emit for the post
637 add_filter('metasync_opengraph_will_emit', [$this, 'will_emit']);
638 }
639
640 /**
641 * Shared predicate: returns true when output_opengraph_tags() will emit
642 * the consolidated OG/Twitter block for the current request.
643 *
644 * Mirrors the early-return guards in output_opengraph_tags() so this
645 * canonical emitter and the legacy emitter stay mutually exclusive —
646 * exactly one fires per page, and neither stays silent on a
647 * MetaSync-only site.
648 *
649 * @param bool $default Filter default (ignored; the real answer is computed).
650 * @return bool
651 */
652 public function will_emit($default = false) {
653 # Feature switched off, so this emitter stays silent. The legacy emitter
654 # checks the same switch independently, so reporting false here cannot
655 # hand it the work.
656 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
657 return false;
658 }
659
660 if (!is_singular($this->get_supported_post_types())) {
661 return false;
662 }
663
664 global $post;
665 if (!$post instanceof WP_Post) {
666 return false;
667 }
668
669 # Only an explicit '0' opt-out disables output; unset/empty counts as enabled
670 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
671 if ($og_enabled === '0') {
672 return false;
673 }
674
675 # OTTO active with persisted OG data owns the page (legacy emitter suppresses too)
676 if ($this->otto_owns_og($post->ID)) {
677 return false;
678 }
679
680 # Third-party SEO plugin active: yield entirely, legacy emitter keeps its original behavior
681 if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
682 return false;
683 }
684
685 return true;
686 }
687
688 /**
689 * Whether OTTO owns this page's Open Graph / Twitter output.
690 *
691 * When OTTO is enabled and has persisted OG data for the post, OTTO's
692 * dynamically-injected tags (and the buffer-level dedup) take precedence, so
693 * the per-post OG meta box values are not emitted on the frontend. Shared by
694 * the frontend emitter (to suppress duplicate output) and the admin meta box
695 * (to warn the user their values won't apply on an OTTO-managed page).
696 *
697 * @param int $post_id
698 * @return bool
699 */
700 private function otto_owns_og($post_id) {
701 if (!class_exists('Metasync_Otto_Config') || !Metasync_Otto_Config::is_otto_enabled()) {
702 return false;
703 }
704 $otto_og_title = get_post_meta($post_id, '_metasync_otto_og_title', true);
705 $otto_og_desc = get_post_meta($post_id, '_metasync_otto_og_description', true);
706 return !empty($otto_og_title) || !empty($otto_og_desc);
707 }
708
709 /**
710 * Add the Open Graph meta box to post and page editors
711 */
712 public function add_meta_box() {
713 # Don't show meta box if user's role doesn't have plugin access
714 if (!Metasync::current_user_has_plugin_access()) {
715 return;
716 }
717
718 # Check if user has permission to edit posts
719 if (!current_user_can('edit_posts')) {
720 return;
721 }
722
723 # Meta title and description are always enabled by default
724 $general_settings = Metasync::get_option('general', []);
725
726 # Check if Social Media & Open Graph meta box is disabled
727 if (!empty($general_settings['disable_social_opengraph_metabox'])) {
728 return;
729 }
730
731 # LPS / custom-HTML pages bake their own OG/social tags into their HTML bundle,
732 # served before wp_head — so this box does nothing on them. Hide it; the SEO
733 # read-only notice covers the messaging.
734 $lps_post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0);
735 if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) {
736 return;
737 }
738
739 # Get supported post types (allow filtering)
740 $post_types = $this->get_supported_post_types();
741 $plugin_name = Metasync::get_effective_plugin_name();
742
743 foreach ($post_types as $post_type) {
744 add_meta_box(
745 self::META_BOX_ID,
746 sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name),
747 [$this, 'render_meta_box'],
748 $post_type,
749 'normal',
750 'high'
751 );
752 }
753 }
754
755 /**
756 * Render the meta box content
757 */
758 public function render_meta_box($post) {
759 # Add nonce for security
760 wp_nonce_field('metasync_opengraph_nonce', 'metasync_opengraph_nonce');
761
762 # Get existing values. The four social title/description keys are read through
763 # get_social_meta(), which collapses the "Auto Draft" placeholder, a stored
764 # snapshot of the post title, and a stored snapshot of the resolved
765 # description to '' — so a polluted legacy row shows an empty field (the
766 # default lives in the placeholder) and re-saving clears it.
767 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
768 $og_title = self::get_social_meta($post->ID, '_metasync_og_title');
769 $og_description = self::get_social_meta($post->ID, '_metasync_og_description');
770 $og_image = get_post_meta($post->ID, '_metasync_og_image', true);
771 $og_url = get_post_meta($post->ID, '_metasync_og_url', true);
772 $og_type = get_post_meta($post->ID, '_metasync_og_type', true);
773
774 # Twitter Card fields
775 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true);
776 $twitter_site = get_post_meta($post->ID, '_metasync_twitter_site', true);
777 $twitter_title = self::get_social_meta($post->ID, '_metasync_twitter_title');
778 $twitter_description = self::get_social_meta($post->ID, '_metasync_twitter_description');
779 $twitter_image = get_post_meta($post->ID, '_metasync_twitter_image', true);
780 $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true);
781
782 # Twitter App Card fields
783 $twitter_app_id_iphone = get_post_meta($post->ID, '_metasync_twitter_app_id_iphone', true);
784 $twitter_app_id_ipad = get_post_meta($post->ID, '_metasync_twitter_app_id_ipad', true);
785 $twitter_app_id_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_id_googleplay', true);
786 $twitter_app_url_iphone = get_post_meta($post->ID, '_metasync_twitter_app_url_iphone', true);
787 $twitter_app_url_ipad = get_post_meta($post->ID, '_metasync_twitter_app_url_ipad', true);
788 $twitter_app_url_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_url_googleplay', true);
789 $twitter_app_country = get_post_meta($post->ID, '_metasync_twitter_app_country', true);
790
791 # Twitter Player Card fields
792 $twitter_player = get_post_meta($post->ID, '_metasync_twitter_player', true);
793 $twitter_player_width = get_post_meta($post->ID, '_metasync_twitter_player_width', true);
794 $twitter_player_height = get_post_meta($post->ID, '_metasync_twitter_player_height', true);
795
796 # Set default values
797 # Note: Check for empty string specifically, not just empty(), since '0' is a valid value
798 if ($og_enabled === '') {
799 # For new posts, default to enabled
800 $og_enabled = '1';
801 }
802 # The post title is the default social title, but it is shown as a
803 # placeholder, never as the field's value. Submitting a page carries
804 # every value attribute to the save handler, so pre-filling the title
805 # persisted a verbatim snapshot of it — one a later rename left stale
806 # (and indistinguishable from a typed title). An empty field plus a
807 # placeholder keeps the default visible while storing nothing, and the
808 # render-time fallback to the live title tracks renames on its own.
809 # social_post_title() suppresses WordPress's own "Auto Draft"
810 # placeholder, so a brand-new post shows no misleading hint either.
811 $title_placeholder = self::social_post_title($post);
812 # What twitter:title actually renders when its own field is blank: the
813 # OG title if one is set, otherwise the same live post title. Shown as
814 # the twitter field's placeholder for the same reason as above.
815 $twitter_title_placeholder = ($og_title !== '') ? $og_title : $title_placeholder;
816 # The resolved description (the manual excerpt, or one generated from
817 # the content) is the default social description, and like the title it
818 # is shown as a placeholder, never as the field's value. Pre-filling it
819 # persisted a snapshot of the excerpt as of save day — one a later
820 # excerpt or content edit left stale, and indistinguishable from a
821 # typed description. An empty field plus a placeholder keeps the
822 # default visible while storing nothing, and the render-time fallback
823 # to the live excerpt tracks edits on its own.
824 $description_placeholder = $this->get_post_excerpt($post);
825 # What twitter:description actually renders when its own field is
826 # blank: the OG description if one is set, otherwise the same resolved
827 # default. Shown as the twitter field's placeholder for the same
828 # reason as above.
829 $twitter_description_placeholder = ($og_description !== '') ? $og_description : $description_placeholder;
830 if (empty($og_url)) {
831 $og_url = $this->get_canonical_url($post);
832 }
833 if (empty($og_type)) {
834 $og_type = 'article';
835 }
836 if (empty($og_image)) {
837 $og_image = $this->get_featured_image_url($post->ID);
838 }
839
840 # Twitter defaults
841 if (empty($twitter_card)) {
842 $twitter_card = 'summary_large_image';
843 }
844 if (empty($twitter_image)) {
845 $twitter_image = $og_image;
846 }
847
848 # Whether OTTO is managing this page's OG output (values below won't apply on the frontend)
849 $otto_owns_og = $this->otto_owns_og($post->ID);
850
851 # Include the meta box template
852 include plugin_dir_path(__FILE__) . '../admin/partials/metasync-opengraph-meta-box.php';
853 }
854
855 /**
856 * Save meta box data
857 */
858 public function save_meta_box_data($post_id) {
859 # Drop the defaults memo before anything below can bail. This handler is
860 # hooked to save_post, so it runs for EVERY save of the post — including
861 # ones initiated by another meta box, a bulk action, or a sync flow that
862 # carries no OG nonce and would have returned early above the old flush
863 # position. Without this clear, a memo seeded earlier in the same request
864 # (a metabox render, a sync read) survives the post-row write and serves
865 # the pre-save title/excerpt to the snapshot comparisons in later
866 # save_post subscribers — the Yoast re-sync on shutdown among them.
867 self::clear_default_og_values_memo();
868
869 # Check if nonce is valid
870 if (!isset($_POST['metasync_opengraph_nonce']) ||
871 !wp_verify_nonce($_POST['metasync_opengraph_nonce'], 'metasync_opengraph_nonce')) {
872 return;
873 }
874
875 # Check if user has permission to edit
876 if (!current_user_can('edit_post', $post_id)) {
877 return;
878 }
879
880 # Check if this is an autosave
881 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
882 return;
883 }
884
885 # Handle the checkbox field separately (unchecked checkboxes don't send POST data)
886 # Meta title and description are always enabled by default
887 if (isset($_POST['_metasync_og_enabled'])) {
888 # User checked the box
889 update_post_meta($post_id, '_metasync_og_enabled', '1');
890 } else {
891 # User unchecked the box
892 update_post_meta($post_id, '_metasync_og_enabled', '0');
893 }
894
895 # Save Open Graph data (excluding the enabled field which is handled above)
896 $og_fields = [
897 '_metasync_og_title' => 'sanitize_text_field',
898 '_metasync_og_description' => 'sanitize_textarea_field',
899 '_metasync_og_image' => 'esc_url_raw',
900 '_metasync_og_url' => 'esc_url_raw',
901 '_metasync_og_type' => 'sanitize_text_field',
902 ];
903
904 # Save Twitter Card data
905 $twitter_fields = [
906 '_metasync_twitter_card' => 'sanitize_text_field',
907 '_metasync_twitter_site' => 'sanitize_text_field',
908 '_metasync_twitter_title' => 'sanitize_text_field',
909 '_metasync_twitter_description' => 'sanitize_textarea_field',
910 '_metasync_twitter_image' => 'esc_url_raw',
911 '_metasync_twitter_image_alt' => 'sanitize_text_field',
912 ];
913
914 # Save Twitter App Card data
915 $twitter_app_fields = [
916 '_metasync_twitter_app_id_iphone' => 'sanitize_text_field',
917 '_metasync_twitter_app_id_ipad' => 'sanitize_text_field',
918 '_metasync_twitter_app_id_googleplay' => 'sanitize_text_field',
919 '_metasync_twitter_app_url_iphone' => 'esc_url_raw',
920 '_metasync_twitter_app_url_ipad' => 'esc_url_raw',
921 '_metasync_twitter_app_url_googleplay' => 'esc_url_raw',
922 '_metasync_twitter_app_country' => 'sanitize_text_field',
923 ];
924
925 # Save Twitter Player Card data
926 $twitter_player_fields = [
927 '_metasync_twitter_player' => 'esc_url_raw',
928 '_metasync_twitter_player_width' => 'absint',
929 '_metasync_twitter_player_height' => 'absint',
930 ];
931
932 $all_fields = array_merge($og_fields, $twitter_fields, $twitter_app_fields, $twitter_player_fields);
933
934 foreach ($all_fields as $field => $sanitize_callback) {
935 if (isset($_POST[$field])) {
936 $value = call_user_func($sanitize_callback, $_POST[$field]);
937
938 # The meta box pre-fills empty social title/description fields from the
939 # post title, and on a brand-new post that title is the "Auto Draft"
940 # placeholder. Persisting it ships "Auto Draft" as the post's social
941 # title and description forever, so store empty instead and let the
942 # render-time fallback chain resolve the real title once one is set.
943 if (in_array($field, self::AUTO_DRAFT_PRONE_KEYS, true)
944 && $this->is_auto_draft_prefill_echo($post_id, $value)
945 ) {
946 $value = '';
947 }
948
949 # A social title identical to the post's title is the default, not a
950 # customization — whether the editor left an older pre-filled form
951 # untouched or typed the title back verbatim. Store empty so the
952 # render-time fallback to the live title applies, which is what keeps
953 # a rename from leaving a stale snapshot behind. Text that differs
954 # from the title passes through untouched. save_post fires after the
955 # post row is written, so the comparison is against the *new* title.
956 if (in_array($field, self::TITLE_DEFAULTED_KEYS, true)) {
957 $value = self::strip_title_snapshot($post_id, $field, $value);
958 }
959
960 # A social description identical to its rendered default — the
961 # resolved excerpt the older pre-filled form carried, or the
962 # same text typed back verbatim — is the default, not a
963 # customization. Store empty so the render-time fallback to the
964 # live excerpt applies, which is what keeps an excerpt or
965 # content edit from leaving a stale snapshot behind. Text that
966 # differs from the default passes through untouched. save_post
967 # fires after the post row is written, so the comparison is
968 # against the *new* excerpt and content.
969 if (in_array($field, self::DESCRIPTION_DEFAULTED_KEYS, true)) {
970 $value = self::strip_description_snapshot($post_id, $field, $value);
971 }
972
973 # An empty value means "no value" for every field in this box,
974 # and must not be stored as one: update_post_meta(..., '') keeps
975 # a row with an empty meta_value in the table (visible only to
976 # metadata_exists, EXISTS queries and exports, which would then
977 # report a customization where the field is merely blank). The
978 # plain read answers '' for a missing row too, so deleting is
979 # behaviour-preserving for every reader. Same pattern the
980 # untitled-flag write below has always used.
981 if ($value === '') {
982 delete_post_meta($post_id, $field);
983 } else {
984 update_post_meta($post_id, $field, $value);
985 }
986 }
987 }
988 }
989
990 /**
991 * Whether a submitted social field value is the meta box echoing back a
992 * placeholder pre-fill rather than something the editor typed.
993 *
994 * Two cases, both narrow on purpose so genuinely typed text is never discarded:
995 * - the value is the "Auto Draft" placeholder itself; or
996 * - the post is still an auto-draft and the value is just its title echoed
997 * back, which is what the pre-fill submits when the editor never touched
998 * the field.
999 *
1000 * @param int $post_id
1001 * @param mixed $value Sanitized submitted value.
1002 * @return bool
1003 */
1004 /**
1005 * save_post tracker maintaining the untitled state this class suppresses by.
1006 *
1007 * Two jobs, both anchored at save time — the only place the "Auto Draft"
1008 * translation reliably resolves — so the render path never has to guess:
1009 *
1010 * 1. Capture: when core creates an auto-draft, its title *is*
1011 * __( 'Auto Draft' ) in the creating request's locale (admin requests
1012 * carry the creating user's locale). Verified against that same __()
1013 * call here, then remembered verbatim, so every locale variant this
1014 * install ever uses is known to all contexts.
1015 * 2. Flag: mark posts saved while still placeholder-titled (recognized via
1016 * literal + translation + the registry above, so REST/CLI saves — where
1017 * the translation stays English — still recognize a registry hit), and
1018 * clear the mark as soon as a real title arrives.
1019 *
1020 * @param int $post_id
1021 * @param WP_Post|mixed $post
1022 * @return void
1023 */
1024 public function track_untitled_post($post_id, $post) {
1025 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
1026 return;
1027 }
1028 if (!$post instanceof WP_Post) {
1029 $post = get_post($post_id);
1030 }
1031 if (!$post instanceof WP_Post || $post->post_type === 'revision' || $post->post_status === 'trash') {
1032 return;
1033 }
1034 if (!in_array($post->post_type, $this->get_supported_post_types(), true)) {
1035 return;
1036 }
1037
1038 $title = trim((string) $post->post_title);
1039
1040 # Capture: an auto-draft's title is core's placeholder in whatever
1041 # locale resolved for the saving request — admin requests carry the
1042 # creating user's locale; on untranslated requests (front end, REST,
1043 # CLI) the equality below degenerates to the English literal, which
1044 # remember_auto_draft_placeholder() already skips. Verified against
1045 # __( 'Auto Draft' ), never trusted as a bare string, so a builder's
1046 # custom-seeded auto-draft title is never captured.
1047 if ($post->post_status === 'auto-draft'
1048 && $title !== '' && $title === trim(__('Auto Draft'))) {
1049 self::remember_auto_draft_placeholder($title);
1050 }
1051
1052 # Flag maintenance: recognition is context-independent thanks to the
1053 # registry (an untranslated REST/CLI save still matches a captured
1054 # variant), so the flag can never go stale in either direction.
1055 if (self::is_auto_draft_title($title)) {
1056 update_post_meta($post_id, self::UNTITLED_FLAG_META, $title);
1057 } else {
1058 delete_post_meta($post_id, self::UNTITLED_FLAG_META);
1059 }
1060 }
1061
1062 private function is_auto_draft_prefill_echo($post_id, $value) {
1063 if (self::is_auto_draft_title($value)) {
1064 return true;
1065 }
1066 if (!is_string($value) || trim($value) === '') {
1067 return false;
1068 }
1069 $post = get_post($post_id);
1070 if (!$post instanceof WP_Post || trim($value) !== trim((string) $post->post_title)) {
1071 return false;
1072 }
1073 if (get_post_status($post_id) === 'auto-draft') {
1074 return true;
1075 }
1076 # State arm: the submitted value echoes a title that was flagged as the
1077 # placeholder at save time. Covers a value the string checks above
1078 # missed — e.g. a localized placeholder stored under an admin user
1079 # whose locale differs from the one that created the post.
1080 $flagged = get_post_meta($post_id, self::UNTITLED_FLAG_META, true);
1081 return is_string($flagged) && trim($flagged) !== '' && trim($flagged) === trim($value);
1082 }
1083
1084 /**
1085 * Enqueue admin scripts and styles
1086 */
1087 public function enqueue_admin_scripts($hook) {
1088 global $post_type;
1089
1090 # Only load on post edit screens for supported post types
1091 if (!in_array($hook, ['post.php', 'post-new.php']) ||
1092 !in_array($post_type, $this->get_supported_post_types())) {
1093 return;
1094 }
1095
1096 wp_enqueue_media();
1097
1098 wp_enqueue_script(
1099 'metasync-opengraph-admin',
1100 plugin_dir_url(__FILE__) . '../admin/js/metasync-opengraph.js',
1101 ['jquery', 'wp-util'],
1102 $this->version,
1103 true
1104 );
1105
1106 wp_enqueue_style(
1107 'metasync-opengraph-admin',
1108 plugin_dir_url(__FILE__) . '../admin/css/metasync-opengraph.css',
1109 [],
1110 $this->version
1111 );
1112
1113 # Get the current post permalink for preview
1114 global $post;
1115 $current_permalink = '';
1116 if ($post && $post->ID) {
1117 $current_permalink = $this->get_canonical_url($post);
1118 }
1119
1120 # Localize script for AJAX
1121 wp_localize_script('metasync-opengraph-admin', 'metasync_og', [
1122 'ajax_url' => admin_url('admin-ajax.php'),
1123 'nonce' => wp_create_nonce('metasync_og_preview_nonce'),
1124 'current_permalink' => $current_permalink,
1125 'strings' => [
1126 'select_image' => esc_html__('Select Image', 'metasync'),
1127 'use_image' => esc_html__('Use This Image', 'metasync'),
1128 'remove_image' => esc_html__('Remove Image', 'metasync'),
1129 ]
1130 ]);
1131 }
1132
1133 /**
1134 * Force enqueue scripts for post edit screens (backup method)
1135 */
1136 public function force_enqueue_scripts() {
1137 global $post_type;
1138
1139 if (!in_array($post_type, $this->get_supported_post_types())) {
1140 return;
1141 }
1142
1143 # Check if already enqueued
1144 if (wp_script_is('metasync-opengraph-admin', 'enqueued')) {
1145 return;
1146 }
1147
1148 wp_enqueue_media();
1149 wp_enqueue_script(
1150 'metasync-opengraph-admin',
1151 plugin_dir_url(__FILE__) . '../admin/js/metasync-opengraph.js',
1152 ['jquery', 'wp-util'],
1153 $this->version,
1154 true
1155 );
1156
1157 wp_enqueue_style(
1158 'metasync-opengraph-admin',
1159 plugin_dir_url(__FILE__) . '../admin/css/metasync-opengraph.css',
1160 [],
1161 $this->version
1162 );
1163
1164 # Get the current post permalink for preview
1165 global $post;
1166 $current_permalink = '';
1167 if ($post && $post->ID) {
1168 $current_permalink = $this->get_canonical_url($post);
1169 }
1170
1171 wp_localize_script('metasync-opengraph-admin', 'metasync_og', [
1172 'ajax_url' => admin_url('admin-ajax.php'),
1173 'nonce' => wp_create_nonce('metasync_og_preview_nonce'),
1174 'current_permalink' => $current_permalink,
1175 'strings' => [
1176 'select_image' => esc_html__('Select Image', 'metasync'),
1177 'use_image' => esc_html__('Use This Image', 'metasync'),
1178 'remove_image' => esc_html__('Remove Image', 'metasync'),
1179 ]
1180 ]);
1181 }
1182
1183 /**
1184 * Output Open Graph and Twitter Card tags in wp_head
1185 */
1186 public function output_opengraph_tags() {
1187 # Social Media & Open Graph switched off — emit nothing.
1188 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
1189 return;
1190 }
1191
1192 if (!is_singular($this->get_supported_post_types())) {
1193 return;
1194 }
1195
1196 global $post;
1197
1198 # Ensure post is a valid object
1199 if (!$post instanceof WP_Post) {
1200 return;
1201 }
1202
1203 # Check if Open Graph is enabled for this post.
1204 # Only an explicit '0' opt-out suppresses output; unset/empty counts as enabled
1205 # so a MetaSync-only site gets one consolidated set whether or not the meta
1206 # box was ever saved. Must stay in sync with will_emit().
1207 if (self::is_social_output_disabled($post->ID)) {
1208 return;
1209 }
1210
1211 # When OTTO owns this page's OG (enabled + persisted OG data), skip legacy
1212 # OG output. For cases where OTTO's pixel injects OG tags dynamically
1213 # (without persisting to _metasync_otto_og_* meta), the buffer-level dedup
1214 # in Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
1215 if ($this->otto_owns_og($post->ID)) {
1216 return;
1217 }
1218
1219 # Check for conflicts with other SEO plugins (allow override via filter)
1220 if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
1221 return;
1222 }
1223
1224 # Open Graph data. The tier order — what the customer set, then OTTO, then
1225 # a value brought in from another SEO plugin — comes from
1226 # Metasync_Seo_Precedence so this emitter and the conflict handler cannot
1227 # disagree about which value the page should carry. The resolver collapses a
1228 # stored "Auto Draft" placeholder, a snapshot of the post title, and a
1229 # snapshot of the resolved description to '' as it walks the chain, so a
1230 # row polluted by the meta box pre-fill falls through to the live
1231 # fallback (or OTTO's tier) rather than outranking it. The literal
1232 # fallbacks below the chain (post title, excerpt, featured image) stay
1233 # here: they are derived at render time, not stored.
1234 $og_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_TITLE)
1235 ?: self::social_post_title($post);
1236 $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION)
1237 ?: $this->get_post_excerpt($post);
1238 $og_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_IMAGE)
1239 ?: $this->get_featured_image_url($post->ID);
1240 $canonical_override = '';
1241 if (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active()) {
1242 # The same two-key chain the canonical tag itself resolves, so the
1243 # two cannot disagree. meta_canonical is not a legacy alias: the
1244 # REST sync and the classic-editor Canonical box both still write
1245 # it, and the GraphQL surface reads both keys as well.
1246 foreach (array('_metasync_canonical_url', 'meta_canonical') as $canonical_key) {
1247 $canonical_override = Metasync_Canonical_Sanitizer::sanitize(
1248 get_post_meta($post->ID, $canonical_key, true)
1249 );
1250 if ($canonical_override !== '') {
1251 break;
1252 }
1253 }
1254 }
1255
1256 # A validated canonical override is the author's final answer. It may
1257 # deliberately name another host, so it bypasses headless rehosting.
1258 if ($canonical_override !== '') {
1259 $og_url = esc_url($canonical_override);
1260 } else {
1261 $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post);
1262
1263 # On a headless site the rendered page is not the public page, so
1264 # an own-host og:url must name the frontend. Applied at the single
1265 # emission source so update_opengraph_url() continues to persist
1266 # the WordPress URL used outside headless mode.
1267 $og_url = $this->maybe_build_headless_url($og_url, $post->post_type);
1268 }
1269 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
1270
1271 # Get Twitter Card data — check persisted key first, fall back to OTTO staging key
1272 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image';
1273 $twitter_site = get_post_meta($post->ID, '_metasync_twitter_site', true);
1274
1275 # Fall back to the site-wide Twitter username (Social Meta settings) so the
1276 # twitter:site / twitter:creator tags the legacy emitter produced are not lost
1277 # now that this emitter is the single canonical OG/Twitter source
1278 $twitter_username = Metasync::get_option('social_meta')['twitter_username'] ?? '';
1279 if (empty($twitter_site) && !empty($twitter_username)) {
1280 $twitter_site = '@' . $twitter_username;
1281 }
1282 $twitter_creator = !empty($twitter_username) ? '@' . $twitter_username : '';
1283 $twitter_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_TITLE)
1284 ?: $og_title;
1285 $twitter_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION)
1286 ?: $og_description;
1287 $twitter_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_TWITTER_IMAGE)
1288 ?: $og_image;
1289 $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true);
1290
1291 # Resolve OG image attachment ID once for reuse (twitter:image:alt fallback + og:image dimensions)
1292 $og_image_attachment_id = 0;
1293 if (!empty($og_image)) {
1294 $og_image_attachment_id = attachment_url_to_postid($og_image);
1295 }
1296
1297 # Fall back to the OG image's WP attachment alt text when no explicit twitter:image:alt is set
1298 if (empty($twitter_image_alt) && $og_image_attachment_id > 0) {
1299 $attachment_alt = get_post_meta($og_image_attachment_id, '_wp_attachment_image_alt', true);
1300 if (!empty($attachment_alt)) {
1301 $twitter_image_alt = $attachment_alt;
1302 }
1303 }
1304
1305 # Per-field toggles from common_meta_settings (default enabled when unset)
1306 $common_meta_settings = Metasync::get_option('common_meta_settings');
1307 if (!is_array($common_meta_settings)) {
1308 $common_meta_settings = [];
1309 }
1310 $og_image_dimensions_enabled = ($common_meta_settings['og_image_dimensions'] ?? 'true') !== 'false';
1311 $twitter_image_alt_enabled = ($common_meta_settings['twitter_image_alt'] ?? 'true') !== 'false';
1312
1313 # Get Twitter App Card data
1314 $twitter_app_id_iphone = get_post_meta($post->ID, '_metasync_twitter_app_id_iphone', true);
1315 $twitter_app_id_ipad = get_post_meta($post->ID, '_metasync_twitter_app_id_ipad', true);
1316 $twitter_app_id_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_id_googleplay', true);
1317 $twitter_app_url_iphone = get_post_meta($post->ID, '_metasync_twitter_app_url_iphone', true);
1318 $twitter_app_url_ipad = get_post_meta($post->ID, '_metasync_twitter_app_url_ipad', true);
1319 $twitter_app_url_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_url_googleplay', true);
1320 $twitter_app_country = get_post_meta($post->ID, '_metasync_twitter_app_country', true);
1321
1322 # Get Twitter Player Card data
1323 $twitter_player = get_post_meta($post->ID, '_metasync_twitter_player', true);
1324 $twitter_player_width = get_post_meta($post->ID, '_metasync_twitter_player_width', true);
1325 $twitter_player_height = get_post_meta($post->ID, '_metasync_twitter_player_height', true);
1326
1327 # Output Open Graph tags
1328 echo "\n<!-- MetaSync Open Graph Tags -->\n";
1329 echo '<meta property="og:locale" content="' . esc_attr(get_locale()) . '">' . "\n";
1330 if ($og_title) {
1331 echo '<meta property="og:title" content="' . esc_attr($og_title) . '">' . "\n";
1332 }
1333 if ($og_description) {
1334 echo '<meta property="og:description" content="' . esc_attr($og_description) . '">' . "\n";
1335 }
1336 if ($og_image) {
1337 echo '<meta property="og:image" content="' . esc_url($og_image) . '">' . "\n";
1338
1339 if ($og_image_dimensions_enabled) {
1340 $og_image_dims = $this->get_og_image_dimensions($og_image, $og_image_attachment_id);
1341 if (is_array($og_image_dims)) {
1342 if (!empty($og_image_dims['width'])) {
1343 echo '<meta property="og:image:width" content="' . esc_attr((string) $og_image_dims['width']) . '">' . "\n";
1344 }
1345 if (!empty($og_image_dims['height'])) {
1346 echo '<meta property="og:image:height" content="' . esc_attr((string) $og_image_dims['height']) . '">' . "\n";
1347 }
1348 if (!empty($og_image_dims['mime'])) {
1349 echo '<meta property="og:image:type" content="' . esc_attr($og_image_dims['mime']) . '">' . "\n";
1350 }
1351 }
1352 }
1353 }
1354 if ($og_url) {
1355 echo '<meta property="og:url" content="' . esc_url($og_url) . '">' . "\n";
1356 }
1357 if ($og_type) {
1358 echo '<meta property="og:type" content="' . esc_attr($og_type) . '">' . "\n";
1359 }
1360 $site_name = get_bloginfo('name');
1361 if ($site_name) {
1362 echo '<meta property="og:site_name" content="' . esc_attr($site_name) . '">' . "\n";
1363 }
1364 if (!empty($post->post_modified)) {
1365 echo '<meta property="og:updated_time" content="' . esc_attr($post->post_modified) . '">' . "\n";
1366 }
1367
1368 # Output Twitter Card tags
1369 echo "<!-- MetaSync Twitter Card Tags -->\n";
1370 if ($twitter_card) {
1371 echo '<meta name="twitter:card" content="' . esc_attr($twitter_card) . '">' . "\n";
1372 }
1373 if ($twitter_site) {
1374 echo '<meta name="twitter:site" content="' . esc_attr($twitter_site) . '">' . "\n";
1375 }
1376 if ($twitter_creator) {
1377 echo '<meta name="twitter:creator" content="' . esc_attr($twitter_creator) . '">' . "\n";
1378 }
1379 if ($twitter_title) {
1380 echo '<meta name="twitter:title" content="' . esc_attr($twitter_title) . '">' . "\n";
1381 }
1382 if ($twitter_description) {
1383 echo '<meta name="twitter:description" content="' . esc_attr($twitter_description) . '">' . "\n";
1384 }
1385 if ($twitter_image) {
1386 echo '<meta name="twitter:image" content="' . esc_url($twitter_image) . '">' . "\n";
1387 }
1388 if ($twitter_image_alt && $twitter_image_alt_enabled) {
1389 echo '<meta name="twitter:image:alt" content="' . esc_attr($twitter_image_alt) . '">' . "\n";
1390 }
1391
1392 # Output Twitter App Card tags (only if card type is 'app')
1393 if ($twitter_card === 'app') {
1394 if ($twitter_app_id_iphone) {
1395 echo '<meta name="twitter:app:id:iphone" content="' . esc_attr($twitter_app_id_iphone) . '">' . "\n";
1396 }
1397 if ($twitter_app_id_ipad) {
1398 echo '<meta name="twitter:app:id:ipad" content="' . esc_attr($twitter_app_id_ipad) . '">' . "\n";
1399 }
1400 if ($twitter_app_id_googleplay) {
1401 echo '<meta name="twitter:app:id:googleplay" content="' . esc_attr($twitter_app_id_googleplay) . '">' . "\n";
1402 }
1403 if ($twitter_app_url_iphone) {
1404 echo '<meta name="twitter:app:url:iphone" content="' . esc_url($twitter_app_url_iphone) . '">' . "\n";
1405 }
1406 if ($twitter_app_url_ipad) {
1407 echo '<meta name="twitter:app:url:ipad" content="' . esc_url($twitter_app_url_ipad) . '">' . "\n";
1408 }
1409 if ($twitter_app_url_googleplay) {
1410 echo '<meta name="twitter:app:url:googleplay" content="' . esc_url($twitter_app_url_googleplay) . '">' . "\n";
1411 }
1412 if ($twitter_app_country) {
1413 echo '<meta name="twitter:app:country" content="' . esc_attr($twitter_app_country) . '">' . "\n";
1414 }
1415 }
1416
1417 # Output Twitter Player Card tags (only if card type is 'player')
1418 if ($twitter_card === 'player') {
1419 if ($twitter_player) {
1420 echo '<meta name="twitter:player" content="' . esc_url($twitter_player) . '">' . "\n";
1421 }
1422 if ($twitter_player_width) {
1423 echo '<meta name="twitter:player:width" content="' . esc_attr($twitter_player_width) . '">' . "\n";
1424 }
1425 if ($twitter_player_height) {
1426 echo '<meta name="twitter:player:height" content="' . esc_attr($twitter_player_height) . '">' . "\n";
1427 }
1428 }
1429
1430 echo "<!-- End MetaSync Social Media Tags -->\n\n";
1431 }
1432
1433 /**
1434 * Resolve OG image dimensions + MIME without making remote HTTP calls.
1435 *
1436 * Returns an array with 'width', 'height', and 'mime' when available,
1437 * or null when no dimensions are known. For WP-hosted attachments the
1438 * data comes from attachment metadata. For external URLs we only read
1439 * a pre-seeded transient (metasync_og_img_dims_{md5(url)}).
1440 *
1441 * @param string $url
1442 * @param int $attachment_id Pre-resolved attachment ID (0 = auto-detect).
1443 * @return array|null
1444 */
1445 private function get_og_image_dimensions($url, $attachment_id = 0) {
1446 if (empty($url) || !is_string($url)) {
1447 return null;
1448 }
1449
1450 if ($attachment_id <= 0) {
1451 $attachment_id = attachment_url_to_postid($url);
1452 }
1453 if ($attachment_id > 0) {
1454 $meta = wp_get_attachment_metadata($attachment_id);
1455 $width = isset($meta['width']) ? (int) $meta['width'] : 0;
1456 $height = isset($meta['height']) ? (int) $meta['height'] : 0;
1457 $mime = get_post_mime_type($attachment_id) ?: '';
1458 if ($width > 0 || $height > 0 || $mime !== '') {
1459 return [
1460 'width' => $width,
1461 'height' => $height,
1462 'mime' => $mime,
1463 ];
1464 }
1465 return null;
1466 }
1467
1468 # External URLs: only read the pre-seeded transient, never make remote HTTP calls here.
1469 $cached = get_transient('metasync_og_img_dims_' . md5($url));
1470 if (is_array($cached)) {
1471 return [
1472 'width' => isset($cached['width']) ? (int) $cached['width'] : 0,
1473 'height' => isset($cached['height']) ? (int) $cached['height'] : 0,
1474 'mime' => isset($cached['mime']) ? (string) $cached['mime'] : '',
1475 ];
1476 }
1477
1478 return null;
1479 }
1480
1481 /**
1482 * Output article:* Open Graph tags for article-type singular views.
1483 *
1484 * Runs independently of has_seo_plugin_conflicts() so we can still emit
1485 * complete article metadata while other SEO plugins handle og:title/description.
1486 * Cross-plugin dedup is handled via register_dedup_filters() instead.
1487 */
1488 public function output_article_tags() {
1489 # article:* tags are part of the same Open Graph block, so they follow
1490 # the same switch as output_opengraph_tags().
1491 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
1492 return;
1493 }
1494
1495 if (!is_singular()) {
1496 return;
1497 }
1498
1499 global $post;
1500 if (!$post instanceof WP_Post) {
1501 return;
1502 }
1503
1504 if (self::is_social_output_disabled($post->ID)) {
1505 return;
1506 }
1507
1508 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
1509 if ($og_type !== 'article') {
1510 return;
1511 }
1512
1513 $article_post_types = apply_filters('metasync_og_article_post_types', ['post']);
1514 if (!is_array($article_post_types) || !in_array($post->post_type, $article_post_types, true)) {
1515 return;
1516 }
1517
1518 $settings = Metasync::get_option('common_meta_settings');
1519 if (!is_array($settings)) {
1520 $settings = [];
1521 }
1522
1523 $article_timestamps_enabled = ($settings['article_timestamps'] ?? 'true') !== 'false';
1524 $article_author_enabled = ($settings['article_author'] ?? 'true') !== 'false';
1525 $article_section_enabled = ($settings['article_section'] ?? 'true') !== 'false';
1526 $article_tags_enabled = ($settings['article_tags'] ?? 'true') !== 'false';
1527
1528 echo "<!-- MetaSync Article Tags -->\n";
1529
1530 # article:published_time / article:modified_time
1531 if ($article_timestamps_enabled) {
1532 if (!empty($post->post_date_gmt) && $post->post_date_gmt !== '0000-00-00 00:00:00') {
1533 $published_ts = strtotime($post->post_date_gmt);
1534 if ($published_ts) {
1535 echo '<meta property="article:published_time" content="' . esc_attr(gmdate('c', $published_ts)) . '">' . "\n";
1536 }
1537 }
1538 if (!empty($post->post_modified_gmt) && $post->post_modified_gmt !== '0000-00-00 00:00:00') {
1539 $modified_ts = strtotime($post->post_modified_gmt);
1540 if ($modified_ts) {
1541 echo '<meta property="article:modified_time" content="' . esc_attr(gmdate('c', $modified_ts)) . '">' . "\n";
1542 }
1543 }
1544 }
1545
1546 # article:author
1547 if ($article_author_enabled) {
1548 $author_url = get_post_meta($post->ID, '_metasync_og_article_author', true);
1549 if (empty($author_url)) {
1550 $author_url = get_the_author_meta('url', $post->post_author);
1551 }
1552 if (empty($author_url)) {
1553 $author_url = get_author_posts_url($post->post_author);
1554 }
1555 if (!empty($author_url)) {
1556 echo '<meta property="article:author" content="' . esc_url($author_url) . '">' . "\n";
1557 }
1558 }
1559
1560 # article:section – prefer explicit primary category, fall back to first category
1561 if ($article_section_enabled) {
1562 $section_name = '';
1563 $primary_category_id = (int) get_post_meta($post->ID, '_metasync_primary_category', true);
1564 if ($primary_category_id > 0) {
1565 $category = get_category($primary_category_id);
1566 if ($category && !is_wp_error($category) && !empty($category->name)) {
1567 $section_name = $category->name;
1568 }
1569 }
1570 if (empty($section_name)) {
1571 $categories = get_the_category($post->ID);
1572 if (!empty($categories) && isset($categories[0]->name)) {
1573 $section_name = $categories[0]->name;
1574 }
1575 }
1576 if (!empty($section_name)) {
1577 echo '<meta property="article:section" content="' . esc_attr($section_name) . '">' . "\n";
1578 }
1579 }
1580
1581 # article:tag – one tag per WP post tag
1582 if ($article_tags_enabled) {
1583 $post_tags = get_the_tags($post->ID);
1584 if (!empty($post_tags) && !is_wp_error($post_tags)) {
1585 foreach ($post_tags as $tag) {
1586 if (!empty($tag->name)) {
1587 echo '<meta property="article:tag" content="' . esc_attr($tag->name) . '">' . "\n";
1588 }
1589 }
1590 }
1591 }
1592
1593 echo "<!-- End MetaSync Article Tags -->\n";
1594 }
1595
1596 /**
1597 * Register cross-plugin dedup filters so Yoast / Rank Math don't double-emit
1598 * article:* tags alongside our own output.
1599 */
1600 private function register_dedup_filters() {
1601 # Ensure is_plugin_active() is available on the frontend too.
1602 if (!function_exists('is_plugin_active')) {
1603 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1604 }
1605
1606 $settings = Metasync::get_option('common_meta_settings');
1607 if (!is_array($settings)) {
1608 $settings = [];
1609 }
1610
1611 $yoast_active = is_plugin_active('wordpress-seo/wp-seo.php')
1612 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
1613 $rank_math_active = is_plugin_active('seo-by-rank-math/rank-math.php');
1614
1615 # The article:* tags these filters suppress are emitted by
1616 # output_article_tags(), which stands down when the Social Media & Open
1617 # Graph feature is switched off. Treat every article feature as disabled
1618 # in that case so the third party keeps rendering its own tags — pulling
1619 # ours without releasing theirs would leave the page with none.
1620 $social_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1621
1622 $article_timestamps_enabled = $social_enabled && ($settings['article_timestamps'] ?? 'true') !== 'false';
1623 $article_author_enabled = $social_enabled && ($settings['article_author'] ?? 'true') !== 'false';
1624 $article_section_enabled = $social_enabled && ($settings['article_section'] ?? 'true') !== 'false';
1625 $article_tags_enabled = $social_enabled && ($settings['article_tags'] ?? 'true') !== 'false';
1626
1627 # Yoast: remove individual presenters based on which MetaSync features are enabled
1628 if ($yoast_active && ($article_timestamps_enabled || $article_author_enabled)) {
1629 add_filter('wpseo_frontend_presenters', function( $presenters ) use ( $article_timestamps_enabled, $article_author_enabled ) {
1630 $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0;
1631 if (self::is_social_output_disabled($post_id)) {
1632 return $presenters;
1633 }
1634 foreach ( $presenters as $key => $presenter ) {
1635 if ( $article_timestamps_enabled && (
1636 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Published_Time_Presenter ||
1637 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Modified_Time_Presenter
1638 )) {
1639 unset( $presenters[ $key ] );
1640 }
1641 if ( $article_author_enabled &&
1642 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Author_Presenter ) {
1643 unset( $presenters[ $key ] );
1644 }
1645 }
1646 return array_values( $presenters );
1647 }, 999 );
1648 }
1649
1650 # Rank Math: suppress individual article:* tags via content filters.
1651 # Rank Math's tag() method passes content through rank_math/opengraph/facebook/{property}
1652 # where {property} is the OG property with colons replaced by underscores.
1653 # Returning false causes tag() to skip output (empty($content) check).
1654 if ($rank_math_active) {
1655 $keep_third_party = function ($value) {
1656 $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0;
1657 return self::is_social_output_disabled($post_id) ? $value : false;
1658 };
1659 if ($article_timestamps_enabled) {
1660 add_filter('rank_math/opengraph/facebook/article_published_time', $keep_third_party, 999);
1661 add_filter('rank_math/opengraph/facebook/article_modified_time', $keep_third_party, 999);
1662 }
1663 if ($article_tags_enabled) {
1664 add_filter('rank_math/opengraph/facebook/article_tag', $keep_third_party, 999);
1665 }
1666 if ($article_author_enabled) {
1667 add_filter('rank_math/opengraph/facebook/article_author', $keep_third_party, 999);
1668 }
1669 if ($article_section_enabled) {
1670 add_filter('rank_math/opengraph/facebook/article_section', $keep_third_party, 999);
1671 }
1672 }
1673 }
1674
1675 /**
1676 * Hook the untitled-placeholder suppression into the title pipelines of
1677 * the SEO plugins that can own the <title>/og:title/twitter:title output
1678 * when MetaSync's own emitter stands down due to a conflict.
1679 *
1680 * Rank Math funnels <title>, og:title and twitter:title through one filter
1681 * (rank_math/frontend/title wraps Paper::get_title()), so one hook covers
1682 * all three. Yoast builds them from separate presenters, so it needs the
1683 * title filter plus the og/twitter title filters.
1684 *
1685 * The hooks are registered unconditionally: each only fires while its
1686 * plugin is actually generating a title, and the callback no-ops unless
1687 * the current post carries the untitled flag.
1688 *
1689 * @return void
1690 */
1691 private function register_untitled_title_filters() {
1692 add_filter('rank_math/frontend/title', [$this, 'strip_untitled_from_seo_title'], 99);
1693 add_filter('wpseo_title', [$this, 'strip_untitled_from_seo_title'], 99);
1694 add_filter('wpseo_opengraph_title', [$this, 'strip_untitled_from_seo_title'], 99);
1695 add_filter('wpseo_twitter_title', [$this, 'strip_untitled_from_seo_title'], 99);
1696 }
1697
1698 /**
1699 * Remove the flagged placeholder from an SEO plugin's assembled title.
1700 *
1701 * The incoming value is the finished template output ("placeholder -
1702 * Site name"), so the placeholder segment is cut out and any dangling
1703 * separator tidied. An empty result (title was the placeholder alone)
1704 * cannot be returned as-is: both plugins treat an empty value as "do
1705 * nothing" and re-use their own generated title, so the site name is the
1706 * replacement of last resort.
1707 *
1708 * @param mixed $title Assembled title from the SEO plugin.
1709 * @return string
1710 */
1711 public function strip_untitled_from_seo_title($title) {
1712 if (!is_string($title) || trim($title) === '') {
1713 return $title;
1714 }
1715 $post = get_post();
1716 if (!$post instanceof WP_Post) {
1717 return $title;
1718 }
1719 $flagged = get_post_meta($post->ID, self::UNTITLED_FLAG_META, true);
1720 if (!is_string($flagged) || trim($flagged) === '') {
1721 return $title;
1722 }
1723 # Same state check as social_post_title(): only while the live title
1724 # still *is* the flagged placeholder.
1725 if (trim((string) $post->post_title) !== trim($flagged)) {
1726 return $title;
1727 }
1728 if (strpos($title, $flagged) === false) {
1729 return $title;
1730 }
1731 $remainder = trim(str_replace($flagged, '', $title));
1732 # Tidy the separator(s) that flanked the removed segment.
1733 $remainder = trim((string) preg_replace('/^[\s\-–—|·»•]+|[\s\-–—|·»•]+$/u', '', $remainder));
1734 if ($remainder === '') {
1735 $remainder = trim((string) get_bloginfo('name'));
1736 }
1737 if ($remainder === '') {
1738 # A space, not '': an empty return is a no-op for these filters.
1739 $remainder = ' ';
1740 }
1741 return $remainder;
1742 }
1743
1744 /**
1745 * AJAX handler for generating social media preview
1746 */
1747 public function ajax_generate_preview() {
1748
1749 try {
1750 # Check nonce
1751 if (!check_ajax_referer('metasync_og_preview_nonce', 'nonce', false)) {
1752 wp_send_json_error(['message' => 'Security check failed']);
1753 return;
1754 }
1755
1756 # Get and sanitize data
1757 $title = sanitize_text_field($_POST['title'] ?? '');
1758 $description = sanitize_textarea_field($_POST['description'] ?? '');
1759 $image = esc_url_raw($_POST['image'] ?? '');
1760 $url = esc_url_raw($_POST['url'] ?? '');
1761
1762 # Get Twitter Card data
1763 $twitter_title = sanitize_text_field($_POST['twitter_title'] ?? '');
1764 $twitter_description = sanitize_textarea_field($_POST['twitter_description'] ?? '');
1765 $twitter_image = esc_url_raw($_POST['twitter_image'] ?? '');
1766
1767 # Generate preview HTML
1768 $preview_html = $this->generate_preview_html($title, $description, $image, $url, $twitter_title, $twitter_description, $twitter_image);
1769
1770 if (empty($preview_html)) {
1771 wp_send_json_error(['message' => 'Failed to generate preview HTML']);
1772 return;
1773 }
1774
1775 wp_send_json_success(['preview' => $preview_html]);
1776
1777 } catch (Exception $e) {
1778 wp_send_json_error(['message' => 'Server error: ' . $e->getMessage()]);
1779 }
1780 }
1781
1782 /**
1783 * Generate HTML for social media preview
1784 */
1785 private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') {
1786 # Parse domain from URL
1787 $domain = '';
1788 if (!empty($url)) {
1789 $parsed = parse_url($url);
1790 $domain = $parsed['host'] ?? '';
1791 }
1792
1793 # Fallback to site URL if no domain found
1794 if (empty($domain)) {
1795 $site_url = get_site_url();
1796 $parsed = parse_url($site_url);
1797 $domain = $parsed['host'] ?? 'your-site.com';
1798 }
1799
1800 # Provide fallbacks for empty values
1801 if (empty($title)) {
1802 $title = 'Your Post Title';
1803 }
1804 if (empty($description)) {
1805 $description = 'Your post description will appear here when shared on social media platforms.';
1806 }
1807
1808 # Use Twitter Card data for Twitter preview, fallback to Open Graph
1809 $twitter_display_title = !empty($twitter_title) ? $twitter_title : $title;
1810 $twitter_display_description = !empty($twitter_description) ? $twitter_description : $description;
1811 $twitter_display_image = !empty($twitter_image) ? $twitter_image : $image;
1812
1813 # Get site name for avatars
1814 $site_name = get_bloginfo('name') ?: 'Your Site';
1815 $site_initial = strtoupper(substr($site_name, 0, 1));
1816
1817 ob_start();
1818 ?>
1819 <div class="metasync-preview-tabs">
1820 <button class="metasync-preview-tab facebook active" data-platform="facebook">
1821 Facebook
1822 </button>
1823 <button class="metasync-preview-tab twitter" data-platform="twitter">
1824 Twitter/X
1825 </button>
1826 <button class="metasync-preview-tab linkedin" data-platform="linkedin">
1827 LinkedIn
1828 </button>
1829 </div>
1830
1831 <div class="metasync-preview-content">
1832 <!-- Facebook Preview -->
1833 <div class="metasync-preview-panel facebook active" data-platform="facebook">
1834 <div class="facebook-preview">
1835 <div class="facebook-post-header">
1836 <div class="facebook-avatar"><?php echo esc_html($site_initial); ?></div>
1837 <div class="facebook-post-info">
1838 <h4><?php echo esc_html($site_name); ?></h4>
1839 <p>2 hours ago • 🌍</p>
1840 </div>
1841 </div>
1842 <div class="facebook-link-preview">
1843 <?php if (!empty($image)): ?>
1844 <div class="facebook-preview-image">
1845 <img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($title); ?>" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
1846 <div class="preview-placeholder" style="display: none;">
1847 <span>📷</span>
1848 <p>Image failed to load</p>
1849 </div>
1850 </div>
1851 <?php else: ?>
1852 <div class="facebook-preview-image preview-no-image">
1853 <div class="preview-placeholder">
1854 <span>📷</span>
1855 <p>No image selected</p>
1856 </div>
1857 </div>
1858 <?php endif; ?>
1859 <div class="facebook-preview-content">
1860 <div class="facebook-preview-domain"><?php echo esc_html(strtoupper($domain)); ?></div>
1861 <div class="facebook-preview-title"><?php echo esc_html($title); ?></div>
1862 <div class="facebook-preview-description"><?php echo esc_html($description); ?></div>
1863 </div>
1864 </div>
1865 </div>
1866 </div>
1867
1868 <!-- Twitter Preview -->
1869 <div class="metasync-preview-panel twitter" data-platform="twitter">
1870 <div class="twitter-preview">
1871 <div class="twitter-post-header">
1872 <div class="twitter-avatar"><?php echo esc_html($site_initial); ?></div>
1873 <div class="twitter-user-info">
1874 <h4><?php echo esc_html($site_name); ?></h4>
1875 <p>@<?php echo esc_html(strtolower(str_replace(' ', '', $site_name ?? ''))); ?> • 2h</p>
1876 </div>
1877 </div>
1878 <div class="twitter-post-text">
1879 Check out this amazing content! 🚀
1880 </div>
1881 <div class="twitter-card">
1882 <?php if (!empty($twitter_display_image)): ?>
1883 <div class="twitter-card-image">
1884 <img src="<?php echo esc_url($twitter_display_image); ?>" alt="<?php echo esc_attr($twitter_display_title); ?>" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
1885 <div class="preview-placeholder" style="display: none;">
1886 <span>📷</span>
1887 <p>Image failed to load</p>
1888 </div>
1889 </div>
1890 <?php else: ?>
1891 <div class="twitter-card-image preview-no-image">
1892 <div class="preview-placeholder">
1893 <span>📷</span>
1894 <p>No image selected</p>
1895 </div>
1896 </div>
1897 <?php endif; ?>
1898 <div class="twitter-card-content">
1899 <div class="twitter-card-domain"><?php echo esc_html($domain); ?></div>
1900 <div class="twitter-card-title"><?php echo esc_html($twitter_display_title); ?></div>
1901 <div class="twitter-card-description"><?php echo esc_html($twitter_display_description); ?></div>
1902 </div>
1903 </div>
1904 </div>
1905 </div>
1906
1907 <!-- LinkedIn Preview -->
1908 <div class="metasync-preview-panel linkedin" data-platform="linkedin">
1909 <div class="linkedin-preview">
1910 <div class="linkedin-post-header">
1911 <div class="linkedin-avatar"><?php echo esc_html($site_initial); ?></div>
1912 <div class="linkedin-user-info">
1913 <h4><?php echo esc_html($site_name); ?></h4>
1914 <p>2 hours ago</p>
1915 </div>
1916 </div>
1917 <div class="linkedin-link-preview">
1918 <?php if (!empty($image)): ?>
1919 <div class="linkedin-preview-image">
1920 <img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($title); ?>" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
1921 <div class="preview-placeholder" style="display: none;">
1922 <span>📷</span>
1923 <p>Image failed to load</p>
1924 </div>
1925 </div>
1926 <?php else: ?>
1927 <div class="linkedin-preview-image preview-no-image">
1928 <div class="preview-placeholder">
1929 <span>📷</span>
1930 <p>No image selected</p>
1931 </div>
1932 </div>
1933 <?php endif; ?>
1934 <div class="linkedin-preview-content">
1935 <div class="linkedin-preview-title"><?php echo esc_html($title); ?></div>
1936 <div class="linkedin-preview-description"><?php echo esc_html($description); ?></div>
1937 <div class="linkedin-preview-domain"><?php echo esc_html($domain); ?></div>
1938 </div>
1939 </div>
1940 </div>
1941 </div>
1942 </div>
1943 <?php
1944 return ob_get_clean();
1945 }
1946
1947 /**
1948 * Get post excerpt for Open Graph description
1949 */
1950 private function get_post_excerpt($post) {
1951 if (!empty($post->post_excerpt)) {
1952 return $post->post_excerpt;
1953 }
1954
1955 # Generate excerpt from content
1956 $content = $post->post_content;
1957
1958 # Do NOT run do_shortcode()/apply_filters('the_content') here.
1959 # This method runs on wp_head (priority 5, before the body renders) to build
1960 # og:description. On page-builder pages (Elementor, etc.) the_content fully
1961 # renders the page — including widgets like Elementor Loop Grid — which makes
1962 # the builder mark those widgets' per-request inline CSS as "already printed".
1963 # When the real widget renders later in the body, the builder's dedup then
1964 # OMITS its inline <style> (e.g. <style id="loop-NNNN"> carrying the loop
1965 # card's flex/width vars), collapsing the layout (stacked cards, full-width
1966 # images). We only need plain text for a meta description, so strip instead of
1967 # render — matching how Metasync_Seo_Output builds its description safely.
1968 $content = strip_shortcodes($content);
1969
1970 # Page builders (Divi, Elementor, WPBakery) store content as shortcodes.
1971 # do_shortcode() only renders shortcodes whose handlers are registered, and when
1972 # this runs server-side (REST/cron/CLI) or before the builder loads the [et_pb_*]
1973 # tags are never expanded. strip_shortcodes() only removes *registered* shortcodes
1974 # too, so any leftover shortcode-style tags are removed by pattern below — otherwise
1975 # raw builder markup leaks into the og:description.
1976 $content = $this->strip_shortcode_markup($content);
1977
1978 # Remove HTML tags to get clean text
1979 $content = wp_strip_all_tags($content);
1980
1981 # Remove extra whitespace, line breaks, and special characters
1982 $content = preg_replace('/\s+/', ' ', $content);
1983 $content = trim($content);
1984
1985 # If content is still empty or too short, fallback to post title. A brand-new
1986 # post has no content and its title is the "Auto Draft" placeholder, which
1987 # must not become the og:/twitter:description — strip it so the caller's
1988 # fallback chain resolves to something real instead.
1989 if (empty($content) || strlen($content) < 20) {
1990 $content = self::social_post_title($post);
1991 }
1992
1993 if ($content === '') {
1994 return '';
1995 }
1996
1997 # Generate excerpt
1998 $excerpt = wp_trim_words($content, 30, '...');
1999
2000 return $excerpt;
2001 }
2002
2003 /**
2004 * Strip shortcode markup from a string.
2005 *
2006 * Removes registered shortcodes via strip_shortcodes(), then strips any
2007 * leftover shortcode-style tags (e.g. unregistered page-builder tags such
2008 * as [et_pb_section ...] / [/et_pb_section]) by pattern. The pattern is
2009 * anchored to a leading letter so legitimate bracketed prose like
2010 * "[2026 Guide]" is preserved.
2011 *
2012 * @param string $content
2013 * @return string
2014 */
2015 private function strip_shortcode_markup($content) {
2016 if (empty($content) || !is_string($content)) {
2017 return (string) $content;
2018 }
2019
2020 $content = strip_shortcodes($content);
2021 $content = preg_replace('/\[\/?[a-zA-Z][^\]]*\]/', '', $content);
2022
2023 return $content;
2024 }
2025
2026 /**
2027 * Get featured image URL
2028 */
2029 private function get_featured_image_url($post_id) {
2030 $thumbnail_id = get_post_thumbnail_id($post_id);
2031 if ($thumbnail_id) {
2032 $image_url = wp_get_attachment_image_url($thumbnail_id, 'large');
2033 return $image_url;
2034 }
2035 return '';
2036 }
2037
2038 /**
2039 * Get supported post types
2040 */
2041 public function get_supported_post_types() {
2042 $post_types = array_values(get_post_types(['public' => true], 'names'));
2043 $post_types = array_diff($post_types, ['attachment']);
2044 return apply_filters('metasync_opengraph_post_types', $post_types);
2045 }
2046
2047 /**
2048 * Add debug menu for testing
2049 */
2050 private function has_seo_plugin_conflicts() {
2051 // Ensure is_plugin_active() is available on the frontend
2052 if (!function_exists('is_plugin_active')) {
2053 require_once ABSPATH . 'wp-admin/includes/plugin.php';
2054 }
2055
2056 # List of SEO plugins that might output Open Graph tags
2057 $seo_plugins = [
2058 'wordpress-seo/wp-seo.php', # Yoast SEO
2059 'seo-by-rank-math/rank-math.php', # RankMath
2060 'all-in-one-seo-pack/all_in_one_seo_pack.php', # AIOSEO Free
2061 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php', # AIOSEO Pro
2062 'seopress/seopress.php', # SEOPress
2063 'the-seo-framework/autodescription.php', # The SEO Framework
2064 ];
2065
2066 foreach ($seo_plugins as $plugin) {
2067 if (is_plugin_active($plugin)) {
2068 return true;
2069 }
2070 }
2071
2072 return false;
2073 }
2074
2075 /**
2076 * Check if a specific SEO plugin is handling Open Graph for current post
2077 */
2078 private function seo_plugin_has_og_data($post_id) {
2079 # Check if Yoast SEO has Open Graph data
2080 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
2081 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
2082 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
2083 if (!empty($yoast_title) || !empty($yoast_desc)) {
2084 return true;
2085 }
2086 }
2087
2088 # Check if RankMath has Open Graph data
2089 if (is_plugin_active('seo-by-rank-math/rank-math.php')) {
2090 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
2091 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
2092 if (!empty($rm_title) || !empty($rm_desc)) {
2093 return true;
2094 }
2095 }
2096
2097 return false;
2098 }
2099
2100 /**
2101 * Get the canonical URL for a post
2102 */
2103 public function get_canonical_url($post) {
2104 # Try to get the permalink using WordPress function
2105 $permalink = get_permalink($post->ID);
2106
2107 # If permalink is not available or is the default query URL, try alternative methods
2108 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
2109 # Force WordPress to generate the proper permalink by temporarily setting post status
2110 $original_status = $post->post_status;
2111 if ($post->post_status === 'auto-draft') {
2112 $post->post_status = 'publish';
2113 }
2114
2115 # Try get_permalink again with the updated status
2116 $permalink = get_permalink($post->ID);
2117
2118 # Restore original status
2119 $post->post_status = $original_status;
2120 }
2121
2122 # If still not working, use WordPress core functions to build proper permalink
2123 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
2124 # Use WordPress core function that respects permalink structure
2125 # This properly handles custom structures, hierarchies, and post types
2126 # Load admin function if not already available
2127 if (!function_exists('get_sample_permalink')) {
2128 require_once ABSPATH . 'wp-admin/includes/post.php';
2129 }
2130 $permalink = get_sample_permalink($post->ID);
2131
2132 if (is_array($permalink)) {
2133 # get_sample_permalink returns array with template and slug
2134 # Replace %postname% or %pagename% with actual slug
2135 $permalink = str_replace(
2136 array('%pagename%', '%postname%'),
2137 $post->post_name,
2138 $permalink[0]
2139 );
2140 }
2141
2142 # Final fallback: if still problematic, construct URL respecting post type structure
2143 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
2144 if (!empty($post->post_name)) {
2145 # For pages, check if there's a parent hierarchy
2146 if ($post->post_type === 'page' && $post->post_parent) {
2147 # Get parent page path for proper hierarchy
2148 $parent = get_post($post->post_parent);
2149 $parent_path = '';
2150
2151 # Build full path including all parent pages
2152 while ($parent) {
2153 $parent_path = $parent->post_name . '/' . $parent_path;
2154 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
2155 }
2156
2157 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
2158 } else {
2159 # For posts and pages without parents, use post type archive base
2160 $post_type_obj = get_post_type_object($post->post_type);
2161 $slug = $post_type_obj->rewrite['slug'] ?? '';
2162
2163 if ($slug && $post->post_type !== 'page') {
2164 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
2165 } else {
2166 $permalink = home_url('/' . $post->post_name . '/');
2167 }
2168 }
2169 } else {
2170 # Fallback to post ID format if no slug available
2171 $permalink = home_url('/?p=' . $post->ID);
2172 }
2173 }
2174 }
2175
2176 return $permalink;
2177 }
2178
2179 /**
2180 * Rehost a URL onto the public frontend when headless mode is active.
2181 *
2182 * Unlike the derived-URL callers elsewhere, the value handed here can be a
2183 * stored one an editor typed, which may legitimately point at a third-party
2184 * host. Only a URL on this site's own host is rewritten; anything else is
2185 * returned untouched. A builder failure is returned as an empty string so
2186 * the emitter cannot advertise the WordPress host on an active headless site.
2187 *
2188 * @param mixed $url URL to consider rehosting.
2189 * @param string $post_type Post type, for the frontend path prefix.
2190 * @return mixed Original URL when inactive or foreign; public URL or empty
2191 * string for an own-host URL while active.
2192 */
2193 private function maybe_build_headless_url($url, $post_type = '') {
2194 if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_active() || !$this->is_own_host_url($url)) {
2195 return $url;
2196 }
2197
2198 return Metasync_Headless_Url_Builder::from_wp_url($url, $post_type);
2199 }
2200
2201 /**
2202 * Whether a URL addresses this WordPress site rather than a foreign host.
2203 *
2204 * Accepts only HTTP(S) URLs on the exact site host, protocol-relative URLs
2205 * on that host, and genuine site-relative paths beginning with one slash.
2206 *
2207 * @param mixed $url URL to test.
2208 * @return bool
2209 */
2210 private function is_own_host_url($url) {
2211 if (!is_string($url) || $url === '' || preg_match('/\s/', $url)) {
2212 return false;
2213 }
2214
2215 if ($url[0] === '/' && (!isset($url[1]) || $url[1] !== '/')) {
2216 return true;
2217 }
2218
2219 $is_protocol_relative = strpos($url, '//') === 0;
2220 $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
2221 if (!$is_protocol_relative && !in_array($scheme, array('http', 'https'), true)) {
2222 return false;
2223 }
2224
2225 $url_host = wp_parse_url($url, PHP_URL_HOST);
2226 $site_host = wp_parse_url(home_url('/'), PHP_URL_HOST);
2227
2228 return !empty($url_host)
2229 && !empty($site_host)
2230 && strcasecmp((string) $url_host, (string) $site_host) === 0;
2231 }
2232
2233 /**
2234 * Update OpenGraph URL when post is saved
2235 */
2236 public function update_opengraph_url($post_id) {
2237 # Only update for supported post types
2238 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
2239 return;
2240 }
2241
2242 # Skip autosaves and revisions
2243 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
2244 return;
2245 }
2246
2247 if (wp_is_post_revision($post_id)) {
2248 return;
2249 }
2250
2251 # Get the post object
2252 $post = get_post($post_id);
2253 if (!$post) {
2254 return;
2255 }
2256
2257 # Check if OpenGraph is enabled
2258 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
2259 if (empty($og_enabled) || $og_enabled !== '1') {
2260 return;
2261 }
2262
2263 # Get the current OpenGraph URL
2264 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
2265
2266 # Generate the proper canonical URL
2267 $canonical_url = $this->get_canonical_url($post);
2268
2269 # Update the OpenGraph URL for new posts or if it's empty/incorrect
2270 # This ensures the URL is populated after first save (even as draft)
2271 if (empty($current_og_url) ||
2272 strpos($current_og_url, '?p=') !== false) {
2273
2274 update_post_meta($post_id, '_metasync_og_url', $canonical_url);
2275 }
2276 }
2277
2278 /**
2279 * Check if post permalink changed and update og:url if needed
2280 */
2281 public function check_permalink_change($post_id, $post_after, $post_before) {
2282 # Only check for supported post types
2283 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
2284 return;
2285 }
2286
2287 # Skip autosaves and revisions
2288 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
2289 return;
2290 }
2291
2292 if (wp_is_post_revision($post_id)) {
2293 return;
2294 }
2295
2296 # Check if OpenGraph is enabled
2297 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
2298 if (empty($og_enabled) || $og_enabled !== '1') {
2299 return;
2300 }
2301
2302 # Get current og:url
2303 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
2304 if (empty($current_og_url)) {
2305 return;
2306 }
2307
2308 # Check if the permalink actually changed by comparing post_name (slug)
2309 if ($post_before->post_name === $post_after->post_name) {
2310 return;
2311 }
2312
2313 # Generate the old and new permalinks
2314 $old_permalink = $this->get_canonical_url($post_before);
2315 $new_permalink = $this->get_canonical_url($post_after);
2316
2317 # If permalinks are the same, no need to update
2318 if ($old_permalink === $new_permalink) {
2319 return;
2320 }
2321
2322 # Check if the current og:url matches the old permalink
2323 # This means the og:url was set to the post permalink (not a custom URL)
2324 if ($current_og_url === $old_permalink) {
2325 # Update og:url to the new permalink
2326 update_post_meta($post_id, '_metasync_og_url', $new_permalink);
2327 }
2328 }
2329
2330 /**
2331 * Check if post status changed and update og:url if needed
2332 */
2333 public function check_status_change($new_status, $old_status, $post) {
2334 # Only check for supported post types
2335 if (!in_array(get_post_type($post->ID), $this->get_supported_post_types())) {
2336 return;
2337 }
2338
2339 # Skip autosaves and revisions
2340 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
2341 return;
2342 }
2343
2344 if (wp_is_post_revision($post->ID)) {
2345 return;
2346 }
2347
2348 # Only check when transitioning to published status
2349 if ($new_status !== 'publish' || $old_status === 'publish') {
2350 return;
2351 }
2352
2353 # Check if OpenGraph is enabled
2354 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
2355 if (empty($og_enabled) || $og_enabled !== '1') {
2356 return;
2357 }
2358
2359 # Get current og:url
2360 $current_og_url = get_post_meta($post->ID, '_metasync_og_url', true);
2361
2362 # Generate the current permalink
2363 $current_permalink = $this->get_canonical_url($post);
2364
2365 # If og:url is empty or matches the old format, update it
2366 if (empty($current_og_url) || strpos($current_og_url, '?p=') !== false) {
2367 update_post_meta($post->ID, '_metasync_og_url', $current_permalink);
2368 }
2369 }
2370
2371 /**
2372 * Check if post slug changed via edit slug functionality
2373 */
2374 public function check_slug_change() {
2375 # Get the post ID from the request
2376 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
2377 if (!$post_id) {
2378 return;
2379 }
2380
2381 # Only check for supported post types
2382 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
2383 return;
2384 }
2385
2386 # Check if OpenGraph is enabled
2387 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
2388 if (empty($og_enabled) || $og_enabled !== '1') {
2389 return;
2390 }
2391
2392 # Get current og:url
2393 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
2394 if (empty($current_og_url)) {
2395 return;
2396 }
2397
2398 # Get the post object
2399 $post = get_post($post_id);
2400 if (!$post) {
2401 return;
2402 }
2403
2404 # Generate the current permalink
2405 $current_permalink = $this->get_canonical_url($post);
2406
2407 # Check if the current og:url matches the old permalink format
2408 # This means the og:url was set to the post permalink (not a custom URL)
2409 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
2410 # Check if the og:url was the old permalink by comparing with a generated old permalink
2411 $old_post = clone $post;
2412 $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name;
2413
2414 # If the og:url doesn't match the current permalink, it might be the old one
2415 # We'll update it to the new permalink
2416 if ($current_og_url !== $current_permalink) {
2417 update_post_meta($post_id, '_metasync_og_url', $current_permalink);
2418 }
2419 }
2420 }
2421 }
2422