languages = $languages; $this->mapping = $mapping; $this->translations = $translations; $this->ai_translator = $ai_translator; $this->internal_links = $internal_links; $this->review_translations = $review_translations; $this->xliiff = $xliiff; } /** * Register hooks. * * @return void */ public function init() { add_filter( 'gettext', array( $this, 'filter_admin_translation_branding' ), 20, 3 ); add_action( 'admin_menu', array( $this, 'admin_menu' ) ); add_action( 'admin_menu', array( $this, 'register_setup_submenu' ), 99 ); add_action( 'admin_init', array( $this, 'maybe_redirect_after_activation' ), 1 ); add_action( 'admin_init', array( $this, 'handle_actions' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'wp_ajax_sitelang_translation_progress', array( $this, 'ajax_translation_progress' ) ); add_action( 'wp_ajax_sitelang_process_translation_step', array( $this, 'ajax_process_translation_step' ) ); add_action( 'wp_ajax_sitelang_translate_menu_item', array( $this, 'ajax_translate_menu_item' ) ); add_action( 'admin_head', array( $this, 'hide_legacy_primary_submenus' ), 99 ); add_filter( 'submenu_file', array( $this, 'active_submenu_file' ), 10, 2 ); add_action( 'add_meta_boxes', array( $this, 'add_metaboxes' ) ); add_action( 'save_post', array( $this, 'save_metabox' ), 20, 2 ); add_filter( 'post_row_actions', array( $this, 'row_actions' ), 10, 2 ); add_filter( 'page_row_actions', array( $this, 'row_actions' ), 10, 2 ); add_action( 'admin_notices', array( $this, 'admin_notices' ) ); add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); } /** Redirect the activating administrator straight to first-run setup. */ public function maybe_redirect_after_activation() { if ( 'plugins.php' !== ( $GLOBALS['pagenow'] ?? '' ) || empty( $_GET['activate'] ) || ! get_current_user_id() ) { return; } $transient = 'sitelang_activation_redirect_' . get_current_user_id(); if ( ! get_transient( $transient ) ) { return; } if ( headers_sent() ) { return; } delete_transient( $transient ); if ( ! current_user_can( $this->capability() ) || ! empty( $this->setup_state()['completed'] ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=sitelang-wc-setup' ) ); exit; } /** Use the Auto product language in admin UI without rewriting disclosures. */ public function filter_admin_translation_branding( $translation, $text, $domain ) { if ( ! is_admin() || 'sitelang' !== $domain || preg_match( '/\b(?:privacy|consent|authorize|provider|retention|personal data|third.party)\b/i', $text ) ) { return $translation; } $translation = preg_replace( '/\bOpenAI\b/i', 'translation service', $translation ); $translation = preg_replace( '/\bAI\s+translation\b/i', 'Auto translation', $translation ); $translation = preg_replace( '/\bAI\s+translate\b/i', 'Auto translate', $translation ); $translation = preg_replace( '/\bAI\s+mode\b/i', 'Auto mode', $translation ); $translation = preg_replace( '/\bAI\s+job\b/i', 'Auto job', $translation ); $translation = preg_replace( '/\bAI\s+plans\b/i', 'Auto plans', $translation ); $translation = preg_replace( '/\bAI\s+settings\b/i', 'Auto settings', $translation ); $translation = preg_replace( '/\bAI\s+service\b/i', 'Auto service', $translation ); $translation = preg_replace( '/\bAI\s+buttons?\b/i', 'Auto buttons', $translation ); return preg_replace( '/\bAI\b/i', 'Auto', $translation ); } /** Register lightweight read models for the redesigned admin shell. */ public function register_rest_routes() { $permission = function () { return current_user_can( $this->capability() ); }; register_rest_route( 'sitelang/v1', '/overview', array( 'methods' => 'GET', 'permission_callback' => $permission, 'callback' => array( $this, 'rest_overview' ) ) ); register_rest_route( 'sitelang/v1', '/license', array( 'methods' => 'GET', 'permission_callback' => $permission, 'callback' => array( $this, 'rest_license' ) ) ); register_rest_route( 'sitelang/v1', '/diagnostics', array( 'methods' => 'GET', 'permission_callback' => $permission, 'callback' => array( $this, 'rest_diagnostics' ) ) ); } /** @return \WP_REST_Response */ public function rest_overview() { $license = $this->license_data_for_rest(); $analytics = $this->get_dashboard_analytics(); $coverage = $this->dashboard_coverage(); $activated = Plugin::instance()->license->is_activated(); $language_settings = $this->languages->get_settings(); return rest_ensure_response( array( 'status' => $activated ? ( $license['sync_state'] ?: 'active' ) : 'not_activated', 'plan' => $license['plan'], 'license' => $license, 'checklist' => array( 'activated' => $activated, 'languages_configured' => count( $this->languages->get_enabled_codes() ) > 0, 'first_translation' => ! empty( $analytics['total'] ), 'switcher_configured' => ! empty( $language_settings['hook_enabled'] ), ), 'usage' => array( 'languages' => count( $this->languages->get_enabled_codes() ), 'mappings' => (int) $analytics['total'], 'complete' => (int) $analytics['complete'], 'completion_rate' => (int) $coverage['rate'], 'eligible_source_slots' => (int) $coverage['eligible'], 'completed_source_slots' => (int) $coverage['complete'], ), 'attention' => array_filter( array( $activated && ! empty( $license['last_error_code'] ) ? array( 'code' => $license['last_error_code'], 'message' => __( 'The last sync reported an error. Open Diagnostics for the next step.', 'sitelang' ) ) : null, $activated && 'stale' === $license['sync_state'] ? array( 'code' => 'stale', 'message' => __( 'The service has not confirmed this installation recently. Published content stays online.', 'sitelang' ) ) : null, ), static function ( $item ) { return is_array( $item ); } ), )); } /** @return \WP_REST_Response */ public function rest_license() { return rest_ensure_response( $this->license_data_for_rest() ); } /** @return \WP_REST_Response */ public function rest_diagnostics() { $license = $this->license_data_for_rest(); return rest_ensure_response( array( 'wordpress' => get_bloginfo( 'version' ), 'php' => PHP_VERSION, 'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'rest_reachable' => true, 'licensed' => Plugin::instance()->license->is_activated(), 'last_sync' => $license['last_validated'], 'sync_state' => $license['sync_state'], 'last_error_code' => $license['last_error_code'], 'content_policy' => __( 'Published translations and checkout remain available during service interruptions.', 'sitelang' ), )); } /** @return array */ private function license_data_for_rest() { $license = Plugin::instance()->license->get_license(); $plan = Plugin::instance()->license->get_current_plan(); return array( 'active' => Plugin::instance()->license->is_activated(), 'status' => sanitize_key( (string) ( $license['status'] ?? 'not_activated' ) ), 'sync_state' => sanitize_key( (string) ( $license['sync_state'] ?? 'not_activated' ) ), 'plan' => $plan, 'license_id' => Plugin::instance()->license->get_license_id_for_display(), 'last_validated' => absint( $license['last_validated'] ?? 0 ), 'applied_revision' => absint( $license['applied_revision'] ?? ( $license['entitlements']['revision'] ?? 0 ) ), 'last_error_code' => sanitize_key( (string) ( $license['last_error_code'] ?? '' ) ), ); } /** * Add menu. * * @return void */ public function admin_menu() { $this->hook_suffix = add_menu_page( __( 'SiteLang', 'sitelang' ), __( 'SiteLang', 'sitelang' ), $this->capability(), 'sitelang-wc', '__return_null', 'dashicons-translation', 56 ); add_submenu_page( 'sitelang-wc', __( 'Overview', 'sitelang' ), __( 'Overview', 'sitelang' ), $this->capability(), 'sitelang-wc', array( $this, 'render_page' ) ); foreach ( array( 'languages' => __( 'Languages', 'sitelang' ), 'translations' => __( 'Translations', 'sitelang' ), 'products' => __( 'Products', 'sitelang' ), 'queue' => __( 'Translation Queue', 'sitelang' ), 'feeds' => __( 'Feeds & Sitemap', 'sitelang' ), 'settings' => __( 'Settings', 'sitelang' ) ) as $section => $label ) { add_submenu_page( 'sitelang-wc', $label, $label, $this->capability(), 'sitelang-wc-' . $section, array( $this, 'render_page' ) ); } } /** Register Setup after License so it remains the final SiteLang sidebar item. */ public function register_setup_submenu() { add_submenu_page( 'sitelang-wc', __( 'Setup', 'sitelang' ), __( 'Setup', 'sitelang' ), $this->capability(), 'sitelang-wc-setup', array( $this, 'render_page' ) ); } /** Keep legacy secondary workflows out of the main WordPress SiteLang menu. */ public function hide_legacy_primary_submenus() { if ( ! current_user_can( $this->capability() ) ) { return; } $hidden = array( 'sitelang-wc-ai', 'sitelang-wc-switcher', 'sitelang-wc-menus', 'sitelang-wc-reviews', 'sitelang-wc-urls', 'sitelang-wc-internal-links', 'sitelang-wc-tools' ); foreach ( $hidden as $slug ) { remove_submenu_page( 'sitelang-wc', $slug ); } } /** Keep WordPress's SiteLang submenu highlight aligned with contextual tab links. */ public function active_submenu_file( $submenu_file, $parent_file ) { if ( 'sitelang-wc' !== $parent_file ) { return $submenu_file; } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; if ( 'sitelang-wc-setup' === $page ) { return 'sitelang-wc-setup'; } if ( 'sitelang-wc' === $page && in_array( $tab, array( 'languages', 'translations', 'products', 'queue', 'feeds', 'settings' ), true ) ) { return 'sitelang-wc-' . $tab; } return $submenu_file; } /** * Enqueue admin assets. * * @param string $hook Current hook. * @return void */ public function enqueue_assets( $hook ) { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $post_type = $screen && ! empty( $screen->post_type ) ? $screen->post_type : ''; $supported = $this->get_translatable_post_types(); $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // Elementor has its own scoped SiteLang editor integration. Keep the // general dashboard bundle out of its editor iframe and admin screen. if ( in_array( $action, array( 'elementor', 'elementor_ajax' ), true ) ) { return; } if ( $hook !== $this->hook_suffix && false === strpos( $hook, 'sitelang-wc-' ) && ! in_array( $post_type, $supported, true ) ) { return; } wp_enqueue_style( 'sitelang-admin-sidebar', SITELANG_URL . 'assets/css/admin-sidebar.css', array(), SITELANG_VERSION ); wp_enqueue_style( 'sitelang-admin', SITELANG_URL . 'assets/css/admin.css', array(), (string) filemtime( SITELANG_PATH . 'assets/css/admin.css' ) ); wp_enqueue_style( 'sitelang-admin-overview', SITELANG_URL . 'assets/css/admin-overview.css', array( 'sitelang-admin' ), (string) filemtime( SITELANG_PATH . 'assets/css/admin-overview.css' ) ); wp_enqueue_style( 'sitelang-admin-brand', SITELANG_URL . 'assets/css/admin-brand.css', array( 'sitelang-admin-overview' ), (string) filemtime( SITELANG_PATH . 'assets/css/admin-brand.css' ) ); wp_enqueue_style( 'sitelang-admin-design-system', SITELANG_URL . 'assets/css/admin-design-system.css', array( 'sitelang-admin-brand' ), (string) filemtime( SITELANG_PATH . 'assets/css/admin-design-system.css' ) ); wp_enqueue_style( 'sitelang-license', SITELANG_URL . 'assets/css/license.css', array( 'sitelang-admin' ), SITELANG_VERSION ); wp_enqueue_script( 'sitelang-license', SITELANG_URL . 'assets/js/license.js', array(), SITELANG_VERSION, true ); wp_enqueue_script( 'sitelang-admin', SITELANG_URL . 'assets/js/admin.js', array(), (string) filemtime( SITELANG_PATH . 'assets/js/admin.js' ), true ); wp_localize_script( 'sitelang-admin', 'slwAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'sitelang_translation_progress' ), 'menuNonce' => wp_create_nonce( 'sitelang_translate_menu_item' ), 'scrollableTableLabel' => __( 'Scrollable table. Use the left and right arrow keys to view more columns.', 'sitelang' ), ) ); } /** * Handle posted settings/actions. * * @return void */ public function handle_actions() { // Gutenberg posts legacy meta-box data to post.php after publishing. Those // requests can include hidden SiteLang fields; handle actions only on our // own admin screens. if ( 'admin.php' !== ( $GLOBALS['pagenow'] ?? '' ) || empty( $_POST['sitelang_action'] ) ) { return; } $request_action = sanitize_key( wp_unslash( $_POST['sitelang_action'] ) ); if ( '' === $request_action || ! current_user_can( $this->capability() ) ) { return; } $action = $request_action; if ( 'skip_setup' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can skip SiteLang setup.', 'sitelang' ) ); } check_admin_referer( 'sitelang_skip_setup' ); $state = $this->setup_state(); $state['step'] = 3; $state['completed'] = 1; $state['skipped'] = 1; $state['completed_at'] = current_time( 'mysql' ); update_option( 'sitelang_setup_wizard', $state, false ); wp_safe_redirect( admin_url( 'admin.php?page=sitelang-wc' ) ); exit; } if ( 'save_languages' === $action ) { check_admin_referer( 'sitelang_save_languages' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_languages validates row shape and sanitizes every language field before persistence. $rows = isset( $_POST['sitelang_languages'] ) && is_array( $_POST['sitelang_languages'] ) ? $this->languages->normalize_languages( wp_unslash( $_POST['sitelang_languages'] ) ) : array(); $allow_empty = ! empty( $_POST['sitelang_confirm_remove_all_languages'] ); if ( ! current_user_can( 'manage_options' ) ) { $this->redirect_with_notice( 'translation_error', __( 'Only a WordPress administrator can change SiteLang languages. Existing languages and translations are unchanged.', 'sitelang' ) ); } $result = $this->languages->save_languages( $rows, $allow_empty ); if ( is_wp_error( $result ) ) { $pending_rows = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $pending_rows[] = array( 'code' => $this->languages->sanitize_code( $row['code'] ?? '' ), 'english_label' => sanitize_text_field( $row['english_label'] ?? '' ), 'native_label' => sanitize_text_field( $row['native_label'] ?? '' ), 'flag' => sanitize_key( $row['flag'] ?? '' ), 'rtl' => ! empty( $row['rtl'] ) ? 1 : 0, 'wp_locale' => sanitize_text_field( $row['wp_locale'] ?? '' ), ); } set_transient( 'sitelang_language_form_' . get_current_user_id(), $pending_rows, 5 * MINUTE_IN_SECONDS ); $this->redirect_with_notice( 'translation_error', $result->get_error_message() ); } $this->redirect_with_notice( 'languages_saved' ); } if ( 'save_setup_step' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang setup.', 'sitelang' ) ); } $this->save_setup_wizard_step(); } if ( 'setup_back' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change setup.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_setup_step' ); $state = $this->setup_state(); $state['step'] = max( 1, min( 3, isset( $_POST['setup_target_step'] ) ? absint( $_POST['setup_target_step'] ) : 1 ) ); if ( $state['step'] < 3 ) { $state['completed'] = 0; } update_option( 'sitelang_setup_wizard', $state, false ); wp_safe_redirect( admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=' . $state['step'] ) ); exit; } if ( 'save_general_settings' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang settings.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_general_settings' ); $settings = $this->languages->get_settings(); $settings['purge_cache_after_translation'] = ! empty( $_POST['purge_cache_after_translation'] ) ? 1 : 0; $this->languages->save_settings( $settings ); $this->redirect_with_notice( 'settings_saved', '', admin_url( 'admin.php?page=sitelang-wc&tab=settings' ) ); } if ( 'save_switcher' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang switcher settings.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_switcher' ); $preset = isset( $_POST['switcher_preset'] ) ? sanitize_key( wp_unslash( $_POST['switcher_preset'] ) ) : 'minimal'; $available_styles = $this->get_switcher_presets(); if ( ! isset( $available_styles[ $preset ] ) ) { $preset = 'minimal'; } $switcher_mode = isset( $_POST['switcher_mode'] ) ? sanitize_key( wp_unslash( $_POST['switcher_mode'] ) ) : 'dropdown'; if ( ! in_array( $switcher_mode, array( 'dropdown', 'pills' ), true ) || in_array( $preset, array( 'floating-popup', 'compact-flag-dropdown', 'labeled-language-button' ), true ) ) { $switcher_mode = 'dropdown'; } $this->languages->save_settings( array( 'switcher_mode' => $switcher_mode, 'switcher_preset' => $preset, 'switcher_show_flag' => ! empty( $_POST['switcher_show_flag'] ) ? 1 : 0, 'switcher_show_name' => ! empty( $_POST['switcher_show_name'] ) ? 1 : 0, 'switcher_radius' => isset( $_POST['switcher_radius'] ) ? min( 32, absint( $_POST['switcher_radius'] ) ) : 8, 'switcher_bg' => isset( $_POST['switcher_bg'] ) ? sanitize_hex_color( wp_unslash( $_POST['switcher_bg'] ) ) : '#ffffff', 'switcher_text' => isset( $_POST['switcher_text'] ) ? sanitize_hex_color( wp_unslash( $_POST['switcher_text'] ) ) : '#1f2937', 'switcher_accent' => isset( $_POST['switcher_accent'] ) ? sanitize_hex_color( wp_unslash( $_POST['switcher_accent'] ) ) : '#2563eb', 'switcher_custom_hover' => ! empty( $_POST['switcher_custom_hover'] ) ? 1 : 0, 'switcher_hover_bg' => isset( $_POST['switcher_hover_bg'] ) ? ( sanitize_hex_color( wp_unslash( $_POST['switcher_hover_bg'] ) ) ?: '#eaf3f5' ) : '#eaf3f5', 'switcher_hover_text' => isset( $_POST['switcher_hover_text'] ) ? ( sanitize_hex_color( wp_unslash( $_POST['switcher_hover_text'] ) ) ?: '#045f78' ) : '#045f78', 'default_flag' => isset( $_POST['default_flag'] ) ? sanitize_key( wp_unslash( $_POST['default_flag'] ) ) : 'us', 'switcher_placement' => isset( $_POST['switcher_placement'] ) ? sanitize_key( wp_unslash( $_POST['switcher_placement'] ) ) : 'header-floating', 'hook_enabled' => ! empty( $_POST['hook_enabled'] ) ? 1 : 0, 'hook_name' => isset( $_POST['hook_name'] ) ? sanitize_key( wp_unslash( $_POST['hook_name'] ) ) : 'wp_body_open', 'mobile_switcher_enabled' => ! empty( $_POST['mobile_switcher_enabled'] ) ? 1 : 0, 'mobile_switcher_mode' => isset( $_POST['mobile_switcher_mode'] ) ? sanitize_key( wp_unslash( $_POST['mobile_switcher_mode'] ) ) : 'dropdown', 'mobile_switcher_target' => isset( $_POST['mobile_switcher_target'] ) ? sanitize_text_field( wp_unslash( $_POST['mobile_switcher_target'] ) ) : '', ) ); $this->redirect_with_notice( 'switcher_saved' ); } if ( 'save_ai_settings' === $action ) { check_admin_referer( 'sitelang_save_ai_settings' ); $raw_temperature = isset( $_POST['ai_temperature'] ) && is_scalar( $_POST['ai_temperature'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_temperature'] ) ) : ''; $this->ai_translator->save_settings( array( 'enabled' => ! empty( $_POST['ai_enabled'] ) ? 1 : 0, 'model' => isset( $_POST['ai_model'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_model'] ) ) : 'gpt-4o-mini', 'reasoning_effort' => isset( $_POST['ai_reasoning_effort'] ) ? sanitize_key( wp_unslash( $_POST['ai_reasoning_effort'] ) ) : 'none', 'temperature' => is_numeric( $raw_temperature ) ? max( 0, min( 2, (float) $raw_temperature ) ) : 0.2, 'max_output_tokens' => isset( $_POST['ai_max_output_tokens'] ) ? absint( $_POST['ai_max_output_tokens'] ) : 16000, 'timeout' => isset( $_POST['ai_timeout'] ) ? absint( $_POST['ai_timeout'] ) : 90, ) ); if ( ! empty( $_POST['openai_api_key'] ) && $this->ai_translator->allows_legacy_direct_openai() ) { $this->ai_translator->save_api_key( sanitize_text_field( wp_unslash( $_POST['openai_api_key'] ) ) ); } $this->redirect_with_notice( 'ai_saved' ); } if ( 'refresh_ai_models' === $action ) { check_admin_referer( 'sitelang_refresh_ai_models' ); $result = $this->ai_translator->refresh_available_models( true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=ai' ) ); } $this->redirect_with_notice( 'ai_models_refreshed', (string) count( $result['ids'] ), admin_url( 'admin.php?page=sitelang-wc&tab=ai' ) ); } if ( 'test_ai_model' === $action ) { check_admin_referer( 'sitelang_test_ai_model' ); $model = isset( $_POST['ai_model'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_model'] ) ) : (string) $this->ai_translator->get_settings()['model']; $result = $this->ai_translator->test_model( $model ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=ai' ) ); } $this->redirect_with_notice( 'ai_model_tested', $result, admin_url( 'admin.php?page=sitelang-wc&tab=ai' ) ); } if ( 'save_url_settings' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang URL settings.', 'sitelang' ) ); } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang URL settings.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_url_settings' ); $settings = array(); $settings['homepages'] = array(); $homepages = isset( $_POST['homepages'] ) && is_array( $_POST['homepages'] ) ? map_deep( wp_unslash( $_POST['homepages'] ), 'absint' ) : array(); foreach ( $homepages as $code => $page_id ) { $code = $this->languages->sanitize_code( $code ); if ( $this->languages->is_enabled( $code ) ) { $settings['homepages'][ $code ] = absint( $page_id ); } } $this->languages->save_settings( $settings ); update_option( 'sitelang_rewrite_flush_needed', 1, false ); $this->redirect_with_notice( 'urls_saved' ); } if ( 'save_feed_settings' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang feed settings.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_feed_settings' ); $this->languages->save_settings( array( 'merchant_exclude_translated_display_ads' => ! empty( $_POST['merchant_exclude_translated_display_ads'] ) ? 1 : 0, ) ); $this->redirect_with_notice( 'feeds_saved', '', admin_url( 'admin.php?page=sitelang-wc&tab=feeds' ) ); } if ( 'save_menu_translations' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change SiteLang menu translations.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_menu_translations' ); $language = isset( $_POST['translation_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['translation_language'] ) ) ) : ''; if ( ! $this->languages->is_enabled( $language ) ) { $this->redirect_with_notice( 'translation_error', __( 'Choose an enabled language before saving menu translations.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=menus' ) ); } $rows = isset( $_POST['menu_translations'] ) && is_array( $_POST['menu_translations'] ) ? map_deep( wp_unslash( $_POST['menu_translations'] ), 'sanitize_text_field' ) : array(); $this->save_menu_translations( $rows, $language ); $this->redirect_with_notice( 'menus_saved', '', admin_url( 'admin.php?page=sitelang-wc&tab=menus&translation_language=' . rawurlencode( $language ) ) ); } if ( 'save_internal_links_settings' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can change internal link settings.', 'sitelang' ) ); } check_admin_referer( 'sitelang_save_internal_links_settings' ); $settings = isset( $_POST['sitelang_internal_links'] ) && is_array( $_POST['sitelang_internal_links'] ) ? map_deep( wp_unslash( $_POST['sitelang_internal_links'] ), 'sanitize_text_field' ) : array(); $this->internal_links->save_settings( $settings ); $this->redirect_with_notice( 'internal_links_saved', '', admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } if ( 'generate_internal_links_preview' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can generate internal link suggestions.', 'sitelang' ) ); } check_admin_referer( 'sitelang_generate_internal_links_preview' ); $args = isset( $_POST['sitelang_internal_links_generate'] ) && is_array( $_POST['sitelang_internal_links_generate'] ) ? map_deep( wp_unslash( $_POST['sitelang_internal_links_generate'] ), 'sanitize_text_field' ) : array(); $rows = $this->internal_links->generate_preview( $args, get_current_user_id() ); if ( is_wp_error( $rows ) ) { $this->redirect_with_notice( 'translation_error', $rows->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } $this->redirect_with_notice( 'internal_links_preview', (string) count( $rows ), admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } if ( 'apply_internal_links' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can apply internal link suggestions.', 'sitelang' ) ); } check_admin_referer( 'sitelang_apply_internal_links' ); $keys = isset( $_POST['internal_link_keys'] ) && is_array( $_POST['internal_link_keys'] ) ? map_deep( wp_unslash( $_POST['internal_link_keys'] ), 'sanitize_text_field' ) : array(); $result = $this->internal_links->apply_selected( $keys, get_current_user_id() ); $this->redirect_with_notice( 'internal_links_applied', absint( $result['applied'] ) . ':' . absint( $result['skipped'] ), admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } if ( 'remove_internal_links' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can remove generated internal links.', 'sitelang' ) ); } check_admin_referer( 'sitelang_remove_internal_links' ); $count = $this->internal_links->remove_generated_links( array( 'source_id' => isset( $_POST['source_id'] ) ? absint( $_POST['source_id'] ) : 0, 'language' => isset( $_POST['language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['language'] ) ) ) : '', ) ); $this->redirect_with_notice( 'internal_links_removed', (string) $count, admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } if ( 'restore_internal_links_backup' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can restore internal link backups.', 'sitelang' ) ); } check_admin_referer( 'sitelang_restore_internal_links_backup' ); $source_id = isset( $_POST['source_id'] ) ? absint( $_POST['source_id'] ) : 0; $result = $this->internal_links->restore_latest_backup( $source_id ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } $this->redirect_with_notice( 'internal_links_restored', '', admin_url( 'admin.php?page=sitelang-wc&tab=internal-links' ) ); } if ( 'export_internal_links_csv' === $action ) { check_admin_referer( 'sitelang_export_internal_links_csv' ); $this->internal_links->export_csv(); } if ( 'create_manual_translation' === $action ) { check_admin_referer( 'sitelang_create_manual_translation' ); $original_id = isset( $_POST['original_id'] ) ? absint( wp_unslash( $_POST['original_id'] ) ) : 0; $language = isset( $_POST['language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['language'] ) ) ) : ''; if ( ! $this->languages->is_enabled( $language ) || ! $original_id ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid source item and enabled target language.', 'sitelang' ) ); } if ( ! current_user_can( 'edit_post', $original_id ) ) { wp_die( esc_html__( 'You do not have permission to translate this item.', 'sitelang' ) ); } $result = $this->translations->create_translation( $original_id, $language, true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message() ); } wp_safe_redirect( Plugin::instance()->manual_editor->get_editor_url( $result ) ); exit; } if ( 'create_translation' === $action ) { check_admin_referer( 'sitelang_create_translation' ); if ( ! Plugin::instance()->entitlements->allows( 'ai_translation' ) ) { $this->redirect_with_notice( 'translation_error', __( 'Auto translation requires an eligible SiteLang subscription. You can create and edit a manual translation for free.', 'sitelang' ) ); } $original_id = isset( $_POST['original_id'] ) ? absint( wp_unslash( $_POST['original_id'] ) ) : 0; $language = isset( $_POST['language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['language'] ) ) ) : ''; if ( ! $this->languages->is_enabled( $language ) || ! $original_id ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid source item and enabled target language.', 'sitelang' ) ); } if ( ! current_user_can( 'edit_post', $original_id ) ) { wp_die( esc_html__( 'You do not have permission to translate this item.', 'sitelang' ) ); } $result = $this->translations->create_translation( $original_id, $language, true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message() ); } $ai_result = $this->queue_ai_translation( $original_id, $result, $language ); if ( is_wp_error( $ai_result ) ) { $this->redirect_with_notice( 'translation_error', $ai_result->get_error_message() ); } $this->redirect_with_notice( 'ai_translation_queued', '', admin_url( 'admin.php?page=sitelang-wc&tab=translations&translation_language=' . rawurlencode( $language ) . '&content_type=' . sanitize_key( get_post_type( $original_id ) ) ) ); } if ( 'ai_translate_existing' === $action ) { check_admin_referer( 'sitelang_ai_translate_existing' ); $translation_id = isset( $_POST['translation_id'] ) ? absint( wp_unslash( $_POST['translation_id'] ) ) : 0; $row = $translation_id ? $this->mapping->get_by_translation_id( $translation_id ) : null; if ( ! $row ) { $this->redirect_with_notice( 'translation_error', __( 'The selected translation could not be found.', 'sitelang' ) ); } if ( ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', $translation_id ) ) { wp_die( esc_html__( 'You do not have permission to translate this item.', 'sitelang' ) ); } $ai_result = $this->queue_ai_translation( (int) $row->original_id, (int) $row->translation_id, $row->language_code ); if ( is_wp_error( $ai_result ) ) { $this->redirect_with_notice( 'translation_error', $ai_result->get_error_message() ); } $this->redirect_with_notice( 'ai_translation_queued', '', admin_url( 'admin.php?page=sitelang-wc&tab=translations&translation_language=' . rawurlencode( sanitize_key( $row->language_code ) ) . '&content_type=' . sanitize_key( get_post_type( (int) $row->original_id ) ) ) ); } if ( 'repair_elementor_translation' === $action ) { check_admin_referer( 'sitelang_repair_elementor_translation' ); $translation_id = isset( $_POST['translation_id'] ) ? absint( wp_unslash( $_POST['translation_id'] ) ) : 0; $row = $translation_id ? $this->mapping->get_by_translation_id( $translation_id ) : null; if ( ! $row ) { $this->redirect_with_notice( 'translation_error', __( 'The selected translation could not be found.', 'sitelang' ) ); } if ( ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', $translation_id ) ) { wp_die( esc_html__( 'You do not have permission to repair this translation.', 'sitelang' ) ); } $repair_result = $this->queue_elementor_repair( $translation_id ); if ( is_wp_error( $repair_result ) ) { $this->redirect_with_notice( 'translation_error', $repair_result->get_error_message() ); } $this->redirect_with_notice( 'elementor_repair_queued', '', admin_url( 'admin.php?page=sitelang-wc&tab=translations&translation_language=' . rawurlencode( sanitize_key( $row->language_code ) ) . '&content_type=' . sanitize_key( get_post_type( (int) $row->original_id ) ) ) ); } if ( 'translate_product_reviews' === $action ) { check_admin_referer( 'sitelang_translate_product_reviews' ); $translation_id = isset( $_POST['translation_id'] ) ? absint( $_POST['translation_id'] ) : 0; $row = $translation_id ? $this->mapping->get_by_translation_id( $translation_id ) : null; if ( ! $row || ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', $translation_id ) ) { wp_die( esc_html__( 'You do not have permission to translate these reviews.', 'sitelang' ) ); } $result = $this->ai_translator->enqueue_review_translation_job( $translation_id ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=reviews' ) ); } $this->redirect_with_notice( 'reviews_queued', '', admin_url( 'admin.php?page=sitelang-wc&tab=reviews' ) ); } if ( 'resume_queue_job' === $action ) { check_admin_referer( 'sitelang_resume_queue_job' ); $translation_id = isset( $_POST['translation_id'] ) ? absint( $_POST['translation_id'] ) : 0; $row = $translation_id ? $this->mapping->get_by_translation_id( $translation_id ) : null; $return_url = admin_url( 'admin.php?page=sitelang-wc&tab=queue' ); if ( ! $row ) { $this->redirect_with_notice( 'translation_error', __( 'The selected queue job could not be found.', 'sitelang' ), $return_url ); } if ( ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', $translation_id ) ) { wp_die( esc_html__( 'You do not have permission to resume this translation.', 'sitelang' ) ); } $job = $this->ai_translator->get_job_progress( $translation_id ); if ( in_array( sanitize_key( $job['state'] ?? '' ), array( 'queued', 'processing', 'polishing' ), true ) ) { $this->redirect_with_notice( 'queue_job_active', '', $return_url ); } $result = $this->queue_ai_translation( (int) $row->original_id, (int) $row->translation_id, $row->language_code ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), $return_url ); } $this->redirect_with_notice( 'queue_job_resumed', '', $return_url ); } if ( 'bulk_create_translations' === $action ) { check_admin_referer( 'sitelang_bulk_create_translations' ); $ids = isset( $_POST['content_ids'] ) && is_array( $_POST['content_ids'] ) ? array_map( 'absint', wp_unslash( $_POST['content_ids'] ) ) : array(); $languages = isset( $_POST['bulk_languages'] ) && is_array( $_POST['bulk_languages'] ) ? array_map( 'sanitize_key', wp_unslash( $_POST['bulk_languages'] ) ) : array(); $count = 0; $failures = array(); $mode = isset( $_POST['translation_mode'] ) ? sanitize_key( wp_unslash( $_POST['translation_mode'] ) ) : 'manual'; if ( ! in_array( $mode, array( 'manual', 'ai' ), true ) ) { $mode = 'manual'; } if ( 'ai' === $mode && ! Plugin::instance()->entitlements->allows( 'ai_translation' ) ) { $this->redirect_with_notice( 'translation_error', __( 'Auto translation requires an eligible SiteLang subscription. No translations were changed.' , 'sitelang' ) ); } foreach ( $ids as $id ) { if ( ! current_user_can( 'edit_post', $id ) ) { $failures[] = __( 'An item was skipped because you cannot edit it.', 'sitelang' ); continue; } foreach ( $languages as $language ) { $language = $this->languages->sanitize_code( $language ); if ( ! $this->languages->is_enabled( $language ) ) { $failures[] = __( 'An item was skipped because the selected language is not enabled.', 'sitelang' ); continue; } $result = $this->translations->create_translation( $id, $language, true ); if ( is_wp_error( $result ) ) { $failures[] = get_the_title( $id ) . ': ' . $result->get_error_message(); continue; } if ( 'ai' === $mode ) { $ai_result = $this->queue_ai_translation( $id, $result, $language ); if ( is_wp_error( $ai_result ) ) { $failures[] = get_the_title( $id ) . ': ' . $ai_result->get_error_message(); continue; } } ++$count; } } $return_language = isset( $_POST['return_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['return_language'] ) ) ) : ''; $return_type = isset( $_POST['return_content_type'] ) ? sanitize_key( wp_unslash( $_POST['return_content_type'] ) ) : 'page'; $return_url = admin_url( 'admin.php?page=sitelang-wc&tab=translations' ); if ( $return_language && $this->languages->is_enabled( $return_language ) ) { $return_url = add_query_arg( array( 'translation_language' => $return_language, 'content_type' => $return_type ), $return_url ); } if ( 'ai' === $mode ) { $this->redirect_with_notice( $failures ? 'bulk_queued_with_errors' : 'bulk_queued', (string) $count, $return_url ); } $this->redirect_with_notice( $failures ? 'bulk_created_with_errors' : 'bulk_created', (string) $count, $return_url ); } if ( 'create_woocommerce_page_translations' === $action ) { check_admin_referer( 'sitelang_create_woocommerce_page_translations' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only a WordPress administrator can create WooCommerce page translations.', 'sitelang' ) ); } $count = $this->create_woocommerce_page_translations(); $this->redirect_with_notice( 'woo_pages_created', (string) $count ); } if ( 'bulk_repair_elementor_translations' === $action ) { check_admin_referer( 'sitelang_bulk_repair_elementor_translations' ); $language = isset( $_POST['repair_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['repair_language'] ) ) ) : ''; $count = $this->queue_bulk_elementor_repairs( $language ); $this->redirect_with_notice( 'elementor_repair_bulk_queued', (string) $count, admin_url( 'admin.php?page=sitelang-wc&tab=tools' ) ); } if ( 'import_settings' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only an administrator can import settings.', 'sitelang' ) ); } check_admin_referer( 'sitelang_import_settings' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- bounded JSON is decoded and checked against explicit keys, shapes, and scalar values before any writes. $json = isset( $_POST['settings_json'] ) && is_string( $_POST['settings_json'] ) ? wp_unslash( $_POST['settings_json'] ) : ''; $data = strlen( $json ) <= MB_IN_BYTES ? json_decode( $json, true ) : null; $valid_import = is_array( $data ) && ! array_diff( array_keys( $data ), array( 'languages', 'settings' ) ); if ( $valid_import && isset( $data['languages'] ) ) { $valid_import = is_array( $data['languages'] ); foreach ( $data['languages'] as $language_row ) { if ( ! is_array( $language_row ) || array_diff( array_keys( $language_row ), array( 'code', 'english_label', 'native_label', 'flag', 'rtl', 'wp_locale' ) ) ) { $valid_import = false; break; } foreach ( $language_row as $value ) { if ( ! is_scalar( $value ) ) { $valid_import = false; break 2; } } } } if ( $valid_import && isset( $data['settings'] ) ) { $allowed_settings = array( 'switcher_mode', 'switcher_preset', 'switcher_show_flag', 'switcher_show_name', 'switcher_radius', 'switcher_bg', 'switcher_text', 'switcher_accent', 'switcher_custom_hover', 'switcher_hover_bg', 'switcher_hover_text', 'switcher_placement', 'mobile_switcher_enabled', 'mobile_switcher_mode', 'merchant_exclude_translated_display_ads', 'purge_cache_after_translation', 'checkout_language_continuity' ); $valid_import = is_array( $data['settings'] ) && ! array_diff( array_keys( $data['settings'] ), $allowed_settings ); if ( $valid_import ) { foreach ( $data['settings'] as $value ) { if ( ! is_scalar( $value ) ) { $valid_import = false; break; } } } } if ( $valid_import && ( isset( $data['languages'] ) || isset( $data['settings'] ) ) ) { if ( isset( $data['languages'] ) && is_array( $data['languages'] ) ) { $result = $this->languages->save_languages( $data['languages'], false ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message() ); } } if ( isset( $data['settings'] ) && is_array( $data['settings'] ) ) { $allowed = $allowed_settings; $imported = array(); foreach ( $allowed as $key ) { if ( ! isset( $data['settings'][ $key ] ) || ! is_scalar( $data['settings'][ $key ] ) ) { continue; } $value = $data['settings'][ $key ]; if ( in_array( $key, array( 'switcher_show_flag', 'switcher_show_name', 'switcher_custom_hover', 'mobile_switcher_enabled', 'merchant_exclude_translated_display_ads', 'purge_cache_after_translation', 'checkout_language_continuity' ), true ) ) { $imported[ $key ] = ! empty( $value ) ? 1 : 0; } elseif ( 'switcher_radius' === $key ) { $imported[ $key ] = min( 32, absint( $value ) ); } elseif ( in_array( $key, array( 'switcher_bg', 'switcher_text', 'switcher_accent', 'switcher_hover_bg', 'switcher_hover_text' ), true ) ) { $color = sanitize_hex_color( $value ); if ( $color ) { $imported[ $key ] = $color; } } else { $imported[ $key ] = sanitize_key( $value ); } } $this->languages->save_settings( $imported ); } update_option( 'sitelang_rewrite_flush_needed', 1, false ); $this->redirect_with_notice( 'settings_imported' ); } $this->redirect_with_notice( 'translation_error', __( 'The settings JSON is invalid, exceeds 1 MB, or contains unsupported fields. No settings were changed.', 'sitelang' ) ); } if ( 'export_xliiff' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only an administrator can exchange translations.', 'sitelang' ) ); } check_admin_referer( 'sitelang_export_xliiff' ); $language = isset( $_POST['xliiff_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['xliiff_language'] ) ) ) : ''; $this->xliiff->export( $language ); } if ( 'import_xliiff' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only an administrator can exchange translations.', 'sitelang' ) ); } check_admin_referer( 'sitelang_import_xliiff' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- the upload is validated below for error, uploaded temp path, size, extension, MIME, then parsed as bounded XML. $file = isset( $_FILES['xliiff_file'] ) && is_array( $_FILES['xliiff_file'] ) ? $_FILES['xliiff_file'] : array(); $filename = isset( $file['name'] ) && is_string( $file['name'] ) ? sanitize_file_name( wp_unslash( $file['name'] ) ) : ''; $extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ); $tmp_name = isset( $file['tmp_name'] ) && is_string( $file['tmp_name'] ) ? $file['tmp_name'] : ''; $tmp_path = $tmp_name ? realpath( $tmp_name ) : false; $file_size = $tmp_path && is_file( $tmp_path ) ? filesize( $tmp_path ) : false; $file_type = $tmp_path ? wp_check_filetype_and_ext( $tmp_path, $filename, array( 'xlf' => 'application/xml', 'xliff' => 'application/xml', 'xml' => 'application/xml' ) ) : array(); $valid_xml_type = is_array( $file_type ) && in_array( strtolower( (string) ( $file_type['ext'] ?? '' ) ), array( 'xlf', 'xliff', 'xml' ), true ) && false !== strpos( strtolower( (string) ( $file_type['type'] ?? '' ) ), 'xml' ); if ( ! $valid_xml_type || ! empty( $file['error'] ) || ! $tmp_path || ! is_uploaded_file( $tmp_name ) || false === $file_size || 0 === $file_size || $file_size > 5 * MB_IN_BYTES ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid XLIFF 1.2 file no larger than 5 MB.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ); } $xml = file_get_contents( $tmp_path, false, null, 0, ( 5 * MB_IN_BYTES ) + 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read a validated, bounded temporary XLIFF upload. $preview = $this->xliiff->preview( $xml ); if ( is_wp_error( $preview ) ) { $this->redirect_with_notice( 'translation_error', $preview->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ); } $token = wp_generate_password( 32, false, false ); set_transient( 'sitelang_xliff_preview_' . get_current_user_id(), array( 'xml' => $xml, 'preview' => $preview, 'token' => $token, 'filename' => $filename ), 15 * MINUTE_IN_SECONDS ); wp_safe_redirect( admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ); exit; } if ( 'apply_xliiff' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Only an administrator can exchange translations.', 'sitelang' ) ); } check_admin_referer( 'sitelang_apply_xliiff' ); $pending = get_transient( 'sitelang_xliff_preview_' . get_current_user_id() ); $token = isset( $_POST['xliiff_preview_token'] ) ? sanitize_text_field( wp_unslash( $_POST['xliiff_preview_token'] ) ) : ''; if ( ! is_array( $pending ) || ! hash_equals( (string) ( $pending['token'] ?? '' ), $token ) ) { $this->redirect_with_notice( 'translation_error', __( 'The XLIFF review has expired. Upload the file again.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ); } $result = $this->xliiff->import( $pending['xml'] ); delete_transient( 'sitelang_xliff_preview_' . get_current_user_id() ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ); } $this->redirect_with_notice( 'xliiff_imported', (string) absint( $result ), admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ); } } /** * Render settings page. * * @return void */ public function render_page() { if ( ! current_user_can( $this->capability() ) ) { wp_die( esc_html__( 'You do not have permission to manage translations.', 'sitelang' ) ); } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ( 0 === strpos( $page, 'sitelang-wc-' ) ? substr( $page, strlen( 'sitelang-wc-' ) ) : 'dashboard' ); $tab = array_key_exists( $tab, $this->tabs() ) ? $tab : 'dashboard'; $setup_state = $this->setup_state(); if ( empty( $setup_state['completed'] ) && 'setup' !== $tab ) { $tab = 'setup'; } $analytics = $this->get_dashboard_analytics(); $is_display_view = 'settings' === $tab && isset( $_GET['settings_tab'] ) && 'display' === sanitize_key( wp_unslash( $_GET['settings_tab'] ) ); $nav_tab = in_array( $tab, array( 'setup', 'switcher' ), true ) ? ( 'setup' === $tab ? 'dashboard' : 'display' ) : $tab; if ( $is_display_view ) { $nav_tab = 'display'; } ?>

SiteLang render_section_navigation( $nav_tab ); ?>

tabs()[ $tab ] ?? $this->tabs()['languages'] ) ); ?>

tab_descriptions()[ $tab ] ?? $this->tab_descriptions()['languages'] ) ); ?>

render_page_heading_action( $tab ); } ?>
render_dashboard_overview( $analytics ); } elseif ( 'queue' === $tab ) { $this->render_translation_queue(); } elseif ( 'products' === $tab ) { $this->render_context_navigation( 'products' ); $this->render_product_overview(); } elseif ( 'settings' === $tab ) { $this->render_settings_overview(); } elseif ( 'license' === $tab && current_user_can( 'manage_options' ) ) { Plugin::instance()->license->render_admin_page(); } elseif ( 'setup' === $tab ) { $this->render_setup_wizard(); } elseif ( 'translations' === $tab ) { $this->render_translations_tab(); } elseif ( 'ai' === $tab ) { $this->render_ai_tab(); } elseif ( 'switcher' === $tab ) { $this->render_switcher_tab(); } elseif ( 'menus' === $tab ) { $this->render_menus_tab(); } elseif ( 'reviews' === $tab ) { $this->render_reviews_tab(); } elseif ( 'urls' === $tab ) { $this->render_urls_tab(); } elseif ( 'feeds' === $tab ) { $this->render_feeds_tab(); } elseif ( 'internal-links' === $tab ) { $this->render_internal_links_tab(); } elseif ( 'tools' === $tab ) { $this->render_tools_tab(); } elseif ( 'languages' === $tab ) { $this->render_languages_tab(); } ?>
array( __( 'Add language', 'sitelang' ), '#sitelang-language-editor' ), 'translations' => array( __( 'Start translating', 'sitelang' ), '#sitelang-translation-language' ), 'queue' => array( __( 'Open translations', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=translations' ) ), 'settings' => array( __( 'Switcher & display', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=settings&settings_tab=display' ) ), ); if ( empty( $actions[ $tab ] ) ) { return; } $action = $actions[ $tab ]; ?>
tabs(); $tabs = array(); foreach ( array( 'dashboard', 'languages', 'translations', 'products', 'queue', 'display', 'feeds', 'settings' ) as $key ) { if ( 'display' === $key ) { $tabs[ $key ] = __( 'Switcher & display', 'sitelang' ); } elseif ( isset( $all_tabs[ $key ] ) ) { $tabs[ $key ] = $all_tabs[ $key ]; } } ?> array( __( 'Content', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=translations' ) ), 'products' => array( __( 'Products', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=products' ) ), 'menus' => array( __( 'Menus', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=menus' ) ), 'reviews' => array( __( 'Reviews', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=reviews' ) ), 'xliff' => array( __( 'XLIFF exchange', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=tools#sitelang-xliff-exchange' ) ), ); ?> array( __( 'Minimal Outline', 'sitelang' ), __( 'A restrained control that fits most headers.', 'sitelang' ) ), 'soft' => array( __( 'Soft Surface', 'sitelang' ), __( 'A light neutral fill with gentle separation.', 'sitelang' ) ), 'compact' => array( __( 'Compact', 'sitelang' ), __( 'A smaller footprint for narrow headers.', 'sitelang' ) ), 'floating' => array( __( 'Elevated', 'sitelang' ), __( 'A subtle shadow that lifts the control.', 'sitelang' ) ), 'underline' => array( __( 'Text + Underline', 'sitelang' ), __( 'A quiet navigation-style selector.', 'sitelang' ) ), 'segmented' => array( __( 'Segmented', 'sitelang' ), __( 'A connected choice bar for a few languages.', 'sitelang' ) ), 'borderless' => array( __( 'Borderless Menu', 'sitelang' ), __( 'A text-first control for busy navigation.', 'sitelang' ) ), 'glass' => array( __( 'Glass', 'sitelang' ), __( 'A translucent surface for layered headers.', 'sitelang' ) ), 'dark' => array( __( 'Dark Contrast', 'sitelang' ), __( 'A high-contrast option for light headers.', 'sitelang' ) ), 'flag-chip' => array( __( 'Flag Chip', 'sitelang' ), __( 'A compact flag-led selector with a label.', 'sitelang' ) ), 'capsule' => array( __( 'Capsule', 'sitelang' ), __( 'A rounded neutral control with a crisp border.', 'sitelang' ) ), 'solid' => array( __( 'Solid Accent', 'sitelang' ), __( 'A filled control using your accent color.', 'sitelang' ) ), 'outline' => array( __( 'Accent Outline', 'sitelang' ), __( 'A clean accent border for light headers.', 'sitelang' ) ), 'muted' => array( __( 'Muted Neutral', 'sitelang' ), __( 'A low-emphasis selector for compact navigation.', 'sitelang' ) ), 'floating-popup' => array( __( 'Floating Language Popup', 'sitelang' ), __( 'A compact flag trigger with a floating language list.', 'sitelang' ) ), 'compact-flag-dropdown' => array( __( 'Compact Flag Dropdown', 'sitelang' ), __( 'A small flag trigger with a crisp list of language choices.', 'sitelang' ) ), 'language-card' => array( __( 'Language Card', 'sitelang' ), __( 'Keep every enabled language visible in a tidy vertical list.', 'sitelang' ) ), 'labeled-language-button' => array( __( 'Labeled Language Button', 'sitelang' ), __( 'A prominent current-language button with a helpful label.', 'sitelang' ) ), ); } private function render_setup_wizard() { $state = $this->setup_state(); $step = isset( $_GET['setup_step'] ) ? absint( $_GET['setup_step'] ) : absint( $state['step'] ); $step = max( 1, min( 3, $step ) ); $enabled = $this->languages->get_enabled(); $presets = $this->languages->get_presets(); $settings = $this->languages->get_settings(); $selected_preset = sanitize_key( $settings['switcher_preset'] ?? 'minimal' ); $switcher_presets = $this->get_switcher_presets(); $labels = array( 1 => __( 'Languages', 'sitelang' ), 2 => __( 'Switcher', 'sitelang' ), 3 => __( 'Ready', 'sitelang' ) ); ?>

·

$preset ) : if ( $code === $this->languages->get_default_code() ) { continue; } ?>

$preset ) : ?>
[sitelang_switcher]
render_setup_back_button( 1 ); ?>

[sitelang_switcher]
languages->get_default_native_label() ); ?>
$language ) : ?>
setup_state(); $step = isset( $_POST['setup_step'] ) ? absint( $_POST['setup_step'] ) : 0; if ( 1 === $step ) { $default = isset( $_POST['setup_source_language'] ) && is_scalar( $_POST['setup_source_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['setup_source_language'] ) ) ) : ''; $presets = $this->languages->get_presets(); if ( ! isset( $presets[ $default ] ) || ( $default !== $this->languages->get_default_code() && $this->mapping->has_mappings() ) ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid original language. A site with linked translations cannot change it.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=1' ) ); } $codes = isset( $_POST['setup_languages'] ) && is_array( $_POST['setup_languages'] ) ? array_unique( array_map( array( $this->languages, 'sanitize_code' ), array_map( 'sanitize_key', wp_unslash( $_POST['setup_languages'] ) ) ) ) : array(); $rows = array_values( $this->languages->get_enabled() ); $existing = $this->languages->get_enabled(); $valid_target_count = count( $existing ); foreach ( $codes as $code ) { if ( $code === $default || ! isset( $presets[ $code ] ) ) { continue; } if ( ! isset( $existing[ $code ] ) ) { $rows[] = array_merge( $presets[ $code ], array( 'code' => $code ) ); } ++$valid_target_count; } if ( 0 === $valid_target_count ) { $this->redirect_with_notice( 'translation_error', __( 'Select at least one target language to continue.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=1' ) ); } $result = $this->languages->save_default_language( $default ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=1' ) ); } $result = $this->languages->save_languages( $rows, true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=1' ) ); } Plugin::instance()->content_access->clear_cache(); $state['step'] = 2; } elseif ( 2 === $step ) { $style = isset( $_POST['setup_switcher_preset'] ) ? sanitize_key( wp_unslash( $_POST['setup_switcher_preset'] ) ) : 'minimal'; $mode = isset( $_POST['setup_switcher_mode'] ) ? sanitize_key( wp_unslash( $_POST['setup_switcher_mode'] ) ) : 'dropdown'; if ( ! in_array( $mode, array( 'dropdown', 'pills' ), true ) || in_array( $style, array( 'floating-popup', 'compact-flag-dropdown', 'labeled-language-button' ), true ) ) { $mode = 'dropdown'; } $available_styles = $this->get_switcher_presets(); if ( ! isset( $available_styles[ $style ] ) || ! in_array( $mode, array( 'dropdown', 'pills' ), true ) ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid switcher style and layout.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=2' ) ); } if ( ! $this->languages->get_enabled() ) { $this->redirect_with_notice( 'translation_error', __( 'Choose at least one target language before finishing setup.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=1' ) ); } $this->languages->save_settings( array( 'switcher_preset' => $style, 'switcher_mode' => $mode ) ); $state['completed'] = 1; $state['completed_at'] = current_time( 'mysql' ); $state['step'] = 3; } else { $this->redirect_with_notice( 'translation_error', __( 'Invalid setup step.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } update_option( 'sitelang_setup_wizard', $state, false ); $destination = ! empty( $state['completed'] ) ? admin_url( 'admin.php?page=sitelang-wc' ) : admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=' . $state['step'] ); wp_safe_redirect( $destination ); exit; } private function render_setup_tab() { $this->render_setup_wizard(); return; $state = $this->setup_state(); $requested_step = isset( $_GET['setup_step'] ) ? absint( $_GET['setup_step'] ) : absint( $state['step'] ?? 1 ); $step = max( 1, min( 4, $requested_step ) ); $active = Plugin::instance()->license->is_activated(); $enabled = $this->languages->get_enabled(); $presets = $this->languages->get_presets(); $default_code = $this->languages->get_default_code(); ?>

license->render_activation_form( 'sitelang-setup-activation', admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); ?>

languages->get_default_native_label() ) ); ?>

languages->get_default_native_label() ); ?>

$language ) : ?>

setup_state(); $step = isset( $_POST['setup_step'] ) ? absint( $_POST['setup_step'] ) : 1; if ( 1 === $step ) { $state['step'] = 2; } elseif ( 2 === $step ) { $code = isset( $_POST['setup_source_language'] ) && is_scalar( $_POST['setup_source_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['setup_source_language'] ) ) ) : ''; $presets = $this->languages->get_presets(); if ( '' === $code || ! isset( $presets[ $code ] ) ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid original language.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } if ( $code !== $this->languages->get_default_code() && $this->mapping->has_mappings() ) { $this->redirect_with_notice( 'translation_error', __( 'The original language cannot be changed after translations have been linked. Existing mappings were left unchanged.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } $result = $this->languages->save_default_language( $code ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } $result = $this->languages->save_languages( array_values( $this->languages->get_enabled() ), true ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } Plugin::instance()->content_access->clear_cache(); $state['step'] = 3; $state['completed'] = 0; } elseif ( 3 === $step ) { $code = isset( $_POST['setup_language'] ) && is_scalar( $_POST['setup_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['setup_language'] ) ) ) : ''; $presets = $this->languages->get_presets(); $preset = $presets[ $code ] ?? array(); if ( '' === $code || empty( $preset ) || $code === $this->languages->get_default_code() ) { $this->redirect_with_notice( 'translation_error', __( 'Choose a valid target language.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } $result = $this->languages->save_languages( array_merge( array_values( $this->languages->get_enabled() ), array( array( 'code' => $code, 'english_label' => $preset['english_label'], 'native_label' => $preset['native_label'], 'flag' => $preset['flag'], 'rtl' => $preset['rtl'], 'wp_locale' => $preset['wp_locale'] ?? '' ) ) ) ); if ( is_wp_error( $result ) ) { $this->redirect_with_notice( 'translation_error', $result->get_error_message(), admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } $state['step'] = 4; } elseif ( 4 === $step ) { if ( ! $this->languages->get_enabled() ) { $this->redirect_with_notice( 'translation_error', __( 'Add at least one target language before finishing setup.', 'sitelang' ), admin_url( 'admin.php?page=sitelang-wc&tab=setup&setup_step=3' ) ); } $state['step'] = 4; $state['completed'] = 1; $state['completed_at'] = current_time( 'mysql' ); } update_option( 'sitelang_setup_wizard', $state, false ); $this->redirect_with_notice( 'setup_saved', '', admin_url( 'admin.php?page=sitelang-wc&tab=setup' ) ); } /** @return array */ private function setup_state() { $stored = get_option( 'sitelang_setup_wizard', false ); if ( false === $stored ) { $stored = array( 'step' => 1, 'completed' => 0, 'first_run' => 1 ); add_option( 'sitelang_setup_wizard', $stored, '', false ); } $state = $stored; $state = is_array( $state ) ? array_merge( array( 'step' => 1, 'completed' => 0 ), $state ) : array( 'step' => 1, 'completed' => 0 ); if ( ! empty( $state['completed'] ) ) { $state['step'] = 3; } return $state; } /** * Render the analytics-oriented plugin home screen. * * @param array $analytics Translation analytics. * @return void */ private function dashboard_source_content_counts() { global $wpdb; $types = array( 'product', 'page', 'post' ); $counts = array_fill_keys( $types, 0 ); $table = $this->mapping->table(); $table_exists = $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $table_exists ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT source_post.post_type, COUNT(*) AS count FROM {$wpdb->posts} AS source_post WHERE source_post.post_type IN (%s, %s, %s) AND source_post.post_status NOT IN ('trash', 'auto-draft') AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} AS translation_meta WHERE translation_meta.post_id = source_post.ID AND translation_meta.meta_key = '_sitelang_is_translation' AND translation_meta.meta_value = '1' ) AND NOT EXISTS (SELECT 1 FROM %i AS translation_map WHERE translation_map.translation_id = source_post.ID) GROUP BY source_post.post_type", 'product', 'page', 'post', $table ) ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT source_post.post_type, COUNT(*) AS count FROM {$wpdb->posts} AS source_post WHERE source_post.post_type IN (%s, %s, %s) AND source_post.post_status NOT IN ('trash', 'auto-draft') AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} AS translation_meta WHERE translation_meta.post_id = source_post.ID AND translation_meta.meta_key = '_sitelang_is_translation' AND translation_meta.meta_value = '1' ) GROUP BY source_post.post_type", 'product', 'page', 'post' ) ); } foreach ( (array) $rows as $row ) { $type = sanitize_key( $row->post_type ); if ( isset( $counts[ $type ] ) ) { $counts[ $type ] = absint( $row->count ); } } return $counts; } private function dashboard_language_stats() { global $wpdb; $table = $this->mapping->table(); if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) { return array(); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT mappings.language_code, mappings.content_type, COUNT(*) AS count FROM %i AS mappings INNER JOIN {$wpdb->posts} AS posts ON posts.ID = mappings.translation_id WHERE mappings.status = 'complete' AND posts.post_type = mappings.content_type AND posts.post_status NOT IN ('trash', 'auto-draft') GROUP BY mappings.language_code, mappings.content_type", $table ) ); $stats = array(); foreach ( (array) $rows as $row ) { $code = sanitize_key( $row->language_code ); $type = sanitize_key( $row->content_type ); $stats[ $code ][ $type ]['complete'] = absint( $row->count ); } return $stats; } /** Coverage counts source products, pages, and posts once per available target language. */ private function dashboard_coverage() { $counts = $this->dashboard_source_content_counts(); $stats = $this->dashboard_language_stats(); $eligible = 0; $complete = 0; foreach ( $this->languages->get_enabled() as $code => $language ) { if ( ! Plugin::instance()->content_access->is_language_unlocked( $code ) ) { continue; } foreach ( array( 'product', 'page', 'post' ) as $type ) { $source_total = absint( $counts[ $type ] ?? 0 ); $eligible += $source_total; $complete += min( $source_total, absint( $stats[ $code ][ $type ]['complete'] ?? 0 ) ); } } return array( 'eligible' => $eligible, 'complete' => $complete, 'rate' => $eligible ? (int) round( $complete / $eligible * 100 ) : 0 ); } private function dashboard_queue( $paged = 1, $per_page = 10 ) { $query = new \WP_Query( array( 'post_type' => $this->get_translatable_post_types(), 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => max( 1, min( 50, absint( $per_page ) ) ), 'paged' => max( 1, absint( $paged ) ), 'meta_query' => array( array( 'key' => '_sitelang_ai_job_state', 'value' => array( 'queued', 'processing', 'polishing', 'failed' ), 'compare' => 'IN' ) ), 'orderby' => 'modified', 'order' => 'DESC', ) ); return $query->posts; } private function dashboard_queue_count() { $query = new \WP_Query( array( 'post_type' => $this->get_translatable_post_types(), 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => array( array( 'key' => '_sitelang_ai_job_state', 'value' => array( 'queued', 'processing', 'polishing', 'failed' ), 'compare' => 'IN' ) ), ) ); return absint( $query->found_posts ); } private function dashboard_active_job_count() { $query = new \WP_Query( array( 'post_type' => $this->get_translatable_post_types(), 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => array( array( 'key' => '_sitelang_ai_job_state', 'value' => array( 'queued', 'processing', 'polishing' ), 'compare' => 'IN' ) ), ) ); return absint( $query->found_posts ); } private function dashboard_products() { global $wpdb; $table = $this->mapping->table(); if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) { return array(); } return array_map( 'absint', (array) $wpdb->get_col( $wpdb->prepare( "SELECT original_id FROM %i WHERE content_type = %s GROUP BY original_id ORDER BY MAX(updated_at) DESC LIMIT 5", $table, 'product' ) ) ); } private function render_dashboard_overview( array $analytics ) { $license = Plugin::instance()->license; $record = $license->get_license(); $snapshot = Plugin::instance()->entitlements->get_snapshot(); $languages = $this->languages->get_enabled(); $unlocked_languages = array_filter( $languages, static function ( $language, $code ) { return Plugin::instance()->content_access->is_language_unlocked( $code ); }, ARRAY_FILTER_USE_BOTH ); $locked_languages = array_diff_key( $languages, $unlocked_languages ); $languages = $unlocked_languages; $stats = $this->dashboard_language_stats(); $source_counts = $this->dashboard_source_content_counts(); $queue = $this->dashboard_queue(); $products = $this->dashboard_products(); $content_limits = isset( $snapshot['limits'] ) && is_array( $snapshot['limits'] ) ? $snapshot['limits'] : array(); $allowance_labels = array( 'pages' => __( 'pages', 'sitelang' ), 'posts' => __( 'posts', 'sitelang' ), 'products' => __( 'products', 'sitelang' ), 'html_blocks' => __( 'blocks', 'sitelang' ), 'sliders' => __( 'sliders', 'sitelang' ) ); $allowance = array(); $language_names = array(); foreach ( $languages as $language ) { $language_names[] = $language['english_label']; } foreach ( $allowance_labels as $key => $label ) { if ( array_key_exists( $key, $content_limits ) ) { $allowance[] = ( null === $content_limits[ $key ] ? __( 'Unlimited', 'sitelang' ) : number_format_i18n( absint( $content_limits[ $key ] ) ) ) . ' ' . $label; } } $active_jobs = $this->dashboard_active_job_count(); $usage_by_category = array(); foreach ( array( 'pages' => 'page', 'posts' => 'post', 'products' => 'product', 'html_blocks' => 'html_block', 'sliders' => 'slider' ) as $category => $type ) { $usage_by_category[ $category ] = $this->mapping->count_content_units_by_category( $category ); } $usage_units = array_sum( $usage_by_category ); $sync_time = absint( $record['last_validated'] ?? 0 ); $sync_state = sanitize_key( (string) ( $record['sync_state'] ?? '' ) ); $connected = $license->is_activated(); ?>

get_current_plan()['label'] . ' · ' . ( 'stale' === $sync_state ? __( 'Sync delayed', 'sitelang' ) : __( 'Managed translation connected', 'sitelang' ) ) : __( 'Local features active', 'sitelang' ) ); ?>
dashboard_coverage(); $eligible_slots = $coverage['eligible']; $complete_slots = $coverage['complete']; $coverage_rate = $coverage['rate']; ?>

%

$language ) : $content_total = array_sum( $source_counts ); $content_complete = 0; foreach ( array( 'product', 'page', 'post' ) as $content_type ) { $content_complete += min( $source_counts[ $content_type ], absint( $stats[ $code ][ $content_type ]['complete'] ?? 0 ) ); } $rate = $content_total ? (int) round( $content_complete / $content_total * 100 ) : 0; ?> 'product', 'page' => 'page', 'post' => 'post' ) as $type => $mapping_type ) : ?>
%

render_dashboard_jobs( $queue ); ?>

render_dashboard_product_table( $products, $languages ); ?>

seo, 'output_canonical' ) ) ? __( 'Hook active', 'sitelang' ) : __( 'Attention', 'sitelang' ) ); ?>
seo, 'output_hreflang' ) ) ? __( 'Hook active', 'sitelang' ) : __( 'Attention', 'sitelang' ) ); ?>

ai_translator->get_job_progress( $post->ID ); $row = $this->mapping->get_by_translation_id( $post->ID ); $state = sanitize_key( $job['state'] ?? '' ); $translation_url = admin_url( 'admin.php?page=sitelang-wc&tab=translations&translation_language=' . rawurlencode( $row ? $row->language_code : '' ) . '&content_type=' . sanitize_key( $post->post_type ) ); ?>
language_code ) . ' · ' . $state : $state ); ?> render_progress_bar( $post->ID, $job ); ?>
' . esc_html__( 'All caught up', 'sitelang' ) . '

' . esc_html__( 'No translation jobs need attention. Start with a page, post, or product when you are ready.', 'sitelang' ) . '

'; } } private function render_dashboard_product_table( array $products, array $languages ) { ?>
$language ) : ?> $language ) : $row = $this->mapping->get_by_original_language( $product_id, $code ); $state = $row ? sanitize_key( $row->status ) : 'not-started'; ?>
#
dashboard_queue_count(); $queue = $this->dashboard_queue( $page_number, $per_page ); // translators: %1$d is the first job row; %2$d is the last row; %3$d is the total matching jobs. ?>

render_dashboard_jobs( $queue ); ?>
1 ) : ?>
languages->get_enabled(); $search = isset( $_GET['product_search'] ) ? sanitize_text_field( wp_unslash( $_GET['product_search'] ) ) : ''; $page = max( 1, isset( $_GET['product_page'] ) ? absint( $_GET['product_page'] ) : 1 ); $query = new \WP_Query( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 20, 'paged' => $page, 's' => $search, 'orderby' => 'title', 'order' => 'ASC', 'meta_query' => array( array( 'key' => '_sitelang_is_translation', 'compare' => 'NOT EXISTS' ) ) ) ); $selected_id = isset( $_GET['product_id'] ) ? absint( $_GET['product_id'] ) : 0; $selected = $selected_id && 'product' === get_post_type( $selected_id ) && ! get_post_meta( $selected_id, '_sitelang_is_translation', true ) ? get_post( $selected_id ) : null; $used = $this->mapping->count_content_units_by_category( 'products' ); $limit = null; ?>

$language ) : ?>posts as $post ) : $product = function_exists( 'wc_get_product' ) ? wc_get_product( $post->ID ) : null; ?> $language ) : $mapping = $this->mapping->get_by_original_language( $post->ID, $code ); ?>posts ) : ?>
#ID ); ?>get_sku() : '—' ); ?>post_status ) ); ?>status ) ) : __( 'Not started', 'sitelang' ) ); ?>translation_id ) ) ); ?>
found_posts ? ( $page - 1 ) * 20 + 1 : 0, min( $page * 20, $query->found_posts ), $query->found_posts ) ); ?> 1 ) : ?>found_posts ) : ?>
languages->get_default_settings(), $this->languages->get_settings() ); $tabs = array( 'general' => __( 'General', 'sitelang' ), 'display' => __( 'Switcher & display', 'sitelang' ), 'translation' => __( 'Translation', 'sitelang' ), 'woocommerce' => __( 'WooCommerce', 'sitelang' ), 'seo' => __( 'SEO', 'sitelang' ), 'integrations' => __( 'Integrations', 'sitelang' ) ); $active = isset( $_GET['settings_tab'] ) ? sanitize_key( wp_unslash( $_GET['settings_tab'] ) ) : 'general'; if ( ! isset( $tabs[ $active ] ) ) { $active = 'general'; } $snapshot = Plugin::instance()->entitlements->get_snapshot(); $counts = $this->dashboard_source_content_counts(); $usage_by_category = array(); foreach ( array( 'pages', 'posts', 'products', 'html_blocks', 'sliders' ) as $category ) { $usage_by_category[ $category ] = $this->mapping->count_content_units_by_category( $category ); } $limits = array_fill_keys( array( 'languages', 'pages', 'posts', 'products', 'html_blocks', 'sliders' ), null ); $labels = array( 'languages' => __( 'Target languages', 'sitelang' ), 'pages' => __( 'Pages', 'sitelang' ), 'posts' => __( 'Posts', 'sitelang' ), 'products' => __( 'Products', 'sitelang' ), 'html_blocks' => __( 'HTML/reusable blocks', 'sitelang' ), 'sliders' => __( 'Sliders', 'sitelang' ) ); ?>

languages->get_default_native_label() . ' (' . strtoupper( $this->languages->get_default_code() ) . ')' ); ?>

languages->get_default_native_label() ); ?>
languages->get_enabled() as $code => $language ) : ?>

render_switcher_tab(); ?>

array( __( 'Products', 'sitelang' ), 'dashicons-products' ), 'page' => array( __( 'Pages', 'sitelang' ), 'dashicons-admin-page' ), 'post' => array( __( 'Posts', 'sitelang' ), 'dashicons-admin-post' ), ); ?>

$card ) : ?>
%

languages->get_enabled() as $code => $language ) : ?> 0, 'complete' => 0 ); $rate = $language_stats['total'] ? (int) round( ( $language_stats['complete'] / $language_stats['total'] ) * 100 ) : 0; ?> % languages->get_enabled() ) ) : ?>

post_title ? $row->post_title : __( '(Untitled)', 'sitelang' ) ); ?>language_code ) . ' · ' . $this->get_post_type_label( $row->content_type ) ); ?>

languages->get_enabled(); $form_languages = $languages; $pending_languages = get_transient( 'sitelang_language_form_' . get_current_user_id() ); if ( is_array( $pending_languages ) ) { delete_transient( 'sitelang_language_form_' . get_current_user_id() ); $form_languages = array(); foreach ( $pending_languages as $pending_language ) { if ( ! is_array( $pending_language ) ) { continue; } $form_languages[] = array( 'code' => $this->languages->sanitize_code( $pending_language['code'] ?? '' ), 'english_label' => sanitize_text_field( $pending_language['english_label'] ?? '' ), 'native_label' => sanitize_text_field( $pending_language['native_label'] ?? '' ), 'flag' => sanitize_key( $pending_language['flag'] ?? '' ), 'rtl' => ! empty( $pending_language['rtl'] ) ? 1 : 0, 'wp_locale' => sanitize_text_field( $pending_language['wp_locale'] ?? '' ), ); } } $presets = $this->languages->get_presets(); $source_counts = $this->dashboard_source_content_counts(); $stats = $this->dashboard_language_stats(); $source_total = array_sum( $source_counts ); $limit = null; ?>
languages->get_default_native_label() . ' (' . strtoupper( $this->languages->get_default_code() ) . ')' ); ?>

$language ) : $completed = 0; foreach ( array( 'page', 'post', 'product' ) as $type ) { $completed += min( absint( $source_counts[ $type ] ?? 0 ), absint( $stats[ $code ][ $type ]['complete'] ?? 0 ) ); } $rate = $source_total ? (int) round( $completed / $source_total * 100 ) : 0; ?>
content_access->is_language_unlocked( $code ) ? __( 'Enabled', 'sitelang' ) : __( 'Plan locked', 'sitelang' ) ); ?>
%

languages->get_default_native_label() ) ); ?>

$language ) : $this->language_row( $index, $language ); ++$index; endforeach; ?>

$language Language. * @return void */ private function language_row( $index, array $language ) { ?> languages->get_enabled(); $supported = $this->get_translatable_post_types(); $languages = array_filter( $languages, static function ( $language, $code ) { return Plugin::instance()->content_access->is_language_unlocked( $code ); }, ARRAY_FILTER_USE_BOTH ); $selected_language = isset( $_GET['translation_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_GET['translation_language'] ) ) ) : ''; $selected_language = isset( $languages[ $selected_language ] ) ? $selected_language : ''; $default = in_array( 'page', $supported, true ) ? 'page' : ( ! empty( $supported ) ? $supported[0] : 'page' ); $type = isset( $_GET['content_type'] ) ? sanitize_key( wp_unslash( $_GET['content_type'] ) ) : $default; $type = in_array( $type, $supported, true ) ? $type : $default; $search = isset( $_GET['sitelang_search'] ) ? sanitize_text_field( wp_unslash( $_GET['sitelang_search'] ) ) : ''; $status_filter = isset( $_GET['sitelang_status'] ) ? sanitize_key( wp_unslash( $_GET['sitelang_status'] ) ) : ''; $page_number = max( 1, isset( $_GET['sitelang_page'] ) ? absint( $_GET['sitelang_page'] ) : 1 ); ?> render_context_navigation( 'translations' ); ?>

mapping->is_translation( $prefilled_source_id ) ) ) { $prefilled_source_id = 0; } $this->render_quick_create_form( $selected_language, $languages[ $selected_language ], $prefilled_source_id ); ?> get_original_items( $type, $page_number, $per_page, $search, $selected_language, $status_filter ); $total_items = $this->original_items_count( $type, $search, $selected_language, $status_filter ); ?>
'dashicons-admin-page', 'post' => 'dashicons-admin-post', 'product' => 'dashicons-products', 'elementor_library' => 'dashicons-layout', 'cms_block' => 'dashicons-editor-code', 'portfolio' => 'dashicons-portfolio', 'woodmart_layout' => 'dashicons-screenoptions', 'woodmart_slide' => 'dashicons-images-alt2', 'wd_popup' => 'dashicons-welcome-view-site', 'wd_floating_block' => 'dashicons-align-wide', 'wp_block' => 'dashicons-block-default', )[ $post_type ] ?? 'dashicons-admin-page'; ?> >get_post_type_label( $post_type ) ); ?>
get_post_type_label( $type ) ) ); ?>
entitlements->allows( 'ai_translation' ) ) : ?>
translation_cell( $item->ID, $selected_language ); ?>
1 ) : ?>
> $languages Enabled languages. * @return void */ private function render_quick_create_form( $selected_language, array $language, $prefilled_source_id = 0 ) { $supported = $this->get_translatable_post_types(); ?>

languages->get_settings(); $default_code = strtoupper( $this->languages->get_default_code() ); $all_languages = $this->languages->get_all(); $default_language = $all_languages[ strtolower( $default_code ) ] ?? array( 'code' => strtolower( $default_code ), 'native_label' => $default_code ); $default_flag = Flag_Assets::resolve_code( $default_language ); $preview_languages = array_slice( $this->languages->get_all(), 0, 4, true ); $presets = $this->get_switcher_presets(); $current_preset = isset( $settings['switcher_preset'] ) ? sanitize_key( $settings['switcher_preset'] ) : 'minimal'; $default_flag_shape = in_array( $current_preset, array( 'flag-chip', 'language-card', 'labeled-language-button' ), true ) ? 'circular' : 'rectangular'; ?>

>

$preset ) : ?>

[sitelang_switcher][sitelang_switcher style="soft" mode="dropdown"]<?php echo sitelang_wc_switcher(); ?>
ai_translator->get_settings(); $registry = $this->ai_translator->get_model_registry(); $selected_meta = isset( $registry[ $settings['model'] ] ) ? $registry[ $settings['model'] ] : array(); $availability = $this->ai_translator->get_model_availability(); $legacy_direct = $this->ai_translator->allows_legacy_direct_openai(); $has_key = $legacy_direct && '' !== $this->ai_translator->get_api_key(); $gateway_ready = Plugin::instance()->license->is_gateway_active(); $key_label = defined( 'SITELANG_OPENAI_API_KEY' ) && SITELANG_OPENAI_API_KEY ? __( 'API key is loaded from wp-config.php.', 'sitelang' ) : __( 'API key is stored in WordPress options.', 'sitelang' ); ?>

License. Provider keys are never stored in this WordPress site.', 'sitelang' ) ); ?>


languages->get_enabled(); $selected_language = isset( $_GET['translation_language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_GET['translation_language'] ) ) ) : ''; $selected_language = isset( $languages[ $selected_language ] ) ? $selected_language : ''; $menus = wp_get_nav_menus(); $translations = get_option( 'sitelang_menu_translations', array() ); $translations = is_array( $translations ) ? $translations : array(); $ai_ready = $this->ai_translator->is_configured(); ?> render_context_navigation( 'menus' ); ?>

term_id ); ?>

name ); ?>

ID ][ $code ] ) && is_array( $translations[ $item->ID ][ $code ] ) ? $translations[ $item->ID ][ $code ] : array(); $title = ! empty( $row['title'] ) ? $row['title'] : ''; $url = ! empty( $row['url'] ) ? $row['url'] : ''; $input_id = 'sitelang-menu-title-' . absint( $item->ID ) . '-' . sanitize_html_class( $code ); ?>
title ); ?>

url ); ?>

languages->get_enabled(); $products = $this->review_translations->get_products_with_reviews(); ?> render_context_navigation( 'reviews' ); ?>

ai_translator->is_configured() ) : ?>

$language ) : ?> mapping->get_by_original_language( $product->ID, $code ); ?>
post_title ); ?>
review_count ) ) ); ?> translation_id ) ) : ?> review_translations->count_translated_reviews( $row->translation_id, $code ); $job = $this->ai_translator->get_job_progress( $row->translation_id ); $status_object = get_post_status_object( get_post_status( $row->translation_id ) ); $status_label = $status_object ? $status_object->label : get_post_status( $row->translation_id ); ?>

·

render_progress_bar( $row->translation_id, $job ); ?>
languages->get_settings(); $homepages = isset( $settings['homepages'] ) && is_array( $settings['homepages'] ) ? $settings['homepages'] : array(); ?>

languages->get_enabled() as $code => $language ) : ?>
'homepages[' . esc_attr( $code ) . ']', 'selected' => absint( $homepages[ $code ] ?? 0 ), 'show_option_none' => esc_html__( 'Use translated front page or default', 'sitelang' ) ) ); ?> //
$this->languages->get_enabled(), 'settings' => array_intersect_key( $this->languages->get_settings(), array_flip( array( 'switcher_mode', 'switcher_preset', 'switcher_show_flag', 'switcher_show_name', 'switcher_radius', 'switcher_bg', 'switcher_text', 'switcher_accent', 'switcher_custom_hover', 'switcher_hover_bg', 'switcher_hover_text', 'switcher_placement', 'mobile_switcher_enabled', 'mobile_switcher_mode', 'merchant_exclude_translated_display_ads', 'purge_cache_after_translation', 'checkout_language_continuity' ) ) ), ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); ?> render_context_navigation( 'xliff' ); ?>

#
feed_language_codes(); $settings = $this->languages->get_settings(); $plugin = Plugin::instance(); $default = $this->languages->get_default_code(); $sitemap_url = home_url( '/sitelang-sitemap.xml' ); $view = isset( $_GET['feed_view'] ) ? sanitize_key( wp_unslash( $_GET['feed_view'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $view = in_array( $view, array( 'overview', 'sitemap', 'merchant', 'seo' ), true ) ? $view : 'overview'; $rows = array(); $total_sitemap_entries = 0; $total_feed_products = 0; $total_feed_offers = 0; $merchant_warnings = 0; foreach ( $codes as $code => $label ) { $unlocked = $code === $default || $plugin->content_access->is_language_unlocked( $code ); $sitemap_entries = $plugin->sitemap->entry_count( $code ); $feed_diagnostics = $plugin->merchant_feed->diagnostics( $code ); $eligible_products = absint( $feed_diagnostics['products'] ?? 0 ); $eligible_offers = absint( $feed_diagnostics['offers'] ?? 0 ); $feed_available = ! empty( $feed_diagnostics['available'] ); $needs_translation = $unlocked && $code !== $default && $eligible_products === 0; $status_class = $unlocked && $sitemap_entries > 0 ? 'good' : 'warning'; $status_label = $unlocked && $sitemap_entries > 0 ? __( 'Available', 'sitelang' ) : __( 'Needs content', 'sitelang' ); $feed_status_class = $feed_available && $eligible_products > 0 ? 'good' : 'warning'; $feed_status_label = $feed_available && $eligible_products > 0 ? __( 'Generated locally', 'sitelang' ) : __( 'Needs attention', 'sitelang' ); if ( ! $unlocked ) { $status_class = 'warning'; $status_label = __( 'Plan locked', 'sitelang' ); $feed_status_class = 'warning'; $feed_status_label = __( 'Plan locked', 'sitelang' ); } $total_sitemap_entries += $sitemap_entries; $total_feed_products += $eligible_products; $total_feed_offers += $eligible_offers; $merchant_warnings += $needs_translation ? 1 : 0; $rows[ $code ] = array( 'label' => $label, 'unlocked' => $unlocked, 'sitemap_url' => home_url( 'sitelang-sitemap/' . $code . '.xml' ), 'feed_url' => home_url( 'sitelang-feed/' . $code . '/google.xml' ), 'sitemap_entries' => $sitemap_entries, 'eligible_products' => $eligible_products, 'eligible_offers' => $eligible_offers, 'sitemap_class' => $status_class, 'sitemap_label' => $status_label, 'feed_class' => $feed_status_class, 'feed_label' => $feed_status_label, 'feed_available' => $feed_available, 'warning' => $needs_translation, ); } $tabs = array( 'overview' => __( 'Overview', 'sitelang' ), 'sitemap' => __( 'Sitemap', 'sitelang' ), 'merchant' => __( 'Merchant feeds', 'sitelang' ), 'seo' => __( 'SEO checks', 'sitelang' ), ); ?>

$row ) : ?>

$row ) : ?>

{language}/
internal_links->get_settings(); $languages = $this->internal_links->language_choices(); $preview = $this->internal_links->get_preview( get_current_user_id() ); $report = $this->internal_links->report(); $supported_types = array( 'page', 'post', 'product' ); ?>

    $count ) : ?>
get_translatable_post_types() as $post_type ) { add_meta_box( 'sitelang_translations', __( 'SiteLang Translations', 'sitelang' ), array( $this, 'render_metabox' ), $post_type, 'side', 'high' ); } } /** * Render translation metabox. * * @param \WP_Post $post Post. * @return void */ public function render_metabox( $post ) { wp_nonce_field( 'sitelang_metabox', 'sitelang_metabox_nonce' ); if ( $this->mapping->is_translation( $post->ID ) ) { $row = $this->mapping->get_by_translation_id( $post->ID ); $original = $row ? get_post( $row->original_id ) : null; ?>

ID, '_sitelang_language', true ) ) ); ?>

post_type ) : ?>

languages->get_enabled(); if ( empty( $languages ) ) { echo '

' . esc_html__( 'No languages enabled yet.', 'sitelang' ) . '

'; return; } foreach ( $languages as $code => $language ) { echo '
'; echo '' . esc_html( $language['native_label'] ) . ''; $this->translation_cell( $post->ID, $code, true ); echo '
'; } } /** * Save metabox. * * @param int $post_id Post ID. * @param \WP_Post $post Post. * @return void */ public function save_metabox( $post_id, $post ) { if ( ! isset( $_POST['sitelang_metabox_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['sitelang_metabox_nonce'] ) ), 'sitelang_metabox' ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) || ! $this->mapping->is_translation( $post_id ) ) { return; } $status = isset( $_POST['sitelang_translation_status'] ) ? sanitize_key( wp_unslash( $_POST['sitelang_translation_status'] ) ) : 'draft'; $inherit = ! empty( $_POST['sitelang_inherit_product_data'] ); if ( ! array_key_exists( $status, $this->statuses() ) ) { $status = 'draft'; } $this->mapping->update_translation_meta( $post_id, $status, $inherit ); } /** * Add row actions. * * @param array $actions Actions. * @param \WP_Post $post Post. * @return array */ public function row_actions( $actions, $post ) { if ( ! in_array( $post->post_type, $this->get_translatable_post_types(), true ) || $this->mapping->is_translation( $post->ID ) ) { return $actions; } foreach ( $this->languages->get_enabled() as $code => $language ) { $translation_id = $this->mapping->get_translation_id( $post->ID, $code ); if ( $translation_id ) { $actions[ 'sitelang_' . $code ] = '' . esc_html( $language['native_label'] ) . ''; } } return $actions; } /** * Translate and save one menu label without submitting the entire menu form. * * @return void */ public function ajax_translate_menu_item() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'sitelang' ) ), 403 ); } check_ajax_referer( 'sitelang_translate_menu_item', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Only a WordPress administrator can save menu translations.', 'sitelang' ) ), 403 ); } if ( ! Plugin::instance()->entitlements->allows( 'menu_translation' ) || ! Plugin::instance()->entitlements->allows( 'ai_translation' ) ) { wp_send_json_error( array( 'message' => __( 'AI menu translation is not included in the current plan.', 'sitelang' ) ), 403 ); } $menu_item_id = isset( $_POST['menu_item_id'] ) ? absint( $_POST['menu_item_id'] ) : 0; $language = isset( $_POST['language'] ) ? $this->languages->sanitize_code( sanitize_key( wp_unslash( $_POST['language'] ) ) ) : ''; $enabled = $this->languages->get_enabled(); if ( ! $menu_item_id || 'nav_menu_item' !== get_post_type( $menu_item_id ) || ! isset( $enabled[ $language ] ) ) { wp_send_json_error( array( 'message' => __( 'The menu item or language is invalid.', 'sitelang' ) ), 400 ); } if ( ! current_user_can( 'edit_post', $menu_item_id ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'sitelang' ) ), 403 ); } $item = wp_setup_nav_menu_item( get_post( $menu_item_id ) ); if ( ! $item || '' === trim( (string) $item->title ) ) { wp_send_json_error( array( 'message' => __( 'This menu item has no label to translate.', 'sitelang' ) ), 400 ); } $result = $this->ai_translator->translate_strings( array( 'menu_label' => $item->title ), $language ); if ( is_wp_error( $result ) || empty( $result['menu_label'] ) ) { $message = is_wp_error( $result ) ? $result->get_error_message() : __( 'OpenAI did not return a translated menu label.', 'sitelang' ); wp_send_json_error( array( 'message' => $message ), 500 ); } $title = sanitize_text_field( $result['menu_label'] ); $translations = get_option( 'sitelang_menu_translations', array() ); $translations = is_array( $translations ) ? $translations : array(); $row = isset( $translations[ $menu_item_id ][ $language ] ) && is_array( $translations[ $menu_item_id ][ $language ] ) ? $translations[ $menu_item_id ][ $language ] : array(); $row['title'] = $title; if ( ! isset( $translations[ $menu_item_id ] ) || ! is_array( $translations[ $menu_item_id ] ) ) { $translations[ $menu_item_id ] = array(); } $translations[ $menu_item_id ][ $language ] = $row; update_option( 'sitelang_menu_translations', $translations, false ); wp_send_json_success( array( 'title' => $title, 'message' => __( 'Translated and saved.', 'sitelang' ), ) ); } /** * Show admin notices. * * @return void */ public function admin_notices() { if ( empty( $_GET['sitelang_notice'] ) ) { return; } $notice = sanitize_key( wp_unslash( $_GET['sitelang_notice'] ) ); $message = ''; if ( 'languages_saved' === $notice ) { $message = __( 'Languages saved. Rewrite rules will refresh safely.', 'sitelang' ); } elseif ( 'ai_saved' === $notice ) { $message = __( 'AI translation settings saved.', 'sitelang' ); } elseif ( 'ai_models_refreshed' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: compatible model count */ _n( '%d compatible model is available to this OpenAI project.', '%d compatible models are available to this OpenAI project.', $count, 'sitelang' ), $count ); } elseif ( 'ai_model_tested' === $notice ) { $message = isset( $_GET['sitelang_extra'] ) ? sanitize_text_field( wp_unslash( $_GET['sitelang_extra'] ) ) : __( 'The selected OpenAI model test succeeded.', 'sitelang' ); } elseif ( 'switcher_saved' === $notice ) { $message = __( 'Switcher settings saved.', 'sitelang' ); } elseif ( 'settings_saved' === $notice ) { $message = __( 'Settings saved.', 'sitelang' ); } elseif ( 'urls_saved' === $notice ) { $message = __( 'URL settings saved.', 'sitelang' ); } elseif ( 'feeds_saved' === $notice ) { $message = __( 'Merchant feed settings saved.', 'sitelang' ); } elseif ( 'settings_saved' === $notice ) { $message = __( 'Settings saved.', 'sitelang' ); } elseif ( 'menus_saved' === $notice ) { $message = __( 'Menu translations saved.', 'sitelang' ); } elseif ( 'reviews_queued' === $notice ) { $message = __( 'Review translation queued. It runs in the background and leaves the translated product unchanged.', 'sitelang' ); } elseif ( 'translation_created' === $notice ) { $message = __( 'Manual translation drafts created. Open each translation to translate, preview, and publish it.', 'sitelang' ); } elseif ( 'ai_translation_created' === $notice ) { $message = __( 'AI translation draft created. Review it, publish it, and mark it Complete when ready.', 'sitelang' ); } elseif ( 'ai_translation_updated' === $notice ) { $message = __( 'AI translation updated. Review it before publishing or marking Complete.', 'sitelang' ); } elseif ( 'ai_translation_queued' === $notice ) { $message = __( 'AI translation queued. It runs in the background; watch the progress bar in the Translations table.', 'sitelang' ); } elseif ( 'queue_job_resumed' === $notice ) { $message = __( 'Translation job resumed. SiteLang will continue the existing mapped translation.', 'sitelang' ); } elseif ( 'queue_job_active' === $notice ) { $message = __( 'That translation job is already queued or running.', 'sitelang' ); } elseif ( 'elementor_repair_queued' === $notice ) { $message = __( 'Elementor description repair queued. It runs in the background and keeps the existing translated title and short description.', 'sitelang' ); } elseif ( 'bulk_created' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of drafts */ _n( '%d translation draft created.', '%d translation drafts created.', $count, 'sitelang' ), $count ); } elseif ( 'bulk_created_with_errors' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of drafts */ _n( '%d manual translation draft created; some selected items could not be created.', '%d manual translation drafts created; some selected items could not be created.', $count, 'sitelang' ), $count ); } elseif ( 'bulk_queued' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of queued jobs */ _n( '%d translation queued.', '%d translations queued.', $count, 'sitelang' ), $count ); } elseif ( 'bulk_queued_with_errors' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; // translators: %d is the number of translation jobs queued. $message = sprintf( __( '%d translations queued. One or more items were skipped because they exceeded entitlement or could not be processed; review the selected items and queue.', 'sitelang' ), $count ); } elseif ( 'elementor_repair_bulk_queued' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of queued repair jobs */ _n( '%d Elementor repair queued.', '%d Elementor repairs queued.', $count, 'sitelang' ), $count ); } elseif ( 'settings_imported' === $notice ) { $message = __( 'Settings imported.', 'sitelang' ); } elseif ( 'xliiff_imported' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of updated translations */ _n( '%d translation updated from XLIFF.', '%d translations updated from XLIFF.', $count, 'sitelang' ), $count ); } elseif ( 'woo_pages_created' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of WooCommerce page drafts */ _n( '%d WooCommerce page draft created.', '%d WooCommerce page drafts created.', $count, 'sitelang' ), $count ); } elseif ( 'internal_links_saved' === $notice ) { $message = __( 'Internal backlink settings saved.', 'sitelang' ); } elseif ( 'internal_links_preview' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of preview rows */ _n( '%d backlink suggestion generated.', '%d backlink suggestions generated.', $count, 'sitelang' ), $count ); } elseif ( 'internal_links_applied' === $notice ) { $extra = isset( $_GET['sitelang_extra'] ) ? sanitize_text_field( wp_unslash( $_GET['sitelang_extra'] ) ) : '0:0'; $parts = array_map( 'absint', explode( ':', $extra ) ); $applied = $parts[0] ?? 0; $skipped = $parts[1] ?? 0; $message = sprintf( /* translators: 1: applied count, 2: skipped count */ __( '%1$d internal links applied. %2$d skipped.', 'sitelang' ), $applied, $skipped ); } elseif ( 'internal_links_removed' === $notice ) { $count = isset( $_GET['sitelang_extra'] ) ? absint( $_GET['sitelang_extra'] ) : 0; $message = sprintf( /* translators: %d: number of source posts cleaned */ _n( 'Generated links removed from %d source item.', 'Generated links removed from %d source items.', $count, 'sitelang' ), $count ); } elseif ( 'internal_links_restored' === $notice ) { $message = __( 'Latest SiteLang internal-links backup restored for the selected source item.', 'sitelang' ); } elseif ( 'translation_error' === $notice ) { $message = isset( $_GET['sitelang_extra'] ) ? sanitize_text_field( wp_unslash( $_GET['sitelang_extra'] ) ) : __( 'The translation action failed.', 'sitelang' ); } if ( $message ) { echo '

' . esc_html( $message ) . '

'; } } /** * AJAX endpoint for live translation progress. * * @return void */ public function ajax_translation_progress() { if ( ! current_user_can( $this->capability() ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'sitelang' ) ), 403 ); } check_ajax_referer( 'sitelang_translation_progress', 'nonce' ); $ids = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? array_map( 'absint', wp_unslash( $_POST['ids'] ) ) : array(); $payload = array(); foreach ( $ids as $translation_id ) { if ( ! $translation_id ) { continue; } $row = $this->mapping->get_by_translation_id( $translation_id ); if ( ! $row || ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', $translation_id ) ) { continue; } $job = $this->ai_translator->get_job_progress( $translation_id ); $payload[ $translation_id ] = $job; if ( in_array( $job['state'], array( 'queued', 'processing', 'polishing' ), true ) ) { if ( 'queued' === $job['state'] ) { $this->ai_translator->ensure_cron_fallback_for_translation( $translation_id ); } $this->ai_translator->maybe_kick_worker(); } } wp_send_json_success( array( 'items' => $payload ) ); } /** * AJAX endpoint that processes one small translation step. * * This makes translation reliable on hosts where WP-Cron loopback or the * Action Scheduler runner is delayed, without forcing a full translation to * happen inside the original button click. * * @return void */ public function ajax_process_translation_step() { if ( ! current_user_can( $this->capability() ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'sitelang' ) ), 403 ); } check_ajax_referer( 'sitelang_translation_progress', 'nonce' ); $translation_id = isset( $_POST['translation_id'] ) ? absint( wp_unslash( $_POST['translation_id'] ) ) : 0; if ( ! $translation_id ) { wp_send_json_error( array( 'message' => __( 'Missing translation ID.', 'sitelang' ) ), 400 ); } $row = $this->mapping->get_by_translation_id( $translation_id ); if ( ! $row || ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', $translation_id ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'sitelang' ) ), 403 ); } $result = $this->ai_translator->process_translation_step( $translation_id ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 500 ); } wp_send_json_success( array( 'item' => $this->ai_translator->get_job_progress( $translation_id ), ) ); } /** * Render a table cell for a translation. * * @param int $original_id Original ID. * @param string $code Language code. * @param bool $compact Compact mode. * @return void */ private function translation_cell( $original_id, $code, $compact = false ) { $row = $this->mapping->get_by_original_language( $original_id, $code ); $workspace_url = add_query_arg( array( 'page' => 'sitelang-wc', 'tab' => 'translations', 'content_type' => sanitize_key( get_post_type( $original_id ) ), 'translation_language' => $this->languages->sanitize_code( $code ), 'sitelang_source_id' => absint( $original_id ), ), admin_url( 'admin.php' ) ); if ( $row && get_post( $row->translation_id ) ) { $status = isset( $this->statuses()[ $row->status ] ) ? $this->statuses()[ $row->status ] : $row->status; echo '' . esc_html( $status ) . ' '; echo '' . esc_html__( 'Translate / edit', 'sitelang' ) . ''; if ( $compact ) { echo ' · ' . esc_html__( 'Translation actions', 'sitelang' ) . ''; return; } if ( Plugin::instance()->entitlements->allows( 'ai_translation' ) ) { echo ' · '; $this->render_inline_action_form( 'ai_translate_existing', 'sitelang_ai_translate_existing', 'translation_id', $row->translation_id, __( 'Auto translate', 'sitelang' ), $original_id ); } else { echo ' · ' . esc_html__( 'Auto translate (Upgrade)', 'sitelang' ) . ''; } if ( 'product' === get_post_type( $original_id ) ) { echo ' · '; $this->render_inline_action_form( 'repair_elementor_translation', 'sitelang_repair_elementor_translation', 'translation_id', $row->translation_id, __( 'Repair Elementor', 'sitelang' ), $original_id ); } if ( $this->mapping->is_frontend_ready_translation( $row->translation_id ) ) { echo ' · ' . esc_html__( 'View', 'sitelang' ) . ''; } elseif ( 'publish' === get_post_status( $row->translation_id ) ) { echo ' · ' . esc_html__( 'Mark Complete to show on frontend', 'sitelang' ) . ''; } $job = $this->ai_translator->get_job_progress( $row->translation_id ); if ( in_array( $job['state'], array( 'queued', 'processing', 'polishing', 'failed', 'complete' ), true ) ) { $this->render_progress_bar( $row->translation_id, $job ); } return; } if ( $compact ) { echo '' . esc_html__( 'Create translation', 'sitelang' ) . ''; return; } ?> render_create_translation_form( $original_id, $code, $compact, 'manual' ); ?> entitlements->allows( 'ai_translation' ) ) : ?> render_create_translation_form( $original_id, $code, $compact, 'auto' ); ?>
$job Job payload. * @return void */ private function render_progress_bar( $translation_id, array $job ) { $progress = isset( $job['progress'] ) ? max( 0, min( 100, absint( $job['progress'] ) ) ) : 0; $state = isset( $job['state'] ) ? sanitize_key( $job['state'] ) : 'idle'; $message = isset( $job['message'] ) ? sanitize_text_field( $job['message'] ) : ''; $active = in_array( $state, array( 'queued', 'processing', 'polishing' ), true ); $visual = $active && 0 === $progress ? 14 : $progress; ?>
%
ai_translator->enqueue_translation_job( $original_id, $translation_id, $language ); if ( is_wp_error( $result ) ) { return $result; } $row = $this->mapping->get_by_translation_id( $translation_id ); $status = $row && 'complete' === $row->status ? 'complete' : 'in_progress'; $inherit = (bool) get_post_meta( $translation_id, '_sitelang_inherit_product_data', true ); $this->mapping->update_translation_meta( $translation_id, $status, $inherit ); return true; } /** * Queue an Elementor-only translation repair. * * @param int $translation_id Translation ID. * @return true|\WP_Error */ private function queue_elementor_repair( $translation_id ) { $result = $this->ai_translator->enqueue_elementor_repair_job( $translation_id ); if ( is_wp_error( $result ) ) { return $result; } return true; } /** * Queue Elementor repairs for complete translated products in a language. * * @param string $language Language code. * @return int */ private function queue_bulk_elementor_repairs( $language ) { $language = $this->languages->sanitize_code( $language ); if ( ! $this->languages->is_enabled( $language ) || ! $this->ai_translator->is_configured() ) { return 0; } $count = 0; foreach ( $this->mapping->get_for_language( $language, 'product' ) as $row ) { if ( ! current_user_can( 'edit_post', (int) $row->original_id ) || ! current_user_can( 'edit_post', (int) $row->translation_id ) ) { continue; } if ( empty( $row->translation_id ) || 'publish' !== get_post_status( (int) $row->translation_id ) ) { continue; } if ( 'complete' !== sanitize_key( (string) $row->status ) && 'complete' !== sanitize_key( (string) get_post_meta( (int) $row->translation_id, '_sitelang_translation_status', true ) ) ) { continue; } if ( ! get_post_meta( (int) $row->original_id, '_elementor_data', true ) ) { continue; } $result = $this->queue_elementor_repair( (int) $row->translation_id ); if ( ! is_wp_error( $result ) ) { $count++; } } return $count; } /** * Create translations for core WooCommerce pages. * * @return int Number of created translations. */ private function create_woocommerce_page_translations() { if ( ! function_exists( 'wc_get_page_id' ) ) { return 0; } $count = 0; foreach ( array( 'shop', 'cart', 'checkout', 'myaccount', 'terms' ) as $page_key ) { $page_id = wc_get_page_id( $page_key ); if ( $page_id <= 0 ) { continue; } if ( ! current_user_can( 'edit_post', $page_id ) ) { continue; } foreach ( $this->languages->get_enabled() as $code => $language ) { $existing = $this->mapping->get_translation_id( $page_id, $code ); if ( $existing ) { continue; } $result = $this->translations->create_translation( $page_id, $code, true ); if ( is_wp_error( $result ) ) { continue; } wp_update_post( array( 'ID' => $result, 'post_content' => $this->woocommerce_page_content( $page_key, get_post_field( 'post_content', $page_id ) ), ) ); $this->mapping->update_translation_meta( $result, 'draft', true ); ++$count; } } return $count; } /** * Suggested translated WooCommerce page title. * * @param string $page_key WooCommerce page key. * @param string $code Language code. * @param array $language Language data. * @return string */ private function translated_woocommerce_page_title( $page_key, $code, array $language ) { $titles = array( 'de' => array( 'shop' => 'Shop', 'cart' => 'Warenkorb', 'checkout' => 'Kasse', 'myaccount'=> 'Mein Konto', 'terms' => 'Geschaeftsbedingungen', ), 'es' => array( 'shop' => 'Tienda', 'cart' => 'Carrito', 'checkout' => 'Finalizar compra', 'myaccount'=> 'Mi cuenta', 'terms' => 'Terminos', ), 'fr' => array( 'shop' => 'Boutique', 'cart' => 'Panier', 'checkout' => 'Commande', 'myaccount'=> 'Mon compte', 'terms' => 'Conditions', ), 'ar' => array( 'shop' => 'المتجر', 'cart' => 'سلة التسوق', 'checkout' => 'إتمام الطلب', 'myaccount'=> 'حسابي', 'terms' => 'الشروط', ), ); if ( isset( $titles[ $code ][ $page_key ] ) ) { return $titles[ $code ][ $page_key ]; } return sprintf( /* translators: 1: original page key, 2: language label */ __( '%1$s (%2$s)', 'sitelang' ), ucwords( str_replace( array( '-', '_' ), ' ', $page_key ) ), $language['native_label'] ?? strtoupper( $code ) ); } /** * Suggested translated WooCommerce page slug. * * @param string $page_key WooCommerce page key. * @param string $code Language code. * @return string */ private function translated_woocommerce_page_slug( $page_key, $code ) { $slugs = array( 'de' => array( 'shop' => 'shop', 'cart' => 'warenkorb', 'checkout' => 'kasse', 'myaccount' => 'mein-konto', 'terms' => 'geschaeftsbedingungen', ), 'es' => array( 'shop' => 'tienda', 'cart' => 'carrito', 'checkout' => 'finalizar-compra', 'myaccount' => 'mi-cuenta', 'terms' => 'terminos', ), 'fr' => array( 'shop' => 'boutique', 'cart' => 'panier', 'checkout' => 'commande', 'myaccount' => 'mon-compte', 'terms' => 'conditions', ), 'ar' => array( 'shop' => 'shop-ar', 'cart' => 'cart-ar', 'checkout' => 'checkout-ar', 'myaccount' => 'account-ar', 'terms' => 'terms-ar', ), ); return $slugs[ $code ][ $page_key ] ?? $page_key . '-' . $code; } /** * WooCommerce page content for generated translated system pages. * * Classic shortcodes keep translated cart item names/order lines filterable * across themes and WooCommerce versions. * * @param string $page_key WooCommerce page key. * @param string $fallback Existing content. * @return string */ private function woocommerce_page_content( $page_key, $fallback ) { $content = array( 'cart' => '[woocommerce_cart]', 'checkout' => '[woocommerce_checkout]', 'myaccount' => '[woocommerce_my_account]', ); return $content[ $page_key ] ?? $fallback; } /** * Get original pages/products. * * @param string $post_type Post type. * @return array */ private function get_original_items( $post_type, $paged = 1, $per_page = 0, $search = '', $language = '', $status = '' ) { $args = array( 'post_type' => $post_type, 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => $per_page ? max( 1, min( 100, absint( $per_page ) ) ) : 100, 'paged' => max( 1, absint( $paged ) ), 'no_found_rows' => ! $per_page, 'fields' => 'all', 'orderby' => 'title', 'order' => 'ASC', 's' => sanitize_text_field( $search ), 'meta_query' => array( array( 'key' => '_sitelang_is_translation', 'compare' => 'NOT EXISTS', ), ), ); if ( $per_page && $language && ( isset( $this->statuses()[ $status ] ) || 'not_started' === $status ) ) { $args['sitelang_mapping_filter'] = array( 'language' => $language, 'status' => $status ); } $query = $this->query_original_items( $args, $post_type ); return $query->posts; } private function original_items_count( $post_type, $search, $language, $status ) { $args = array( 'post_type' => $post_type, 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, 's' => sanitize_text_field( $search ), 'meta_query' => array( array( 'key' => '_sitelang_is_translation', 'compare' => 'NOT EXISTS' ) ), ); if ( $language && ( isset( $this->statuses()[ $status ] ) || 'not_started' === $status ) ) { $args['sitelang_mapping_filter'] = array( 'language' => $language, 'status' => $status ); } $query = $this->query_original_items( $args, $post_type ); return absint( $query->found_posts ); } /** Apply translation status filtering in SQL without materializing every mapped ID in PHP. */ private function query_original_items( array $args, $post_type ) { global $wpdb; $filter = isset( $args['sitelang_mapping_filter'] ) && is_array( $args['sitelang_mapping_filter'] ) ? $args['sitelang_mapping_filter'] : array(); if ( empty( $filter['language'] ) || empty( $filter['status'] ) ) { return new \WP_Query( $args ); } unset( $args['sitelang_mapping_filter'] ); $table = $this->mapping->table(); $table_exists = $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( ! $table_exists ) { if ( 'not_started' !== $filter['status'] ) { $args['post__in'] = array( 0 ); } return new \WP_Query( $args ); } $language = $this->languages->sanitize_code( $filter['language'] ); $status = sanitize_key( $filter['status'] ); if ( ! $this->languages->is_enabled( $language ) || ( 'not_started' !== $status && ! isset( $this->statuses()[ $status ] ) ) ) { $args['post__in'] = array( 0 ); return new \WP_Query( $args ); } $marker = wp_generate_password( 20, false, false ); $args['sitelang_mapping_filter_marker'] = $marker; $query_filter = static function ( $where, $query ) use ( $wpdb, $table, $post_type, $language, $status, $marker ) { if ( $marker !== $query->get( 'sitelang_mapping_filter_marker' ) ) { return $where; } if ( 'not_started' === $status ) { $where .= $wpdb->prepare( " AND NOT EXISTS ( SELECT 1 FROM %i AS mappings INNER JOIN {$wpdb->posts} AS mapped_translation ON mapped_translation.ID = mappings.translation_id WHERE mappings.original_id = {$wpdb->posts}.ID AND mappings.content_type = %s AND mappings.language_code = %s AND mapped_translation.post_status NOT IN ('trash', 'auto-draft'))", $table, $post_type, $language ); } else { $where .= $wpdb->prepare( " AND EXISTS ( SELECT 1 FROM %i AS mappings INNER JOIN {$wpdb->posts} AS mapped_translation ON mapped_translation.ID = mappings.translation_id WHERE mappings.original_id = {$wpdb->posts}.ID AND mappings.content_type = %s AND mappings.language_code = %s AND mapped_translation.post_status NOT IN ('trash', 'auto-draft') AND mappings.status = %s)", $table, $post_type, $language, $status ); } return $where; }; add_filter( 'posts_where', $query_filter, 10, 2 ); $query = new \WP_Query( $args ); remove_filter( 'posts_where', $query_filter, 10 ); return $query; } /** * Get post types supported by SiteLang translation workflows. * * @return array */ private function get_translatable_post_types() { if ( method_exists( $this->translations, 'get_translatable_post_types' ) ) { $types = $this->translations->get_translatable_post_types(); } else { $types = array( 'page', 'product' ); } return array_values( array_filter( array_map( 'sanitize_key', (array) $types ) ) ); } /** * Get human-readable label for a post type. * * @param string $post_type Post type. * @return string */ private function get_post_type_label( $post_type ) { $object = get_post_type_object( $post_type ); if ( $object && ! empty( $object->labels->name ) ) { return (string) $object->labels->name; } return ucwords( str_replace( '_', ' ', sanitize_key( $post_type ) ) ); } /** * Save menu item translations. * * @param array $rows Posted rows. * @return void */ private function save_menu_translations( array $rows, $selected_language ) { $languages = $this->languages->get_enabled(); if ( ! isset( $languages[ $selected_language ] ) ) { return; } $saved = get_option( 'sitelang_menu_translations', array() ); $saved = is_array( $saved ) ? $saved : array(); foreach ( $rows as $menu_item_id => $language_rows ) { $menu_item_id = absint( $menu_item_id ); if ( ! $menu_item_id || 'nav_menu_item' !== get_post_type( $menu_item_id ) || ! is_array( $language_rows ) ) { continue; } $row = isset( $language_rows[ $selected_language ] ) && is_array( $language_rows[ $selected_language ] ) ? $language_rows[ $selected_language ] : array(); $title = isset( $row['title'] ) ? sanitize_text_field( $row['title'] ) : ''; $url = isset( $row['url'] ) ? esc_url_raw( $row['url'] ) : ''; if ( '' === $title && '' === $url ) { unset( $saved[ $menu_item_id ][ $selected_language ] ); if ( empty( $saved[ $menu_item_id ] ) ) { unset( $saved[ $menu_item_id ] ); } continue; } if ( ! isset( $saved[ $menu_item_id ] ) || ! is_array( $saved[ $menu_item_id ] ) ) { $saved[ $menu_item_id ] = array(); } $saved[ $menu_item_id ][ $selected_language ] = array( 'title' => $title, 'url' => $url, ); } update_option( 'sitelang_menu_translations', $saved, false ); } /** * Tabs. * * @return array */ private function tabs() { $tabs = array( 'dashboard' => __( 'Overview', 'sitelang' ), 'languages' => __( 'Languages', 'sitelang' ), 'translations' => __( 'Translations', 'sitelang' ), 'products' => __( 'Products', 'sitelang' ), 'queue' => __( 'Translation Queue', 'sitelang' ), 'feeds' => __( 'Feeds & Sitemap', 'sitelang' ), 'settings' => __( 'Settings', 'sitelang' ), 'setup' => __( 'Setup guide', 'sitelang' ), 'ai' => __( 'AI translation', 'sitelang' ), 'switcher' => __( 'Language switcher', 'sitelang' ), 'menus' => __( 'Menu translations', 'sitelang' ), 'reviews' => __( 'Review translations', 'sitelang' ), 'urls' => __( 'URL Settings', 'sitelang' ), 'tools' => __( 'Tools', 'sitelang' ), ); return $tabs; } /** * Group admin navigation by task. * * @return array> */ private function tab_groups() { return array( __( 'Workspace', 'sitelang' ) => array( 'dashboard', 'languages', 'translations', 'products', 'queue', 'feeds', 'settings' ), __( 'Translate', 'sitelang' ) => array( 'reviews', 'menus' ), __( 'Configuration', 'sitelang' ) => array( 'setup', 'switcher', 'urls' ), ); } /** * Short orientation text for each workspace. * * @return array */ private function tab_descriptions() { return array( 'dashboard' => __( 'Monitor language coverage, translation activity, and store readiness.', 'sitelang' ), 'products' => __( 'Review product translations and open linked products.', 'sitelang' ), 'queue' => __( 'Monitor resumable translation jobs and resume eligible work.', 'sitelang' ), 'settings' => __( 'Open language, switcher, and URL settings.', 'sitelang' ), 'setup' => __( 'Choose languages, place your switcher, and optionally connect managed Auto.', 'sitelang' ), 'languages' => __( 'Add languages and control their labels, flags, and RTL behavior.', 'sitelang' ), 'translations' => __( 'Create, monitor, edit, and repair linked translations.', 'sitelang' ), 'switcher' => __( 'Choose the language switcher style and where it can be displayed.', 'sitelang' ), 'menus' => __( 'Localize navigation labels and optional custom links for each language.', 'sitelang' ), 'reviews' => __( 'Translate approved product reviews without changing translated product data.', 'sitelang' ), 'urls' => __( 'Manage virtual language prefixes and language-specific homepages.', 'sitelang' ), 'feeds' => __( 'Copy sitemap and Merchant Center feed URLs for each language.', 'sitelang' ), 'tools' => __( 'Exchange translations with XLIFF files and access maintenance tools.', 'sitelang' ), ); } /** * Small visual markers for the admin tabs. * * @param string $tab Tab key. * @return string */ private function tab_icon( $tab ) { $icons = array( 'dashboard' => 'dashicons-chart-area', 'products' => 'dashicons-products', 'queue' => 'dashicons-clock', 'settings' => 'dashicons-admin-settings', 'setup' => 'dashicons-admin-settings', 'languages' => 'dashicons-translation', 'translations' => 'dashicons-update-alt', 'switcher' => 'dashicons-layout', 'menus' => 'dashicons-menu-alt3', 'reviews' => 'dashicons-star-filled', 'urls' => 'dashicons-admin-links', 'feeds' => 'dashicons-rss', ); return isset( $icons[ $tab ] ) ? $icons[ $tab ] : 'dashicons-admin-generic'; } /** * Aggregate lightweight translation analytics for the dashboard. * * @return array */ private function get_dashboard_analytics() { global $wpdb; $analytics = array( 'total' => 0, 'complete' => 0, 'in_progress' => 0, 'draft' => 0, 'completion_rate' => 0, 'by_type' => array(), 'by_type_complete' => array(), 'by_language' => array(), 'recent' => array(), ); $table = $this->mapping->table(); if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) { return $analytics; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT mappings.content_type, mappings.language_code, mappings.status, COUNT(*) AS total FROM %i AS mappings INNER JOIN {$wpdb->posts} AS posts ON posts.ID = mappings.translation_id WHERE posts.post_status NOT IN ('trash', 'auto-draft') GROUP BY mappings.content_type, mappings.language_code, mappings.status", $table ) ); foreach ( $rows as $row ) { $count = absint( $row->total ); $type = sanitize_key( $row->content_type ); $language = $this->languages->sanitize_code( $row->language_code ); $status = sanitize_key( $row->status ); $analytics['total'] += $count; $analytics[ $status ] = isset( $analytics[ $status ] ) ? $analytics[ $status ] + $count : $count; $analytics['by_type'][ $type ] = isset( $analytics['by_type'][ $type ] ) ? $analytics['by_type'][ $type ] + $count : $count; if ( ! isset( $analytics['by_language'][ $language ] ) ) { $analytics['by_language'][ $language ] = array( 'total' => 0, 'complete' => 0 ); } $analytics['by_language'][ $language ]['total'] += $count; if ( 'complete' === $status ) { $analytics['by_type_complete'][ $type ] = isset( $analytics['by_type_complete'][ $type ] ) ? $analytics['by_type_complete'][ $type ] + $count : $count; $analytics['by_language'][ $language ]['complete'] += $count; } } if ( $analytics['total'] ) { $analytics['completion_rate'] = (int) round( ( $analytics['complete'] / $analytics['total'] ) * 100 ); } $analytics['recent'] = $wpdb->get_results( $wpdb->prepare( "SELECT mappings.translation_id, mappings.language_code, mappings.content_type, mappings.status, posts.post_title FROM %i AS mappings INNER JOIN {$wpdb->posts} AS posts ON posts.ID = mappings.translation_id WHERE posts.post_status NOT IN ('trash', 'auto-draft') ORDER BY mappings.updated_at DESC LIMIT 7", $table ) ); return $analytics; } /** * Translation statuses. * * @return array */ private function statuses() { return array( 'draft' => __( 'Draft', 'sitelang' ), 'in_progress' => __( 'In Progress', 'sitelang' ), 'complete' => __( 'Complete', 'sitelang' ), ); } /** * Required capability. * * @return string */ private function capability() { return current_user_can( 'manage_woocommerce' ) ? 'manage_woocommerce' : 'manage_options'; } /** * Languages shown in feeds/sitemap UI. * * @return array */ private function feed_language_codes() { $codes = array( $this->languages->get_default_code() => $this->languages->get_default_native_label(), ); foreach ( $this->languages->get_enabled() as $code => $language ) { $codes[ $code ] = ! empty( $language['native_label'] ) ? $language['native_label'] : strtoupper( $code ); } return $codes; } /** * Redirect with notice. * * @param string $notice Notice key. * @param string $extra Extra message. * @param string $fallback Fallback URL. * @return void */ private function redirect_with_notice( $notice, $extra = '', $fallback = '' ) { $url = wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=sitelang-wc' ); if ( $fallback ) { $url = $fallback; } $url = remove_query_arg( array( 'sitelang_notice', 'sitelang_extra' ), $url ); $url = add_query_arg( 'sitelang_notice', $notice, $url ); if ( '' !== $extra ) { $url = add_query_arg( 'sitelang_extra', $extra, $url ); } wp_safe_redirect( $url ); exit; } }