switcher = $switcher; $this->languages = $languages; $this->mapping = $mapping; $this->translations = $translations; $this->ai_translator = $ai_translator; } /** * Register hooks. * * @return void */ public function init() { add_action( 'init', array( $this, 'enable_cms_block_editor_preview' ), 999 ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_rtl_auto_translation_styles' ), 100 ); add_filter( 'body_class', array( $this, 'add_rtl_auto_translation_body_class' ) ); add_filter( 'template_include', array( $this, 'use_cms_block_editor_preview_template' ), 99 ); add_filter( 'elementor/document/urls/preview', array( $this, 'filter_cms_block_preview_url' ), 99, 2 ); add_action( 'elementor/widgets/register', array( $this, 'register_widget' ) ); add_action( 'elementor/elements/categories_registered', array( $this, 'register_category' ) ); add_action( 'elementor/editor/after_enqueue_scripts', array( $this, 'enqueue_editor_assets' ) ); add_action( 'wp_ajax_sitelang_elementor_context', array( $this, 'ajax_context' ) ); add_action( 'wp_ajax_sitelang_elementor_translate', array( $this, 'ajax_translate' ) ); add_action( 'wp_ajax_sitelang_elementor_process_step', array( $this, 'ajax_process_step' ) ); } /** * Load the fluid-width rule only while rendering an RTL language. * * @return void */ public function enqueue_rtl_auto_translation_styles() { $language = $this->languages->get_current(); $is_rtl = $this->languages->is_rtl( $language ); if ( ! $is_rtl ) { $post_id = get_queried_object_id(); $language = $post_id ? get_post_meta( $post_id, '_sitelang_language', true ) : ''; $is_rtl = $post_id && get_post_meta( $post_id, '_sitelang_ai_translated_at', true ) && $this->languages->is_rtl( $language ); } if ( ! $is_rtl ) { return; } wp_enqueue_style( 'sitelang-rtl-auto-elementor', SITELANG_URL . 'assets/css/rtl-auto-elementor.css', array(), SITELANG_VERSION ); } /** * Scope responsive overrides to pages that contain an automatic RTL translation. * * @param string[] $classes Body classes. * @return string[] */ public function add_rtl_auto_translation_body_class( $classes ) { $post_id = get_queried_object_id(); $language = $post_id ? get_post_meta( $post_id, '_sitelang_language', true ) : ''; if ( $post_id && get_post_meta( $post_id, '_sitelang_ai_translated_at', true ) && $this->languages->is_rtl( $language ) ) { $classes[] = 'sitelang-rtl-auto-page'; } return $classes; } /** * Point Elementor's preview iframe directly at Woodmart CMS block records. * * Elementor can inherit the current translated page URL when editing a block * embedded in a layout. That URL does not query the cms_block post itself. * * @param string $url Preview URL. * @param \Elementor\Core\Base\Document $document Elementor document. * @return string */ public function filter_cms_block_preview_url( $url, $document ) { if ( ! is_object( $document ) || ! method_exists( $document, 'get_main_id' ) ) { return $url; } $post_id = absint( $document->get_main_id() ); if ( ! $post_id || 'cms_block' !== get_post_type( $post_id ) ) { return $url; } $query = array( 'post_type' => 'cms_block', 'p' => $post_id, 'elementor-preview' => $post_id, ); $parts = wp_parse_url( $url ); if ( ! empty( $parts['query'] ) ) { parse_str( $parts['query'], $existing_query ); if ( isset( $existing_query['ver'] ) && is_scalar( $existing_query['ver'] ) ) { $query['ver'] = sanitize_text_field( wp_unslash( $existing_query['ver'] ) ); } } return add_query_arg( $query, home_url( '/' ) ); } /** * Allow an authenticated Elementor preview to query Woodmart content blocks. * * Woodmart intentionally registers cms_block as non-publicly-queryable, which * prevents Elementor's preview iframe from reaching the normal content loop. * Keep the post type private to regular requests and open queryability only for * an editor who can edit the exact block being previewed. * * @return void */ public function enable_cms_block_editor_preview() { $preview_id = isset( $_GET['elementor-preview'] ) ? absint( wp_unslash( $_GET['elementor-preview'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $post = $preview_id ? get_post( $preview_id ) : null; if ( ! $post || 'cms_block' !== $post->post_type || ! current_user_can( 'edit_post', $preview_id ) ) { return; } $post_type = get_post_type_object( 'cms_block' ); if ( $post_type ) { $post_type->publicly_queryable = true; } } /** * Provide Elementor a normal the_content() loop for a Woodmart block preview. * * @param string $template Selected theme template. * @return string */ public function use_cms_block_editor_preview_template( $template ) { if ( ! is_singular( 'cms_block' ) ) { return $template; } $post_id = get_queried_object_id(); $preview_id = isset( $_GET['elementor-preview'] ) ? absint( wp_unslash( $_GET['elementor-preview'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $post_id || $preview_id !== $post_id || ! current_user_can( 'edit_post', $post_id ) ) { return $template; } $preview_template = SITELANG_PATH . 'templates/single-cms-block-elementor-preview.php'; return is_readable( $preview_template ) ? $preview_template : $template; } /** * Enqueue SiteLang tools inside Elementor editor. * * @return void */ public function enqueue_editor_assets() { $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { return; } wp_enqueue_style( 'sitelang-elementor-editor', SITELANG_URL . 'assets/css/elementor-editor.css', array(), SITELANG_VERSION ); wp_enqueue_script( 'sitelang-elementor-editor', SITELANG_URL . 'assets/js/elementor-editor.js', array( 'jquery' ), SITELANG_VERSION, true ); wp_localize_script( 'sitelang-elementor-editor', 'slwElementorEditor', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'sitelang_elementor_editor' ), 'postId' => $post_id, 'i18n' => array( 'title' => __( 'SiteLang Translation', 'sitelang' ), 'loading' => __( 'Loading translation context...', 'sitelang' ), 'selectLanguage' => __( 'Select language', 'sitelang' ), 'translatePage' => __( 'Translate whole page', 'sitelang' ), 'translateElementor'=> __( 'Translate Elementor content only', 'sitelang' ), 'translateSelected' => __( 'Translate selected element', 'sitelang' ), 'openTranslation' => __( 'Open translated editor', 'sitelang' ), 'selectedElement' => __( 'Selected Elementor item', 'sitelang' ), 'noneSelected' => __( 'Click a section, column, or widget in Elementor to select it.', 'sitelang' ), 'selectedReady' => __( 'Ready to translate this selected item into the translated page.', 'sitelang' ), 'selectedMissing' => __( 'Select a widget, column, or section in Elementor first.', 'sitelang' ), 'queued' => __( 'Translation queued. You can keep editing while SiteLang works.', 'sitelang' ), 'error' => __( 'SiteLang could not start the translation.', 'sitelang' ), ), ) ); } /** * AJAX: return Elementor translation context. * * @return void */ public function ajax_context() { check_ajax_referer( 'sitelang_elementor_editor', 'nonce' ); $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( array( 'message' => __( 'You cannot edit this document.', 'sitelang' ) ), 403 ); } wp_send_json_success( $this->build_context_payload( $post_id ) ); } /** * AJAX: create/queue an Elementor translation. * * @return void */ public function ajax_translate() { check_ajax_referer( 'sitelang_elementor_editor', 'nonce' ); $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $raw_language = isset( $_POST['language'] ) && is_scalar( $_POST['language'] ) ? sanitize_key( wp_unslash( $_POST['language'] ) ) : ''; $language = $this->languages->sanitize_code( $raw_language ); $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : 'whole'; $element = isset( $_POST['element_id'] ) ? sanitize_key( wp_unslash( $_POST['element_id'] ) ) : ''; if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( array( 'message' => __( 'You cannot edit this document.', 'sitelang' ) ), 403 ); } if ( ! $this->ai_translator->is_configured() ) { wp_send_json_error( array( 'message' => __( 'OpenAI translation is not configured yet.', 'sitelang' ) ), 400 ); } if ( ! $this->languages->get_language( $language ) ) { wp_send_json_error( array( 'message' => __( 'Choose a valid target language.', 'sitelang' ) ), 400 ); } $context = $this->resolve_document_context( $post_id ); $original_id = (int) $context['original_id']; if ( ! $original_id || ! current_user_can( 'edit_post', $original_id ) ) { wp_send_json_error( array( 'message' => __( 'The original document could not be resolved.', 'sitelang' ) ), 404 ); } $translation_id = $this->mapping->get_translation_id( $original_id, $language ); if ( ! $translation_id ) { $translation_id = $this->translations->create_translation( $original_id, $language, true ); if ( is_wp_error( $translation_id ) ) { wp_send_json_error( array( 'message' => $translation_id->get_error_message() ), 400 ); } } $translation_id = absint( $translation_id ); if ( ! $translation_id || ! current_user_can( 'edit_post', $translation_id ) ) { wp_send_json_error( array( 'message' => __( 'The translated document could not be opened for editing.', 'sitelang' ) ), 403 ); } if ( 'selected' === $mode ) { $result = $this->ai_translator->enqueue_elementor_selection_job( $translation_id, array( $element ) ); } elseif ( 'elementor_only' === $mode ) { $result = $this->ai_translator->enqueue_elementor_repair_job( $translation_id ); } else { $result = $this->ai_translator->enqueue_translation_job( $original_id, $translation_id, $language ); } if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); } $row = $this->mapping->get_by_translation_id( $translation_id ); $inherit = (bool) get_post_meta( $translation_id, '_sitelang_inherit_product_data', true ); $this->mapping->update_translation_meta( $translation_id, 'in_progress', $inherit ); wp_send_json_success( array( 'message' => __( 'Translation queued.', 'sitelang' ), 'translationId' => $translation_id, 'editUrl' => $this->elementor_edit_url( $translation_id ), 'frontendUrl' => get_permalink( $translation_id ), 'status' => $row && ! empty( $row->status ) ? sanitize_key( $row->status ) : 'in_progress', 'progress' => $this->ai_translator->get_job_progress( $translation_id ), 'context' => $this->build_context_payload( $post_id ), ) ); } /** * AJAX: process one queued translation step from inside Elementor. * * @return void */ public function ajax_process_step() { check_ajax_referer( 'sitelang_elementor_editor', 'nonce' ); $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_send_json_error( array( 'message' => __( 'Invalid translation item.', '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() ), 400 ); } wp_send_json_success( array( 'progress' => $this->ai_translator->get_job_progress( $translation_id ), ) ); } /** * Resolve original/translation identity for an Elementor document. * * @param int $post_id Post ID. * @return array{post_id:int,original_id:int,is_translation:bool,current_language:string} */ private function resolve_document_context( $post_id ) { $post_id = absint( $post_id ); if ( $this->mapping->is_translation( $post_id ) ) { $row = $this->mapping->get_by_translation_id( $post_id ); return array( 'post_id' => $post_id, 'original_id' => $row ? absint( $row->original_id ) : $post_id, 'is_translation' => true, 'current_language'=> $row && ! empty( $row->language_code ) ? $this->languages->sanitize_code( $row->language_code ) : '', ); } return array( 'post_id' => $post_id, 'original_id' => $post_id, 'is_translation' => false, 'current_language'=> '', ); } /** * Build editor panel context payload. * * @param int $post_id Current Elementor document ID. * @return array */ private function build_context_payload( $post_id ) { $context = $this->resolve_document_context( $post_id ); $original_id = (int) $context['original_id']; $languages = array(); foreach ( $this->languages->get_enabled() as $code => $language ) { $row = $this->mapping->get_by_original_language( $original_id, $code ); $translation_id = $row ? absint( $row->translation_id ) : 0; $languages[] = array( 'code' => $code, 'label' => ! empty( $language['native_label'] ) ? $language['native_label'] : strtoupper( $code ), 'englishLabel' => ! empty( $language['english_label'] ) ? $language['english_label'] : strtoupper( $code ), 'flag' => ! empty( $language['flag'] ) ? $language['flag'] : '', 'translationId' => $translation_id, 'status' => $row && ! empty( $row->status ) ? sanitize_key( $row->status ) : '', 'editUrl' => $translation_id ? $this->elementor_edit_url( $translation_id ) : '', 'frontendUrl' => $translation_id ? get_permalink( $translation_id ) : '', 'progress' => $translation_id ? $this->ai_translator->get_job_progress( $translation_id ) : array(), ); } return array( 'postId' => absint( $post_id ), 'originalId' => $original_id, 'isTranslation' => (bool) $context['is_translation'], 'currentLanguage'=> (string) $context['current_language'], 'title' => get_the_title( $post_id ), 'originalTitle' => get_the_title( $original_id ), 'canTranslate' => $this->ai_translator->is_configured(), 'summary' => $this->scan_elementor_summary( $original_id ), 'languages' => $languages, ); } /** * Get an Elementor editor URL. * * @param int $post_id Post ID. * @return string */ private function elementor_edit_url( $post_id ) { return admin_url( 'post.php?post=' . absint( $post_id ) . '&action=elementor' ); } /** * Lightweight scan summary for Elementor text. * * @param int $post_id Post ID. * @return array */ private function scan_elementor_summary( $post_id ) { $data = $this->get_elementor_data( $post_id ); $summary = array( 'elements' => 0, 'fields' => 0, 'html' => 0, ); if ( is_array( $data ) ) { $this->scan_elementor_node( $data, $summary ); } return $summary; } /** * Recursive scan helper. * * @param array $node Elementor node. * @param array $summary Summary. * @return void */ private function scan_elementor_node( array $node, array &$summary ) { if ( ! empty( $node['id'] ) && is_string( $node['id'] ) ) { $summary['elements']++; } foreach ( $node as $key => $value ) { if ( is_array( $value ) ) { $this->scan_elementor_node( $value, $summary ); continue; } if ( ! is_string( $value ) || '' === trim( $value ) ) { continue; } $plain = trim( wp_strip_all_tags( $value ) ); if ( '' === $plain || ! preg_match( '/[A-Za-z]/', $plain ) ) { continue; } if ( preg_match( '/(title|text|editor|description|caption|label|placeholder|content|heading|html|button|subtitle|message|copy)/i', (string) $key ) ) { $summary['fields']++; if ( false !== strpos( $value, '<' ) ) { $summary['html']++; } } } } /** * Parse Elementor data. * * @param int $post_id Post ID. * @return array|null */ private function get_elementor_data( $post_id ) { $raw = get_post_meta( absint( $post_id ), '_elementor_data', true ); if ( ! is_string( $raw ) || '' === trim( $raw ) ) { return null; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { $data = json_decode( wp_unslash( $raw ), true ); } return is_array( $data ) ? $data : null; } /** * Add SiteLang category. * * @param \Elementor\Elements_Manager $elements_manager Elements manager. * @return void */ public function register_category( $elements_manager ) { $elements_manager->add_category( 'sitelang', array( 'title' => __( 'SiteLang', 'sitelang' ), 'icon' => 'fa fa-language', ) ); } /** * Register widget after Elementor has loaded Widget_Base. * * @param \Elementor\Widgets_Manager $widgets_manager Widgets manager. * @return void */ public function register_widget( $widgets_manager ) { if ( ! class_exists( '\Elementor\Widget_Base' ) ) { return; } $switcher = $this->switcher; $widgets_manager->register( new class( $switcher ) extends \Elementor\Widget_Base { /** * Switcher renderer. * * @var \SiteLangWC\Switcher */ private $switcher; /** * Constructor. * * @param \SiteLangWC\Switcher $switcher Switcher renderer. * @param array $data Widget data. * @param array|null $args Widget args. */ public function __construct( \SiteLangWC\Switcher $switcher, array $data = array(), $args = null ) { $this->switcher = $switcher; parent::__construct( $data, $args ); } /** * Widget name. * * @return string */ public function get_name() { return 'sitelang_switcher'; } /** * Widget title. * * @return string */ public function get_title() { return __( 'SiteLang Switcher', 'sitelang' ); } /** * Widget icon. * * @return string */ public function get_icon() { return 'eicon-globe'; } /** * Widget categories. * * @return array */ public function get_categories() { return array( 'sitelang', 'general' ); } /** * Register switcher controls in Elementor. * * @return void */ protected function register_controls() { $this->start_controls_section( 'sitelang_switcher_content', array( 'label' => __( 'Language Switcher', 'sitelang' ) ) ); $this->add_control( 'mode', array( 'label' => __( 'Interaction', 'sitelang' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'dropdown', 'options' => array( 'dropdown' => __( 'Dropdown', 'sitelang' ), 'pills' => __( 'Direct choices', 'sitelang' ), ), ) ); $this->add_control( 'style_preset', array( 'label' => __( 'Design preset', 'sitelang' ), 'type' => \Elementor\Controls_Manager::SELECT, 'default' => 'minimal', 'options' => array( 'minimal' => __( 'Minimal Outline', 'sitelang' ), 'soft' => __( 'Soft Surface', 'sitelang' ), 'compact' => __( 'Compact', 'sitelang' ), 'floating' => __( 'Floating', 'sitelang' ), 'underline' => __( 'Text + Underline', 'sitelang' ), 'segmented' => __( 'Segmented', 'sitelang' ), ), ) ); $this->add_control( 'show_flag', array( 'label' => __( 'Show flags', 'sitelang' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'return_value' => '1', 'default' => '1', ) ); $this->add_control( 'show_name', array( 'label' => __( 'Show native name', 'sitelang' ), 'type' => \Elementor\Controls_Manager::SWITCHER, 'return_value' => '1', 'default' => '', ) ); $this->end_controls_section(); } /** * Render widget. * * @return void */ protected function render() { $settings = $this->get_settings_for_display(); echo wp_kses_post( $this->switcher->render( array( 'mode' => $settings['mode'] ?? 'dropdown', 'style' => $settings['style_preset'] ?? 'minimal', 'show_flag' => ! empty( $settings['show_flag'] ) ? '1' : '0', 'show_name' => ! empty( $settings['show_name'] ) ? '1' : '0', ) ) ); } } ); } }