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

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

858 lines 36.3 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 key for primary category (used in breadcrumbs and canonical URL)
38 */
39 const META_PRIMARY_CATEGORY = '_metasync_primary_category';
40
41 /**
42 * Meta key for primary product category (WooCommerce)
43 */
44 const META_PRIMARY_PRODUCT_CAT = '_metasync_primary_product_cat';
45
46 /**
47 * Meta key for hreflang / language alternates (JSON-encoded array)
48 */
49 const META_HREFLANG = '_metasync_hreflang';
50
51 /**
52 * Meta key for plugin sync timestamps (JSON-encoded object keyed by plugin slug)
53 */
54 const META_PLUGIN_SYNC_TS = '_metasync_plugin_sync_ts';
55
56 /**
57 * Meta key for advanced robots directives (JSON-encoded object)
58 */
59 const META_ROBOTS_ADVANCED = '_metasync_robots_advanced';
60
61 /**
62 * Constructor
63 *
64 * @param string $version Plugin version
65 */
66 public function __construct($version = '1.0.0') {
67 $this->version = $version;
68 $this->init();
69 }
70
71 /**
72 * Initialize hooks
73 */
74 private function init() {
75 // Register meta fields for REST API
76 add_action('init', array($this, 'register_meta_fields'));
77
78 // Enqueue block editor assets
79 add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
80
81 // Track meta state before REST API save, then clean up after (all public post types)
82 add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
83
84 // Clean up primary category meta when category is unchecked (all post types)
85 add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
86
87 // Frontend hooks for outputting SEO meta
88 // Custom values ALWAYS take priority over OTTO suggestions
89 add_action('wp_head', array($this, 'output_seo_meta_description'), 2);
90 add_filter('pre_get_document_title', array($this, 'filter_document_title'), 100);
91 add_filter('document_title_parts', array($this, 'filter_document_title_parts'), 100);
92 }
93
94 /**
95 * Register REST API save/cleanup hooks for all public post types
96 */
97 public function register_rest_save_hooks() {
98 $post_types = get_post_types(array('public' => true), 'names');
99 unset($post_types['attachment']);
100 foreach ($post_types as $post_type) {
101 add_filter("rest_pre_insert_{$post_type}", array($this, 'track_meta_before_save'), 10, 2);
102 add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
103 }
104 }
105
106 /**
107 * Check if OTTO SSR is globally enabled
108 *
109 * @return bool True if OTTO is enabled globally
110 */
111 public static function is_otto_enabled_globally() {
112 $metasync_options = get_option('metasync_options');
113 $otto_enabled = $metasync_options['general']['otto_enable'] ?? false;
114 return ($otto_enabled === 'true' || $otto_enabled === true);
115 }
116
117 /**
118 * Track meta state before REST API save
119 * Stores which SEO meta keys existed before the save
120 *
121 * @param stdClass $prepared_post Prepared post object
122 * @param WP_REST_Request $request Request object
123 * @return stdClass Unmodified prepared post object
124 */
125 public function track_meta_before_save($prepared_post, $request) {
126 $post_id = $request->get_param('id');
127 if (!$post_id) {
128 return $prepared_post;
129 }
130
131 // Track which meta keys existed before this save
132 $meta_existed = array(
133 self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
134 self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
135 );
136
137 // Store in transient for use in cleanup
138 set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
139
140 return $prepared_post;
141 }
142
143 /**
144 * Clean up empty SEO meta values after REST API save
145 * This prevents blanking fields that are showing OTTO fallback values
146 *
147 * Deletes meta keys that are empty AND didn't exist before the save
148 * (meaning they were showing OTTO fallback, not user-edited values)
149 *
150 * @param WP_Post $post Inserted or updated post object
151 * @param WP_REST_Request $_request Request object (unused)
152 * @param bool $_creating True when creating a post, false when updating (unused)
153 */
154 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
155 $post_id = $post->ID;
156
157 // Get the before-save state
158 $meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
159 if ($meta_existed === false) {
160 // Transient expired or not set, can't make safe decision
161 return;
162 }
163
164 // Delete transient
165 delete_transient('metasync_seo_meta_existed_' . $post_id);
166
167 // Check each of our SEO meta keys
168 foreach ($meta_existed as $meta_key => $existed_before) {
169 // Get the current value
170 $meta_value = get_post_meta($post_id, $meta_key, true);
171
172 // Only delete if:
173 // 1. Value is now empty
174 // 2. It didn't exist before the save (was showing OTTO fallback)
175 if (empty($meta_value) && !$existed_before) {
176 delete_post_meta($post_id, $meta_key);
177 }
178 }
179 }
180
181 /**
182 * Output meta description tags from sidebar field
183 * Custom values ALWAYS take priority over OTTO
184 * Outputs: meta description, og:description, and twitter:description
185 */
186 public function output_seo_meta_description() {
187 // Skip for admin, feeds, etc.
188 if (is_admin() || is_feed() || is_robots()) {
189 return;
190 }
191
192 // Only run on singular pages (posts, pages, custom post types)
193 if (!is_singular()) {
194 return;
195 }
196
197 $post_id = get_the_ID();
198 if (!$post_id) {
199 return;
200 }
201
202 // WP-196: When synced to an active SEO plugin, that plugin outputs
203 // the description from its native storage — skip MetaSync's own tag.
204 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
205 if ($conflict_handler->has_active_seo_plugin()) {
206 $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
207 if (!empty($sync_ts)) {
208 return;
209 }
210 }
211
212 // Get the custom SEO description (takes priority)
213 $description = get_post_meta($post_id, self::META_DESCRIPTION, true);
214
215 // Output meta description tags if custom value exists
216 // This will be output BEFORE OTTO processes the page, and since we're using
217 // a high priority filter, it will override OTTO's meta description
218 if (!empty($description)) {
219 $description_escaped = esc_attr($description);
220 // Standard meta description
221 echo '<meta name="description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
222 // Open Graph description
223 echo '<meta property="og:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
224 // Twitter description
225 echo '<meta name="twitter:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
226 }
227 }
228
229 /**
230 * Filter document title (pre_get_document_title filter)
231 * Custom values ALWAYS take priority over OTTO
232 *
233 * @param string $title Current title
234 * @return string Modified title or original
235 */
236 public function filter_document_title($title) {
237 // Skip for admin
238 if (is_admin()) {
239 return $title;
240 }
241
242 // Only run on singular pages
243 if (!is_singular()) {
244 return $title;
245 }
246
247 $post_id = get_the_ID();
248 if (!$post_id) {
249 return $title;
250 }
251
252 // Get the custom SEO title (takes priority)
253 $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
254
255 // Return custom SEO title if set
256 return !empty($seo_title) ? $seo_title : $title;
257 }
258
259 /**
260 * Filter document title parts (for themes using wp_get_document_title)
261 * Custom values ALWAYS take priority over OTTO
262 *
263 * @param array $title_parts Title parts array
264 * @return array Modified title parts
265 */
266 public function filter_document_title_parts($title_parts) {
267 // Skip for admin
268 if (is_admin()) {
269 return $title_parts;
270 }
271
272 // Only run on singular pages
273 if (!is_singular()) {
274 return $title_parts;
275 }
276
277 $post_id = get_the_ID();
278 if (!$post_id) {
279 return $title_parts;
280 }
281
282 // Get the custom SEO title (takes priority)
283 $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
284
285 // Replace title part if custom SEO title is set
286 if (!empty($seo_title)) {
287 $title_parts['title'] = $seo_title;
288 // Remove tagline and site for clean SEO title
289 unset($title_parts['tagline']);
290 unset($title_parts['site']);
291 }
292
293 return $title_parts;
294 }
295
296 /**
297 * Register meta fields for REST API access
298 */
299 public function register_meta_fields() {
300 // Get all public post types
301 $post_types = get_post_types(array('public' => true), 'names');
302
303 foreach ($post_types as $post_type) {
304 // Register SEO title meta
305 register_post_meta($post_type, self::META_SEO_TITLE, array(
306 'show_in_rest' => true,
307 'single' => true,
308 'type' => 'string',
309 'sanitize_callback' => 'sanitize_text_field',
310 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
311 if (empty($object_id)) {
312 $pt_obj = get_post_type_object($post_type);
313 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
314 }
315 return current_user_can('edit_post', $object_id);
316 },
317 ));
318
319 // Register meta description
320 register_post_meta($post_type, self::META_DESCRIPTION, array(
321 'show_in_rest' => true,
322 'single' => true,
323 'type' => 'string',
324 'sanitize_callback' => 'sanitize_textarea_field',
325 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
326 if (empty($object_id)) {
327 $pt_obj = get_post_type_object($post_type);
328 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
329 }
330 return current_user_can('edit_post', $object_id);
331 },
332 ));
333
334 // Register breadcrumb title override
335 register_post_meta($post_type, '_metasync_breadcrumb_title', array(
336 'show_in_rest' => true,
337 'single' => true,
338 'type' => 'string',
339 'sanitize_callback' => 'sanitize_text_field',
340 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
341 if (empty($object_id)) {
342 $pt_obj = get_post_type_object($post_type);
343 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
344 }
345 return current_user_can('edit_post', $object_id);
346 },
347 ));
348
349 // Register primary category for breadcrumbs
350 register_post_meta($post_type, '_metasync_primary_category', array(
351 'show_in_rest' => true,
352 'single' => true,
353 'type' => 'integer',
354 'sanitize_callback' => 'absint',
355 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
356 if (empty($object_id)) {
357 $pt_obj = get_post_type_object($post_type);
358 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
359 }
360 return current_user_can('edit_post', $object_id);
361 },
362 ));
363
364 // Register primary product category meta (WooCommerce-specific)
365 register_post_meta($post_type, self::META_PRIMARY_PRODUCT_CAT, array(
366 'show_in_rest' => true,
367 'single' => true,
368 'type' => 'integer',
369 'sanitize_callback' => 'absint',
370 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
371 if (empty($object_id)) {
372 $pt_obj = get_post_type_object($post_type);
373 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
374 }
375 return current_user_can('edit_post', $object_id);
376 },
377 ));
378
379 // Register OTTO meta keys for REST API (read-only for fallback/prefill)
380 register_post_meta($post_type, '_metasync_otto_title', array(
381 'show_in_rest' => true,
382 'single' => true,
383 'type' => 'string',
384 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
385 if (empty($object_id)) {
386 $pt_obj = get_post_type_object($post_type);
387 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
388 }
389 return current_user_can('edit_post', $object_id);
390 },
391 ));
392
393 register_post_meta($post_type, '_metasync_otto_description', array(
394 'show_in_rest' => true,
395 'single' => true,
396 'type' => 'string',
397 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
398 if (empty($object_id)) {
399 $pt_obj = get_post_type_object($post_type);
400 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
401 }
402 return current_user_can('edit_post', $object_id);
403 },
404 ));
405
406 // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
407 register_post_meta($post_type, '_metasync_otto_disabled', array(
408 'show_in_rest' => true,
409 'single' => true,
410 'type' => 'string',
411 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
412 if (empty($object_id)) {
413 $pt_obj = get_post_type_object($post_type);
414 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
415 }
416 return current_user_can('edit_post', $object_id);
417 },
418 ));
419
420 // Register hreflang / language alternates meta (JSON-encoded array)
421 register_post_meta($post_type, self::META_HREFLANG, array(
422 'show_in_rest' => true,
423 'single' => true,
424 'type' => 'string',
425 'sanitize_callback' => function($value) {
426 $decoded = json_decode($value, true);
427 if (!is_array($decoded)) {
428 return '[]';
429 }
430 return wp_json_encode($decoded);
431 },
432 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
433 if (empty($object_id)) {
434 $pt_obj = get_post_type_object($post_type);
435 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
436 }
437 return current_user_can('edit_post', $object_id);
438 },
439 ));
440
441 // Register plugin sync timestamp meta (WP-196)
442 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
443 'show_in_rest' => true,
444 'single' => true,
445 'type' => 'string',
446 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
447 if (empty($object_id)) {
448 $pt_obj = get_post_type_object($post_type);
449 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
450 }
451 return current_user_can('edit_post', $object_id);
452 },
453 ));
454
455 // Register advanced robots directives meta (WP-197)
456 register_post_meta($post_type, self::META_ROBOTS_ADVANCED, array(
457 'show_in_rest' => true,
458 'single' => true,
459 'type' => 'string',
460 'sanitize_callback' => function($value) {
461 $decoded = json_decode($value, true);
462 if (!is_array($decoded)) {
463 return '{}';
464 }
465 $allowed_bool = ['nofollow', 'noarchive', 'nosnippet', 'noimageindex'];
466 $sanitized = [];
467 foreach ($allowed_bool as $key) {
468 if (isset($decoded[$key])) {
469 $sanitized[$key] = (bool) $decoded[$key];
470 }
471 }
472 if (isset($decoded['max_snippet'])) {
473 $sanitized['max_snippet'] = (int) $decoded['max_snippet'];
474 }
475 if (isset($decoded['max_image_preview'])) {
476 $allowed = ['none', 'standard', 'large'];
477 $sanitized['max_image_preview'] = in_array($decoded['max_image_preview'], $allowed, true)
478 ? $decoded['max_image_preview'] : 'large';
479 }
480 if (isset($decoded['max_video_preview'])) {
481 $sanitized['max_video_preview'] = (int) $decoded['max_video_preview'];
482 }
483 return wp_json_encode($sanitized);
484 },
485 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
486 if (empty($object_id)) {
487 $pt_obj = get_post_type_object($post_type);
488 return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
489 }
490 return current_user_can('edit_post', $object_id);
491 },
492 ));
493 }
494 }
495
496 /**
497 * Build a read-only list of WPML translation entries for a post.
498 *
499 * Returns an array of {lang, url} objects by querying the
500 * `icl_translations` table. Used to seed the "Language Alternates"
501 * Gutenberg panel with auto-populated values.
502 *
503 * @param int $post_id Post ID.
504 * @return array
505 */
506 private function get_wpml_entries_for_post($post_id) {
507 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
508 return array();
509 }
510
511 global $wpdb;
512 $post = get_post($post_id);
513 if (!$post) {
514 return array();
515 }
516
517 $element_type = 'post_' . $post->post_type;
518 $table = $wpdb->prefix . 'icl_translations';
519
520 $trid = $wpdb->get_var($wpdb->prepare(
521 "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
522 $post_id,
523 $element_type
524 ));
525 if (empty($trid)) {
526 return array();
527 }
528
529 $rows = $wpdb->get_results($wpdb->prepare(
530 "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
531 $trid
532 ));
533 if (empty($rows)) {
534 return array();
535 }
536
537 $entries = array();
538 foreach ($rows as $row) {
539 $permalink = get_permalink((int) $row->element_id);
540 if (empty($permalink)) {
541 continue;
542 }
543 $entries[] = array(
544 'lang' => (string) $row->language_code,
545 'url' => $permalink,
546 );
547 }
548 return $entries;
549 }
550
551 /**
552 * Enqueue block editor assets
553 */
554 public function enqueue_block_editor_assets() {
555 // Get current screen
556 $screen = get_current_screen();
557
558 // Only load on post edit screens
559 if (!$screen || $screen->base !== 'post') {
560 return;
561 }
562
563 // Enqueue the sidebar script
564 wp_enqueue_script(
565 'metasync-seo-sidebar',
566 plugin_dir_url(__FILE__) . 'js/metasync-seo-sidebar.js',
567 array(
568 'wp-plugins',
569 'wp-edit-post',
570 'wp-element',
571 'wp-components',
572 'wp-data',
573 'wp-compose',
574 'wp-i18n',
575 'wp-api-fetch',
576 ),
577 $this->version,
578 true
579 );
580
581 // Enqueue sidebar styles
582 wp_enqueue_style(
583 'metasync-seo-sidebar',
584 plugin_dir_url(__FILE__) . 'css/metasync-seo-sidebar.css',
585 array('wp-components'),
586 $this->version
587 );
588
589 // Get whitelabel plugin name
590 $plugin_name = 'MetaSync';
591 if (class_exists('Metasync') && method_exists('Metasync', 'get_effective_plugin_name')) {
592 $plugin_name = Metasync::get_effective_plugin_name('MetaSync');
593 }
594
595 // Get whitelabel icon URL for the block editor sidebar.
596 // Priority: white_label_plugin_menu_icon (the icon set in WL Settings → Branding)
597 // because that's the same field driving the admin menu icon.
598 // Falls back to whitelabel['logo'], then the bundled default SVG.
599 $icon_url = plugin_dir_url(__FILE__) . 'images/icon-256x256.svg';
600 $menu_icon = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
601 if (!empty($menu_icon) && filter_var($menu_icon, FILTER_VALIDATE_URL)) {
602 $icon_url = $menu_icon;
603 } elseif (!empty(Metasync::get_whitelabel_logo())) {
604 $icon_url = Metasync::get_whitelabel_logo();
605 }
606
607 // Get OTTO whitelabel name
608 $otto_name = 'OTTO';
609 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
610 $otto_name = Metasync::get_whitelabel_otto_name();
611 }
612
613 // Get current post ID and check if meta keys exist in database
614 $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
615 $has_seo_title = false;
616 $has_seo_description = false;
617
618 if ($post_id > 0) {
619 // Check if meta keys exist in database (even if value is empty)
620 $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
621 $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
622 }
623
624 // Auto-detected WPML entries for the "Language Alternates" panel.
625 $wpml_entries = $this->get_wpml_entries_for_post($post_id);
626
627 // Link Suggestions configuration
628 $link_suggestions_config = array(
629 'restUrl' => rest_url('metasync/v1/link-suggestions'),
630 'yoastPremiumActive' => class_exists('WPSEO_Premium'),
631 'rankMathActive' => defined('RANK_MATH_VERSION'),
632 'i18n' => array(
633 'panelTitle' => __('Internal Link Suggestions', 'metasync'),
634 'insertButton' => __('Insert', 'metasync'),
635 'refreshButton' => __('Refresh', 'metasync'),
636 'noSuggestions' => __('No suggestions found.', 'metasync'),
637 'loading' => __('Analyzing content…', 'metasync'),
638 'matchedPhrase' => __('Matched phrase:', 'metasync'),
639 'yoastNotice' => __('Yoast Premium detected. Their internal linking tool may overlap with this feature.', 'metasync'),
640 'rankMathNotice' => __('Rank Math detected. Their internal linking tool may overlap with this feature.', 'metasync'),
641 ),
642 );
643
644 // Detect active SEO plugins that provide their own primary category selector
645 $other_seo_primary = array(
646 'yoastActive' => defined('WPSEO_VERSION'),
647 'rankMathActive' => defined('RANK_MATH_VERSION'),
648 'aioseoActive' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
649 );
650
651 // Schema Content configuration
652 $schema_content_config = array(
653 'restUrl' => rest_url('metasync/v1/schema-content'),
654 'woocommerceActive' => class_exists('WooCommerce'),
655 'woocommerceData' => $this->get_woocommerce_data_for_post($post_id),
656 'i18n' => array(
657 'panelTitle' => __('Schema Markup Content', 'metasync'),
658 'saveButton' => __('Save Schema Content', 'metasync'),
659 'autoPopulateWC' => __('Auto-populate from WooCommerce', 'metasync'),
660 'noSchemaTypes' => __('No schema types configured. Add schema types in the classic editor or via the Schema Markup metabox.', 'metasync'),
661 'addQuestion' => __('Add Question', 'metasync'),
662 'removeQuestion' => __('Remove', 'metasync'),
663 'addStep' => __('Add Step', 'metasync'),
664 'removeStep' => __('Remove', 'metasync'),
665 'addIngredient' => __('Add Ingredient', 'metasync'),
666 'removeIngredient' => __('Remove', 'metasync'),
667 'addInstruction' => __('Add Instruction', 'metasync'),
668 'removeInstruction' => __('Remove', 'metasync'),
669 'saving' => __('Saving...', 'metasync'),
670 'saved' => __('Schema content saved.', 'metasync'),
671 'saveError' => __('Failed to save schema content.', 'metasync'),
672 ),
673 );
674
675 // Localize script with meta keys and settings
676 wp_localize_script('metasync-seo-sidebar', 'metasyncSeoSidebar', array(
677 'iconUrl' => $icon_url,
678 'otherSeoPrimary' => $other_seo_primary,
679 'metaKeys' => array(
680 'seoTitle' => self::META_SEO_TITLE,
681 'metaDescription' => self::META_DESCRIPTION,
682 // Breadcrumb meta keys
683 'breadcrumbTitle' => '_metasync_breadcrumb_title',
684 'primaryCategory' => '_metasync_primary_category',
685 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
686 'ottoTitle' => '_metasync_otto_title',
687 'ottoDescription' => '_metasync_otto_description',
688 // OTTO disabled per-post flag
689 'ottoDisabled' => '_metasync_otto_disabled',
690 // Primary category
691 'primaryCategory' => self::META_PRIMARY_CATEGORY,
692 'primaryProductCat' => self::META_PRIMARY_PRODUCT_CAT,
693 // Language alternates (hreflang) — JSON-encoded array
694 'hreflang' => self::META_HREFLANG,
695 // Plugin sync timestamp (WP-196)
696 'pluginSyncTs' => self::META_PLUGIN_SYNC_TS,
697 // Advanced robots directives (WP-197)
698 'robotsAdvanced' => self::META_ROBOTS_ADVANCED,
699 ),
700 'activeSeoPlugins' => array(
701 'yoast' => defined('WPSEO_VERSION'),
702 'rankmath' => defined('RANK_MATH_VERSION'),
703 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
704 ),
705 'wpmlEntries' => $wpml_entries,
706 'hasMetaKeys' => array(
707 // Whether the manual meta keys exist in database (PHP check)
708 'seoTitle' => $has_seo_title,
709 'metaDescription' => $has_seo_description,
710 ),
711 'otto' => array(
712 'globalEnabled' => self::is_otto_enabled_globally(),
713 'name' => $otto_name,
714 ),
715 'limits' => array(
716 'seoTitle' => array(
717 'min' => 50,
718 'max' => 60,
719 'absolute' => 70,
720 ),
721 'metaDescription' => array(
722 'min' => 120,
723 'max' => 160,
724 'absolute' => 200,
725 ),
726 ),
727 'schemaContent' => $schema_content_config,
728 'linkSuggestions' => $link_suggestions_config,
729 'i18n' => array(
730 /* translators: %s: Plugin name (whitelabel-aware) */
731 'panelTitle' => sprintf(__('%s SEO', 'metasync'), $plugin_name),
732 'seoTitleLabel' => __('SEO Title', 'metasync'),
733 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
734 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
735 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
736 'urlSlugLabel' => __('URL Slug', 'metasync'),
737 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
738 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
739 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
740 'serpDesktop' => __('Desktop', 'metasync'),
741 'serpMobile' => __('Mobile', 'metasync'),
742 'characters' => __('characters', 'metasync'),
743 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
744 'primaryCategoryHelp' => __('Used in breadcrumbs and canonical URL when multiple categories are assigned.', 'metasync'),
745 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
746 'ottoPrefillHelp' => sprintf(
747 __('Pre-filled from %s. Edit to customize.', 'metasync'),
748 $otto_name
749 ),
750 'breadcrumbPanelTitle' => __('Breadcrumbs', 'metasync'),
751 'breadcrumbTitleLabel' => __('Breadcrumb Title Override', 'metasync'),
752 'breadcrumbTitleHelp' => __('Custom label for this page in breadcrumb trails. Leave empty to use the post title.', 'metasync'),
753 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
754 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
755 /* translators: %s: OTTO name (whitelabel) */
756 'ottoOverrideNotice' => sprintf(
757 __('%s is enabled. Any SEO title and description changes from %s will be overwritten by your custom values entered here.', 'metasync'),
758 $otto_name,
759 $otto_name
760 ),
761 // Language Alternates (hreflang) panel strings
762 'languageAlternatesTitle' => __('Language Alternates', 'metasync'),
763 'addAlternate' => __('Add alternate', 'metasync'),
764 'langLabel' => __('Language', 'metasync'),
765 'regionLabel' => __('Region', 'metasync'),
766 'urlLabel' => __('URL', 'metasync'),
767 'editManually' => __('Edit Manually', 'metasync'),
768 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
769 // Advanced Robots Directives (WP-197)
770 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
771 'nofollowLabel' => __('Nofollow', 'metasync'),
772 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),
773 'noarchiveLabel' => __('Noarchive', 'metasync'),
774 'noarchiveHelp' => __('Prevent search engines from showing cached versions.', 'metasync'),
775 'nosnippetLabel' => __('Nosnippet', 'metasync'),
776 'nosnippetHelp' => __('Prevent search engines from showing text snippets.', 'metasync'),
777 'noimageindexLabel' => __('No Image Index', 'metasync'),
778 'noimageindexHelp' => __('Prevent this page from appearing as image search referrer.', 'metasync'),
779 'maxSnippetLabel' => __('Max Snippet Length', 'metasync'),
780 'maxSnippetHelp' => __('Maximum character length for text snippets. -1 for unlimited.', 'metasync'),
781 'maxImagePreviewLabel' => __('Max Image Preview', 'metasync'),
782 'maxImagePreviewHelp' => __('Maximum size of image preview in search results.', 'metasync'),
783 // Plugin Sync Status (WP-196)
784 'syncStatusTitle' => __('Plugin Sync Status', 'metasync'),
785 'syncedTo' => __('Synced to:', 'metasync'),
786 'syncNever' => __('Never synced', 'metasync'),
787 'syncAgo' => __('ago', 'metasync'),
788 ),
789 ));
790 }
791
792 /**
793 * Clean up primary category meta if the selected category was unchecked from the post.
794 * Runs on save_post to cover all post types, not just 'post' and 'page'.
795 *
796 * @param int $post_id Post ID
797 */
798 public function cleanup_primary_category_meta($post_id) {
799 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
800 return;
801 }
802
803 // Clean up primary category
804 $primary_cat = (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true);
805 if ($primary_cat > 0) {
806 $taxonomies = get_object_taxonomies(get_post_type($post_id), 'names');
807 if (empty($taxonomies)) {
808 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
809 return;
810 }
811 $term_ids = wp_get_object_terms($post_id, $taxonomies, array('fields' => 'ids'));
812 if (!is_wp_error($term_ids) && !in_array($primary_cat, $term_ids)) {
813 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
814 }
815 }
816
817 // Clean up primary product category (WooCommerce)
818 $primary_product_cat = (int) get_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT, true);
819 if ($primary_product_cat > 0) {
820 $product_cat_ids = wp_get_object_terms($post_id, 'product_cat', array('fields' => 'ids'));
821 if (!is_wp_error($product_cat_ids) && !in_array($primary_product_cat, $product_cat_ids)) {
822 delete_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT);
823 }
824 }
825 }
826
827 /**
828 * Get WooCommerce product data for a post (if WC is active and post is a product)
829 *
830 * @param int $post_id Post ID
831 * @return array|null Product data or null
832 */
833 private function get_woocommerce_data_for_post($post_id) {
834 if (!class_exists('WooCommerce') || $post_id <= 0) {
835 return null;
836 }
837
838 $post = get_post($post_id);
839 if (!$post || $post->post_type !== 'product') {
840 return null;
841 }
842
843 $product = wc_get_product($post_id);
844 if (!$product) {
845 return null;
846 }
847
848 return array(
849 'price' => $product->get_price(),
850 'currency' => get_woocommerce_currency(),
851 'availability' => $product->is_in_stock() ? 'InStock' : 'OutOfStock',
852 'sku' => $product->get_sku(),
853 );
854 }
855
856 }
857
858