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

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