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

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