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

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