PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-opengraph.php

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

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