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

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