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

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