version = $version;
$this->init();
}
/**
* Initialize hooks
*/
private function init() {
// Register meta fields for REST API
add_action('init', array($this, 'register_meta_fields'));
// Enqueue block editor assets
add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
// Track meta state before REST API save, then clean up after (all public post types)
add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
// Clean up primary category meta when category is unchecked (all post types)
add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
// Frontend hooks for outputting SEO meta
// Custom values ALWAYS take priority over OTTO suggestions
add_action('wp_head', array($this, 'output_seo_meta_description'), 2);
add_filter('pre_get_document_title', array($this, 'filter_document_title'), 100);
add_filter('document_title_parts', array($this, 'filter_document_title_parts'), 100);
}
/**
* Register REST API save/cleanup hooks for all public post types
*/
public function register_rest_save_hooks() {
$post_types = get_post_types(array('public' => true), 'names');
unset($post_types['attachment']);
foreach ($post_types as $post_type) {
add_filter("rest_pre_insert_{$post_type}", array($this, 'track_meta_before_save'), 10, 2);
add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
}
}
/**
* Check if OTTO SSR is globally enabled
*
* @return bool True if OTTO is enabled globally
*/
public static function is_otto_enabled_globally() {
$metasync_options = get_option('metasync_options');
$otto_enabled = $metasync_options['general']['otto_enable'] ?? false;
return ($otto_enabled === 'true' || $otto_enabled === true);
}
/**
* Track meta state before REST API save
* Stores which SEO meta keys existed before the save
*
* @param stdClass $prepared_post Prepared post object
* @param WP_REST_Request $request Request object
* @return stdClass Unmodified prepared post object
*/
public function track_meta_before_save($prepared_post, $request) {
$post_id = $request->get_param('id');
if (!$post_id) {
return $prepared_post;
}
// Track which meta keys existed before this save
$meta_existed = array(
self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
);
// Store in transient for use in cleanup
set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
return $prepared_post;
}
/**
* Clean up empty SEO meta values after REST API save
* This prevents blanking fields that are showing OTTO fallback values
*
* Deletes meta keys that are empty AND didn't exist before the save
* (meaning they were showing OTTO fallback, not user-edited values)
*
* @param WP_Post $post Inserted or updated post object
* @param WP_REST_Request $_request Request object (unused)
* @param bool $_creating True when creating a post, false when updating (unused)
*/
public function cleanup_empty_seo_meta($post, $_request, $_creating) {
$post_id = $post->ID;
// Get the before-save state
$meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
if ($meta_existed === false) {
// Transient expired or not set, can't make safe decision
return;
}
// Delete transient
delete_transient('metasync_seo_meta_existed_' . $post_id);
// Check each of our SEO meta keys
foreach ($meta_existed as $meta_key => $existed_before) {
// Get the current value
$meta_value = get_post_meta($post_id, $meta_key, true);
// Only delete if:
// 1. Value is now empty
// 2. It didn't exist before the save (was showing OTTO fallback)
if (empty($meta_value) && !$existed_before) {
delete_post_meta($post_id, $meta_key);
}
}
}
/**
* Output meta description tags from sidebar field
* Custom values ALWAYS take priority over OTTO
* Outputs: meta description, og:description, and twitter:description
*/
public function output_seo_meta_description() {
// Skip for admin, feeds, etc.
if (is_admin() || is_feed() || is_robots()) {
return;
}
// Only run on singular pages (posts, pages, custom post types)
if (!is_singular()) {
return;
}
$post_id = get_the_ID();
if (!$post_id) {
return;
}
// WP-196: When synced to an active SEO plugin, that plugin outputs
// the description from its native storage — skip MetaSync's own tag.
$conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
if ($conflict_handler->has_active_seo_plugin()) {
$sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
if (!empty($sync_ts)) {
return;
}
}
// Get the custom SEO description (takes priority)
$description = get_post_meta($post_id, self::META_DESCRIPTION, true);
// Output meta description tags if custom value exists
// This will be output BEFORE OTTO processes the page, and since we're using
// a high priority filter, it will override OTTO's meta description
if (!empty($description)) {
$description_escaped = esc_attr($description);
// Standard meta description
echo '' . "\n";
// Open Graph description
echo '' . "\n";
// Twitter description
echo '' . "\n";
}
}
/**
* Filter document title (pre_get_document_title filter)
* Custom values ALWAYS take priority over OTTO
*
* @param string $title Current title
* @return string Modified title or original
*/
public function filter_document_title($title) {
// Skip for admin
if (is_admin()) {
return $title;
}
// Only run on singular pages
if (!is_singular()) {
return $title;
}
$post_id = get_the_ID();
if (!$post_id) {
return $title;
}
// Get the custom SEO title (takes priority)
$seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
// Return custom SEO title if set
return !empty($seo_title) ? $seo_title : $title;
}
/**
* Filter document title parts (for themes using wp_get_document_title)
* Custom values ALWAYS take priority over OTTO
*
* @param array $title_parts Title parts array
* @return array Modified title parts
*/
public function filter_document_title_parts($title_parts) {
// Skip for admin
if (is_admin()) {
return $title_parts;
}
// Only run on singular pages
if (!is_singular()) {
return $title_parts;
}
$post_id = get_the_ID();
if (!$post_id) {
return $title_parts;
}
// Get the custom SEO title (takes priority)
$seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
// Replace title part if custom SEO title is set
if (!empty($seo_title)) {
$title_parts['title'] = $seo_title;
// Remove tagline and site for clean SEO title
unset($title_parts['tagline']);
unset($title_parts['site']);
}
return $title_parts;
}
/**
* Register meta fields for REST API access
*/
public function register_meta_fields() {
// Get all public post types
$post_types = get_post_types(array('public' => true), 'names');
foreach ($post_types as $post_type) {
// Register SEO title meta
register_post_meta($post_type, self::META_SEO_TITLE, array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register meta description
register_post_meta($post_type, self::META_DESCRIPTION, array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register breadcrumb title override
register_post_meta($post_type, '_metasync_breadcrumb_title', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register primary category for breadcrumbs
register_post_meta($post_type, '_metasync_primary_category', array(
'show_in_rest' => true,
'single' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register primary product category meta (WooCommerce-specific)
register_post_meta($post_type, self::META_PRIMARY_PRODUCT_CAT, array(
'show_in_rest' => true,
'single' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register OTTO meta keys for REST API (read-only for fallback/prefill)
register_post_meta($post_type, '_metasync_otto_title', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
register_post_meta($post_type, '_metasync_otto_description', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
register_post_meta($post_type, '_metasync_otto_disabled', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register hreflang / language alternates meta (JSON-encoded array)
register_post_meta($post_type, self::META_HREFLANG, array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => function($value) {
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return '[]';
}
return wp_json_encode($decoded);
},
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register plugin sync timestamp meta (WP-196)
register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
// Register advanced robots directives meta (WP-197)
register_post_meta($post_type, self::META_ROBOTS_ADVANCED, array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => function($value) {
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return '{}';
}
$allowed_bool = ['nofollow', 'noarchive', 'nosnippet', 'noimageindex'];
$sanitized = [];
foreach ($allowed_bool as $key) {
if (isset($decoded[$key])) {
$sanitized[$key] = (bool) $decoded[$key];
}
}
if (isset($decoded['max_snippet'])) {
$sanitized['max_snippet'] = (int) $decoded['max_snippet'];
}
if (isset($decoded['max_image_preview'])) {
$allowed = ['none', 'standard', 'large'];
$sanitized['max_image_preview'] = in_array($decoded['max_image_preview'], $allowed, true)
? $decoded['max_image_preview'] : 'large';
}
if (isset($decoded['max_video_preview'])) {
$sanitized['max_video_preview'] = (int) $decoded['max_video_preview'];
}
return wp_json_encode($sanitized);
},
'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
if (empty($object_id)) {
$pt_obj = get_post_type_object($post_type);
return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
}
return current_user_can('edit_post', $object_id);
},
));
}
}
/**
* Build a read-only list of WPML translation entries for a post.
*
* Returns an array of {lang, url} objects by querying the
* `icl_translations` table. Used to seed the "Language Alternates"
* Gutenberg panel with auto-populated values.
*
* @param int $post_id Post ID.
* @return array
*/
private function get_wpml_entries_for_post($post_id) {
if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
return array();
}
global $wpdb;
$post = get_post($post_id);
if (!$post) {
return array();
}
$element_type = 'post_' . $post->post_type;
$table = $wpdb->prefix . 'icl_translations';
$trid = $wpdb->get_var($wpdb->prepare(
"SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
$post_id,
$element_type
));
if (empty($trid)) {
return array();
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT language_code, element_id FROM {$table} WHERE trid = %d",
$trid
));
if (empty($rows)) {
return array();
}
$entries = array();
foreach ($rows as $row) {
$permalink = get_permalink((int) $row->element_id);
if (empty($permalink)) {
continue;
}
$entries[] = array(
'lang' => (string) $row->language_code,
'url' => $permalink,
);
}
return $entries;
}
/**
* Enqueue block editor assets
*/
public function enqueue_block_editor_assets() {
// Get current screen
$screen = get_current_screen();
// Only load on post edit screens
if (!$screen || $screen->base !== 'post') {
return;
}
// Enqueue the sidebar script
wp_enqueue_script(
'metasync-seo-sidebar',
plugin_dir_url(__FILE__) . 'js/metasync-seo-sidebar.js',
array(
'wp-plugins',
'wp-edit-post',
'wp-element',
'wp-components',
'wp-data',
'wp-compose',
'wp-i18n',
'wp-api-fetch',
),
$this->version,
true
);
// Enqueue sidebar styles
wp_enqueue_style(
'metasync-seo-sidebar',
plugin_dir_url(__FILE__) . 'css/metasync-seo-sidebar.css',
array('wp-components'),
$this->version
);
// Get whitelabel plugin name
$plugin_name = 'MetaSync';
if (class_exists('Metasync') && method_exists('Metasync', 'get_effective_plugin_name')) {
$plugin_name = Metasync::get_effective_plugin_name('MetaSync');
}
// Get whitelabel icon URL for the block editor sidebar.
// Priority: white_label_plugin_menu_icon (the icon set in WL Settings → Branding)
// because that's the same field driving the admin menu icon.
// Falls back to whitelabel['logo'], then the bundled default SVG.
$icon_url = plugin_dir_url(__FILE__) . 'images/icon-256x256.svg';
$menu_icon = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
if (!empty($menu_icon) && filter_var($menu_icon, FILTER_VALIDATE_URL)) {
$icon_url = $menu_icon;
} elseif (!empty(Metasync::get_whitelabel_logo())) {
$icon_url = Metasync::get_whitelabel_logo();
}
// Get OTTO whitelabel name
$otto_name = 'OTTO';
if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
$otto_name = Metasync::get_whitelabel_otto_name();
}
// Get current post ID and check if meta keys exist in database
$post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
$has_seo_title = false;
$has_seo_description = false;
if ($post_id > 0) {
// Check if meta keys exist in database (even if value is empty)
$has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
$has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
}
// Auto-detected WPML entries for the "Language Alternates" panel.
$wpml_entries = $this->get_wpml_entries_for_post($post_id);
// Link Suggestions configuration
$link_suggestions_config = array(
'restUrl' => rest_url('metasync/v1/link-suggestions'),
'yoastPremiumActive' => class_exists('WPSEO_Premium'),
'rankMathActive' => defined('RANK_MATH_VERSION'),
'i18n' => array(
'panelTitle' => __('Internal Link Suggestions', 'metasync'),
'insertButton' => __('Insert', 'metasync'),
'refreshButton' => __('Refresh', 'metasync'),
'noSuggestions' => __('No suggestions found.', 'metasync'),
'loading' => __('Analyzing content…', 'metasync'),
'matchedPhrase' => __('Matched phrase:', 'metasync'),
'yoastNotice' => __('Yoast Premium detected. Their internal linking tool may overlap with this feature.', 'metasync'),
'rankMathNotice' => __('Rank Math detected. Their internal linking tool may overlap with this feature.', 'metasync'),
),
);
// Detect active SEO plugins that provide their own primary category selector
$other_seo_primary = array(
'yoastActive' => defined('WPSEO_VERSION'),
'rankMathActive' => defined('RANK_MATH_VERSION'),
'aioseoActive' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
);
// Schema Content configuration
$schema_content_config = array(
'restUrl' => rest_url('metasync/v1/schema-content'),
'woocommerceActive' => class_exists('WooCommerce'),
'woocommerceData' => $this->get_woocommerce_data_for_post($post_id),
'i18n' => array(
'panelTitle' => __('Schema Markup Content', 'metasync'),
'saveButton' => __('Save Schema Content', 'metasync'),
'autoPopulateWC' => __('Auto-populate from WooCommerce', 'metasync'),
'noSchemaTypes' => __('No schema types configured. Add schema types in the classic editor or via the Schema Markup metabox.', 'metasync'),
'addQuestion' => __('Add Question', 'metasync'),
'removeQuestion' => __('Remove', 'metasync'),
'addStep' => __('Add Step', 'metasync'),
'removeStep' => __('Remove', 'metasync'),
'addIngredient' => __('Add Ingredient', 'metasync'),
'removeIngredient' => __('Remove', 'metasync'),
'addInstruction' => __('Add Instruction', 'metasync'),
'removeInstruction' => __('Remove', 'metasync'),
'saving' => __('Saving...', 'metasync'),
'saved' => __('Schema content saved.', 'metasync'),
'saveError' => __('Failed to save schema content.', 'metasync'),
),
);
// Localize script with meta keys and settings
wp_localize_script('metasync-seo-sidebar', 'metasyncSeoSidebar', array(
'iconUrl' => $icon_url,
'otherSeoPrimary' => $other_seo_primary,
'metaKeys' => array(
'seoTitle' => self::META_SEO_TITLE,
'metaDescription' => self::META_DESCRIPTION,
// Breadcrumb meta keys
'breadcrumbTitle' => '_metasync_breadcrumb_title',
'primaryCategory' => '_metasync_primary_category',
// OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
'ottoTitle' => '_metasync_otto_title',
'ottoDescription' => '_metasync_otto_description',
// OTTO disabled per-post flag
'ottoDisabled' => '_metasync_otto_disabled',
// Primary category
'primaryCategory' => self::META_PRIMARY_CATEGORY,
'primaryProductCat' => self::META_PRIMARY_PRODUCT_CAT,
// Language alternates (hreflang) — JSON-encoded array
'hreflang' => self::META_HREFLANG,
// Plugin sync timestamp (WP-196)
'pluginSyncTs' => self::META_PLUGIN_SYNC_TS,
// Advanced robots directives (WP-197)
'robotsAdvanced' => self::META_ROBOTS_ADVANCED,
),
'activeSeoPlugins' => array(
'yoast' => defined('WPSEO_VERSION'),
'rankmath' => defined('RANK_MATH_VERSION'),
'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
),
'wpmlEntries' => $wpml_entries,
'hasMetaKeys' => array(
// Whether the manual meta keys exist in database (PHP check)
'seoTitle' => $has_seo_title,
'metaDescription' => $has_seo_description,
),
'otto' => array(
'globalEnabled' => self::is_otto_enabled_globally(),
'name' => $otto_name,
),
'limits' => array(
'seoTitle' => array(
'min' => 50,
'max' => 60,
'absolute' => 70,
),
'metaDescription' => array(
'min' => 120,
'max' => 160,
'absolute' => 200,
),
),
'schemaContent' => $schema_content_config,
'linkSuggestions' => $link_suggestions_config,
'i18n' => array(
/* translators: %s: Plugin name (whitelabel-aware) */
'panelTitle' => sprintf(__('%s SEO', 'metasync'), $plugin_name),
'seoTitleLabel' => __('SEO Title', 'metasync'),
'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
'metaDescriptionLabel' => __('Meta Description', 'metasync'),
'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
'urlSlugLabel' => __('URL Slug', 'metasync'),
'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
'serpPreviewTitle' => __('SERP Preview', 'metasync'),
'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
'serpDesktop' => __('Desktop', 'metasync'),
'serpMobile' => __('Mobile', 'metasync'),
'characters' => __('characters', 'metasync'),
'primaryCategoryLabel' => __('Primary Category', 'metasync'),
'primaryCategoryHelp' => __('Used in breadcrumbs and canonical URL when multiple categories are assigned.', 'metasync'),
'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
'ottoPrefillHelp' => sprintf(
__('Pre-filled from %s. Edit to customize.', 'metasync'),
$otto_name
),
'breadcrumbPanelTitle' => __('Breadcrumbs', 'metasync'),
'breadcrumbTitleLabel' => __('Breadcrumb Title Override', 'metasync'),
'breadcrumbTitleHelp' => __('Custom label for this page in breadcrumb trails. Leave empty to use the post title.', 'metasync'),
'primaryCategoryLabel' => __('Primary Category', 'metasync'),
'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
/* translators: %s: OTTO name (whitelabel) */
'ottoOverrideNotice' => sprintf(
__('%s is enabled. Any SEO title and description changes from %s will be overwritten by your custom values entered here.', 'metasync'),
$otto_name,
$otto_name
),
// Language Alternates (hreflang) panel strings
'languageAlternatesTitle' => __('Language Alternates', 'metasync'),
'addAlternate' => __('Add alternate', 'metasync'),
'langLabel' => __('Language', 'metasync'),
'regionLabel' => __('Region', 'metasync'),
'urlLabel' => __('URL', 'metasync'),
'editManually' => __('Edit Manually', 'metasync'),
'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
// Advanced Robots Directives (WP-197)
'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
'nofollowLabel' => __('Nofollow', 'metasync'),
'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),
'noarchiveLabel' => __('Noarchive', 'metasync'),
'noarchiveHelp' => __('Prevent search engines from showing cached versions.', 'metasync'),
'nosnippetLabel' => __('Nosnippet', 'metasync'),
'nosnippetHelp' => __('Prevent search engines from showing text snippets.', 'metasync'),
'noimageindexLabel' => __('No Image Index', 'metasync'),
'noimageindexHelp' => __('Prevent this page from appearing as image search referrer.', 'metasync'),
'maxSnippetLabel' => __('Max Snippet Length', 'metasync'),
'maxSnippetHelp' => __('Maximum character length for text snippets. -1 for unlimited.', 'metasync'),
'maxImagePreviewLabel' => __('Max Image Preview', 'metasync'),
'maxImagePreviewHelp' => __('Maximum size of image preview in search results.', 'metasync'),
// Plugin Sync Status (WP-196)
'syncStatusTitle' => __('Plugin Sync Status', 'metasync'),
'syncedTo' => __('Synced to:', 'metasync'),
'syncNever' => __('Never synced', 'metasync'),
'syncAgo' => __('ago', 'metasync'),
),
));
}
/**
* Clean up primary category meta if the selected category was unchecked from the post.
* Runs on save_post to cover all post types, not just 'post' and 'page'.
*
* @param int $post_id Post ID
*/
public function cleanup_primary_category_meta($post_id) {
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
// Clean up primary category
$primary_cat = (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true);
if ($primary_cat > 0) {
$taxonomies = get_object_taxonomies(get_post_type($post_id), 'names');
if (empty($taxonomies)) {
delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
return;
}
$term_ids = wp_get_object_terms($post_id, $taxonomies, array('fields' => 'ids'));
if (!is_wp_error($term_ids) && !in_array($primary_cat, $term_ids)) {
delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
}
}
// Clean up primary product category (WooCommerce)
$primary_product_cat = (int) get_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT, true);
if ($primary_product_cat > 0) {
$product_cat_ids = wp_get_object_terms($post_id, 'product_cat', array('fields' => 'ids'));
if (!is_wp_error($product_cat_ids) && !in_array($primary_product_cat, $product_cat_ids)) {
delete_post_meta($post_id, self::META_PRIMARY_PRODUCT_CAT);
}
}
}
/**
* Get WooCommerce product data for a post (if WC is active and post is a product)
*
* @param int $post_id Post ID
* @return array|null Product data or null
*/
private function get_woocommerce_data_for_post($post_id) {
if (!class_exists('WooCommerce') || $post_id <= 0) {
return null;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'product') {
return null;
}
$product = wc_get_product($post_id);
if (!$product) {
return null;
}
return array(
'price' => $product->get_price(),
'currency' => get_woocommerce_currency(),
'availability' => $product->is_in_stock() ? 'InStock' : 'OutOfStock',
'sku' => $product->get_sku(),
);
}
}