init_field_maps();
}
/**
* Initialize field mappings
*/
private function init_field_maps() {
// EXACT translatable field names used by Elementor
$this->translatable_fields = [
// Text widget fields
'editor', 'text', 'title', 'title_text', 'description_text', 'inner_text', 'text_field',
'heading', 'subtitle', 'prefix', 'suffix', 'highlighted_text',
// Premium Addons / Essential Addons repeater + text fields (use "txt" not "text")
'item_txt', 'item_text', 'premium_text', 'pa_text', 'before_text', 'after_text', 'rotating_text',
'button', 'button_text', 'link_text', 'read_more_text',
'field_label', 'placeholder', 'field_html', 'acceptance_text', 'success_message', 'error_message',
'caption', 'alt', 'image_caption', 'lightbox_description',
// Testimonial & Toggle / Accordion / Tabs widget fields
'testimonial_content', 'testimonial_name', 'testimonial_job',
'tab_title', 'tab_content', 'tab_label', 'tab_body', 'tab_text', 'tab_desc', 'tab_sub_title',
'toggle_title', 'toggle_content', 'toggle_text', 'toggle_description', 'toggle_sub_title', 'toggle_tab_title', 'toggle_body',
'accordion_title', 'accordion_content', 'accordion_text', 'accordion_description', 'accordion_item_title', 'accordion_item_content', 'accordion_body',
'item_title', 'item_content', 'item_description', 'item_subtitle', 'item_desc', 'item_heading',
'faq_question', 'faq_answer', 'question', 'answer',
// Third-party addons (Essential Addons, Premium Addons, Ultimate Addons, etc.)
'eael_adv_accordion_tab_title', 'eael_adv_accordion_tab_content', 'eael_toggle_title', 'eael_toggle_content',
'premium_accordion_title', 'premium_accordion_content', 'uael_accordion_title', 'uael_accordion_content',
'list_title', 'list_content', 'list_text', 'list_description',
'card_title', 'card_content', 'card_description', 'card_text',
'feature_title', 'feature_text', 'feature_description',
'step_title', 'step_text', 'step_description', 'step_content',
'timeline_title', 'timeline_text', 'timeline_content',
'info_box_title', 'info_box_description', 'info_box_content',
'flipbox_title_front', 'flipbox_title_back', 'flipbox_content_front', 'flipbox_content_back',
'alert_title', 'alert_description', 'cta_title', 'cta_description', 'cta_button',
'html', 'shortcode', 'excerpt', 'before_text', 'after_text',
// Add more specific fields
'label', 'description', 'content', 'message',
// Elementor v4 atomic widget fields
'paragraph',
// JetSmartFilters translatable widget fields
'show_all_filter_label', 'filter_placeholder', 'search_placeholder', 'dropdown_placeholder',
'filter_label', 'apply_button_text', 'remove_filters_button_text', 'search_placeholder_text',
];
// Protected fields that should NEVER be translated
$this->protected_fields = [
// Core Elementor structure
'_id', 'id', 'custom_id', 'css_id', 'css_classes', '_css_classes',
'widgetType', 'elType', 'isInner', 'elements', 'settings',
// Links and media
'url', 'link', 'video_link', 'custom_url', 'website_link', 'external_link',
'image', 'icon', 'selected_icon', 'media', 'video', 'gallery', 'background_image',
// Colors and styling
'color', 'background_color', 'border_color', 'text_color', 'hover_color',
'background_type', 'background_size', 'background_position', 'background_repeat',
// Layout and positioning
'width', 'height', 'min_height', 'max_width', 'position', 'z_index',
'margin', 'padding', 'border', 'border_radius', 'box_shadow',
'flex_direction', 'justify_content', 'align_items', 'gap',
'content_width', 'content_position', 'column_position',
// Animations and effects
'animation', 'hover_animation', '_animation', 'entrance_animation',
'motion_fx_motion_fx_scrolling', 'motion_fx_motion_fx_mouse',
// Typography settings (sizes, weights, etc - not text content)
'font_family', 'font_size', 'font_weight', 'text_transform', 'line_height',
'letter_spacing', 'text_decoration', 'font_style',
// Technical attributes
'data_settings', 'custom_css', 'html_tag', 'size', 'display',
'overflow', 'pointer_events', 'user_select',
// Widget-specific technical fields
'columns', 'slides_to_show', 'autoplay', 'infinite', 'speed',
'effect', 'direction', 'navigation', 'pagination', 'scrollbar',
// Form and input attributes
'name', 'value', 'placeholder_text', 'required', 'multiple',
'accept', 'capture', 'autocomplete', 'pattern', 'min', 'max', 'step',
// Advanced CSS selectors and custom code
'selector', 'custom_selector', 'css_filters', 'mix_blend_mode',
'render_type', 'prefix_class', 'hide_desktop', 'hide_tablet', 'hide_mobile',
// Additional Elementor layout fields
'stretch_section', 'boxed_width', 'content_width_tablet', 'content_width_mobile',
'gap', 'gap_tablet', 'gap_mobile', 'column_gap', 'column_gap_tablet', 'column_gap_mobile',
'structure', 'section_width', 'section_position', 'layout', 'content_layout',
// Elementor column/section specific
'_column_size', '_inline_size', 'space_between_widgets', 'content_position',
'content_width', 'element_width', 'element_position', 'custom_width',
'widget_width', 'widget_position', '_widget_width', '_element_width',
// Image and media specific
'image_size', 'image_width', 'image_height', 'object_fit', 'object_position',
'container_class', 'image_class', 'wrapper_class', 'css_class',
// Additional protection for containers and layout
'_width', '_height', '_max_width', '_max_height', '_min_width', '_min_height',
'section_width', 'container_width', 'content_width', 'full_width',
'boxed_width', 'content_width_tablet', 'content_width_mobile',
'column_width', 'widget_width', 'element_width', 'custom_width',
// CSS and styling that can affect layout
'css_before', 'css_after', 'css_selector', 'custom_css',
'css_filters', 'css_transform', '_css_classes', 'css_id',
'container_class', 'element_class', 'widget_class',
// Responsive settings
'_element_width_tablet', '_element_width_mobile',
'_element_custom_width_tablet', '_element_custom_width_mobile',
'hide_desktop', 'hide_tablet', 'hide_mobile',
// CRITICAL: Any field containing 'width', 'height', 'size'
// This is a catch-all protection
];
}
/**
* Parse Elementor data for translation
*
* @param array $elementor_data The Elementor data array
* @return array Parsed data with translation map
*/
public function parse_for_translation($elementor_data) {
$translation_map = [];
AITRFOEL_Logger::log('Starting to parse Elementor data. Root elements count: ' . count($elementor_data), 'debug');
// Parse each root element with index path
foreach ($elementor_data as $index => $element) {
$this->parse_element_recursive($element, $translation_map, [$index]);
}
AITRFOEL_Logger::log('Parsing complete. Found ' . count($translation_map) . ' translatable strings', 'info');
// Debug: Log first 3 translation paths
$sample_paths = array_slice(array_keys($translation_map), 0, 3);
foreach ($sample_paths as $path) {
$item = $translation_map[$path];
$original_display = is_array($item['original']) ? 'Array[' . count($item['original']) . ']' : substr((string)$item['original'], 0, 50) . '...';
AITRFOEL_Logger::log("Sample translation path: $path => " . $original_display, 'debug');
}
return $translation_map;
}
/**
* Parse individual element recursively with proper path tracking
*/
private function parse_element_recursive($element, &$translation_map, $path = []) {
if (!is_array($element)) {
return;
}
// Parse settings if they exist
if (isset($element['settings']) && is_array($element['settings'])) {
$this->parse_settings_recursive($element['settings'], $translation_map, array_merge($path, ['settings']));
}
// Parse child elements if they exist
if (isset($element['elements']) && is_array($element['elements'])) {
foreach ($element['elements'] as $child_index => $child_element) {
$this->parse_element_recursive($child_element, $translation_map, array_merge($path, ['elements', $child_index]));
}
}
}
/**
* Parse settings recursively with proper path tracking
*/
private function parse_settings_recursive($settings, &$translation_map, $path = []) {
foreach ($settings as $key => $value) {
// Handle common direct attributes at this settings level (title/alt/aria-label/placeholder)
if (is_string($value) && in_array($key, ['title','alt','aria-label','placeholder'], true)) {
if ($this->is_translatable_content($value)) {
$attr_path = implode('.', array_merge($path, [$key]));
$translation_map[$attr_path] = [
'original' => $value,
'display_text' => $this->clean_html_for_display($value),
'type' => 'attr',
'field_name' => $key,
'path' => $attr_path,
'path_array' => array_merge($path, [$key])
];
}
continue; // prevent re-processing by generic rules
}
// Special-case: image/media arrays may contain alt/title that should be translated safely
if (($key === 'image' || $key === 'media') && is_array($value)) {
$img_path = array_merge($path, [$key]);
$img_path_str = implode('.', $img_path);
foreach (['alt','title'] as $attr_name) {
if (!empty($value[$attr_name]) && is_string($value[$attr_name]) && $this->is_translatable_content($value[$attr_name])) {
$attr_path = $img_path_str . '.' . $attr_name;
$translation_map[$attr_path] = [
'original' => $value[$attr_name],
'display_text' => $this->clean_html_for_display($value[$attr_name]),
'type' => 'attr',
'field_name' => $attr_name,
'path' => $attr_path,
'path_array' => array_merge($img_path, [$attr_name])
];
}
}
// Do not recurse into image/media; skip url/id to avoid accidental processing
continue;
}
// Special-case: inspect 'link' for custom_attributes (aria-label) even if protected
if ($key === 'link' && is_array($value)) {
$current_path = array_merge($path, ['link']);
// Elementor stores custom attributes as a space-separated string
if (!empty($value['custom_attributes']) && is_string($value['custom_attributes'])) {
$attrs = $value['custom_attributes'];
if (preg_match('/(^|\s)aria-label\s*=\s*"([^"]*)"/u', $attrs, $m)) {
$attr_value = $m[2];
if ($this->is_translatable_content($attr_value)) {
$container_path = array_merge($current_path, ['custom_attributes']);
$container_path_string = implode('.', $container_path);
$path_string = $container_path_string . '#attr:aria-label';
$translation_map[$path_string] = [
'original' => $attr_value,
'display_text' => $this->clean_html_for_display($attr_value),
'type' => 'attr',
'field_name' => 'aria-label',
'path' => $path_string,
'attr_name' => 'aria-label',
'attr_container_path' => $container_path_string,
'path_array' => $container_path,
];
}
}
}
// Never process url/rel/target for translation
continue;
}
// Special-case: form fields repeater (common in Elementor forms)
if ($key === 'form_fields' && is_array($value)) {
foreach ($value as $idx => $ff) {
if (!is_array($ff)) continue;
foreach (['placeholder','field_label','title'] as $k) {
if (!empty($ff[$k]) && is_string($ff[$k]) && $this->is_translatable_content($ff[$k])) {
$p = array_merge($path, ['form_fields', $idx, $k]);
$ps = implode('.', $p);
$translation_map[$ps] = [
'original' => (string)$ff[$k],
'display_text' => $this->clean_html_for_display($ff[$k]),
'type' => 'attr',
'field_name' => $k,
'path' => $ps,
'path_array' => $p,
];
}
}
}
// continue to next setting key (do not run generic recursion here)
continue;
}
// Skip protected fields
if ($this->is_protected_field($key)) {
continue;
}
$current_path = array_merge($path, [$key]);
// ── Elementor v4 atomic typed-prop ──────────────────────────────
// Atomic widgets (e-heading, e-paragraph, etc.) store text in a
// typed-prop object: { "$$type": "html-v3", "value": [...] }
// The value array is either:
// - Delta format: [{"insert":"text …"}, {"insert":"\n"}, …]
// - Tree format: [{"type":"text","text":"…"}, …]
// We only process text-bearing $$types; layout/style types are skipped.
if (is_array($value)
&& isset($value['$$type'])
&& in_array($value['$$type'], ['html-v3', 'html', 'rich-text'], true)
&& isset($value['value'])
&& is_array($value['value'])
&& $this->is_translatable_field($key)
) {
$text = $this->extract_atomic_text($value['value']);
if ($text !== '' && $this->is_translatable_content($text)) {
$path_string = implode('.', $current_path);
AITRFOEL_Logger::log("Found atomic field '$key' ({$value['$$type']}) at path: $path_string", 'debug');
$translation_map[$path_string] = [
'original' => $text,
'display_text' => $this->clean_html_for_display($text),
'type' => 'atomic',
'field_name' => $key,
'path' => $path_string,
'path_array' => $current_path,
'atomic_type' => $value['$$type'],
'atomic_value' => $value['value'],
];
}
continue; // Do not recurse further into this typed prop.
}
if (is_string($value) && $this->is_translatable_field($key) && $this->is_translatable_content($value)) {
// Found translatable content
$path_string = implode('.', $current_path);
AITRFOEL_Logger::log("Found translatable field '$key' at path: $path_string", 'debug');
$translation_map[$path_string] = [
'original' => $value,
'display_text' => $this->clean_html_for_display($value),
'type' => 'field',
'field_name' => $key,
'path' => $path_string,
'path_array' => $current_path // Store as array for easier processing
];
} elseif (is_array($value)) {
// Recursively parse nested arrays (like repeater fields, tabs, etc.)
$this->parse_nested_structure($value, $translation_map, $current_path);
}
}
}
/**
* Parse nested structures like repeaters, tabs, accordions
*/
/**
* Extract plain text from an Elementor v4 html-v3 value array.
*
* Supports two known formats:
* - Delta: [ {"insert":"text"}, {"insert":"\n"}, … ]
* - Tree: [ {"type":"text","text":"…"}, {"type":"br"}, … ]
*
* Returns the concatenated text with newlines collapsed.
*/
private function extract_atomic_text(array $nodes): string {
$parts = [];
foreach ($nodes as $node) {
if (!is_array($node)) {
continue;
}
// Delta format
if (array_key_exists('insert', $node) && is_string($node['insert'])) {
$t = $node['insert'];
if ($t !== "\n" && trim($t) !== '') {
$parts[] = $t;
}
continue;
}
// Tree format — leaf text node
if (isset($node['type']) && $node['type'] === 'text' && isset($node['text']) && is_string($node['text'])) {
if (trim($node['text']) !== '') {
$parts[] = $node['text'];
}
continue;
}
// Tree format — recurse into children
if (!empty($node['children']) && is_array($node['children'])) {
$child_text = $this->extract_atomic_text($node['children']);
if ($child_text !== '') {
$parts[] = $child_text;
}
}
}
return implode(' ', $parts);
}
/**
* Rebuild an Elementor v4 html-v3 value array with translated text.
*
* Replaces all text-bearing nodes with the translated string (inserted as
* a single Delta text-run), preserving non-text nodes (br, newline).
* If the original format is unrecognised, returns the original unchanged.
*/
private function apply_atomic_text(array $nodes, string $translated): array {
// Detect format from first text-bearing node.
$is_delta = false;
foreach ($nodes as $node) {
if (is_array($node) && array_key_exists('insert', $node)) {
$is_delta = true;
break;
}
}
if ($is_delta) {
// Replace all string-insert nodes with translated text; keep structural "\n" nodes.
$result = [];
$injected = false;
foreach ($nodes as $node) {
if (!is_array($node)) {
$result[] = $node;
continue;
}
if (array_key_exists('insert', $node) && is_string($node['insert'])) {
if ($node['insert'] === "\n") {
$result[] = $node; // preserve paragraph-break markers
} elseif (!$injected) {
// Replace the first text run with the translation; drop the rest.
$result[] = array_merge($node, ['insert' => $translated]);
$injected = true;
}
// Subsequent non-newline text runs are dropped (they were part of same string).
continue;
}
$result[] = $node;
}
// If we never injected (nothing to replace), prepend a new text run.
if (!$injected) {
array_unshift($result, ['insert' => $translated]);
}
return $result;
}
// Tree format: replace text in leaf nodes recursively.
$first = true;
$replace_tree = function (array &$arr) use (&$replace_tree, $translated, &$first): void {
foreach ($arr as &$node) {
if (!is_array($node)) {
continue;
}
if (isset($node['type']) && $node['type'] === 'text' && isset($node['text'])) {
if ($first) {
$node['text'] = $translated;
$first = false;
} else {
$node['text'] = ''; // Blank out subsequent text runs.
}
continue;
}
if (!empty($node['children']) && is_array($node['children'])) {
$replace_tree($node['children']);
}
}
unset($node);
};
$result = $nodes;
$replace_tree($result);
return $result;
}
private function parse_nested_structure($array, &$translation_map, $path = []) {
foreach ($array as $key => $value) {
// Skip if key is protected
if (is_string($key) && $this->is_protected_field($key)) {
continue;
}
$current_path = array_merge($path, [$key]);
if (is_string($value) && is_string($key) && $this->is_translatable_field($key) && $this->is_translatable_content($value)) {
$path_string = implode('.', $current_path);
AITRFOEL_Logger::log("Found nested translatable field '$key' at path: $path_string", 'debug');
$translation_map[$path_string] = [
'original' => $value,
'display_text' => $this->clean_html_for_display($value),
'type' => 'nested_field',
'field_name' => $key,
'path' => $path_string,
'path_array' => $current_path
];
} elseif (is_array($value)) {
// Continue recursion
$this->parse_nested_structure($value, $translation_map, $current_path);
}
}
}
/**
* Apply translations back to Elementor data - FIXED VERSION
*/
public function apply_translations($elementor_data, $translations) {
if (empty($translations)) {
AITRFOEL_Logger::log('No translations to apply', 'warning');
return $elementor_data;
}
// Create a deep copy to avoid modifying the original
$translated_data = json_decode(json_encode($elementor_data), true);
$success_count = 0;
$fail_count = 0;
$total_count = count($translations);
AITRFOEL_Logger::log("Starting to apply $total_count translations", 'info');
foreach ($translations as $path => $translated_text) {
// Skip empty translations
if (empty($translated_text)) {
AITRFOEL_Logger::log("Skipping empty translation for path: $path", 'debug');
$fail_count++;
continue;
}
// Handle attribute updates marked with #attr:
tags are sent raw to the AI which drops them,
// causing "Satisfait ou
remboursé" → "Satisfaction orrefund" (no line break, words merged).
$inline_tags = ['u','small','sup','sub','mark','code','kbd','br'];
$map = [];
$counter = 0;
// Load as HTML fragment
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->encoding = 'UTF-8';
libxml_use_internal_errors(true);
$wrapped = '