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

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