PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 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 All 141 releases
metasync / admin / class-metasync-seo-sidebar.php

class-metasync-seo-sidebar.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at admin/class-metasync-seo-sidebar.php

1,125 lines 52.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync SEO Sidebar for Gutenberg Block Editor
4 *
5 * Provides a sidebar panel in the WordPress Block Editor for editing
6 * SEO metadata (title and description) directly within the post editor.
7 *
8 * @package Metasync
9 * @subpackage Metasync/admin
10 * @since 2.7.0
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 class Metasync_SEO_Sidebar {
18
19 /**
20 * Plugin version
21 *
22 * @var string
23 */
24 private $version;
25
26 /**
27 * Meta key for SEO title (manual edits via sidebar)
28 */
29 const META_SEO_TITLE = '_metasync_seo_title';
30
31 /**
32 * Meta key for meta description (manual edits via sidebar)
33 */
34 const META_DESCRIPTION = '_metasync_seo_desc';
35
36 /**
37 * Meta keys for values brought in by the external SEO importer.
38 *
39 * Deliberately separate from META_SEO_TITLE / META_DESCRIPTION. Those two
40 * mean "the customer typed this" and therefore outrank OTTO — the Classic
41 * meta box tells the user as much ("Leave the field blank to use OTTO's
42 * suggestion"). A bulk import is not a per-post customer decision, so
43 * writing it there silently opted every imported post out of OTTO.
44 *
45 * These keys render only where OTTO has no suggestion. See
46 * filter_document_title_imported() and output_seo_meta_description().
47 */
48 const META_IMPORTED_SEO_TITLE = Metasync_Seo_Precedence::KEY_IMPORTED_TITLE;
49 const META_IMPORTED_DESCRIPTION = Metasync_Seo_Precedence::KEY_IMPORTED_DESC;
50
51 /**
52 * Meta key for the OTTO focus keyword (read-only, written by OTTO)
53 */
54 const META_OTTO_KEYWORDS = '_metasync_otto_keywords';
55
56 /**
57 * Meta key for primary category (used in breadcrumbs and canonical URL)
58 */
59 const META_PRIMARY_CATEGORY = '_metasync_primary_category';
60
61 /**
62 * Meta key for primary product category (WooCommerce)
63 */
64 const META_PRIMARY_PRODUCT_CAT = '_metasync_primary_product_cat';
65
66 /**
67 * Meta key for hreflang / language alternates (JSON-encoded array)
68 */
69 const META_HREFLANG = '_metasync_hreflang';
70
71 /**
72 * Meta key for plugin sync timestamps (JSON-encoded object keyed by plugin slug)
73 */
74 const META_PLUGIN_SYNC_TS = '_metasync_plugin_sync_ts';
75
76 /**
77 * Meta key for advanced robots directives (JSON-encoded object)
78 */
79 const META_ROBOTS_ADVANCED = '_metasync_robots_advanced';
80
81 /**
82 * Constructor
83 *
84 * @param string $version Plugin version
85 */
86 public function __construct($version = '1.0.0') {
87 $this->version = $version;
88 $this->init();
89 }
90
91 /**
92 * Initialize hooks
93 */
94 private function init() {
95 // Register meta fields for REST API
96 add_action('init', array($this, 'register_meta_fields'));
97
98 // Enqueue block editor assets
99 add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
100
101 // Clean up emptied SEO meta rows after REST saves (all public post types)
102 add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
103
104 // Clean up primary category meta when category is unchecked (all post types)
105 add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
106
107 // Frontend hooks for outputting SEO meta
108 // Custom values ALWAYS take priority over OTTO suggestions
109 add_action('wp_head', array($this, 'output_seo_meta_description'), 2);
110 add_filter('pre_get_document_title', array($this, 'filter_document_title'), 100);
111 add_filter('document_title_parts', array($this, 'filter_document_title_parts'), 100);
112
113 // Imported values sit BELOW OTTO. Priority 98 runs before OTTO's own
114 // pre_get_document_title callback (priority 99, see
115 // otto/metasync-otto-seo-functions.php), so OTTO overwrites this value
116 // whenever it has a suggestion and leaves it alone when it does not.
117 // The customer's own title at priority 100 still beats both.
118 add_filter('pre_get_document_title', array($this, 'filter_document_title_imported'), 98);
119 }
120
121 /**
122 * Register REST API cleanup hooks for all public post types
123 */
124 public function register_rest_save_hooks() {
125 $post_types = get_post_types(array('public' => true), 'names');
126 unset($post_types['attachment']);
127 foreach ($post_types as $post_type) {
128 add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
129 }
130 }
131
132 /**
133 * Check if OTTO SSR is globally enabled
134 *
135 * @return bool True if OTTO is enabled globally
136 */
137 public static function is_otto_enabled_globally() {
138 $metasync_options = get_option('metasync_options');
139 $otto_enabled = $metasync_options['general']['otto_enable'] ?? false;
140 return ($otto_enabled === 'true' || $otto_enabled === true);
141 }
142
143 /**
144 * Clean up empty SEO meta values after a REST save
145 *
146 * The REST API cannot delete a meta key — clearing a sidebar field
147 * persists an empty string, and that empty row would keep outranking
148 * OTTO/imported suggestions forever. Mirror the classic metabox save
149 * path instead: an empty value loses its row so the post falls back to
150 * the suggestion tiers again.
151 *
152 * @param WP_Post|int $post Inserted or updated post object (or its ID)
153 * @param WP_REST_Request $_request Request object (unused)
154 * @param bool $_creating True when creating a post, false when updating (unused)
155 */
156 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
157 $post_id = is_object($post) ? (int) $post->ID : (int) $post;
158 if (!$post_id) {
159 return;
160 }
161
162 foreach (array(self::META_SEO_TITLE, self::META_DESCRIPTION) as $meta_key) {
163 $meta_value = get_post_meta($post_id, $meta_key, true);
164
165 // Strict '' check: get_post_meta() returns '' for a missing row
166 // too, where delete_post_meta() is a harmless no-op. A real
167 // (non-empty) value the user typed is never dropped.
168 if ($meta_value === '') {
169 delete_post_meta($post_id, $meta_key);
170 }
171 }
172 }
173
174 /**
175 * Output meta description tags from sidebar field
176 * Custom values ALWAYS take priority over OTTO
177 * Outputs: meta description, og:description, and twitter:description,
178 * plus og:title / twitter:title when a suppressed third-party tag needs replacing
179 */
180 public function output_seo_meta_description() {
181 // Skip for admin, feeds, etc.
182 if (is_admin() || is_feed() || is_robots()) {
183 return;
184 }
185
186 // Only run on singular pages (posts, pages, custom post types)
187 if (!is_singular()) {
188 return;
189 }
190
191 $post_id = get_the_ID();
192 if (!$post_id) {
193 return;
194 }
195
196 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
197
198 // og:title / twitter:title replacement (WP-609).
199 //
200 // Still needed, but for a narrower case than when it was written. The
201 // conflict-handler filters now hand a third-party plugin MetaSync's OG
202 // title whenever one is set, so that plugin renders it and nothing is
203 // required here. What the filters cannot cover is a post holding a page
204 // title and no OG-specific value: there they pass the plugin's own tag
205 // through, and without this block the page carries the plugin's og:title
206 // while <title> carries ours. og_title_needs_replacement() returns false
207 // once the filters are supplying the tag, so the two never both emit.
208 if ($conflict_handler->og_title_needs_replacement()) {
209 $title = $conflict_handler->get_metasync_title();
210 if (!empty($title)) {
211 $title_escaped = esc_attr($title);
212 echo '<meta property="og:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
213 echo '<meta name="twitter:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
214 }
215 }
216
217 // With an active third-party SEO plugin, that plugin emits the description
218 // tag and the conflict handler hands it MetaSync's value when MetaSync
219 // holds one. Emitting here as well would put two identical description
220 // tags on the page.
221 //
222 // The condition is "will that plugin render a description", not "has a
223 // sync run": a timestamp only records that a sync happened, and the
224 // mirrored copy can be edited or emptied in the plugin afterwards. When
225 // neither side holds anything we fall through, so a stale or partial sync
226 // cannot drop the description entirely.
227 $defer_meta_description = false;
228 if ($conflict_handler->has_active_seo_plugin()) {
229 if ($conflict_handler->metasync_has_description()
230 || $conflict_handler->active_plugin_has_description($post_id)) {
231 $defer_meta_description = true;
232 }
233 }
234
235 // With a plugin active the conflict handler now supplies og:/twitter:
236 // description too — falling back to the page description when no
237 // OG-specific value is set — so that plugin renders exactly one. Emitting
238 // here as well is always a second tag, never a replacement.
239 $defer_og_description = $conflict_handler->has_active_seo_plugin()
240 || $conflict_handler->third_party_owns_og_description();
241
242 if ($defer_meta_description && $defer_og_description) {
243 return;
244 }
245
246 // Which stored value wins is decided in one place, so the global
247 // "SEO Meta Priority" setting reaches this emitter
248 // without a second copy of the rule. Under the default this is still
249 // the customer's description; under OTTO priority the chain hands back
250 // OTTO's value and falls back to the custom one where OTTO has none.
251 $resolved = Metasync_Seo_Precedence::resolve($post_id, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
252 $description = $resolved['value'];
253
254 // The marker has to describe what actually won, not which code path
255 // emitted it: Otto_html_class treats data-metasync-seo="custom" as an
256 // explicit override that must survive the buffer pass, and a tag
257 // carrying OTTO's own value must not claim to be one. The resolver
258 // already names the tier it took the value from, so the marker follows
259 // that rather than being inferred a second time here.
260 $is_imported_fallback = $resolved['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
261 $is_otto_value = Metasync_Seo_Precedence::is_otto_value(
262 $post_id,
263 Metasync_Seo_Precedence::FIELD_DESCRIPTION,
264 $resolved
265 );
266
267 // Exactly one printer owns each description value. When OTTO's stored
268 // suggestion is the resolved winner, this emitter stands down and
269 // metasync_output_otto_meta_description() (wp_head priority 1) prints
270 // it — that emitter already covers every delivery path this one runs
271 // on, and both firing ships two identical description tags on pages
272 // the OTTO SSR buffer dedup never reaches (cold/served-from-cache,
273 // rate-limited, or Cloudflare-pixel mode). Every other tier — the
274 // customer's value, the persisted pair, imported fallbacks — is still
275 // printed here, and the og:title replacement above is unaffected.
276 // Mirrors the ownership rule in that emitter's stand-down guard.
277 if ($resolved['key'] === Metasync_Seo_Precedence::KEY_OTTO_DESC) {
278 return;
279 }
280
281 // An imported value is migration data rather than a per-post customer
282 // decision, so it only renders where MetaSync owns the output.
283 if ($is_imported_fallback && $this->third_party_owns_output()) {
284 $description = '';
285 $is_imported_fallback = false;
286 }
287
288 // Output meta description tags if custom value exists
289 // This will be output BEFORE OTTO processes the page, and since we're using
290 // a high priority filter, it will override OTTO's meta description
291 if (!empty($description)) {
292 $description_escaped = esc_attr($description);
293 if ($is_imported_fallback) {
294 $marker = ' data-metasync-seo="imported"';
295 } elseif ($is_otto_value) {
296 $marker = ' data-metasync-otto="true"';
297 } else {
298 $marker = ' data-metasync-seo="custom"';
299 }
300 // Standard meta description
301 if (!$defer_meta_description) {
302 echo '<meta name="description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
303 }
304
305 // og:description / twitter:description are Open Graph & social tags, so they
306 // answer to the site-wide Social Media & Open Graph switch as well as the
307 // per-post "Enable Open Graph & Social Media Tags" toggle. For the per-post
308 // toggle only an explicit '0' opt-out disables them (unset/empty counts as
309 // enabled), mirroring Metasync_OpenGraph::will_emit(). When either says no,
310 // MetaSync emits no OG/Twitter description, so it neither overrides a
311 // third-party plugin's tag nor leaves one sourced from our database.
312 //
313 // The plain <meta name="description"> above is deliberately NOT gated here:
314 // it belongs to the SEO title/description feature, which has its own setting.
315 if (!$defer_og_description
316 && !Metasync_OpenGraph::is_social_output_disabled($post_id)) {
317 // Open Graph description
318 echo '<meta property="og:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
319 // Twitter description
320 echo '<meta name="twitter:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
321 }
322 }
323 }
324
325 /**
326 * Filter document title (pre_get_document_title filter)
327 * Custom values ALWAYS take priority over OTTO
328 *
329 * @param string $title Current title
330 * @return string Modified title or original
331 */
332 public function filter_document_title($title) {
333 // Skip for admin
334 if (is_admin()) {
335 return $title;
336 }
337
338 // Only run on singular pages
339 if (!is_singular()) {
340 return $title;
341 }
342
343 $post_id = get_the_ID();
344 if (!$post_id) {
345 return $title;
346 }
347
348 // Which stored value wins is decided in one place, so the global
349 // "SEO Meta Priority" setting reaches this filter
350 // without a second copy of the rule. Under the default the customer's
351 // title still wins; under OTTO priority the chain hands back OTTO's
352 // value and falls back to the custom one where OTTO has none.
353 //
354 // The resolver reads the whole chain, so it also answers for a post
355 // whose title lives on the persisted or imported tier — cases this
356 // filter used to miss entirely by reading only the sidebar key.
357 $seo_title = Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
358
359 return !empty($seo_title) ? $seo_title : $title;
360 }
361
362 /**
363 * Whether a third-party SEO plugin is the one actually rendering this page.
364 *
365 * Mirrors the guard OTTO's own filters use
366 * (otto/metasync-otto-seo-functions.php): when an active plugin owns output
367 * and OTTO has nothing live to say, leave that plugin's tags alone.
368 *
369 * Gates the *imported* value only. Imported values are migration data with
370 * no per-post customer intent behind them, so they must never compete with a
371 * plugin the customer is still using. The description emitter is an echo on
372 * wp_head rather than a filter return, so without this it would add a second
373 * <meta name="description"> next to the third-party plugin's own.
374 *
375 * @return bool True when MetaSync should stand down for imported values.
376 */
377 private function third_party_owns_output() {
378 if (!class_exists('Metasync_SEO_Conflict_Handler')) {
379 return false;
380 }
381
382 $handler = Metasync_SEO_Conflict_Handler::get_instance();
383
384 return $handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions();
385 }
386
387 /**
388 * Supply an imported SEO title as a fallback BELOW OTTO.
389 *
390 * Registered at priority 98 so OTTO's own callback (priority 99) runs
391 * afterwards: OTTO overwrites this whenever it has a suggestion, and
392 * returns the title untouched when it does not — which is exactly when the
393 * imported value should show. The customer's own title (priority 100) still
394 * wins over both.
395 *
396 * @param string $title Current title.
397 * @return string Imported title when it should apply, else unchanged.
398 */
399 public function filter_document_title_imported($title) {
400 if (is_admin() || !is_singular()) {
401 return $title;
402 }
403
404 $post_id = get_the_ID();
405 if (!$post_id) {
406 return $title;
407 }
408
409 // Never outrank a third-party SEO plugin that is the one actually
410 // rendering this page.
411 if ($this->third_party_owns_output()) {
412 return $title;
413 }
414
415 $imported = get_post_meta($post_id, self::META_IMPORTED_SEO_TITLE, true);
416
417 return (!empty($imported) && is_string($imported)) ? $imported : $title;
418 }
419
420 /**
421 * Filter document title parts (for themes using wp_get_document_title)
422 *
423 * Which stored value wins is decided by Metasync_Seo_Precedence, so this
424 * filter follows the global "SEO Meta Priority" setting
425 * without restating the order.
426 *
427 * @param array $title_parts Title parts array
428 * @return array Modified title parts
429 */
430 public function filter_document_title_parts($title_parts) {
431 // Skip for admin
432 if (is_admin()) {
433 return $title_parts;
434 }
435
436 // Only run on singular pages
437 if (!is_singular()) {
438 return $title_parts;
439 }
440
441 $post_id = get_the_ID();
442 if (!$post_id) {
443 return $title_parts;
444 }
445
446 $seo_title = Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
447
448 // Replace title part if MetaSync holds a title for this post
449 if (!empty($seo_title)) {
450 $title_parts['title'] = $seo_title;
451 // Remove tagline and site for clean SEO title
452 unset($title_parts['tagline']);
453 unset($title_parts['site']);
454 }
455
456 return $title_parts;
457 }
458
459 /**
460 * Register meta fields for REST API access
461 */
462 public function register_meta_fields() {
463 // Get all public post types
464 $post_types = get_post_types(array('public' => true), 'names');
465
466 foreach ($post_types as $post_type) {
467 // Register SEO title meta
468 register_post_meta($post_type, self::META_SEO_TITLE, array(
469 'show_in_rest' => true,
470 'single' => true,
471 'type' => 'string',
472 'sanitize_callback' => 'sanitize_text_field',
473 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
474 if (empty($object_id)) {
475 $pt_obj = get_post_type_object($post_type);
476 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
477 }
478 return current_user_can('edit_post', $object_id);
479 },
480 ));
481
482 // Register meta description
483 register_post_meta($post_type, self::META_DESCRIPTION, array(
484 'show_in_rest' => true,
485 'single' => true,
486 'type' => 'string',
487 'sanitize_callback' => 'sanitize_textarea_field',
488 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
489 if (empty($object_id)) {
490 $pt_obj = get_post_type_object($post_type);
491 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
492 }
493 return current_user_can('edit_post', $object_id);
494 },
495 ));
496
497 // Register breadcrumb title override
498 register_post_meta($post_type, '_metasync_breadcrumb_title', array(
499 'show_in_rest' => true,
500 'single' => true,
501 'type' => 'string',
502 'sanitize_callback' => 'sanitize_text_field',
503 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
504 if (empty($object_id)) {
505 $pt_obj = get_post_type_object($post_type);
506 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
507 }
508 return current_user_can('edit_post', $object_id);
509 },
510 ));
511
512 // Register primary category for breadcrumbs
513 register_post_meta($post_type, '_metasync_primary_category', array(
514 'show_in_rest' => true,
515 'single' => true,
516 'type' => 'integer',
517 'sanitize_callback' => 'absint',
518 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
519 if (empty($object_id)) {
520 $pt_obj = get_post_type_object($post_type);
521 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
522 }
523 return current_user_can('edit_post', $object_id);
524 },
525 ));
526
527 // Register primary product category meta (WooCommerce-specific)
528 register_post_meta($post_type, self::META_PRIMARY_PRODUCT_CAT, array(
529 'show_in_rest' => true,
530 'single' => true,
531 'type' => 'integer',
532 'sanitize_callback' => 'absint',
533 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
534 if (empty($object_id)) {
535 $pt_obj = get_post_type_object($post_type);
536 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
537 }
538 return current_user_can('edit_post', $object_id);
539 },
540 ));
541
542 // Register OTTO meta keys for REST API (read-only for fallback/prefill)
543 register_post_meta($post_type, '_metasync_otto_title', array(
544 'show_in_rest' => true,
545 'single' => true,
546 'type' => 'string',
547 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
548 if (empty($object_id)) {
549 $pt_obj = get_post_type_object($post_type);
550 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
551 }
552 return current_user_can('edit_post', $object_id);
553 },
554 ));
555
556 register_post_meta($post_type, '_metasync_otto_description', array(
557 'show_in_rest' => true,
558 'single' => true,
559 'type' => 'string',
560 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
561 if (empty($object_id)) {
562 $pt_obj = get_post_type_object($post_type);
563 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
564 }
565 return current_user_can('edit_post', $object_id);
566 },
567 ));
568
569 // Register imported SEO meta for REST API (read-only fallback shown
570 // as a placeholder only when OTTO has no suggestion)
571 foreach (array(self::META_IMPORTED_SEO_TITLE, self::META_IMPORTED_DESCRIPTION) as $imported_key) {
572 register_post_meta($post_type, $imported_key, array(
573 'show_in_rest' => true,
574 'single' => true,
575 'type' => 'string',
576 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
577 if (empty($object_id)) {
578 $pt_obj = get_post_type_object($post_type);
579 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
580 }
581 return current_user_can('edit_post', $object_id);
582 },
583 ));
584 }
585
586 // Register OTTO focus keyword for REST API (read-only display in sidebar)
587 register_post_meta($post_type, self::META_OTTO_KEYWORDS, array(
588 'show_in_rest' => true,
589 'single' => true,
590 'type' => 'string',
591 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
592 if (empty($object_id)) {
593 $pt_obj = get_post_type_object($post_type);
594 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
595 }
596 return current_user_can('edit_post', $object_id);
597 },
598 ));
599
600 // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
601 register_post_meta($post_type, '_metasync_otto_disabled', array(
602 'show_in_rest' => true,
603 'single' => true,
604 'type' => 'string',
605 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
606 if (empty($object_id)) {
607 $pt_obj = get_post_type_object($post_type);
608 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
609 }
610 return current_user_can('edit_post', $object_id);
611 },
612 ));
613
614 // Register hreflang / language alternates meta (JSON-encoded array).
615 // Gated on the Language Alternates feature flag: when the feature is
616 // disabled the meta is not registered for REST, so the Gutenberg panel
617 // has nothing to bind to. Saved values are kept regardless.
618 //
619 // defined() rather than class_exists(): during the upgrade window a
620 // stale opcache copy of Metasync_Feature_Flags satisfies
621 // class_exists() while lacking a newly added constant, and reading
622 // LANGUAGE_ALTERNATES off it is a fatal. defined() checks the
623 // constant itself, which is the thing this line actually needs.
624 // @phpstan-ignore-next-line function.alreadyNarrowedType
625 if (defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES') && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES)) {
626 register_post_meta($post_type, self::META_HREFLANG, array(
627 'show_in_rest' => true,
628 'single' => true,
629 'type' => 'string',
630 'sanitize_callback' => array(__CLASS__, 'sanitize_hreflang_meta'),
631 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
632 if (empty($object_id)) {
633 $pt_obj = get_post_type_object($post_type);
634 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
635 }
636 return current_user_can('edit_post', $object_id);
637 },
638 ));
639 }
640
641 // Register plugin sync timestamp meta
642 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
643 'show_in_rest' => true,
644 'single' => true,
645 'type' => 'string',
646 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
647 if (empty($object_id)) {
648 $pt_obj = get_post_type_object($post_type);
649 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
650 }
651 return current_user_can('edit_post', $object_id);
652 },
653 ));
654
655 // Register advanced robots directives meta
656 register_post_meta($post_type, self::META_ROBOTS_ADVANCED, array(
657 'show_in_rest' => true,
658 'single' => true,
659 'type' => 'string',
660 'sanitize_callback' => function($value) {
661 $decoded = json_decode($value, true);
662 if (!is_array($decoded)) {
663 return '{}';
664 }
665 $allowed_bool = ['nofollow', 'noarchive', 'nosnippet', 'noimageindex'];
666 $sanitized = [];
667 foreach ($allowed_bool as $key) {
668 if (isset($decoded[$key])) {
669 $sanitized[$key] = (bool) $decoded[$key];
670 }
671 }
672 if (isset($decoded['max_snippet'])) {
673 $sanitized['max_snippet'] = (int) $decoded['max_snippet'];
674 }
675 if (isset($decoded['max_image_preview'])) {
676 $allowed = ['none', 'standard', 'large'];
677 $sanitized['max_image_preview'] = in_array($decoded['max_image_preview'], $allowed, true)
678 ? $decoded['max_image_preview'] : 'large';
679 }
680 if (isset($decoded['max_video_preview'])) {
681 $sanitized['max_video_preview'] = (int) $decoded['max_video_preview'];
682 }
683 return wp_json_encode($sanitized);
684 },
685 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
686 if (empty($object_id)) {
687 $pt_obj = get_post_type_object($post_type);
688 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
689 }
690 return current_user_can('edit_post', $object_id);
691 },
692 ));
693 }
694 }
695
696 /**
697 * Sanitize the `_metasync_hreflang` meta value (register_post_meta
698 * sanitize_callback; static so it is directly testable).
699 *
700 * The stored value is a JSON array of {lang, region, url} rows. Rows are
701 * normalised through Metasync_Hreflang_Output::normalize_entry() (so
702 * `en_US` becomes `en`+`US`, casing is folded, `x-default` drops its
703 * region), fully blank rows are dropped, and malformed input degrades to
704 * an empty array. Rows whose language code still fails validation after
705 * normalisation are KEPT — the emitter skips them, and the editor panel
706 * warns — so saving never silently erases what the user typed.
707 *
708 * @param mixed $value Incoming JSON string.
709 * @return string JSON array string.
710 */
711 public static function sanitize_hreflang_meta($value) {
712 // A non-string (e.g. a future importer passing an array straight to
713 // update_post_meta) must degrade to "no alternates" rather than
714 // fatal inside sanitize_meta on PHP 8.
715 if (!is_string($value)) {
716 return '[]';
717 }
718
719 $decoded = json_decode($value, true);
720 if (!is_array($decoded)) {
721 return '[]';
722 }
723
724 $clean = array();
725 foreach ($decoded as $entry) {
726 if (!is_array($entry)) {
727 continue;
728 }
729 $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
730 if ($normalized['lang'] === '' && $normalized['url'] === '') {
731 // A stray blank row ("Add alternate" clicked, never filled
732 // in) must not flip the post into MetaSync-emits mode on
733 // WPML sites.
734 continue;
735 }
736 $clean[] = array(
737 'lang' => $normalized['lang'],
738 'region' => $normalized['region'],
739 'url' => $normalized['url'],
740 );
741 }
742
743 // UNESCAPED_* keeps non-ASCII URLs readable in storage and free of
744 // \uXXXX escapes that could be corrupted by a later unslash.
745 return wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
746 }
747
748 /**
749 * Build a read-only list of WPML translation entries for a post.
750 *
751 * Delegates to the shared implementation in
752 * Metasync_Hreflang_Output::get_wpml_entries() so the editor panel shows
753 * exactly what the front end emits — including the synthesised
754 * x-default row and the region part — instead of a divergent copy of
755 * the query.
756 *
757 * @param int $post_id Post ID.
758 * @return array
759 */
760 private function get_wpml_entries_for_post($post_id) {
761 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
762 return array();
763 }
764
765 $hreflang = new Metasync_Hreflang_Output();
766 $entries = $hreflang->get_wpml_entries($post_id);
767
768 $out = array();
769 foreach ($entries as $entry) {
770 $out[] = array(
771 'lang' => isset($entry['lang']) ? $entry['lang'] : '',
772 'region' => isset($entry['region']) ? $entry['region'] : '',
773 'url' => isset($entry['url']) ? $entry['url'] : '',
774 );
775 }
776 return $out;
777 }
778
779 /**
780 * Enqueue block editor assets
781 */
782 public function enqueue_block_editor_assets() {
783 // Get current screen
784 $screen = get_current_screen();
785
786 // Only load on post edit screens
787 if (!$screen || $screen->base !== 'post') {
788 return;
789 }
790
791 // Enqueue the sidebar script
792 wp_enqueue_script(
793 'metasync-seo-sidebar',
794 plugin_dir_url(__FILE__) . 'js/metasync-seo-sidebar.js',
795 array(
796 'wp-plugins',
797 'wp-edit-post',
798 'wp-element',
799 'wp-components',
800 'wp-data',
801 'wp-compose',
802 'wp-i18n',
803 'wp-api-fetch',
804 ),
805 $this->version,
806 true
807 );
808
809 // Enqueue sidebar styles
810 wp_enqueue_style(
811 'metasync-seo-sidebar',
812 plugin_dir_url(__FILE__) . 'css/metasync-seo-sidebar.css',
813 array('wp-components'),
814 $this->version
815 );
816
817 // Get whitelabel plugin name
818 $plugin_name = 'MetaSync';
819 if (class_exists('Metasync') && method_exists('Metasync', 'get_effective_plugin_name')) {
820 $plugin_name = Metasync::get_effective_plugin_name('MetaSync');
821 }
822
823 // Get whitelabel icon URL for the block editor sidebar.
824 // Priority: white_label_plugin_menu_icon (the icon set in WL Settings → Branding)
825 // because that's the same field driving the admin menu icon.
826 // Falls back to whitelabel['logo'], then the bundled default SVG.
827 $icon_url = plugin_dir_url(__FILE__) . 'images/icon-256x256.svg';
828 $menu_icon = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
829 if (!empty($menu_icon) && filter_var($menu_icon, FILTER_VALIDATE_URL)) {
830 $icon_url = $menu_icon;
831 } elseif (!empty(Metasync::get_whitelabel_logo())) {
832 $icon_url = Metasync::get_whitelabel_logo();
833 }
834
835 // Get OTTO whitelabel name
836 $otto_name = 'OTTO';
837 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
838 $otto_name = Metasync::get_whitelabel_otto_name();
839 }
840
841 // Get current post ID (used for the LPS/custom-page check and WPML
842 // entries below)
843 $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post ID for editor script data
844
845 // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
846 // panels and surface a read-only notice instead.
847 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
848 // required conditionally, so the function_exists guard is needed at runtime.
849 $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
850
851 // Auto-detected WPML entries for the "Language Alternates" panel.
852 // Gated on the feature flag: when disabled the panel is hidden, so the
853 // entries are zeroed to avoid seeding data the UI cannot bind to.
854 //
855 // defined() rather than class_exists(): see register_post_meta() above —
856 // a stale opcache copy of the class passes class_exists() but has no
857 // LANGUAGE_ALTERNATES constant, and reading it is a fatal.
858 // @phpstan-ignore-next-line function.alreadyNarrowedType
859 $language_alternates_enabled = defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES')
860 && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES);
861 $wpml_entries = $language_alternates_enabled
862 ? $this->get_wpml_entries_for_post($post_id)
863 : array();
864
865 // Link Suggestions configuration
866 $link_suggestions_config = array(
867 'restUrl' => rest_url('metasync/v1/link-suggestions'),
868 'yoastPremiumActive' => class_exists('WPSEO_Premium'),
869 'rankMathActive' => defined('RANK_MATH_VERSION'),
870 'i18n' => array(
871 'panelTitle' => __('Internal Link Suggestions', 'metasync'),
872 'insertButton' => __('Insert', 'metasync'),
873 'refreshButton' => __('Refresh', 'metasync'),
874 'noSuggestions' => __('No suggestions found.', 'metasync'),
875 'loading' => __('Analyzing content…', 'metasync'),
876 'matchedPhrase' => __('Matched phrase:', 'metasync'),
877 'yoastNotice' => __('Yoast Premium detected. Their internal linking tool may overlap with this feature.', 'metasync'),
878 'rankMathNotice' => __('Rank Math detected. Their internal linking tool may overlap with this feature.', 'metasync'),
879 ),
880 );
881
882 // Detect active SEO plugins that provide their own primary category selector
883 $other_seo_primary = array(
884 'yoastActive' => defined('WPSEO_VERSION'),
885 'rankMathActive' => defined('RANK_MATH_VERSION'),
886 'aioseoActive' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
887 );
888
889 // Schema Content configuration
890 $schema_content_config = array(
891 'restUrl' => rest_url('metasync/v1/schema-content'),
892 'woocommerceActive' => class_exists('WooCommerce'),
893 'woocommerceData' => $this->get_woocommerce_data_for_post($post_id),
894 'i18n' => array(
895 'panelTitle' => __('Schema Markup Content', 'metasync'),
896 'saveButton' => __('Save Schema Content', 'metasync'),
897 'autoPopulateWC' => __('Auto-populate from WooCommerce', 'metasync'),
898 'noSchemaTypes' => __('No schema types configured. Add schema types in the classic editor or via the Schema Markup metabox.', 'metasync'),
899 'addQuestion' => __('Add Question', 'metasync'),
900 'removeQuestion' => __('Remove', 'metasync'),
901 'addStep' => __('Add Step', 'metasync'),
902 'removeStep' => __('Remove', 'metasync'),
903 'addIngredient' => __('Add Ingredient', 'metasync'),
904 'removeIngredient' => __('Remove', 'metasync'),
905 'addInstruction' => __('Add Instruction', 'metasync'),
906 'removeInstruction' => __('Remove', 'metasync'),
907 'saving' => __('Saving...', 'metasync'),
908 'saved' => __('Schema content saved.', 'metasync'),
909 'saveError' => __('Failed to save schema content.', 'metasync'),
910 ),
911 );
912
913 // Localize script with meta keys and settings
914 wp_localize_script('metasync-seo-sidebar', 'metasyncSeoSidebar', array(
915 'iconUrl' => $icon_url,
916 'isCustomOrLpsPage' => $is_lps_page,
917 'otherSeoPrimary' => $other_seo_primary,
918 'metaKeys' => array(
919 'seoTitle' => self::META_SEO_TITLE,
920 'metaDescription' => self::META_DESCRIPTION,
921 // Breadcrumb meta keys
922 'breadcrumbTitle' => '_metasync_breadcrumb_title',
923 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
924 'ottoTitle' => '_metasync_otto_title',
925 'ottoDescription' => '_metasync_otto_description',
926 // Imported keys (read-only). Rank below OTTO — used as the
927 // placeholder only when OTTO has no suggestion for the post.
928 'importedTitle' => self::META_IMPORTED_SEO_TITLE,
929 'importedDescription' => self::META_IMPORTED_DESCRIPTION,
930 // OTTO focus keyword (read-only display only)
931 'ottoKeywords' => self::META_OTTO_KEYWORDS,
932 // OTTO disabled per-post flag
933 'ottoDisabled' => '_metasync_otto_disabled',
934 // Primary category
935 'primaryCategory' => self::META_PRIMARY_CATEGORY,
936 'primaryProductCat' => self::META_PRIMARY_PRODUCT_CAT,
937 // Language alternates (hreflang) — JSON-encoded array
938 'hreflang' => self::META_HREFLANG,
939 // Plugin sync timestamp
940 'pluginSyncTs' => self::META_PLUGIN_SYNC_TS,
941 // Advanced robots directives
942 'robotsAdvanced' => self::META_ROBOTS_ADVANCED,
943 ),
944 'activeSeoPlugins' => array(
945 'yoast' => defined('WPSEO_VERSION'),
946 'rankmath' => defined('RANK_MATH_VERSION'),
947 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
948 ),
949 // Breadcrumb Title Override visibility. False only when MetaSync
950 // breadcrumbs are off AND no Yoast/Rank Math breadcrumb provider is
951 // active — in that state the override can never reach a trail.
952 // Stored overrides are never deleted, so this is display-only.
953 'breadcrumbs' => array(
954 'titleFieldEnabled' => class_exists('Metasync_Breadcrumbs')
955 ? Metasync_Breadcrumbs::is_breadcrumb_title_meaningful()
956 : true,
957 ),
958 'wpmlEntries' => $wpml_entries,
959 // '1' when enabled, '' when disabled. wp_localize_script() casts
960 // top-level scalars with (string), so a raw boolean false would
961 // arrive as "" and could not be distinguished from a missing key
962 // by a strict JS check. Follows the isCustomOrLpsPage pattern.
963 'languageAlternatesEnabled' => $language_alternates_enabled ? '1' : '',
964 'otto' => array(
965 'globalEnabled' => self::is_otto_enabled_globally(),
966 'name' => $otto_name,
967 ),
968 'limits' => array(
969 'seoTitle' => array(
970 'min' => 50,
971 'max' => 60,
972 'absolute' => 70,
973 ),
974 'metaDescription' => array(
975 'min' => 120,
976 'max' => 160,
977 'absolute' => 200,
978 ),
979 ),
980 'schemaContent' => $schema_content_config,
981 'linkSuggestions' => $link_suggestions_config,
982 'i18n' => array(
983 /* translators: %s: Plugin name (whitelabel-aware) */
984 'panelTitle' => sprintf(__('%s SEO', 'metasync'), $plugin_name),
985 'seoTitleLabel' => __('SEO Title', 'metasync'),
986 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
987 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
988 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
989 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
990 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
991 'serpDesktop' => __('Desktop', 'metasync'),
992 'serpMobile' => __('Mobile', 'metasync'),
993 'characters' => __('characters', 'metasync'),
994 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
995 'ottoPrefillHelp' => sprintf(
996 /* translators: %s: OTTO name (whitelabel). */
997 __('Pre-filled from %s. Edit to customize.', 'metasync'),
998 $otto_name
999 ),
1000 'importedPrefillHelp' => sprintf(
1001 /* translators: %s: OTTO name (whitelabel) */
1002 __('Suggestion imported from another SEO plugin. Used only until %s has its own suggestion for this page.', 'metasync'),
1003 $otto_name
1004 ),
1005 // Focus Keyword: read-only field surfacing the OTTO keyword
1006 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
1007 'focusKeywordHelp' => sprintf(
1008 /* translators: %s: OTTO name (whitelabel) */
1009 __('Managed by %s. Set in Search Atlas — read-only here.', 'metasync'),
1010 $otto_name
1011 ),
1012 'breadcrumbPanelTitle' => __('Breadcrumbs', 'metasync'),
1013 'breadcrumbTitleLabel' => __('Breadcrumb Title Override', 'metasync'),
1014 'breadcrumbTitleHelp' => __('Custom label for this page in breadcrumb trails. Leave empty to use the post title.', 'metasync'),
1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
1016 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
1017 /* translators: %s: OTTO name (whitelabel) */
1018 'ottoOverrideNotice' => sprintf(
1019 /* translators: 1: OTTO name (whitelabel), 2: OTTO name (whitelabel). */
1020 __('%1$s is enabled. Any SEO title and description changes from %2$s will be overwritten by your custom values entered here.', 'metasync'),
1021 $otto_name,
1022 $otto_name
1023 ),
1024 // Language Alternates (hreflang) panel strings
1025 'languageAlternatesTitle' => __('Language Alternates', 'metasync'),
1026 'addAlternate' => __('Add alternate', 'metasync'),
1027 'langLabel' => __('Language', 'metasync'),
1028 'regionLabel' => __('Region', 'metasync'),
1029 'urlLabel' => __('URL', 'metasync'),
1030 'editManually' => __('Edit Manually', 'metasync'),
1031 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
1032 'hreflangFormatHelp' => __('Use ISO codes (e.g. en, en-US, x-default — hyphen, not underscore) and absolute URLs (https://…). A self-reference for this page is added automatically when missing.', 'metasync'),
1033 'hreflangInvalidRow' => __('This row will not be emitted: the language code must look like "en" or "en-US", and the URL must be absolute (https://…).', 'metasync'),
1034 // Advanced Robots Directives
1035 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
1036 'nofollowLabel' => __('Nofollow', 'metasync'),
1037 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),
1038 'noarchiveLabel' => __('Noarchive', 'metasync'),
1039 'noarchiveHelp' => __('Prevent search engines from showing cached versions.', 'metasync'),
1040 'nosnippetLabel' => __('Nosnippet', 'metasync'),
1041 'nosnippetHelp' => __('Prevent search engines from showing text snippets.', 'metasync'),
1042 'noimageindexLabel' => __('No Image Index', 'metasync'),
1043 'noimageindexHelp' => __('Prevent this page from appearing as image search referrer.', 'metasync'),
1044 'maxSnippetLabel' => __('Max Snippet Length', 'metasync'),
1045 'maxSnippetHelp' => __('Maximum character length for text snippets. -1 for unlimited.', 'metasync'),
1046 'maxImagePreviewLabel' => __('Max Image Preview', 'metasync'),
1047 'maxImagePreviewHelp' => __('Maximum size of image preview in search results.', 'metasync'),
1048 // Plugin Sync Status
1049 'syncStatusTitle' => __('Plugin Sync Status', 'metasync'),
1050 'syncedTo' => __('Synced to:', 'metasync'),
1051 'syncNever' => __('Never synced', 'metasync'),
1052 'syncAgo' => __('ago', 'metasync'),
1053 // LPS / custom-HTML read-only notice
1054 'lpsNotice' => __('The SEO for this page is managed by WebStudio.', 'metasync'),
1055 ),
1056 ));
1057 }
1058
1059 /**
1060 * Clean up primary category meta if the selected category was unchecked from the post.
1061 * Runs on save_post to cover all post types, not just 'post' and 'page'.
1062 *
1063 * @param int $post_id Post ID
1064 */
1065 public function cleanup_primary_category_meta($post_id) {
1066 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1067 return;
1068 }
1069
1070 // Clean up primary category
1071 $primary_cat = (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true);
1072 if ($primary_cat > 0) {
1073 $taxonomies = get_object_taxonomies(get_post_type($post_id), 'names');
1074 if (empty($taxonomies)) {
1075 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
1076 return;
1077 }
1078 $term_ids = wp_get_object_terms($post_id, $taxonomies, array('fields' => 'ids'));
1079 if (!is_wp_error($term_ids) && !in_array($primary_cat, $term_ids)) {
1080 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
1081 }
1082 }
1083
1084 // Clean up primary product category (WooCommerce)
1085 $primary_product_cat = (int) get_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT, true);
1086 if ($primary_product_cat > 0) {
1087 $product_cat_ids = wp_get_object_terms($post_id, 'product_cat', array('fields' => 'ids'));
1088 if (!is_wp_error($product_cat_ids) && !in_array($primary_product_cat, $product_cat_ids)) {
1089 delete_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT);
1090 }
1091 }
1092 }
1093
1094 /**
1095 * Get WooCommerce product data for a post (if WC is active and post is a product)
1096 *
1097 * @param int $post_id Post ID
1098 * @return array|null Product data or null
1099 */
1100 private function get_woocommerce_data_for_post($post_id) {
1101 if (!class_exists('WooCommerce') || $post_id <= 0) {
1102 return null;
1103 }
1104
1105 $post = get_post($post_id);
1106 if (!$post || $post->post_type !== 'product') {
1107 return null;
1108 }
1109
1110 $product = wc_get_product($post_id);
1111 if (!$product) {
1112 return null;
1113 }
1114
1115 return array(
1116 'price' => $product->get_price(),
1117 'currency' => get_woocommerce_currency(),
1118 'availability' => $product->is_in_stock() ? 'InStock' : 'OutOfStock',
1119 'sku' => $product->get_sku(),
1120 );
1121 }
1122
1123 }
1124
1125