PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.12
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.12
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.12, at includes/class-metasync-opengraph.php

1,437 lines 59.5 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 # Process shortcodes to get the actual rendered content (what users see on frontend)
1070 # This converts page builder shortcodes into their actual HTML output
1071 $content = do_shortcode($content);
1072
1073 # Apply WordPress content filters (same filters used on the frontend)
1074 # This ensures we get the exact same content as displayed on the page
1075 $content = apply_filters('the_content', $content);
1076
1077 # Remove HTML tags to get clean text
1078 $content = wp_strip_all_tags($content);
1079
1080 # Remove extra whitespace, line breaks, and special characters
1081 $content = preg_replace('/\s+/', ' ', $content);
1082 $content = trim($content);
1083
1084 # If content is still empty or too short, fallback to post title
1085 if (empty($content) || strlen($content) < 20) {
1086 $content = $post->post_title;
1087 }
1088
1089 # Generate excerpt
1090 $excerpt = wp_trim_words($content, 30, '...');
1091
1092 return $excerpt;
1093 }
1094
1095 /**
1096 * Get featured image URL
1097 */
1098 private function get_featured_image_url($post_id) {
1099 $thumbnail_id = get_post_thumbnail_id($post_id);
1100 if ($thumbnail_id) {
1101 $image_url = wp_get_attachment_image_url($thumbnail_id, 'large');
1102 return $image_url;
1103 }
1104 return '';
1105 }
1106
1107 /**
1108 * Get supported post types
1109 */
1110 public function get_supported_post_types() {
1111 $post_types = array_values(get_post_types(['public' => true], 'names'));
1112 $post_types = array_diff($post_types, ['attachment']);
1113 return apply_filters('metasync_opengraph_post_types', $post_types);
1114 }
1115
1116 /**
1117 * Add debug menu for testing
1118 */
1119 private function has_seo_plugin_conflicts() {
1120 // Ensure is_plugin_active() is available on the frontend
1121 if (!function_exists('is_plugin_active')) {
1122 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1123 }
1124
1125 # List of SEO plugins that might output Open Graph tags
1126 $seo_plugins = [
1127 'wordpress-seo/wp-seo.php', # Yoast SEO
1128 'seo-by-rank-math/rank-math.php', # RankMath
1129 'all-in-one-seo-pack/all_in_one_seo_pack.php', # AIOSEO Free
1130 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php', # AIOSEO Pro
1131 'seopress/seopress.php', # SEOPress
1132 'the-seo-framework/autodescription.php', # The SEO Framework
1133 ];
1134
1135 foreach ($seo_plugins as $plugin) {
1136 if (is_plugin_active($plugin)) {
1137 return true;
1138 }
1139 }
1140
1141 return false;
1142 }
1143
1144 /**
1145 * Check if a specific SEO plugin is handling Open Graph for current post
1146 */
1147 private function seo_plugin_has_og_data($post_id) {
1148 # Check if Yoast SEO has Open Graph data
1149 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
1150 $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
1151 $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
1152 if (!empty($yoast_title) || !empty($yoast_desc)) {
1153 return true;
1154 }
1155 }
1156
1157 # Check if RankMath has Open Graph data
1158 if (is_plugin_active('seo-by-rank-math/rank-math.php')) {
1159 $rm_title = get_post_meta($post_id, 'rank_math_title', true);
1160 $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
1161 if (!empty($rm_title) || !empty($rm_desc)) {
1162 return true;
1163 }
1164 }
1165
1166 return false;
1167 }
1168
1169 /**
1170 * Get the canonical URL for a post
1171 */
1172 public function get_canonical_url($post) {
1173 # Try to get the permalink using WordPress function
1174 $permalink = get_permalink($post->ID);
1175
1176 # If permalink is not available or is the default query URL, try alternative methods
1177 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1178 # Force WordPress to generate the proper permalink by temporarily setting post status
1179 $original_status = $post->post_status;
1180 if ($post->post_status === 'auto-draft') {
1181 $post->post_status = 'publish';
1182 }
1183
1184 # Try get_permalink again with the updated status
1185 $permalink = get_permalink($post->ID);
1186
1187 # Restore original status
1188 $post->post_status = $original_status;
1189 }
1190
1191 # If still not working, use WordPress core functions to build proper permalink
1192 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1193 # Use WordPress core function that respects permalink structure
1194 # This properly handles custom structures, hierarchies, and post types
1195 # Load admin function if not already available
1196 if (!function_exists('get_sample_permalink')) {
1197 require_once ABSPATH . 'wp-admin/includes/post.php';
1198 }
1199 $permalink = get_sample_permalink($post->ID);
1200
1201 if (is_array($permalink)) {
1202 # get_sample_permalink returns array with template and slug
1203 # Replace %postname% or %pagename% with actual slug
1204 $permalink = str_replace(
1205 array('%pagename%', '%postname%'),
1206 $post->post_name,
1207 $permalink[0]
1208 );
1209 }
1210
1211 # Final fallback: if still problematic, construct URL respecting post type structure
1212 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1213 if (!empty($post->post_name)) {
1214 # For pages, check if there's a parent hierarchy
1215 if ($post->post_type === 'page' && $post->post_parent) {
1216 # Get parent page path for proper hierarchy
1217 $parent = get_post($post->post_parent);
1218 $parent_path = '';
1219
1220 # Build full path including all parent pages
1221 while ($parent) {
1222 $parent_path = $parent->post_name . '/' . $parent_path;
1223 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
1224 }
1225
1226 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
1227 } else {
1228 # For posts and pages without parents, use post type archive base
1229 $post_type_obj = get_post_type_object($post->post_type);
1230 $slug = $post_type_obj->rewrite['slug'] ?? '';
1231
1232 if ($slug && $post->post_type !== 'page') {
1233 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
1234 } else {
1235 $permalink = home_url('/' . $post->post_name . '/');
1236 }
1237 }
1238 } else {
1239 # Fallback to post ID format if no slug available
1240 $permalink = home_url('/?p=' . $post->ID);
1241 }
1242 }
1243 }
1244
1245 return $permalink;
1246 }
1247
1248 /**
1249 * Update OpenGraph URL when post is saved
1250 */
1251 public function update_opengraph_url($post_id) {
1252 # Only update for supported post types
1253 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
1254 return;
1255 }
1256
1257 # Skip autosaves and revisions
1258 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
1259 return;
1260 }
1261
1262 if (wp_is_post_revision($post_id)) {
1263 return;
1264 }
1265
1266 # Get the post object
1267 $post = get_post($post_id);
1268 if (!$post) {
1269 return;
1270 }
1271
1272 # Check if OpenGraph is enabled
1273 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
1274 if (empty($og_enabled) || $og_enabled !== '1') {
1275 return;
1276 }
1277
1278 # Get the current OpenGraph URL
1279 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
1280
1281 # Generate the proper canonical URL
1282 $canonical_url = $this->get_canonical_url($post);
1283
1284 # Update the OpenGraph URL for new posts or if it's empty/incorrect
1285 # This ensures the URL is populated after first save (even as draft)
1286 if (empty($current_og_url) ||
1287 strpos($current_og_url, '?p=') !== false) {
1288
1289 update_post_meta($post_id, '_metasync_og_url', $canonical_url);
1290 }
1291 }
1292
1293 /**
1294 * Check if post permalink changed and update og:url if needed
1295 */
1296 public function check_permalink_change($post_id, $post_after, $post_before) {
1297 # Only check for supported post types
1298 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
1299 return;
1300 }
1301
1302 # Skip autosaves and revisions
1303 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
1304 return;
1305 }
1306
1307 if (wp_is_post_revision($post_id)) {
1308 return;
1309 }
1310
1311 # Check if OpenGraph is enabled
1312 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
1313 if (empty($og_enabled) || $og_enabled !== '1') {
1314 return;
1315 }
1316
1317 # Get current og:url
1318 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
1319 if (empty($current_og_url)) {
1320 return;
1321 }
1322
1323 # Check if the permalink actually changed by comparing post_name (slug)
1324 if ($post_before->post_name === $post_after->post_name) {
1325 return;
1326 }
1327
1328 # Generate the old and new permalinks
1329 $old_permalink = $this->get_canonical_url($post_before);
1330 $new_permalink = $this->get_canonical_url($post_after);
1331
1332 # If permalinks are the same, no need to update
1333 if ($old_permalink === $new_permalink) {
1334 return;
1335 }
1336
1337 # Check if the current og:url matches the old permalink
1338 # This means the og:url was set to the post permalink (not a custom URL)
1339 if ($current_og_url === $old_permalink) {
1340 # Update og:url to the new permalink
1341 update_post_meta($post_id, '_metasync_og_url', $new_permalink);
1342 }
1343 }
1344
1345 /**
1346 * Check if post status changed and update og:url if needed
1347 */
1348 public function check_status_change($new_status, $old_status, $post) {
1349 # Only check for supported post types
1350 if (!in_array(get_post_type($post->ID), $this->get_supported_post_types())) {
1351 return;
1352 }
1353
1354 # Skip autosaves and revisions
1355 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
1356 return;
1357 }
1358
1359 if (wp_is_post_revision($post->ID)) {
1360 return;
1361 }
1362
1363 # Only check when transitioning to published status
1364 if ($new_status !== 'publish' || $old_status === 'publish') {
1365 return;
1366 }
1367
1368 # Check if OpenGraph is enabled
1369 $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true);
1370 if (empty($og_enabled) || $og_enabled !== '1') {
1371 return;
1372 }
1373
1374 # Get current og:url
1375 $current_og_url = get_post_meta($post->ID, '_metasync_og_url', true);
1376
1377 # Generate the current permalink
1378 $current_permalink = $this->get_canonical_url($post);
1379
1380 # If og:url is empty or matches the old format, update it
1381 if (empty($current_og_url) || strpos($current_og_url, '?p=') !== false) {
1382 update_post_meta($post->ID, '_metasync_og_url', $current_permalink);
1383 }
1384 }
1385
1386 /**
1387 * Check if post slug changed via edit slug functionality
1388 */
1389 public function check_slug_change() {
1390 # Get the post ID from the request
1391 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1392 if (!$post_id) {
1393 return;
1394 }
1395
1396 # Only check for supported post types
1397 if (!in_array(get_post_type($post_id), $this->get_supported_post_types())) {
1398 return;
1399 }
1400
1401 # Check if OpenGraph is enabled
1402 $og_enabled = get_post_meta($post_id, '_metasync_og_enabled', true);
1403 if (empty($og_enabled) || $og_enabled !== '1') {
1404 return;
1405 }
1406
1407 # Get current og:url
1408 $current_og_url = get_post_meta($post_id, '_metasync_og_url', true);
1409 if (empty($current_og_url)) {
1410 return;
1411 }
1412
1413 # Get the post object
1414 $post = get_post($post_id);
1415 if (!$post) {
1416 return;
1417 }
1418
1419 # Generate the current permalink
1420 $current_permalink = $this->get_canonical_url($post);
1421
1422 # Check if the current og:url matches the old permalink format
1423 # This means the og:url was set to the post permalink (not a custom URL)
1424 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
1425 # Check if the og:url was the old permalink by comparing with a generated old permalink
1426 $old_post = clone $post;
1427 $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name;
1428
1429 # If the og:url doesn't match the current permalink, it might be the old one
1430 # We'll update it to the new permalink
1431 if ($current_og_url !== $current_permalink) {
1432 update_post_meta($post_id, '_metasync_og_url', $current_permalink);
1433 }
1434 }
1435 }
1436 }
1437