PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
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.6.25, at admin/class-metasync-seo-sidebar.php

1,091 lines 47.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 // Track meta state before REST API save, then clean up after (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 save/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_filter("rest_pre_insert_{$post_type}", array($this, 'track_meta_before_save'), 10, 2);
129 add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
130 }
131 }
132
133 /**
134 * Check if OTTO SSR is globally enabled
135 *
136 * @return bool True if OTTO is enabled globally
137 */
138 public static function is_otto_enabled_globally() {
139 $metasync_options = get_option('metasync_options');
140 $otto_enabled = $metasync_options['general']['otto_enable'] ?? false;
141 return ($otto_enabled === 'true' || $otto_enabled === true);
142 }
143
144 /**
145 * Track meta state before REST API save
146 * Stores which SEO meta keys existed before the save
147 *
148 * @param stdClass $prepared_post Prepared post object
149 * @param WP_REST_Request $request Request object
150 * @return stdClass Unmodified prepared post object
151 */
152 public function track_meta_before_save($prepared_post, $request) {
153 $post_id = $request->get_param('id');
154 if (!$post_id) {
155 return $prepared_post;
156 }
157
158 // Track which meta keys existed before this save
159 $meta_existed = array(
160 self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
161 self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
162 );
163
164 // Store in transient for use in cleanup
165 set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
166
167 return $prepared_post;
168 }
169
170 /**
171 * Clean up empty SEO meta values after REST API save
172 * This prevents blanking fields that are showing OTTO fallback values
173 *
174 * Deletes meta keys that are empty AND didn't exist before the save
175 * (meaning they were showing OTTO fallback, not user-edited values)
176 *
177 * @param WP_Post $post Inserted or updated post object
178 * @param WP_REST_Request $_request Request object (unused)
179 * @param bool $_creating True when creating a post, false when updating (unused)
180 */
181 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
182 $post_id = $post->ID;
183
184 // Get the before-save state
185 $meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
186 if ($meta_existed === false) {
187 // Transient expired or not set, can't make safe decision
188 return;
189 }
190
191 // Delete transient
192 delete_transient('metasync_seo_meta_existed_' . $post_id);
193
194 // Check each of our SEO meta keys
195 foreach ($meta_existed as $meta_key => $existed_before) {
196 // Get the current value
197 $meta_value = get_post_meta($post_id, $meta_key, true);
198
199 // Only delete if:
200 // 1. Value is now empty
201 // 2. It didn't exist before the save (was showing OTTO fallback)
202 if (empty($meta_value) && !$existed_before) {
203 delete_post_meta($post_id, $meta_key);
204 }
205 }
206 }
207
208 /**
209 * Output meta description tags from sidebar field
210 * Custom values ALWAYS take priority over OTTO
211 * Outputs: meta description, og:description, and twitter:description,
212 * plus og:title / twitter:title when a suppressed third-party tag needs replacing
213 */
214 public function output_seo_meta_description() {
215 // Skip for admin, feeds, etc.
216 if (is_admin() || is_feed() || is_robots()) {
217 return;
218 }
219
220 // Only run on singular pages (posts, pages, custom post types)
221 if (!is_singular()) {
222 return;
223 }
224
225 $post_id = get_the_ID();
226 if (!$post_id) {
227 return;
228 }
229
230 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
231
232 // og:title / twitter:title replacement (WP-609).
233 //
234 // Still needed, but for a narrower case than when it was written. The
235 // conflict-handler filters now hand a third-party plugin MetaSync's OG
236 // title whenever one is set, so that plugin renders it and nothing is
237 // required here. What the filters cannot cover is a post holding a page
238 // title and no OG-specific value: there they pass the plugin's own tag
239 // through, and without this block the page carries the plugin's og:title
240 // while <title> carries ours. og_title_needs_replacement() returns false
241 // once the filters are supplying the tag, so the two never both emit.
242 if ($conflict_handler->og_title_needs_replacement()) {
243 $title = $conflict_handler->get_metasync_title();
244 if (!empty($title)) {
245 $title_escaped = esc_attr($title);
246 echo '<meta property="og:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n";
247 echo '<meta name="twitter:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n";
248 }
249 }
250
251 // With an active third-party SEO plugin, that plugin emits the description
252 // tag and the conflict handler hands it MetaSync's value when MetaSync
253 // holds one. Emitting here as well would put two identical description
254 // tags on the page.
255 //
256 // The condition is "will that plugin render a description", not "has a
257 // sync run": a timestamp only records that a sync happened, and the
258 // mirrored copy can be edited or emptied in the plugin afterwards. When
259 // neither side holds anything we fall through, so a stale or partial sync
260 // cannot drop the description entirely.
261 $defer_meta_description = false;
262 if ($conflict_handler->has_active_seo_plugin()) {
263 if ($conflict_handler->metasync_has_description()
264 || $conflict_handler->active_plugin_has_description($post_id)) {
265 $defer_meta_description = true;
266 }
267 }
268
269 // With a plugin active the conflict handler now supplies og:/twitter:
270 // description too — falling back to the page description when no
271 // OG-specific value is set — so that plugin renders exactly one. Emitting
272 // here as well is always a second tag, never a replacement.
273 $defer_og_description = $conflict_handler->has_active_seo_plugin()
274 || $conflict_handler->third_party_owns_og_description();
275
276 if ($defer_meta_description && $defer_og_description) {
277 return;
278 }
279
280 // Get the custom SEO description (takes priority)
281 $description = get_post_meta($post_id, self::META_DESCRIPTION, true);
282
283 // Fall back to an imported description ONLY when the customer has not
284 // set one and OTTO has no suggestion for this post. Imported values are
285 // migration data, not a per-post customer decision, so they must never
286 // displace OTTO. Emitted without the data-metasync-seo="custom" marker
287 // so Otto_html_class::force_custom_meta() does not mistake it for an
288 // explicit override.
289 $is_imported_fallback = false;
290 if (empty($description)) {
291 $otto_description = $this->otto_disabled_for_post($post_id)
292 ? ''
293 : get_post_meta($post_id, '_metasync_otto_description', true);
294 if (empty($otto_description)) {
295 $imported = get_post_meta($post_id, self::META_IMPORTED_DESCRIPTION, true);
296 if (!empty($imported) && is_string($imported) && !$this->third_party_owns_output()) {
297 $description = $imported;
298 $is_imported_fallback = true;
299 }
300 }
301 }
302
303 // Output meta description tags if custom value exists
304 // This will be output BEFORE OTTO processes the page, and since we're using
305 // a high priority filter, it will override OTTO's meta description
306 if (!empty($description)) {
307 $description_escaped = esc_attr($description);
308 $marker = $is_imported_fallback ? ' data-metasync-seo="imported"' : ' data-metasync-seo="custom"';
309 // Standard meta description
310 if (!$defer_meta_description) {
311 echo '<meta name="description" content="' . $description_escaped . '"' . $marker . ' />' . "\n";
312 }
313
314 // og:description / twitter:description are Open Graph & social tags, so they
315 // answer to the site-wide Social Media & Open Graph switch as well as the
316 // per-post "Enable Open Graph & Social Media Tags" toggle. For the per-post
317 // toggle only an explicit '0' opt-out disables them (unset/empty counts as
318 // enabled), mirroring Metasync_OpenGraph::will_emit(). When either says no,
319 // MetaSync emits no OG/Twitter description, so it neither overrides a
320 // third-party plugin's tag nor leaves one sourced from our database.
321 //
322 // The plain <meta name="description"> above is deliberately NOT gated here:
323 // it belongs to the SEO title/description feature, which has its own setting.
324 if (!$defer_og_description
325 && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG)
326 && get_post_meta($post_id, '_metasync_og_enabled', true) !== '0') {
327 // Open Graph description
328 echo '<meta property="og:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n";
329 // Twitter description
330 echo '<meta name="twitter:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n";
331 }
332 }
333 }
334
335 /**
336 * Filter document title (pre_get_document_title filter)
337 * Custom values ALWAYS take priority over OTTO
338 *
339 * @param string $title Current title
340 * @return string Modified title or original
341 */
342 public function filter_document_title($title) {
343 // Skip for admin
344 if (is_admin()) {
345 return $title;
346 }
347
348 // Only run on singular pages
349 if (!is_singular()) {
350 return $title;
351 }
352
353 $post_id = get_the_ID();
354 if (!$post_id) {
355 return $title;
356 }
357
358 // Get the custom SEO title (takes priority)
359 $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
360
361 // Return custom SEO title if set
362 return !empty($seo_title) ? $seo_title : $title;
363 }
364
365 /**
366 * Whether the per-post "Disable OTTO" toggle is set for this post.
367 *
368 * OTTO stands down completely for such a post, so its persisted suggestion is
369 * dead data. Treating that dead value as "OTTO has something to say" would
370 * block the imported fallback and leave the page with no description at all —
371 * OTTO suppressed, imported suppressed, nothing emitted.
372 *
373 * @param int $post_id Post ID.
374 * @return bool True when OTTO is switched off for this post.
375 */
376 private function otto_disabled_for_post($post_id) {
377 if (!class_exists('Metasync_Otto_Frontend_Toolbar')) {
378 return false;
379 }
380
381 return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id);
382 }
383
384 /**
385 * Whether a third-party SEO plugin is the one actually rendering this page.
386 *
387 * Mirrors the guard OTTO's own filters use
388 * (otto/metasync-otto-seo-functions.php): when an active plugin owns output
389 * and OTTO has nothing live to say, leave that plugin's tags alone.
390 *
391 * Gates the *imported* value only. Imported values are migration data with
392 * no per-post customer intent behind them, so they must never compete with a
393 * plugin the customer is still using. The description emitter is an echo on
394 * wp_head rather than a filter return, so without this it would add a second
395 * <meta name="description"> next to the third-party plugin's own.
396 *
397 * @return bool True when MetaSync should stand down for imported values.
398 */
399 private function third_party_owns_output() {
400 if (!class_exists('Metasync_SEO_Conflict_Handler')) {
401 return false;
402 }
403
404 $handler = Metasync_SEO_Conflict_Handler::get_instance();
405
406 return $handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions();
407 }
408
409 /**
410 * Supply an imported SEO title as a fallback BELOW OTTO.
411 *
412 * Registered at priority 98 so OTTO's own callback (priority 99) runs
413 * afterwards: OTTO overwrites this whenever it has a suggestion, and
414 * returns the title untouched when it does not — which is exactly when the
415 * imported value should show. The customer's own title (priority 100) still
416 * wins over both.
417 *
418 * @param string $title Current title.
419 * @return string Imported title when it should apply, else unchanged.
420 */
421 public function filter_document_title_imported($title) {
422 if (is_admin() || !is_singular()) {
423 return $title;
424 }
425
426 $post_id = get_the_ID();
427 if (!$post_id) {
428 return $title;
429 }
430
431 // Never outrank a third-party SEO plugin that is the one actually
432 // rendering this page.
433 if ($this->third_party_owns_output()) {
434 return $title;
435 }
436
437 $imported = get_post_meta($post_id, self::META_IMPORTED_SEO_TITLE, true);
438
439 return (!empty($imported) && is_string($imported)) ? $imported : $title;
440 }
441
442 /**
443 * Filter document title parts (for themes using wp_get_document_title)
444 * Custom values ALWAYS take priority over OTTO
445 *
446 * @param array $title_parts Title parts array
447 * @return array Modified title parts
448 */
449 public function filter_document_title_parts($title_parts) {
450 // Skip for admin
451 if (is_admin()) {
452 return $title_parts;
453 }
454
455 // Only run on singular pages
456 if (!is_singular()) {
457 return $title_parts;
458 }
459
460 $post_id = get_the_ID();
461 if (!$post_id) {
462 return $title_parts;
463 }
464
465 // Get the custom SEO title (takes priority)
466 $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
467
468 // Replace title part if custom SEO title is set
469 if (!empty($seo_title)) {
470 $title_parts['title'] = $seo_title;
471 // Remove tagline and site for clean SEO title
472 unset($title_parts['tagline']);
473 unset($title_parts['site']);
474 }
475
476 return $title_parts;
477 }
478
479 /**
480 * Register meta fields for REST API access
481 */
482 public function register_meta_fields() {
483 // Get all public post types
484 $post_types = get_post_types(array('public' => true), 'names');
485
486 foreach ($post_types as $post_type) {
487 // Register SEO title meta
488 register_post_meta($post_type, self::META_SEO_TITLE, array(
489 'show_in_rest' => true,
490 'single' => true,
491 'type' => 'string',
492 'sanitize_callback' => 'sanitize_text_field',
493 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
494 if (empty($object_id)) {
495 $pt_obj = get_post_type_object($post_type);
496 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
497 }
498 return current_user_can('edit_post', $object_id);
499 },
500 ));
501
502 // Register meta description
503 register_post_meta($post_type, self::META_DESCRIPTION, array(
504 'show_in_rest' => true,
505 'single' => true,
506 'type' => 'string',
507 'sanitize_callback' => 'sanitize_textarea_field',
508 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
509 if (empty($object_id)) {
510 $pt_obj = get_post_type_object($post_type);
511 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
512 }
513 return current_user_can('edit_post', $object_id);
514 },
515 ));
516
517 // Register breadcrumb title override
518 register_post_meta($post_type, '_metasync_breadcrumb_title', array(
519 'show_in_rest' => true,
520 'single' => true,
521 'type' => 'string',
522 'sanitize_callback' => 'sanitize_text_field',
523 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
524 if (empty($object_id)) {
525 $pt_obj = get_post_type_object($post_type);
526 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
527 }
528 return current_user_can('edit_post', $object_id);
529 },
530 ));
531
532 // Register primary category for breadcrumbs
533 register_post_meta($post_type, '_metasync_primary_category', array(
534 'show_in_rest' => true,
535 'single' => true,
536 'type' => 'integer',
537 'sanitize_callback' => 'absint',
538 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
539 if (empty($object_id)) {
540 $pt_obj = get_post_type_object($post_type);
541 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
542 }
543 return current_user_can('edit_post', $object_id);
544 },
545 ));
546
547 // Register primary product category meta (WooCommerce-specific)
548 register_post_meta($post_type, self::META_PRIMARY_PRODUCT_CAT, array(
549 'show_in_rest' => true,
550 'single' => true,
551 'type' => 'integer',
552 'sanitize_callback' => 'absint',
553 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
554 if (empty($object_id)) {
555 $pt_obj = get_post_type_object($post_type);
556 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
557 }
558 return current_user_can('edit_post', $object_id);
559 },
560 ));
561
562 // Register OTTO meta keys for REST API (read-only for fallback/prefill)
563 register_post_meta($post_type, '_metasync_otto_title', array(
564 'show_in_rest' => true,
565 'single' => true,
566 'type' => 'string',
567 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
568 if (empty($object_id)) {
569 $pt_obj = get_post_type_object($post_type);
570 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
571 }
572 return current_user_can('edit_post', $object_id);
573 },
574 ));
575
576 register_post_meta($post_type, '_metasync_otto_description', array(
577 'show_in_rest' => true,
578 'single' => true,
579 'type' => 'string',
580 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
581 if (empty($object_id)) {
582 $pt_obj = get_post_type_object($post_type);
583 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
584 }
585 return current_user_can('edit_post', $object_id);
586 },
587 ));
588
589 // Register imported SEO meta for REST API (read-only fallback shown
590 // as a placeholder only when OTTO has no suggestion)
591 foreach (array(self::META_IMPORTED_SEO_TITLE, self::META_IMPORTED_DESCRIPTION) as $imported_key) {
592 register_post_meta($post_type, $imported_key, array(
593 'show_in_rest' => true,
594 'single' => true,
595 'type' => 'string',
596 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
597 if (empty($object_id)) {
598 $pt_obj = get_post_type_object($post_type);
599 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
600 }
601 return current_user_can('edit_post', $object_id);
602 },
603 ));
604 }
605
606 // Register OTTO focus keyword for REST API (read-only display in sidebar)
607 register_post_meta($post_type, self::META_OTTO_KEYWORDS, array(
608 'show_in_rest' => true,
609 'single' => true,
610 'type' => 'string',
611 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
612 if (empty($object_id)) {
613 $pt_obj = get_post_type_object($post_type);
614 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
615 }
616 return current_user_can('edit_post', $object_id);
617 },
618 ));
619
620 // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
621 register_post_meta($post_type, '_metasync_otto_disabled', array(
622 'show_in_rest' => true,
623 'single' => true,
624 'type' => 'string',
625 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
626 if (empty($object_id)) {
627 $pt_obj = get_post_type_object($post_type);
628 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
629 }
630 return current_user_can('edit_post', $object_id);
631 },
632 ));
633
634 // Register hreflang / language alternates meta (JSON-encoded array)
635 register_post_meta($post_type, self::META_HREFLANG, array(
636 'show_in_rest' => true,
637 'single' => true,
638 'type' => 'string',
639 'sanitize_callback' => function($value) {
640 $decoded = json_decode($value, true);
641 if (!is_array($decoded)) {
642 return '[]';
643 }
644 return wp_json_encode($decoded);
645 },
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 plugin sync timestamp meta
656 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
657 'show_in_rest' => true,
658 'single' => true,
659 'type' => 'string',
660 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
661 if (empty($object_id)) {
662 $pt_obj = get_post_type_object($post_type);
663 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
664 }
665 return current_user_can('edit_post', $object_id);
666 },
667 ));
668
669 // Register advanced robots directives meta
670 register_post_meta($post_type, self::META_ROBOTS_ADVANCED, array(
671 'show_in_rest' => true,
672 'single' => true,
673 'type' => 'string',
674 'sanitize_callback' => function($value) {
675 $decoded = json_decode($value, true);
676 if (!is_array($decoded)) {
677 return '{}';
678 }
679 $allowed_bool = ['nofollow', 'noarchive', 'nosnippet', 'noimageindex'];
680 $sanitized = [];
681 foreach ($allowed_bool as $key) {
682 if (isset($decoded[$key])) {
683 $sanitized[$key] = (bool) $decoded[$key];
684 }
685 }
686 if (isset($decoded['max_snippet'])) {
687 $sanitized['max_snippet'] = (int) $decoded['max_snippet'];
688 }
689 if (isset($decoded['max_image_preview'])) {
690 $allowed = ['none', 'standard', 'large'];
691 $sanitized['max_image_preview'] = in_array($decoded['max_image_preview'], $allowed, true)
692 ? $decoded['max_image_preview'] : 'large';
693 }
694 if (isset($decoded['max_video_preview'])) {
695 $sanitized['max_video_preview'] = (int) $decoded['max_video_preview'];
696 }
697 return wp_json_encode($sanitized);
698 },
699 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
700 if (empty($object_id)) {
701 $pt_obj = get_post_type_object($post_type);
702 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
703 }
704 return current_user_can('edit_post', $object_id);
705 },
706 ));
707 }
708 }
709
710 /**
711 * Build a read-only list of WPML translation entries for a post.
712 *
713 * Returns an array of {lang, url} objects by querying the
714 * `icl_translations` table. Used to seed the "Language Alternates"
715 * Gutenberg panel with auto-populated values.
716 *
717 * @param int $post_id Post ID.
718 * @return array
719 */
720 private function get_wpml_entries_for_post($post_id) {
721 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
722 return array();
723 }
724
725 global $wpdb;
726 $post = get_post($post_id);
727 if (!$post) {
728 return array();
729 }
730
731 $element_type = 'post_' . $post->post_type;
732 $table = $wpdb->prefix . 'icl_translations';
733
734 $trid = $wpdb->get_var($wpdb->prepare(
735 "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
736 $post_id,
737 $element_type
738 ));
739 if (empty($trid)) {
740 return array();
741 }
742
743 $rows = $wpdb->get_results($wpdb->prepare(
744 "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
745 $trid
746 ));
747 if (empty($rows)) {
748 return array();
749 }
750
751 $entries = array();
752 foreach ($rows as $row) {
753 $permalink = get_permalink((int) $row->element_id);
754 if (empty($permalink)) {
755 continue;
756 }
757 $entries[] = array(
758 'lang' => (string) $row->language_code,
759 'url' => $permalink,
760 );
761 }
762 return $entries;
763 }
764
765 /**
766 * Enqueue block editor assets
767 */
768 public function enqueue_block_editor_assets() {
769 // Get current screen
770 $screen = get_current_screen();
771
772 // Only load on post edit screens
773 if (!$screen || $screen->base !== 'post') {
774 return;
775 }
776
777 // Enqueue the sidebar script
778 wp_enqueue_script(
779 'metasync-seo-sidebar',
780 plugin_dir_url(__FILE__) . 'js/metasync-seo-sidebar.js',
781 array(
782 'wp-plugins',
783 'wp-edit-post',
784 'wp-element',
785 'wp-components',
786 'wp-data',
787 'wp-compose',
788 'wp-i18n',
789 'wp-api-fetch',
790 ),
791 $this->version,
792 true
793 );
794
795 // Enqueue sidebar styles
796 wp_enqueue_style(
797 'metasync-seo-sidebar',
798 plugin_dir_url(__FILE__) . 'css/metasync-seo-sidebar.css',
799 array('wp-components'),
800 $this->version
801 );
802
803 // Get whitelabel plugin name
804 $plugin_name = 'MetaSync';
805 if (class_exists('Metasync') && method_exists('Metasync', 'get_effective_plugin_name')) {
806 $plugin_name = Metasync::get_effective_plugin_name('MetaSync');
807 }
808
809 // Get whitelabel icon URL for the block editor sidebar.
810 // Priority: white_label_plugin_menu_icon (the icon set in WL Settings → Branding)
811 // because that's the same field driving the admin menu icon.
812 // Falls back to whitelabel['logo'], then the bundled default SVG.
813 $icon_url = plugin_dir_url(__FILE__) . 'images/icon-256x256.svg';
814 $menu_icon = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
815 if (!empty($menu_icon) && filter_var($menu_icon, FILTER_VALIDATE_URL)) {
816 $icon_url = $menu_icon;
817 } elseif (!empty(Metasync::get_whitelabel_logo())) {
818 $icon_url = Metasync::get_whitelabel_logo();
819 }
820
821 // Get OTTO whitelabel name
822 $otto_name = 'OTTO';
823 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
824 $otto_name = Metasync::get_whitelabel_otto_name();
825 }
826
827 // Get current post ID and check if meta keys exist in database
828 $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
829 $has_seo_title = false;
830 $has_seo_description = false;
831
832 if ($post_id > 0) {
833 // Check if meta keys exist in database (even if value is empty)
834 $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
835 $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
836 }
837
838 // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
839 // panels and surface a read-only notice instead.
840 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
841 // required conditionally, so the function_exists guard is needed at runtime.
842 $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
843
844 // Auto-detected WPML entries for the "Language Alternates" panel.
845 $wpml_entries = $this->get_wpml_entries_for_post($post_id);
846
847 // Link Suggestions configuration
848 $link_suggestions_config = array(
849 'restUrl' => rest_url('metasync/v1/link-suggestions'),
850 'yoastPremiumActive' => class_exists('WPSEO_Premium'),
851 'rankMathActive' => defined('RANK_MATH_VERSION'),
852 'i18n' => array(
853 'panelTitle' => __('Internal Link Suggestions', 'metasync'),
854 'insertButton' => __('Insert', 'metasync'),
855 'refreshButton' => __('Refresh', 'metasync'),
856 'noSuggestions' => __('No suggestions found.', 'metasync'),
857 'loading' => __('Analyzing content…', 'metasync'),
858 'matchedPhrase' => __('Matched phrase:', 'metasync'),
859 'yoastNotice' => __('Yoast Premium detected. Their internal linking tool may overlap with this feature.', 'metasync'),
860 'rankMathNotice' => __('Rank Math detected. Their internal linking tool may overlap with this feature.', 'metasync'),
861 ),
862 );
863
864 // Detect active SEO plugins that provide their own primary category selector
865 $other_seo_primary = array(
866 'yoastActive' => defined('WPSEO_VERSION'),
867 'rankMathActive' => defined('RANK_MATH_VERSION'),
868 'aioseoActive' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
869 );
870
871 // Schema Content configuration
872 $schema_content_config = array(
873 'restUrl' => rest_url('metasync/v1/schema-content'),
874 'woocommerceActive' => class_exists('WooCommerce'),
875 'woocommerceData' => $this->get_woocommerce_data_for_post($post_id),
876 'i18n' => array(
877 'panelTitle' => __('Schema Markup Content', 'metasync'),
878 'saveButton' => __('Save Schema Content', 'metasync'),
879 'autoPopulateWC' => __('Auto-populate from WooCommerce', 'metasync'),
880 'noSchemaTypes' => __('No schema types configured. Add schema types in the classic editor or via the Schema Markup metabox.', 'metasync'),
881 'addQuestion' => __('Add Question', 'metasync'),
882 'removeQuestion' => __('Remove', 'metasync'),
883 'addStep' => __('Add Step', 'metasync'),
884 'removeStep' => __('Remove', 'metasync'),
885 'addIngredient' => __('Add Ingredient', 'metasync'),
886 'removeIngredient' => __('Remove', 'metasync'),
887 'addInstruction' => __('Add Instruction', 'metasync'),
888 'removeInstruction' => __('Remove', 'metasync'),
889 'saving' => __('Saving...', 'metasync'),
890 'saved' => __('Schema content saved.', 'metasync'),
891 'saveError' => __('Failed to save schema content.', 'metasync'),
892 ),
893 );
894
895 // Localize script with meta keys and settings
896 wp_localize_script('metasync-seo-sidebar', 'metasyncSeoSidebar', array(
897 'iconUrl' => $icon_url,
898 'isCustomOrLpsPage' => $is_lps_page,
899 'otherSeoPrimary' => $other_seo_primary,
900 'metaKeys' => array(
901 'seoTitle' => self::META_SEO_TITLE,
902 'metaDescription' => self::META_DESCRIPTION,
903 // Breadcrumb meta keys
904 'breadcrumbTitle' => '_metasync_breadcrumb_title',
905 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
906 'ottoTitle' => '_metasync_otto_title',
907 'ottoDescription' => '_metasync_otto_description',
908 // Imported keys (read-only). Rank below OTTO — used as the
909 // placeholder only when OTTO has no suggestion for the post.
910 'importedTitle' => self::META_IMPORTED_SEO_TITLE,
911 'importedDescription' => self::META_IMPORTED_DESCRIPTION,
912 // OTTO focus keyword (read-only display only)
913 'ottoKeywords' => self::META_OTTO_KEYWORDS,
914 // OTTO disabled per-post flag
915 'ottoDisabled' => '_metasync_otto_disabled',
916 // Primary category
917 'primaryCategory' => self::META_PRIMARY_CATEGORY,
918 'primaryProductCat' => self::META_PRIMARY_PRODUCT_CAT,
919 // Language alternates (hreflang) — JSON-encoded array
920 'hreflang' => self::META_HREFLANG,
921 // Plugin sync timestamp
922 'pluginSyncTs' => self::META_PLUGIN_SYNC_TS,
923 // Advanced robots directives
924 'robotsAdvanced' => self::META_ROBOTS_ADVANCED,
925 ),
926 'activeSeoPlugins' => array(
927 'yoast' => defined('WPSEO_VERSION'),
928 'rankmath' => defined('RANK_MATH_VERSION'),
929 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
930 ),
931 'wpmlEntries' => $wpml_entries,
932 'hasMetaKeys' => array(
933 // Whether the manual meta keys exist in database (PHP check)
934 'seoTitle' => $has_seo_title,
935 'metaDescription' => $has_seo_description,
936 ),
937 'otto' => array(
938 'globalEnabled' => self::is_otto_enabled_globally(),
939 'name' => $otto_name,
940 ),
941 'limits' => array(
942 'seoTitle' => array(
943 'min' => 50,
944 'max' => 60,
945 'absolute' => 70,
946 ),
947 'metaDescription' => array(
948 'min' => 120,
949 'max' => 160,
950 'absolute' => 200,
951 ),
952 ),
953 'schemaContent' => $schema_content_config,
954 'linkSuggestions' => $link_suggestions_config,
955 'i18n' => array(
956 /* translators: %s: Plugin name (whitelabel-aware) */
957 'panelTitle' => sprintf(__('%s SEO', 'metasync'), $plugin_name),
958 'seoTitleLabel' => __('SEO Title', 'metasync'),
959 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
960 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
961 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
962 'urlSlugLabel' => __('URL Slug', 'metasync'),
963 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
964 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
965 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
966 'serpDesktop' => __('Desktop', 'metasync'),
967 'serpMobile' => __('Mobile', 'metasync'),
968 'characters' => __('characters', 'metasync'),
969 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
970 'ottoPrefillHelp' => sprintf(
971 __('Pre-filled from %s. Edit to customize.', 'metasync'),
972 $otto_name
973 ),
974 // Focus Keyword: read-only field surfacing the OTTO keyword
975 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
976 'focusKeywordHelp' => sprintf(
977 /* translators: %s: OTTO name (whitelabel) */
978 __('Managed by %s. Set in Search Atlas — read-only here.', 'metasync'),
979 $otto_name
980 ),
981 'breadcrumbPanelTitle' => __('Breadcrumbs', 'metasync'),
982 'breadcrumbTitleLabel' => __('Breadcrumb Title Override', 'metasync'),
983 'breadcrumbTitleHelp' => __('Custom label for this page in breadcrumb trails. Leave empty to use the post title.', 'metasync'),
984 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
985 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
986 /* translators: %s: OTTO name (whitelabel) */
987 'ottoOverrideNotice' => sprintf(
988 __('%s is enabled. Any SEO title and description changes from %s will be overwritten by your custom values entered here.', 'metasync'),
989 $otto_name,
990 $otto_name
991 ),
992 // Language Alternates (hreflang) panel strings
993 'languageAlternatesTitle' => __('Language Alternates', 'metasync'),
994 'addAlternate' => __('Add alternate', 'metasync'),
995 'langLabel' => __('Language', 'metasync'),
996 'regionLabel' => __('Region', 'metasync'),
997 'urlLabel' => __('URL', 'metasync'),
998 'editManually' => __('Edit Manually', 'metasync'),
999 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
1000 // Advanced Robots Directives
1001 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
1002 'nofollowLabel' => __('Nofollow', 'metasync'),
1003 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),
1004 'noarchiveLabel' => __('Noarchive', 'metasync'),
1005 'noarchiveHelp' => __('Prevent search engines from showing cached versions.', 'metasync'),
1006 'nosnippetLabel' => __('Nosnippet', 'metasync'),
1007 'nosnippetHelp' => __('Prevent search engines from showing text snippets.', 'metasync'),
1008 'noimageindexLabel' => __('No Image Index', 'metasync'),
1009 'noimageindexHelp' => __('Prevent this page from appearing as image search referrer.', 'metasync'),
1010 'maxSnippetLabel' => __('Max Snippet Length', 'metasync'),
1011 'maxSnippetHelp' => __('Maximum character length for text snippets. -1 for unlimited.', 'metasync'),
1012 'maxImagePreviewLabel' => __('Max Image Preview', 'metasync'),
1013 'maxImagePreviewHelp' => __('Maximum size of image preview in search results.', 'metasync'),
1014 // Plugin Sync Status
1015 'syncStatusTitle' => __('Plugin Sync Status', 'metasync'),
1016 'syncedTo' => __('Synced to:', 'metasync'),
1017 'syncNever' => __('Never synced', 'metasync'),
1018 'syncAgo' => __('ago', 'metasync'),
1019 // LPS / custom-HTML read-only notice
1020 'lpsNotice' => __('The SEO for this page is managed by WebStudio.', 'metasync'),
1021 ),
1022 ));
1023 }
1024
1025 /**
1026 * Clean up primary category meta if the selected category was unchecked from the post.
1027 * Runs on save_post to cover all post types, not just 'post' and 'page'.
1028 *
1029 * @param int $post_id Post ID
1030 */
1031 public function cleanup_primary_category_meta($post_id) {
1032 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1033 return;
1034 }
1035
1036 // Clean up primary category
1037 $primary_cat = (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true);
1038 if ($primary_cat > 0) {
1039 $taxonomies = get_object_taxonomies(get_post_type($post_id), 'names');
1040 if (empty($taxonomies)) {
1041 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
1042 return;
1043 }
1044 $term_ids = wp_get_object_terms($post_id, $taxonomies, array('fields' => 'ids'));
1045 if (!is_wp_error($term_ids) && !in_array($primary_cat, $term_ids)) {
1046 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
1047 }
1048 }
1049
1050 // Clean up primary product category (WooCommerce)
1051 $primary_product_cat = (int) get_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT, true);
1052 if ($primary_product_cat > 0) {
1053 $product_cat_ids = wp_get_object_terms($post_id, 'product_cat', array('fields' => 'ids'));
1054 if (!is_wp_error($product_cat_ids) && !in_array($primary_product_cat, $product_cat_ids)) {
1055 delete_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT);
1056 }
1057 }
1058 }
1059
1060 /**
1061 * Get WooCommerce product data for a post (if WC is active and post is a product)
1062 *
1063 * @param int $post_id Post ID
1064 * @return array|null Product data or null
1065 */
1066 private function get_woocommerce_data_for_post($post_id) {
1067 if (!class_exists('WooCommerce') || $post_id <= 0) {
1068 return null;
1069 }
1070
1071 $post = get_post($post_id);
1072 if (!$post || $post->post_type !== 'product') {
1073 return null;
1074 }
1075
1076 $product = wc_get_product($post_id);
1077 if (!$product) {
1078 return null;
1079 }
1080
1081 return array(
1082 'price' => $product->get_price(),
1083 'currency' => get_woocommerce_currency(),
1084 'availability' => $product->is_in_stock() ? 'InStock' : 'OutOfStock',
1085 'sku' => $product->get_sku(),
1086 );
1087 }
1088
1089 }
1090
1091