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

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

1,441 lines 59.9 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 * Meta box ID
36 */
37 const META_BOX_ID = 'metasync_opengraph_meta_box';
38
39 /**
40 * Initialize the class and set its properties.
41 */
42 public function __construct($plugin_name, $version) {
43 $this->plugin_name = $plugin_name;
44 $this->version = $version;
45 }
46
47 /**
48 * Register all hooks for this class
49 */
50 public function init() {
51 # Admin hooks
52 add_action('add_meta_boxes', [$this, 'add_meta_box']);
53 add_action('save_post', [$this, 'save_meta_box_data']);
54 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
55
56 # Alternative script loading for post edit screens
57 add_action('admin_print_scripts-post.php', [$this, 'force_enqueue_scripts']);
58 add_action('admin_print_scripts-post-new.php', [$this, 'force_enqueue_scripts']);
59
60 # Frontend hooks
61 add_action('wp_head', [$this, 'output_opengraph_tags'], 5);
62 add_action('wp_head', [$this, 'output_article_tags'], 6);
63
64 # Update OpenGraph URL when post is published/updated
65 add_action('save_post', [$this, 'update_opengraph_url'], 20);
66
67 # Update OpenGraph URL when post permalink changes
68 add_action('post_updated', [$this, 'check_permalink_change'], 10, 3);
69
70 # Also check on transition_post_status for status changes
71 add_action('transition_post_status', [$this, 'check_status_change'], 10, 3);
72
73 # Check when post slug is updated via edit slug functionality
74 add_action('wp_ajax_sample-permalink', [$this, 'check_slug_change'], 5);
75
76 # AJAX hooks for preview
77 add_action('wp_ajax_metasync_og_preview', [$this, 'ajax_generate_preview']);
78
79 # Register cross-plugin dedup filters (Yoast / Rank Math) when their plugins are active
80 $this->register_dedup_filters();
81
82 # Shared predicate so the legacy emitter (Metasync_Seo_Output::hook_metasync_metatags)
83 # can suppress its own OG/Twitter blocks whenever this class will emit for the post
84 add_filter('metasync_opengraph_will_emit', [$this, 'will_emit']);
85 }
86
87 /**
88 * Shared predicate: returns true when output_opengraph_tags() will emit
89 * the consolidated OG/Twitter block for the current request.
90 *
91 * Mirrors the early-return guards in output_opengraph_tags() so this
92 * canonical emitter and the legacy emitter stay mutually exclusive —
93 * exactly one fires per page, and neither stays silent on a
94 * MetaSync-only site (WP-411).
95 *
96 * @param bool $default Filter default (ignored; the real answer is computed).
97 * @return bool
98 */
99 public function will_emit($default = false) {
100 if (!is_singular($this->get_supported_post_types())) {
101 return false;
102 }
103
104 global $post;
105 if (!$post instanceof WP_Post) {
106 return false;
107 }
108
109 # Only an explicit '0' opt-out disables output; unset/empty counts as enabled
110 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
111 if ($og_enabled === '0') {
112 return false;
113 }
114
115 # OTTO active with persisted OG data owns the page (legacy emitter suppresses too)
116 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_otto_enabled()) {
117 $otto_og_title = get_post_meta($post->ID, '_metasync_otto_og_title', true);
118 $otto_og_desc = get_post_meta($post->ID, '_metasync_otto_og_description', true);
119 if (!empty($otto_og_title) || !empty($otto_og_desc)) {
120 return false;
121 }
122 }
123
124 # Third-party SEO plugin active: yield entirely, legacy emitter keeps its original behavior
125 if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
126 return false;
127 }
128
129 return true;
130 }
131
132 /**
133 * Add the Open Graph meta box to post and page editors
134 */
135 public function add_meta_box() {
136 # Don't show meta box if user's role doesn't have plugin access
137 if (!Metasync::current_user_has_plugin_access()) {
138 return;
139 }
140
141 # Check if user has permission to edit posts
142 if (!current_user_can('edit_posts')) {
143 return;
144 }
145
146 # Meta title and description are always enabled by default
147 $general_settings = Metasync::get_option('general', []);
148
149 # Check if Social Media & Open Graph meta box is disabled
150 if (!empty($general_settings['disable_social_opengraph_metabox'])) {
151 return;
152 }
153
154 # Get supported post types (allow filtering)
155 $post_types = $this->get_supported_post_types();
156 $plugin_name = Metasync::get_effective_plugin_name();
157
158 foreach ($post_types as $post_type) {
159 add_meta_box(
160 self::META_BOX_ID,
161 sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name),
162 [$this, 'render_meta_box'],
163 $post_type,
164 'normal',
165 'high'
166 );
167 }
168 }
169
170 /**
171 * Render the meta box content
172 */
173 public function render_meta_box($post) {
174 # Add nonce for security
175 wp_nonce_field('metasync_opengraph_nonce', 'metasync_opengraph_nonce');
176
177 # Get existing values
178 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
179 $og_title = get_post_meta($post->ID, '_metasync_og_title', true);
180 $og_description = get_post_meta($post->ID, '_metasync_og_description', true);
181 $og_image = get_post_meta($post->ID, '_metasync_og_image', true);
182 $og_url = get_post_meta($post->ID, '_metasync_og_url', true);
183 $og_type = get_post_meta($post->ID, '_metasync_og_type', true);
184
185 # Twitter Card fields
186 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true);
187 $twitter_site = get_post_meta($post->ID, '_metasync_twitter_site', true);
188 $twitter_title = get_post_meta($post->ID, '_metasync_twitter_title', true);
189 $twitter_description = get_post_meta($post->ID, '_metasync_twitter_description', true);
190 $twitter_image = get_post_meta($post->ID, '_metasync_twitter_image', true);
191 $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true);
192
193 # Twitter App Card fields
194 $twitter_app_id_iphone = get_post_meta($post->ID, '_metasync_twitter_app_id_iphone', true);
195 $twitter_app_id_ipad = get_post_meta($post->ID, '_metasync_twitter_app_id_ipad', true);
196 $twitter_app_id_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_id_googleplay', true);
197 $twitter_app_url_iphone = get_post_meta($post->ID, '_metasync_twitter_app_url_iphone', true);
198 $twitter_app_url_ipad = get_post_meta($post->ID, '_metasync_twitter_app_url_ipad', true);
199 $twitter_app_url_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_url_googleplay', true);
200 $twitter_app_country = get_post_meta($post->ID, '_metasync_twitter_app_country', true);
201
202 # Twitter Player Card fields
203 $twitter_player = get_post_meta($post->ID, '_metasync_twitter_player', true);
204 $twitter_player_width = get_post_meta($post->ID, '_metasync_twitter_player_width', true);
205 $twitter_player_height = get_post_meta($post->ID, '_metasync_twitter_player_height', true);
206
207 # Set default values
208 # Note: Check for empty string specifically, not just empty(), since '0' is a valid value
209 if ($og_enabled === '') {
210 # For new posts, default to enabled
211 $og_enabled = '1';
212 }
213 if (empty($og_title)) {
214 $og_title = $post->post_title;
215 }
216 if (empty($og_description)) {
217 $og_description = $this->get_post_excerpt($post);
218 }
219 if (empty($og_url)) {
220 $og_url = $this->get_canonical_url($post);
221 }
222 if (empty($og_type)) {
223 $og_type = 'article';
224 }
225 if (empty($og_image)) {
226 $og_image = $this->get_featured_image_url($post->ID);
227 }
228
229 # Twitter defaults
230 if (empty($twitter_card)) {
231 $twitter_card = 'summary_large_image';
232 }
233 if (empty($twitter_title)) {
234 $twitter_title = $og_title;
235 }
236 if (empty($twitter_description)) {
237 $twitter_description = $og_description;
238 }
239 if (empty($twitter_image)) {
240 $twitter_image = $og_image;
241 }
242
243 # Include the meta box template
244 include plugin_dir_path(__FILE__) . '../admin/partials/metasync-opengraph-meta-box.php';
245 }
246
247 /**
248 * Save meta box data
249 */
250 public function save_meta_box_data($post_id) {
251 # Check if nonce is valid
252 if (!isset($_POST['metasync_opengraph_nonce']) ||
253 !wp_verify_nonce($_POST['metasync_opengraph_nonce'], 'metasync_opengraph_nonce')) {
254 return;
255 }
256
257 # Check if user has permission to edit
258 if (!current_user_can('edit_post', $post_id)) {
259 return;
260 }
261
262 # Check if this is an autosave
263 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
264 return;
265 }
266
267 # Handle the checkbox field separately (unchecked checkboxes don't send POST data)
268 # Meta title and description are always enabled by default
269 if (isset($_POST['_metasync_og_enabled'])) {
270 # User checked the box
271 update_post_meta($post_id, '_metasync_og_enabled', '1');
272 } else {
273 # User unchecked the box
274 update_post_meta($post_id, '_metasync_og_enabled', '0');
275 }
276
277 # Save Open Graph data (excluding the enabled field which is handled above)
278 $og_fields = [
279 '_metasync_og_title' => 'sanitize_text_field',
280 '_metasync_og_description' => 'sanitize_textarea_field',
281 '_metasync_og_image' => 'esc_url_raw',
282 '_metasync_og_url' => 'esc_url_raw',
283 '_metasync_og_type' => 'sanitize_text_field',
284 ];
285
286 # Save Twitter Card data
287 $twitter_fields = [
288 '_metasync_twitter_card' => 'sanitize_text_field',
289 '_metasync_twitter_site' => 'sanitize_text_field',
290 '_metasync_twitter_title' => 'sanitize_text_field',
291 '_metasync_twitter_description' => 'sanitize_textarea_field',
292 '_metasync_twitter_image' => 'esc_url_raw',
293 '_metasync_twitter_image_alt' => 'sanitize_text_field',
294 ];
295
296 # Save Twitter App Card data
297 $twitter_app_fields = [
298 '_metasync_twitter_app_id_iphone' => 'sanitize_text_field',
299 '_metasync_twitter_app_id_ipad' => 'sanitize_text_field',
300 '_metasync_twitter_app_id_googleplay' => 'sanitize_text_field',
301 '_metasync_twitter_app_url_iphone' => 'esc_url_raw',
302 '_metasync_twitter_app_url_ipad' => 'esc_url_raw',
303 '_metasync_twitter_app_url_googleplay' => 'esc_url_raw',
304 '_metasync_twitter_app_country' => 'sanitize_text_field',
305 ];
306
307 # Save Twitter Player Card data
308 $twitter_player_fields = [
309 '_metasync_twitter_player' => 'esc_url_raw',
310 '_metasync_twitter_player_width' => 'absint',
311 '_metasync_twitter_player_height' => 'absint',
312 ];
313
314 $all_fields = array_merge($og_fields, $twitter_fields, $twitter_app_fields, $twitter_player_fields);
315
316 foreach ($all_fields as $field => $sanitize_callback) {
317 if (isset($_POST[$field])) {
318 $value = call_user_func($sanitize_callback, $_POST[$field]);
319 update_post_meta($post_id, $field, $value);
320 }
321 }
322 }
323
324 /**
325 * Enqueue admin scripts and styles
326 */
327 public function enqueue_admin_scripts($hook) {
328 global $post_type;
329
330 # Only load on post edit screens for supported post types
331 if (!in_array($hook, ['post.php', 'post-new.php']) ||
332 !in_array($post_type, $this->get_supported_post_types())) {
333 return;
334 }
335
336 wp_enqueue_media();
337
338 wp_enqueue_script(
339 'metasync-opengraph-admin',
340 plugin_dir_url(__FILE__) . '../admin/js/metasync-opengraph.js',
341 ['jquery', 'wp-util'],
342 $this->version,
343 true
344 );
345
346 wp_enqueue_style(
347 'metasync-opengraph-admin',
348 plugin_dir_url(__FILE__) . '../admin/css/metasync-opengraph.css',
349 [],
350 $this->version
351 );
352
353 # Get the current post permalink for preview
354 global $post;
355 $current_permalink = '';
356 if ($post && $post->ID) {
357 $current_permalink = $this->get_canonical_url($post);
358 }
359
360 # Localize script for AJAX
361 wp_localize_script('metasync-opengraph-admin', 'metasync_og', [
362 'ajax_url' => admin_url('admin-ajax.php'),
363 'nonce' => wp_create_nonce('metasync_og_preview_nonce'),
364 'current_permalink' => $current_permalink,
365 'strings' => [
366 'select_image' => esc_html__('Select Image', 'metasync'),
367 'use_image' => esc_html__('Use This Image', 'metasync'),
368 'remove_image' => esc_html__('Remove Image', 'metasync'),
369 ]
370 ]);
371 }
372
373 /**
374 * Force enqueue scripts for post edit screens (backup method)
375 */
376 public function force_enqueue_scripts() {
377 global $post_type;
378
379 if (!in_array($post_type, $this->get_supported_post_types())) {
380 return;
381 }
382
383 # Check if already enqueued
384 if (wp_script_is('metasync-opengraph-admin', 'enqueued')) {
385 return;
386 }
387
388 wp_enqueue_media();
389 wp_enqueue_script(
390 'metasync-opengraph-admin',
391 plugin_dir_url(__FILE__) . '../admin/js/metasync-opengraph.js',
392 ['jquery', 'wp-util'],
393 $this->version,
394 true
395 );
396
397 wp_enqueue_style(
398 'metasync-opengraph-admin',
399 plugin_dir_url(__FILE__) . '../admin/css/metasync-opengraph.css',
400 [],
401 $this->version
402 );
403
404 # Get the current post permalink for preview
405 global $post;
406 $current_permalink = '';
407 if ($post && $post->ID) {
408 $current_permalink = $this->get_canonical_url($post);
409 }
410
411 wp_localize_script('metasync-opengraph-admin', 'metasync_og', [
412 'ajax_url' => admin_url('admin-ajax.php'),
413 'nonce' => wp_create_nonce('metasync_og_preview_nonce'),
414 'current_permalink' => $current_permalink,
415 'strings' => [
416 'select_image' => esc_html__('Select Image', 'metasync'),
417 'use_image' => esc_html__('Use This Image', 'metasync'),
418 'remove_image' => esc_html__('Remove Image', 'metasync'),
419 ]
420 ]);
421 }
422
423 /**
424 * Output Open Graph and Twitter Card tags in wp_head
425 */
426 public function output_opengraph_tags() {
427 if (!is_singular($this->get_supported_post_types())) {
428 return;
429 }
430
431 global $post;
432
433 # Ensure post is a valid object
434 if (!$post instanceof WP_Post) {
435 return;
436 }
437
438 # Check if Open Graph is enabled for this post.
439 # Only an explicit '0' opt-out suppresses output; unset/empty counts as enabled
440 # so a MetaSync-only site gets one consolidated set whether or not the meta
441 # box was ever saved (WP-411). Must stay in sync with will_emit().
442 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
443 if ($og_enabled === '0') {
444 return;
445 }
446
447 # When OTTO is active AND has OG data for this post, skip legacy OG output.
448 # For cases where OTTO's pixel injects OG tags dynamically (without
449 # persisting to _metasync_otto_og_* meta), the buffer-level dedup in
450 # Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
451 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_otto_enabled()) {
452 $otto_og_title = get_post_meta($post->ID, '_metasync_otto_og_title', true);
453 $otto_og_desc = get_post_meta($post->ID, '_metasync_otto_og_description', true);
454 if (!empty($otto_og_title) || !empty($otto_og_desc)) {
455 return;
456 }
457 }
458
459 # Check for conflicts with other SEO plugins (allow override via filter)
460 if (apply_filters('metasync_opengraph_check_conflicts', true) && $this->has_seo_plugin_conflicts()) {
461 return;
462 }
463
464 # Get Open Graph data — check persisted key first, fall back to OTTO staging key, then post default
465 $og_title = get_post_meta($post->ID, '_metasync_og_title', true)
466 ?: get_post_meta($post->ID, '_metasync_otto_og_title', true)
467 ?: $post->post_title;
468 $og_description = get_post_meta($post->ID, '_metasync_og_description', true)
469 ?: get_post_meta($post->ID, '_metasync_otto_og_description', true)
470 ?: $this->get_post_excerpt($post);
471 $og_image = get_post_meta($post->ID, '_metasync_og_image', true) ?: $this->get_featured_image_url($post->ID);
472 $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post);
473 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
474
475 # Get Twitter Card data — check persisted key first, fall back to OTTO staging key
476 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image';
477 $twitter_site = get_post_meta($post->ID, '_metasync_twitter_site', true);
478
479 # Fall back to the site-wide Twitter username (Social Meta settings) so the
480 # twitter:site / twitter:creator tags the legacy emitter produced are not lost
481 # now that this emitter is the single canonical OG/Twitter source (WP-411)
482 $twitter_username = Metasync::get_option('social_meta')['twitter_username'] ?? '';
483 if (empty($twitter_site) && !empty($twitter_username)) {
484 $twitter_site = '@' . $twitter_username;
485 }
486 $twitter_creator = !empty($twitter_username) ? '@' . $twitter_username : '';
487 $twitter_title = get_post_meta($post->ID, '_metasync_twitter_title', true)
488 ?: get_post_meta($post->ID, '_metasync_otto_twitter_title', true)
489 ?: $og_title;
490 $twitter_description = get_post_meta($post->ID, '_metasync_twitter_description', true)
491 ?: get_post_meta($post->ID, '_metasync_otto_twitter_description', true)
492 ?: $og_description;
493 $twitter_image = get_post_meta($post->ID, '_metasync_twitter_image', true) ?: $og_image;
494 $twitter_image_alt = get_post_meta($post->ID, '_metasync_twitter_image_alt', true);
495
496 # Resolve OG image attachment ID once for reuse (twitter:image:alt fallback + og:image dimensions)
497 $og_image_attachment_id = 0;
498 if (!empty($og_image)) {
499 $og_image_attachment_id = attachment_url_to_postid($og_image);
500 }
501
502 # Fall back to the OG image's WP attachment alt text when no explicit twitter:image:alt is set
503 if (empty($twitter_image_alt) && $og_image_attachment_id > 0) {
504 $attachment_alt = get_post_meta($og_image_attachment_id, '_wp_attachment_image_alt', true);
505 if (!empty($attachment_alt)) {
506 $twitter_image_alt = $attachment_alt;
507 }
508 }
509
510 # Per-field toggles from common_meta_settings (default enabled when unset)
511 $common_meta_settings = Metasync::get_option('common_meta_settings');
512 if (!is_array($common_meta_settings)) {
513 $common_meta_settings = [];
514 }
515 $og_image_dimensions_enabled = ($common_meta_settings['og_image_dimensions'] ?? 'true') !== 'false';
516 $twitter_image_alt_enabled = ($common_meta_settings['twitter_image_alt'] ?? 'true') !== 'false';
517
518 # Get Twitter App Card data
519 $twitter_app_id_iphone = get_post_meta($post->ID, '_metasync_twitter_app_id_iphone', true);
520 $twitter_app_id_ipad = get_post_meta($post->ID, '_metasync_twitter_app_id_ipad', true);
521 $twitter_app_id_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_id_googleplay', true);
522 $twitter_app_url_iphone = get_post_meta($post->ID, '_metasync_twitter_app_url_iphone', true);
523 $twitter_app_url_ipad = get_post_meta($post->ID, '_metasync_twitter_app_url_ipad', true);
524 $twitter_app_url_googleplay = get_post_meta($post->ID, '_metasync_twitter_app_url_googleplay', true);
525 $twitter_app_country = get_post_meta($post->ID, '_metasync_twitter_app_country', true);
526
527 # Get Twitter Player Card data
528 $twitter_player = get_post_meta($post->ID, '_metasync_twitter_player', true);
529 $twitter_player_width = get_post_meta($post->ID, '_metasync_twitter_player_width', true);
530 $twitter_player_height = get_post_meta($post->ID, '_metasync_twitter_player_height', true);
531
532 # Output Open Graph tags
533 echo "\n<!-- MetaSync Open Graph Tags -->\n";
534 echo '<meta property="og:locale" content="' . esc_attr(get_locale()) . '">' . "\n";
535 if ($og_title) {
536 echo '<meta property="og:title" content="' . esc_attr($og_title) . '">' . "\n";
537 }
538 if ($og_description) {
539 echo '<meta property="og:description" content="' . esc_attr($og_description) . '">' . "\n";
540 }
541 if ($og_image) {
542 echo '<meta property="og:image" content="' . esc_url($og_image) . '">' . "\n";
543
544 if ($og_image_dimensions_enabled) {
545 $og_image_dims = $this->get_og_image_dimensions($og_image, $og_image_attachment_id);
546 if (is_array($og_image_dims)) {
547 if (!empty($og_image_dims['width'])) {
548 echo '<meta property="og:image:width" content="' . esc_attr((string) $og_image_dims['width']) . '">' . "\n";
549 }
550 if (!empty($og_image_dims['height'])) {
551 echo '<meta property="og:image:height" content="' . esc_attr((string) $og_image_dims['height']) . '">' . "\n";
552 }
553 if (!empty($og_image_dims['mime'])) {
554 echo '<meta property="og:image:type" content="' . esc_attr($og_image_dims['mime']) . '">' . "\n";
555 }
556 }
557 }
558 }
559 if ($og_url) {
560 echo '<meta property="og:url" content="' . esc_url($og_url) . '">' . "\n";
561 }
562 if ($og_type) {
563 echo '<meta property="og:type" content="' . esc_attr($og_type) . '">' . "\n";
564 }
565 $site_name = get_bloginfo('name');
566 if ($site_name) {
567 echo '<meta property="og:site_name" content="' . esc_attr($site_name) . '">' . "\n";
568 }
569 if (!empty($post->post_modified)) {
570 echo '<meta property="og:updated_time" content="' . esc_attr($post->post_modified) . '">' . "\n";
571 }
572
573 # Output Twitter Card tags
574 echo "<!-- MetaSync Twitter Card Tags -->\n";
575 if ($twitter_card) {
576 echo '<meta name="twitter:card" content="' . esc_attr($twitter_card) . '">' . "\n";
577 }
578 if ($twitter_site) {
579 echo '<meta name="twitter:site" content="' . esc_attr($twitter_site) . '">' . "\n";
580 }
581 if ($twitter_creator) {
582 echo '<meta name="twitter:creator" content="' . esc_attr($twitter_creator) . '">' . "\n";
583 }
584 if ($twitter_title) {
585 echo '<meta name="twitter:title" content="' . esc_attr($twitter_title) . '">' . "\n";
586 }
587 if ($twitter_description) {
588 echo '<meta name="twitter:description" content="' . esc_attr($twitter_description) . '">' . "\n";
589 }
590 if ($twitter_image) {
591 echo '<meta name="twitter:image" content="' . esc_url($twitter_image) . '">' . "\n";
592 }
593 if ($twitter_image_alt && $twitter_image_alt_enabled) {
594 echo '<meta name="twitter:image:alt" content="' . esc_attr($twitter_image_alt) . '">' . "\n";
595 }
596
597 # Output Twitter App Card tags (only if card type is 'app')
598 if ($twitter_card === 'app') {
599 if ($twitter_app_id_iphone) {
600 echo '<meta name="twitter:app:id:iphone" content="' . esc_attr($twitter_app_id_iphone) . '">' . "\n";
601 }
602 if ($twitter_app_id_ipad) {
603 echo '<meta name="twitter:app:id:ipad" content="' . esc_attr($twitter_app_id_ipad) . '">' . "\n";
604 }
605 if ($twitter_app_id_googleplay) {
606 echo '<meta name="twitter:app:id:googleplay" content="' . esc_attr($twitter_app_id_googleplay) . '">' . "\n";
607 }
608 if ($twitter_app_url_iphone) {
609 echo '<meta name="twitter:app:url:iphone" content="' . esc_url($twitter_app_url_iphone) . '">' . "\n";
610 }
611 if ($twitter_app_url_ipad) {
612 echo '<meta name="twitter:app:url:ipad" content="' . esc_url($twitter_app_url_ipad) . '">' . "\n";
613 }
614 if ($twitter_app_url_googleplay) {
615 echo '<meta name="twitter:app:url:googleplay" content="' . esc_url($twitter_app_url_googleplay) . '">' . "\n";
616 }
617 if ($twitter_app_country) {
618 echo '<meta name="twitter:app:country" content="' . esc_attr($twitter_app_country) . '">' . "\n";
619 }
620 }
621
622 # Output Twitter Player Card tags (only if card type is 'player')
623 if ($twitter_card === 'player') {
624 if ($twitter_player) {
625 echo '<meta name="twitter:player" content="' . esc_url($twitter_player) . '">' . "\n";
626 }
627 if ($twitter_player_width) {
628 echo '<meta name="twitter:player:width" content="' . esc_attr($twitter_player_width) . '">' . "\n";
629 }
630 if ($twitter_player_height) {
631 echo '<meta name="twitter:player:height" content="' . esc_attr($twitter_player_height) . '">' . "\n";
632 }
633 }
634
635 echo "<!-- End MetaSync Social Media Tags -->\n\n";
636 }
637
638 /**
639 * Resolve OG image dimensions + MIME without making remote HTTP calls.
640 *
641 * Returns an array with 'width', 'height', and 'mime' when available,
642 * or null when no dimensions are known. For WP-hosted attachments the
643 * data comes from attachment metadata. For external URLs we only read
644 * a pre-seeded transient (metasync_og_img_dims_{md5(url)}).
645 *
646 * @param string $url
647 * @param int $attachment_id Pre-resolved attachment ID (0 = auto-detect).
648 * @return array|null
649 */
650 private function get_og_image_dimensions($url, $attachment_id = 0) {
651 if (empty($url) || !is_string($url)) {
652 return null;
653 }
654
655 if ($attachment_id <= 0) {
656 $attachment_id = attachment_url_to_postid($url);
657 }
658 if ($attachment_id > 0) {
659 $meta = wp_get_attachment_metadata($attachment_id);
660 $width = isset($meta['width']) ? (int) $meta['width'] : 0;
661 $height = isset($meta['height']) ? (int) $meta['height'] : 0;
662 $mime = get_post_mime_type($attachment_id) ?: '';
663 if ($width > 0 || $height > 0 || $mime !== '') {
664 return [
665 'width' => $width,
666 'height' => $height,
667 'mime' => $mime,
668 ];
669 }
670 return null;
671 }
672
673 # External URLs: only read the pre-seeded transient, never make remote HTTP calls here.
674 $cached = get_transient('metasync_og_img_dims_' . md5($url));
675 if (is_array($cached)) {
676 return [
677 'width' => isset($cached['width']) ? (int) $cached['width'] : 0,
678 'height' => isset($cached['height']) ? (int) $cached['height'] : 0,
679 'mime' => isset($cached['mime']) ? (string) $cached['mime'] : '',
680 ];
681 }
682
683 return null;
684 }
685
686 /**
687 * Output article:* Open Graph tags for article-type singular views.
688 *
689 * Runs independently of has_seo_plugin_conflicts() so we can still emit
690 * complete article metadata while other SEO plugins handle og:title/description.
691 * Cross-plugin dedup is handled via register_dedup_filters() instead.
692 */
693 public function output_article_tags() {
694 if (!is_singular()) {
695 return;
696 }
697
698 global $post;
699 if (!$post instanceof WP_Post) {
700 return;
701 }
702
703 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
704 if ($og_type !== 'article') {
705 return;
706 }
707
708 $article_post_types = apply_filters('metasync_og_article_post_types', ['post']);
709 if (!is_array($article_post_types) || !in_array($post->post_type, $article_post_types, true)) {
710 return;
711 }
712
713 $settings = Metasync::get_option('common_meta_settings');
714 if (!is_array($settings)) {
715 $settings = [];
716 }
717
718 $article_timestamps_enabled = ($settings['article_timestamps'] ?? 'true') !== 'false';
719 $article_author_enabled = ($settings['article_author'] ?? 'true') !== 'false';
720 $article_section_enabled = ($settings['article_section'] ?? 'true') !== 'false';
721 $article_tags_enabled = ($settings['article_tags'] ?? 'true') !== 'false';
722
723 echo "<!-- MetaSync Article Tags -->\n";
724
725 # article:published_time / article:modified_time
726 if ($article_timestamps_enabled) {
727 if (!empty($post->post_date_gmt) && $post->post_date_gmt !== '0000-00-00 00:00:00') {
728 $published_ts = strtotime($post->post_date_gmt);
729 if ($published_ts) {
730 echo '<meta property="article:published_time" content="' . esc_attr(gmdate('c', $published_ts)) . '">' . "\n";
731 }
732 }
733 if (!empty($post->post_modified_gmt) && $post->post_modified_gmt !== '0000-00-00 00:00:00') {
734 $modified_ts = strtotime($post->post_modified_gmt);
735 if ($modified_ts) {
736 echo '<meta property="article:modified_time" content="' . esc_attr(gmdate('c', $modified_ts)) . '">' . "\n";
737 }
738 }
739 }
740
741 # article:author
742 if ($article_author_enabled) {
743 $author_url = get_post_meta($post->ID, '_metasync_og_article_author', true);
744 if (empty($author_url)) {
745 $author_url = get_the_author_meta('url', $post->post_author);
746 }
747 if (empty($author_url)) {
748 $author_url = get_author_posts_url($post->post_author);
749 }
750 if (!empty($author_url)) {
751 echo '<meta property="article:author" content="' . esc_url($author_url) . '">' . "\n";
752 }
753 }
754
755 # article:section – prefer explicit primary category, fall back to first category
756 if ($article_section_enabled) {
757 $section_name = '';
758 $primary_category_id = (int) get_post_meta($post->ID, '_metasync_primary_category', true);
759 if ($primary_category_id > 0) {
760 $category = get_category($primary_category_id);
761 if ($category && !is_wp_error($category) && !empty($category->name)) {
762 $section_name = $category->name;
763 }
764 }
765 if (empty($section_name)) {
766 $categories = get_the_category($post->ID);
767 if (!empty($categories) && isset($categories[0]->name)) {
768 $section_name = $categories[0]->name;
769 }
770 }
771 if (!empty($section_name)) {
772 echo '<meta property="article:section" content="' . esc_attr($section_name) . '">' . "\n";
773 }
774 }
775
776 # article:tag – one tag per WP post tag
777 if ($article_tags_enabled) {
778 $post_tags = get_the_tags($post->ID);
779 if (!empty($post_tags) && !is_wp_error($post_tags)) {
780 foreach ($post_tags as $tag) {
781 if (!empty($tag->name)) {
782 echo '<meta property="article:tag" content="' . esc_attr($tag->name) . '">' . "\n";
783 }
784 }
785 }
786 }
787
788 echo "<!-- End MetaSync Article Tags -->\n";
789 }
790
791 /**
792 * Register cross-plugin dedup filters so Yoast / Rank Math don't double-emit
793 * article:* tags alongside our own output.
794 */
795 private function register_dedup_filters() {
796 # Ensure is_plugin_active() is available on the frontend too.
797 if (!function_exists('is_plugin_active')) {
798 require_once ABSPATH . 'wp-admin/includes/plugin.php';
799 }
800
801 $settings = Metasync::get_option('common_meta_settings');
802 if (!is_array($settings)) {
803 $settings = [];
804 }
805
806 $yoast_active = is_plugin_active('wordpress-seo/wp-seo.php')
807 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
808 $rank_math_active = is_plugin_active('seo-by-rank-math/rank-math.php');
809
810 $article_timestamps_enabled = ($settings['article_timestamps'] ?? 'true') !== 'false';
811 $article_author_enabled = ($settings['article_author'] ?? 'true') !== 'false';
812 $article_section_enabled = ($settings['article_section'] ?? 'true') !== 'false';
813 $article_tags_enabled = ($settings['article_tags'] ?? 'true') !== 'false';
814
815 # Yoast: remove individual presenters based on which MetaSync features are enabled
816 if ($yoast_active && ($article_timestamps_enabled || $article_author_enabled)) {
817 add_filter('wpseo_frontend_presenters', function( $presenters ) use ( $article_timestamps_enabled, $article_author_enabled ) {
818 foreach ( $presenters as $key => $presenter ) {
819 if ( $article_timestamps_enabled && (
820 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Published_Time_Presenter ||
821 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Modified_Time_Presenter
822 )) {
823 unset( $presenters[ $key ] );
824 }
825 if ( $article_author_enabled &&
826 $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Author_Presenter ) {
827 unset( $presenters[ $key ] );
828 }
829 }
830 return array_values( $presenters );
831 }, 999 );
832 }
833
834 # Rank Math: suppress individual article:* tags via content filters.
835 # Rank Math's tag() method passes content through rank_math/opengraph/facebook/{property}
836 # where {property} is the OG property with colons replaced by underscores.
837 # Returning false causes tag() to skip output (empty($content) check).
838 if ($rank_math_active) {
839 if ($article_timestamps_enabled) {
840 add_filter('rank_math/opengraph/facebook/article_published_time', '__return_false', 999);
841 add_filter('rank_math/opengraph/facebook/article_modified_time', '__return_false', 999);
842 }
843 if ($article_tags_enabled) {
844 add_filter('rank_math/opengraph/facebook/article_tag', '__return_false', 999);
845 }
846 if ($article_author_enabled) {
847 add_filter('rank_math/opengraph/facebook/article_author', '__return_false', 999);
848 }
849 if ($article_section_enabled) {
850 add_filter('rank_math/opengraph/facebook/article_section', '__return_false', 999);
851 }
852 }
853 }
854
855 /**
856 * AJAX handler for generating social media preview
857 */
858 public function ajax_generate_preview() {
859
860 try {
861 # Check nonce
862 if (!check_ajax_referer('metasync_og_preview_nonce', 'nonce', false)) {
863 wp_send_json_error(['message' => 'Security check failed']);
864 return;
865 }
866
867 # Get and sanitize data
868 $title = sanitize_text_field($_POST['title'] ?? '');
869 $description = sanitize_textarea_field($_POST['description'] ?? '');
870 $image = esc_url_raw($_POST['image'] ?? '');
871 $url = esc_url_raw($_POST['url'] ?? '');
872
873 # Get Twitter Card data
874 $twitter_title = sanitize_text_field($_POST['twitter_title'] ?? '');
875 $twitter_description = sanitize_textarea_field($_POST['twitter_description'] ?? '');
876 $twitter_image = esc_url_raw($_POST['twitter_image'] ?? '');
877
878 # Generate preview HTML
879 $preview_html = $this->generate_preview_html($title, $description, $image, $url, $twitter_title, $twitter_description, $twitter_image);
880
881 if (empty($preview_html)) {
882 wp_send_json_error(['message' => 'Failed to generate preview HTML']);
883 return;
884 }
885
886 wp_send_json_success(['preview' => $preview_html]);
887
888 } catch (Exception $e) {
889 wp_send_json_error(['message' => 'Server error: ' . $e->getMessage()]);
890 }
891 }
892
893 /**
894 * Generate HTML for social media preview
895 */
896 private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') {
897 # Parse domain from URL
898 $domain = '';
899 if (!empty($url)) {
900 $parsed = parse_url($url);
901 $domain = $parsed['host'] ?? '';
902 }
903
904 # Fallback to site URL if no domain found
905 if (empty($domain)) {
906 $site_url = get_site_url();
907 $parsed = parse_url($site_url);
908 $domain = $parsed['host'] ?? 'your-site.com';
909 }
910
911 # Provide fallbacks for empty values
912 if (empty($title)) {
913 $title = 'Your Post Title';
914 }
915 if (empty($description)) {
916 $description = 'Your post description will appear here when shared on social media platforms.';
917 }
918
919 # Use Twitter Card data for Twitter preview, fallback to Open Graph
920 $twitter_display_title = !empty($twitter_title) ? $twitter_title : $title;
921 $twitter_display_description = !empty($twitter_description) ? $twitter_description : $description;
922 $twitter_display_image = !empty($twitter_image) ? $twitter_image : $image;
923
924 # Get site name for avatars
925 $site_name = get_bloginfo('name') ?: 'Your Site';
926 $site_initial = strtoupper(substr($site_name, 0, 1));
927
928 ob_start();
929 ?>
930 <div class="metasync-preview-tabs">
931 <button class="metasync-preview-tab facebook active" data-platform="facebook">
932 Facebook
933 </button>
934 <button class="metasync-preview-tab twitter" data-platform="twitter">
935 Twitter/X
936 </button>
937 <button class="metasync-preview-tab linkedin" data-platform="linkedin">
938 LinkedIn
939 </button>
940 </div>
941
942 <div class="metasync-preview-content">
943 <!-- Facebook Preview -->
944 <div class="metasync-preview-panel facebook active" data-platform="facebook">
945 <div class="facebook-preview">
946 <div class="facebook-post-header">
947 <div class="facebook-avatar"><?php echo esc_html($site_initial); ?></div>
948 <div class="facebook-post-info">
949 <h4><?php echo esc_html($site_name); ?></h4>
950 <p>2 hours ago 🌍</p>
951 </div>
952 </div>
953 <div class="facebook-link-preview">
954 <?php if (!empty($image)): ?>
955 <div class="facebook-preview-image">
956 <img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($title); ?>" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
957 <div class="preview-placeholder" style="display: none;">
958 <span>📷</span>
959 <p>Image failed to load</p>
960 </div>
961 </div>
962 <?php else: ?>
963 <div class="facebook-preview-image preview-no-image">
964 <div class="preview-placeholder">
965 <span>📷</span>
966 <p>No image selected</p>
967 </div>
968 </div>
969 <?php endif; ?>
970 <div class="facebook-preview-content">
971 <div class="facebook-preview-domain"><?php echo esc_html(strtoupper($domain)); ?></div>
972 <div class="facebook-preview-title"><?php echo esc_html($title); ?></div>
973 <div class="facebook-preview-description"><?php echo esc_html($description); ?></div>
974 </div>
975 </div>
976 </div>
977 </div>
978
979 <!-- Twitter Preview -->
980 <div class="metasync-preview-panel twitter" data-platform="twitter">
981 <div class="twitter-preview">
982 <div class="twitter-post-header">
983 <div class="twitter-avatar"><?php echo esc_html($site_initial); ?></div>
984 <div class="twitter-user-info">
985 <h4><?php echo esc_html($site_name); ?></h4>
986 <p>@<?php echo esc_html(strtolower(str_replace(' ', '', $site_name ?? ''))); ?> 2h</p>
987 </div>
988 </div>
989 <div class="twitter-post-text">
990 Check out this amazing content! 🚀
991 </div>
992 <div class="twitter-card">
993 <?php if (!empty($twitter_display_image)): ?>
994 <div class="twitter-card-image">
995 <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';">
996 <div class="preview-placeholder" style="display: none;">
997 <span>📷</span>
998 <p>Image failed to load</p>
999 </div>
1000 </div>
1001 <?php else: ?>
1002 <div class="twitter-card-image preview-no-image">
1003 <div class="preview-placeholder">
1004 <span>📷</span>
1005 <p>No image selected</p>
1006 </div>
1007 </div>
1008 <?php endif; ?>
1009 <div class="twitter-card-content">
1010 <div class="twitter-card-domain"><?php echo esc_html($domain); ?></div>
1011 <div class="twitter-card-title"><?php echo esc_html($twitter_display_title); ?></div>
1012 <div class="twitter-card-description"><?php echo esc_html($twitter_display_description); ?></div>
1013 </div>
1014 </div>
1015 </div>
1016 </div>
1017
1018 <!-- LinkedIn Preview -->
1019 <div class="metasync-preview-panel linkedin" data-platform="linkedin">
1020 <div class="linkedin-preview">
1021 <div class="linkedin-post-header">
1022 <div class="linkedin-avatar"><?php echo esc_html($site_initial); ?></div>
1023 <div class="linkedin-user-info">
1024 <h4><?php echo esc_html($site_name); ?></h4>
1025 <p>2 hours ago</p>
1026 </div>
1027 </div>
1028 <div class="linkedin-link-preview">
1029 <?php if (!empty($image)): ?>
1030 <div class="linkedin-preview-image">
1031 <img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($title); ?>" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
1032 <div class="preview-placeholder" style="display: none;">
1033 <span>📷</span>
1034 <p>Image failed to load</p>
1035 </div>
1036 </div>
1037 <?php else: ?>
1038 <div class="linkedin-preview-image preview-no-image">
1039 <div class="preview-placeholder">
1040 <span>📷</span>
1041 <p>No image selected</p>
1042 </div>
1043 </div>
1044 <?php endif; ?>
1045 <div class="linkedin-preview-content">
1046 <div class="linkedin-preview-title"><?php echo esc_html($title); ?></div>
1047 <div class="linkedin-preview-description"><?php echo esc_html($description); ?></div>
1048 <div class="linkedin-preview-domain"><?php echo esc_html($domain); ?></div>
1049 </div>
1050 </div>
1051 </div>
1052 </div>
1053 </div>
1054 <?php
1055 return ob_get_clean();
1056 }
1057
1058 /**
1059 * Get post excerpt for Open Graph description
1060 */
1061 private function get_post_excerpt($post) {
1062 if (!empty($post->post_excerpt)) {
1063 return $post->post_excerpt;
1064 }
1065
1066 # Generate excerpt from content
1067 $content = $post->post_content;
1068
1069 # WP-510: Do NOT run do_shortcode()/apply_filters('the_content') here.
1070 # This method runs on wp_head (priority 5, before the body renders) to build
1071 # og:description. On page-builder pages (Elementor, etc.) the_content fully
1072 # renders the page — including widgets like Elementor Loop Grid — which makes
1073 # the builder mark those widgets' per-request inline CSS as "already printed".
1074 # When the real widget renders later in the body, the builder's dedup then
1075 # OMITS its inline <style> (e.g. <style id="loop-NNNN"> carrying the loop
1076 # card's flex/width vars), collapsing the layout (stacked cards, full-width
1077 # images). We only need plain text for a meta description, so strip instead of
1078 # render — matching how Metasync_Seo_Output builds its description safely.
1079 $content = strip_shortcodes($content);
1080
1081 # Remove HTML tags to get clean text
1082 $content = wp_strip_all_tags($content);
1083
1084 # Remove extra whitespace, line breaks, and special characters
1085 $content = preg_replace('/\s+/', ' ', $content);
1086 $content = trim($content);
1087
1088 # If content is still empty or too short, fallback to post title
1089 if (empty($content) || strlen($content) < 20) {
1090 $content = $post->post_title;
1091 }
1092
1093 # Generate excerpt
1094 $excerpt = wp_trim_words($content, 30, '...');
1095
1096 return $excerpt;
1097 }
1098
1099 /**
1100 * Get featured image URL
1101 */
1102 private function get_featured_image_url($post_id) {
1103 $thumbnail_id = get_post_thumbnail_id($post_id);
1104 if ($thumbnail_id) {
1105 $image_url = wp_get_attachment_image_url($thumbnail_id, 'large');
1106 return $image_url;
1107 }
1108 return '';
1109 }
1110
1111 /**
1112 * Get supported post types
1113 */
1114 public function get_supported_post_types() {
1115 $post_types = array_values(get_post_types(['public' => true], 'names'));
1116 $post_types = array_diff($post_types, ['attachment']);
1117 return apply_filters('metasync_opengraph_post_types', $post_types);
1118 }
1119
1120 /**
1121 * Add debug menu for testing
1122 */
1123 private function has_seo_plugin_conflicts() {
1124 // Ensure is_plugin_active() is available on the frontend
1125 if (!function_exists('is_plugin_active')) {
1126 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1127 }
1128
1129 # List of SEO plugins that might output Open Graph tags
1130 $seo_plugins = [
1131 'wordpress-seo/wp-seo.php', # Yoast SEO
1132 'seo-by-rank-math/rank-math.php', # RankMath
1133 'all-in-one-seo-pack/all_in_one_seo_pack.php', # AIOSEO Free
1134 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php', # AIOSEO Pro
1135 'seopress/seopress.php', # SEOPress
1136 'the-seo-framework/autodescription.php', # The SEO Framework
1137 ];
1138
1139 foreach ($seo_plugins as $plugin) {
1140 if (is_plugin_active($plugin)) {
1141 return true;
1142 }
1143 }
1144
1145 return false;
1146 }
1147
1148 /**
1149 * Check if a specific SEO plugin is handling Open Graph for current post
1150 */
1151 private function seo_plugin_has_og_data($post_id) {
1152 # Check if Yoast SEO has Open Graph data
1153 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
1154 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
1155 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
1156 if (!empty($yoast_title) || !empty($yoast_desc)) {
1157 return true;
1158 }
1159 }
1160
1161 # Check if RankMath has Open Graph data
1162 if (is_plugin_active('seo-by-rank-math/rank-math.php')) {
1163 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
1164 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
1165 if (!empty($rm_title) || !empty($rm_desc)) {
1166 return true;
1167 }
1168 }
1169
1170 return false;
1171 }
1172
1173 /**
1174 * Get the canonical URL for a post
1175 */
1176 public function get_canonical_url($post) {
1177 # Try to get the permalink using WordPress function
1178 $permalink = get_permalink($post->ID);
1179
1180 # If permalink is not available or is the default query URL, try alternative methods
1181 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1182 # Force WordPress to generate the proper permalink by temporarily setting post status
1183 $original_status = $post->post_status;
1184 if ($post->post_status === 'auto-draft') {
1185 $post->post_status = 'publish';
1186 }
1187
1188 # Try get_permalink again with the updated status
1189 $permalink = get_permalink($post->ID);
1190
1191 # Restore original status
1192 $post->post_status = $original_status;
1193 }
1194
1195 # If still not working, use WordPress core functions to build proper permalink
1196 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1197 # Use WordPress core function that respects permalink structure
1198 # This properly handles custom structures, hierarchies, and post types
1199 # Load admin function if not already available
1200 if (!function_exists('get_sample_permalink')) {
1201 require_once ABSPATH . 'wp-admin/includes/post.php';
1202 }
1203 $permalink = get_sample_permalink($post->ID);
1204
1205 if (is_array($permalink)) {
1206 # get_sample_permalink returns array with template and slug
1207 # Replace %postname% or %pagename% with actual slug
1208 $permalink = str_replace(
1209 array('%pagename%', '%postname%'),
1210 $post->post_name,
1211 $permalink[0]
1212 );
1213 }
1214
1215 # Final fallback: if still problematic, construct URL respecting post type structure
1216 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1217 if (!empty($post->post_name)) {
1218 # For pages, check if there's a parent hierarchy
1219 if ($post->post_type === 'page' && $post->post_parent) {
1220 # Get parent page path for proper hierarchy
1221 $parent = get_post($post->post_parent);
1222 $parent_path = '';
1223
1224 # Build full path including all parent pages
1225 while ($parent) {
1226 $parent_path = $parent->post_name . '/' . $parent_path;
1227 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
1228 }
1229
1230 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
1231 } else {
1232 # For posts and pages without parents, use post type archive base
1233 $post_type_obj = get_post_type_object($post->post_type);
1234 $slug = $post_type_obj->rewrite['slug'] ?? '';
1235
1236 if ($slug && $post->post_type !== 'page') {
1237 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
1238 } else {
1239 $permalink = home_url('/' . $post->post_name . '/');
1240 }
1241 }
1242 } else {
1243 # Fallback to post ID format if no slug available
1244 $permalink = home_url('/?p=' . $post->ID);
1245 }
1246 }
1247 }
1248
1249 return $permalink;
1250 }
1251
1252 /**
1253 * Update OpenGraph URL when post is saved
1254 */
1255 public function update_opengraph_url($post_id) {
1256 # Only update for supported post types
1257 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
1258 return;
1259 }
1260
1261 # Skip autosaves and revisions
1262 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
1263 return;
1264 }
1265
1266 if (wp_is_post_revision($post_id)) {
1267 return;
1268 }
1269
1270 # Get the post object
1271 $post = get_post($post_id);
1272 if (!$post) {
1273 return;
1274 }
1275
1276 # Check if OpenGraph is enabled
1277 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
1278 if (empty($og_enabled) || $og_enabled !== '1') {
1279 return;
1280 }
1281
1282 # Get the current OpenGraph URL
1283 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
1284
1285 # Generate the proper canonical URL
1286 $canonical_url = $this->get_canonical_url($post);
1287
1288 # Update the OpenGraph URL for new posts or if it's empty/incorrect
1289 # This ensures the URL is populated after first save (even as draft)
1290 if (empty($current_og_url) ||
1291 strpos($current_og_url, '?p=') !== false) {
1292
1293 update_post_meta($post_id, '_metasync_og_url', $canonical_url);
1294 }
1295 }
1296
1297 /**
1298 * Check if post permalink changed and update og:url if needed
1299 */
1300 public function check_permalink_change($post_id, $post_after, $post_before) {
1301 # Only check for supported post types
1302 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
1303 return;
1304 }
1305
1306 # Skip autosaves and revisions
1307 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
1308 return;
1309 }
1310
1311 if (wp_is_post_revision($post_id)) {
1312 return;
1313 }
1314
1315 # Check if OpenGraph is enabled
1316 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
1317 if (empty($og_enabled) || $og_enabled !== '1') {
1318 return;
1319 }
1320
1321 # Get current og:url
1322 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
1323 if (empty($current_og_url)) {
1324 return;
1325 }
1326
1327 # Check if the permalink actually changed by comparing post_name (slug)
1328 if ($post_before->post_name === $post_after->post_name) {
1329 return;
1330 }
1331
1332 # Generate the old and new permalinks
1333 $old_permalink = $this->get_canonical_url($post_before);
1334 $new_permalink = $this->get_canonical_url($post_after);
1335
1336 # If permalinks are the same, no need to update
1337 if ($old_permalink === $new_permalink) {
1338 return;
1339 }
1340
1341 # Check if the current og:url matches the old permalink
1342 # This means the og:url was set to the post permalink (not a custom URL)
1343 if ($current_og_url === $old_permalink) {
1344 # Update og:url to the new permalink
1345 update_post_meta($post_id, '_metasync_og_url', $new_permalink);
1346 }
1347 }
1348
1349 /**
1350 * Check if post status changed and update og:url if needed
1351 */
1352 public function check_status_change($new_status, $old_status, $post) {
1353 # Only check for supported post types
1354 if (!in_array(get_post_type($post->ID), $this->get_supported_post_types())) {
1355 return;
1356 }
1357
1358 # Skip autosaves and revisions
1359 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
1360 return;
1361 }
1362
1363 if (wp_is_post_revision($post->ID)) {
1364 return;
1365 }
1366
1367 # Only check when transitioning to published status
1368 if ($new_status !== 'publish' || $old_status === 'publish') {
1369 return;
1370 }
1371
1372 # Check if OpenGraph is enabled
1373 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
1374 if (empty($og_enabled) || $og_enabled !== '1') {
1375 return;
1376 }
1377
1378 # Get current og:url
1379 $current_og_url = get_post_meta($post->ID, '_metasync_og_url', true);
1380
1381 # Generate the current permalink
1382 $current_permalink = $this->get_canonical_url($post);
1383
1384 # If og:url is empty or matches the old format, update it
1385 if (empty($current_og_url) || strpos($current_og_url, '?p=') !== false) {
1386 update_post_meta($post->ID, '_metasync_og_url', $current_permalink);
1387 }
1388 }
1389
1390 /**
1391 * Check if post slug changed via edit slug functionality
1392 */
1393 public function check_slug_change() {
1394 # Get the post ID from the request
1395 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1396 if (!$post_id) {
1397 return;
1398 }
1399
1400 # Only check for supported post types
1401 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
1402 return;
1403 }
1404
1405 # Check if OpenGraph is enabled
1406 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
1407 if (empty($og_enabled) || $og_enabled !== '1') {
1408 return;
1409 }
1410
1411 # Get current og:url
1412 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
1413 if (empty($current_og_url)) {
1414 return;
1415 }
1416
1417 # Get the post object
1418 $post = get_post($post_id);
1419 if (!$post) {
1420 return;
1421 }
1422
1423 # Generate the current permalink
1424 $current_permalink = $this->get_canonical_url($post);
1425
1426 # Check if the current og:url matches the old permalink format
1427 # This means the og:url was set to the post permalink (not a custom URL)
1428 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
1429 # Check if the og:url was the old permalink by comparing with a generated old permalink
1430 $old_post = clone $post;
1431 $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name;
1432
1433 # If the og:url doesn't match the current permalink, it might be the old one
1434 # We'll update it to the new permalink
1435 if ($current_og_url !== $current_permalink) {
1436 update_post_meta($post_id, '_metasync_og_url', $current_permalink);
1437 }
1438 }
1439 }
1440 }
1441