array( 'name' => __( 'TranslateSense Config Posts', 'translatesense' ), 'singular_name' => __( 'TranslateSense Config Post', 'translatesense' ), ), 'public' => false, 'publicly_queryable' => true, 'show_ui' => true, 'show_in_menu' => false, 'show_in_nav_menus' => false, 'show_in_admin_bar' => false, 'exclude_from_search' => true, 'has_archive' => false, 'query_var' => true, 'rewrite' => false, 'show_in_rest' => true, 'supports' => array( 'title', 'editor', 'custom-fields', 'elementor' ), ); register_post_type( self::POST_TYPE, $args ); if ( did_action( 'elementor/loaded' ) || class_exists( '\Elementor\Plugin' ) ) { add_post_type_support( self::POST_TYPE, 'elementor' ); } } /** * Ensure Elementor recognizes the config post type as supported. */ public static function filter_elementor_cpt_support( $cpt ) { if ( ! is_array( $cpt ) ) { $cpt = array( 'post', 'page' ); } if ( ! in_array( self::POST_TYPE, $cpt, true ) ) { $cpt[] = self::POST_TYPE; } return $cpt; } /** * Restrict frontend access of config post type to editors/admins only. */ public static function protect_frontend_access() { if ( is_singular( self::POST_TYPE ) && ! current_user_can( 'edit_posts' ) ) { wp_safe_redirect( home_url() ); exit; } } /** * Check if Config Mapping Mode is active for a given post. */ public static function is_translatesense_config_post( int $post_id = 0 ) { if ( $post_id ) { $post_type = get_post_type( $post_id ); if ( self::POST_TYPE !== $post_type ) { return false; } $config_builder = get_post_meta( $post_id, '_translatesense_config_post', true ); if ( ! empty( $config_builder ) ) { $config_post_id = self::get_config_post_id( $config_builder ); if ( $config_post_id && $config_post_id === $post_id ) { return true; } } } return false; } final public static function get_config_post_id( string $builder ) { $builder_ids = get_option( 'translatesense_config_posts', array() ); if ( ! empty( $builder_ids[ $builder ] ) ) { $post = get_post( (int) $builder_ids[ $builder ] ); if ( $post && self::POST_TYPE === $post->post_type && 'trash' !== $post->post_status ) { if ( 'elementor' === $builder && 'publish' !== $post->post_status ) { wp_update_post( array( 'ID' => $post->ID, 'post_status' => 'publish' ) ); } return (int) $post->ID; } } return 0; } /** * Get or create a config sandbox post ID for the specified builder. * * @param string $builder Builder identifier ('gutenberg', 'elementor', 'classic') * @return int Post ID of the config sandbox post */ public static function get_or_create_config_post( string $builder = 'gutenberg' ): int { $builder = sanitize_key( $builder ); // Check cached option first $builder_ids = get_option( 'translatesense_config_posts', array() ); if ( ! empty( $builder_ids[ $builder ] ) ) { $post = get_post( (int) $builder_ids[ $builder ] ); if ( $post && self::POST_TYPE === $post->post_type && 'trash' !== $post->post_status ) { if ( 'elementor' === $builder && 'publish' !== $post->post_status ) { wp_update_post( array( 'ID' => $post->ID, 'post_status' => 'publish' ) ); } return (int) $post->ID; } } // Find existing draft sandbox post via query $existing_sandbox = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => array( 'draft', 'publish' ), 'meta_key' => '_translatesense_config_post', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'meta_value' => $builder, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'posts_per_page' => 1, ) ); if ( ! empty( $existing_sandbox ) ) { $sandbox_id = (int) $existing_sandbox[0]->ID; if ( 'elementor' === $builder && 'publish' !== $existing_sandbox[0]->post_status ) { wp_update_post( array( 'ID' => $sandbox_id, 'post_status' => 'publish' ) ); } } else { $sandbox_id = (int) wp_insert_post( array( 'post_title' => sprintf( 'TranslateSense Config Sandbox (%s)', ucfirst( $builder ) ), 'post_type' => self::POST_TYPE, 'post_status' => ( 'elementor' === $builder ) ? 'publish' : 'draft', 'post_content' => '
Translate This Content
', ) ); update_post_meta( $sandbox_id, '_translatesense_config_post', $builder ); $builder_ids[ $builder ] = $sandbox_id; update_option( 'translatesense_config_posts', $builder_ids ); } return (int) $sandbox_id; } /** * Handle standard WP post save (Gutenberg / Classic). */ public static function handle_save_post( $post_id, $post ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( wp_is_post_revision( $post_id ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } if ( ! self::is_translatesense_config_post( $post_id ) ) { return; } // Parse Gutenberg content $content = $post->post_content; if ( function_exists( 'has_blocks' ) && has_blocks( $content ) ) { $blocks = parse_blocks( $content ); self::scan_blocks_for_mapping( $blocks ); } } /** * Recursively scan Gutenberg blocks for unmapped text or dummy markers. * * @param array $blocks * @param int $depth */ public static function scan_blocks_for_mapping( $blocks, $depth = 0 ) { if ( ! is_array( $blocks ) || empty( $blocks ) || $depth > 25 ) { return; } $rules = Xml_Engine::get_all_rules(); $gutenberg_rules = $rules['gutenberg']; foreach ( $blocks as $block ) { if ( ! is_array( $block ) ) { continue; } $block_name = $block['blockName'] ?? ''; if ( empty( $block_name ) ) { if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { self::scan_blocks_for_mapping( $block['innerBlocks'], $depth + 1 ); } continue; } $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $no_translate = isset( $gutenberg_rules[ $block_name ]['no_translate'] ) ? array_keys( $gutenberg_rules[ $block_name ]['no_translate'] ) : array(); // Scan attributes recursively supporting nested array items (with key="*") and objects $new_fields = self::extract_translatable_gutenberg_attrs( $attrs, true, $no_translate ); // If new fields found, register into custom XML if ( ! empty( $new_fields ) ) { Xml_Engine::append_custom_rule( 'gutenberg', $block_name, $new_fields ); } // Recurse inner blocks with depth limit if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { self::scan_blocks_for_mapping( $block['innerBlocks'], $depth + 1 ); } } } /** * Handle Elementor document save data. */ public static function handle_elementor_save( $document, $data ) { $post_id = $document->get_id(); if ( ! self::is_translatesense_config_post( $post_id ) ) { return $data; } $elements = $data['elements'] ?? array(); if ( ! empty( $elements ) && is_array( $elements ) ) { self::scan_elementor_elements( $elements ); } return $data; } /** * Recursively scan Elementor elements tree for unmapped settings with depth protection. * * @param array $elements * @param int $depth */ public static function scan_elementor_elements( $elements, $depth = 0 ) { if ( ! is_array( $elements ) || empty( $elements ) || $depth > 25 ) { return; } $rules = Xml_Engine::get_all_rules(); $elementor_rules = $rules['elementor']; foreach ( $elements as $element ) { if ( ! is_array( $element ) ) { continue; } $el_type = $element['elType'] ?? ''; $widget_type = $element['widgetType'] ?? ( 'widget' === $el_type ? ( $element['name'] ?? '' ) : '' ); if ( 'widget' === $el_type && ! empty( $widget_type ) ) { $settings = isset( $element['settings'] ) && is_array( $element['settings'] ) ? $element['settings'] : array(); $existing_fields = isset( $elementor_rules[ $widget_type ] ) ? array_keys( $elementor_rules[ $widget_type ]['fields'] ) : array(); $no_translate = isset( $elementor_rules[ $widget_type ]['no_translate'] ) ? array_keys( $elementor_rules[ $widget_type ]['no_translate'] ) : array(); $ignore_fields = array_merge( $existing_fields, $no_translate ); $new_fields = array(); self::find_elementor_text_settings( $settings, '', $ignore_fields, $new_fields, 0 ); if ( ! empty( $new_fields ) ) { Xml_Engine::append_custom_rule( 'elementor', $widget_type, $new_fields, array( 'widgetType' => $widget_type ) ); } } // Recurse inner elements with depth limit if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) { self::scan_elementor_elements( $element['elements'], $depth + 1 ); } } } /** * Detect unmapped fields / dummy text from Gutenberg blocks in memory and generate XML snippet. * Enforces depth and node limits to prevent resource exhaustion. * * @param mixed $blocks * @return array|null */ public static function detect_from_gutenberg_blocks( $blocks ) { if ( ! is_array( $blocks ) || empty( $blocks ) ) { return null; } $found_items = array(); $scanned_nodes = 0; $max_depth = 25; $max_nodes = 2000; $scan_recursive = function ( $block_list, $depth = 0 ) use ( &$scan_recursive, &$found_items, &$scanned_nodes, $max_depth, $max_nodes ) { if ( ! is_array( $block_list ) || $depth > $max_depth || $scanned_nodes >= $max_nodes ) { return; } foreach ( $block_list as $block ) { if ( ! is_array( $block ) || $scanned_nodes >= $max_nodes ) { break; } ++$scanned_nodes; $block_name = $block['blockName'] ?? ''; if ( empty( $block_name ) ) { if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $scan_recursive( $block['innerBlocks'], $depth + 1 ); } continue; } $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $extracted = self::extract_translatable_gutenberg_attrs( $attrs, false ); if ( ! empty( $extracted ) ) { if ( ! isset( $found_items[ $block_name ] ) ) { $found_items[ $block_name ] = array(); } $found_items[ $block_name ] = array_merge( $found_items[ $block_name ], $extracted ); } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $scan_recursive( $block['innerBlocks'], $depth + 1 ); } } }; $scan_recursive( $blocks, 0 ); if ( empty( $found_items ) ) { return null; } $generated_xml = "\n