batch_size = get_option('aitrfoel_batch_size', 20); $this->cache_enabled = (bool) get_option('aitrfoel_cache_enabled', true); } /** * Count words in text using Unicode-aware regex. * Unlike PHP's str_word_count(), this correctly handles apostrophes * inside words (e.g. French "l'eau", "d'une" count as 1 word each). * * @param string $text Plain text (should be HTML-stripped before calling) * @return int Word count */ public static function count_words($text) { if (empty($text)) { return 0; } $count = preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\x{2019}\x{2018}\'-]*/u', $text, $matches); return $count ?: 0; } /** * Main translation function * Supports Elementor, Gutenberg, and Classic Editor content * * @param int $page_id Original page ID * @param string $target_lang Target language * @param string $source_lang Source language * @param array $strings_to_translate Selected strings for translation (optional) * @return int|WP_Error New page ID or error */ public function translate_page($page_id, $target_lang, $source_lang = 'auto', $strings_to_translate = []) { // Track translation start time for analytics $translation_start_time = microtime(true); // Detect content type $content_detector = AITRFOEL_Content_Detector::get_instance(); $content_type = $content_detector->detect($page_id); AITRFOEL_Logger::log( sprintf('Content type detected: %s for page %d', $content_type, $page_id), 'info', $page_id ); // WooCommerce structural pages (cart / checkout / my-account / terms) hold only a // block/shortcode, so there is nothing to translate in the body — their UI strings // come from WooCommerce core translations via locale switching. We still want a // linked, published shell so the page registers in the admin, routes under the // language prefix, and the wc page-id option filters can swap to it. $is_wc_structural = $this->is_wc_structural_page($page_id); // Contact Form 7 specific translation path if (get_post_type($page_id) === 'wpcf7_contact_form') { return $this->translate_contact_form_7($page_id, $target_lang, $source_lang, $translation_start_time); } // Route to appropriate translation method based on content type $result = null; switch ($content_type) { case AITRFOEL_Content_Detector::TYPE_ELEMENTOR: $result = $this->translate_elementor_page($page_id, $target_lang, $source_lang, $strings_to_translate, $translation_start_time); break; case AITRFOEL_Content_Detector::TYPE_GUTENBERG: $result = $this->translate_gutenberg_page($page_id, $target_lang, $source_lang, $translation_start_time); break; case AITRFOEL_Content_Detector::TYPE_CLASSIC: $result = $this->translate_classic_page($page_id, $target_lang, $source_lang, $translation_start_time); break; case AITRFOEL_Content_Detector::TYPE_WOOCOMMERCE: $result = $this->translate_woocommerce_product($page_id, $target_lang, $source_lang, $translation_start_time); break; default: if ($is_wc_structural) { return $this->translate_wc_structural_page($page_id, $target_lang, $source_lang, $translation_start_time); } $this->track_translation_failure('unsupported_content', 'Unsupported content type', $translation_start_time); return new WP_Error( 'unsupported_content', __('This page does not contain any translatable content. Please ensure the page has content created with Elementor, Gutenberg (Block Editor), or Classic Editor.', 'ai-translator-for-elementor-and-polylang') ); } // For WooCommerce structural pages, if normal translation found nothing to translate, // still create the linked shell so the page registers and routes (UI via locale switch). if ($is_wc_structural && is_wp_error($result) && in_array($result->get_error_code(), ['no_translatable_content', 'no_content'], true)) { AITRFOEL_Logger::log( sprintf('WooCommerce structural page %d has no translatable body; creating linked shell.', $page_id), 'info', $page_id ); return $this->translate_wc_structural_page($page_id, $target_lang, $source_lang, $translation_start_time); } // After a page is translated, optionally translate the Elementor templates it // relies on (embedded [elementor-template] / Template widget, plus theme-builder // singles/headers/footers) into the same language, so non-technical users don't // have to know a page pulls in an extra template. Opt-in; uses extra words. if (!is_wp_error($result) && get_post_type($page_id) !== 'elementor_library') { $this->maybe_auto_translate_templates($page_id, $target_lang, $source_lang); } return $result; } /** * If auto-translate-templates is enabled, translate every untranslated Elementor * template into $target_lang (covers embeds and theme-builder templates). Guarded * against re-entry so translating a template never recurses. * * @param int $page_id The page that was just translated (for logging). * @param string $target_lang * @param string $source_lang */ private function maybe_auto_translate_templates($page_id, $target_lang, $source_lang) { static $running = false; if ($running) { return; } if (!get_option('aitrfoel_auto_translate_templates', false)) { return; } if (!post_type_exists('elementor_library')) { return; } $template_ids = $this->get_untranslated_template_ids($target_lang); if (empty($template_ids)) { return; } $running = true; $done = 0; foreach ($template_ids as $tid) { try { $res = $this->translate_page((int) $tid, $target_lang, $source_lang); if (!is_wp_error($res)) { $done++; } } catch (\Throwable $e) { if (class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log('Auto template translation failed for #' . $tid . ': ' . $e->getMessage(), 'warning', $tid); } } } $running = false; if ($done > 0 && class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log( sprintf('Auto-translated %d Elementor template(s) to %s after translating page %d', $done, $target_lang, $page_id), 'success', $page_id ); } } /** * IDs of published default-language Elementor templates that have no translation in * $target_lang yet. Excludes templates that are themselves translations. * * @param string $target_lang * @return int[] */ public function get_untranslated_template_ids($target_lang) { // Elementor templates, plus JetEngine listing items (post type 'jet-engine') // which are Elementor-built templates carrying static text (labels) that need // translating too. Extensible via filter for other builder template types. $template_types = apply_filters('aitrfoel_template_post_types', ['elementor_library', 'jet-engine', 'jet-theme-core', 'elementor-hf']); $template_types = array_values(array_filter((array) $template_types, 'post_type_exists')); if (empty($template_types)) { return []; } $mm = class_exists('AITRFOEL_Multilingual_Manager') ? AITRFOEL_Multilingual_Manager::get_instance() : null; $all = get_posts([ 'post_type' => $template_types, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, ]); $ids = []; foreach ($all as $tid) { $tid = (int) $tid; // Skip templates that are themselves a translation. $src = (int) get_post_meta($tid, '_aitrfoel_source_page', true); if ($src && $src !== $tid) { continue; } // Skip if a translation already exists in the target language. if ($mm && method_exists($mm, 'get_post_translations')) { $tr = $mm->get_post_translations($tid); if (is_array($tr) && isset($tr[$target_lang])) { continue; } } $ids[] = $tid; } return $ids; } /** * Approximate translatable word count across all untranslated templates for a * language — used to warn the user before a bulk template translation. * * @param string $target_lang * @return array{count:int,words:int} */ /** * Find every Elementor template that is actually referenced (embedded) somewhere, * so templates without display conditions but used via the Template/Loop widgets * or an [elementor-template] shortcode are recognised as "used" rather than * buried as optional. Scans _elementor_data and post_content; cached briefly. * * @return int[] Referenced template IDs. */ public function get_referenced_template_ids() { $cached = get_transient('aitrfoel_referenced_template_ids'); if (is_array($cached)) { return $cached; } global $wpdb; $ids = []; // Elementor widgets that embed a template store "template_id":"123" (Template // widget, Loop Grid, Theme Builder, Posts/Portfolio with a skin, etc.). $data_rows = $wpdb->get_col( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_elementor_data' AND (meta_value LIKE '%template_id%' OR meta_value LIKE '%elementor-template%')" ); foreach ((array) $data_rows as $data) { if (preg_match_all('/"(?:template_id|selected_template|listing_id)"\s*:\s*"?(\d+)"?/', (string) $data, $m)) { foreach ($m[1] as $id) { $ids[(int) $id] = true; } } if (preg_match_all('/elementor-template\s+id=\\\\?["\']?(\d+)/', (string) $data, $m2)) { foreach ($m2[1] as $id) { $ids[(int) $id] = true; } } } // [elementor-template id="123"] shortcodes embedded in post content. $content_rows = $wpdb->get_col( "SELECT post_content FROM {$wpdb->posts} WHERE post_content LIKE '%elementor-template%' AND post_status NOT IN ('trash','auto-draft')" ); foreach ((array) $content_rows as $content) { if (preg_match_all('/elementor-template\s+id=["\']?(\d+)/', (string) $content, $m)) { foreach ($m[1] as $id) { $ids[(int) $id] = true; } } } $result = array_values(array_map('intval', array_keys($ids))); set_transient('aitrfoel_referenced_template_ids', $result, 5 * MINUTE_IN_SECONDS); return $result; } public function get_untranslated_templates_summary($target_lang) { $ids = $this->get_untranslated_template_ids($target_lang); $words = 0; foreach ($ids as $tid) { $raw = get_post_meta($tid, '_elementor_data', true); if (!empty($raw)) { $words += self::count_words(wp_strip_all_tags((string) $raw)); } } return ['count' => count($ids), 'words' => $words]; } /** * Whether the page is a WooCommerce structural page (cart / checkout / my-account / terms). * The Shop page is intentionally excluded — it has its own handling (classic shell + the * product-archive query conversion). * * @param int $page_id * @return bool */ private function is_wc_structural_page($page_id) { if (!function_exists('wc_get_page_id')) { return false; } $page_id = (int) $page_id; foreach (['cart', 'checkout', 'myaccount', 'terms'] as $key) { $pid = (int) wc_get_page_id($key); if ($pid > 0 && $pid === $page_id) { return true; } } return false; } /** * Create a linked, published shell translation for a WooCommerce structural page. * The body (a WooCommerce block/shortcode) is copied as-is; only the title is translated. * The visible UI strings are rendered by WooCommerce core translations via locale switching. * * @param int $page_id * @param string $target_lang * @param string $source_lang * @param float $translation_start_time * @return int|WP_Error new page ID or error */ private function translate_wc_structural_page($page_id, $target_lang, $source_lang, $translation_start_time) { $post = get_post($page_id); if (!$post) { $this->track_translation_failure('no_content', 'WooCommerce structural page not found', $translation_start_time); return new WP_Error('no_content', __('Page not found.', 'ai-translator-for-elementor-and-polylang')); } // Translate just the title; copy the block/shortcode body unchanged. $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = $api->translate_title($post->post_title, $target_lang, $source_lang); if (is_wp_error($translated_title) || empty($translated_title)) { $translated_title = null; // create_translated_page_generic falls back to "Title (LANG)" } $detected_lang = ($source_lang === 'auto') ? 'en' : $source_lang; $new_page_id = $this->create_translated_page_generic( $page_id, (string) $post->post_content, $target_lang, $detected_lang, 'woocommerce', $translated_title ); if (is_wp_error($new_page_id)) { return $new_page_id; } // Structural WooCommerce pages must be published: the pre_option page-id filters and // routing only swap to a *published* translation. A draft would silently break the // localized cart/checkout/account routing. if (get_post_status($new_page_id) !== 'publish') { wp_update_post(['ID' => $new_page_id, 'post_status' => 'publish']); clean_post_cache($new_page_id); } AITRFOEL_Logger::log( sprintf('WooCommerce structural page %d → %d (%s): linked shell created; UI strings via locale switching.', $page_id, $new_page_id, $target_lang), 'success', $page_id, $new_page_id ); if (class_exists('AITRFOEL_Translation_History')) { AITRFOEL_Translation_History::record([ 'original_post_id' => $page_id, 'translated_post_id' => $new_page_id, 'source_lang' => $detected_lang, 'target_lang' => $target_lang, 'content_type' => 'woocommerce', 'word_count' => 0, 'status' => 'success', 'duration' => round(microtime(true) - $translation_start_time, 2), ]); } return $new_page_id; } // ========================================================================= // ASYNC TRANSLATION (submit → poll, no blocking HTTP in the AJAX handler) // ========================================================================= /** * Async Phase 1: Parse page content, check word limit, submit uncached strings * to the proxy async endpoint. Returns immediately (< 2 s). * * @param int $page_id Original page ID * @param string $target_lang Target language code * @param string $source_lang Source language code or 'auto' * @return string|WP_Error * proxy_job_id string — job enqueued on proxy; call poll_page_async() to check * 'sync_required' — Elementor content; caller must use synchronous path * 'cache_hit:{key}' — all strings cached; page created; retrieve ID via get_transient($key) * WP_Error — on failure */ public function submit_page_async($page_id, $target_lang, $source_lang = 'auto') { $content_type = AITRFOEL_Content_Detector::get_instance()->detect($page_id); // Elementor has complex inline placeholder handling. It stays on the proven sync // path by default; the async (submit→poll) path is opt-in via a setting and gated // here so existing behavior is unchanged unless explicitly enabled. if ($content_type === AITRFOEL_Content_Detector::TYPE_ELEMENTOR) { if (!get_option('aitrfoel_elementor_async_enabled', false)) { return 'sync_required'; } $submitted = $this->submit_elementor_async($page_id, $target_lang, $source_lang); // On any failure, fall back to the synchronous path rather than erroring out. if (is_wp_error($submitted)) { AITRFOEL_Logger::log('Elementor async submit failed, falling back to sync: ' . $submitted->get_error_message(), 'warning', $page_id); return 'sync_required'; } return $submitted; } $parse = $this->parse_page_content_for_async($page_id, $content_type, $source_lang); if (is_wp_error($parse)) { return $parse; } $strings = $parse['strings']; $source = $parse['detected_lang']; $extra = $parse['extra']; // Separate cache hits from strings that must go to the proxy $cache = AITRFOEL_Cache_Manager::get_instance(); $cached_map = []; $to_send = []; foreach ($strings as $s) { $orig = $s['original'] ?? ''; if (empty(trim($orig))) { continue; } $hit = $this->cache_enabled ? $cache->get_from_cache($orig, $target_lang, $source) : null; if ($hit) { $cached_map[$orig] = $hit; } else { $to_send[] = $orig; } } // Check word limit only against uncached strings — already-cached content is free. // This allows retrying a partially-translated page even when the budget is tight. if (!empty($to_send)) { $to_send_for_check = array_map(static function($s) { return ['original' => $s]; }, $to_send); $check = $this->check_word_limit($to_send_for_check, $page_id); if (is_wp_error($check)) { return $check; } } $context = [ 'page_id' => $page_id, 'target_lang' => $target_lang, 'source_lang' => $source, 'content_type' => $content_type, 'strings' => $strings, 'extra' => $extra, 'cached_map' => $cached_map, 'to_send' => $to_send, 'start_time' => microtime(true), ]; if (empty($to_send)) { // All strings were already cached — apply immediately, no async round-trip needed $new_page_id = $this->apply_async_page($context, []); if (is_wp_error($new_page_id)) { return $new_page_id; } $key = 'aitrfoel_ah_' . $page_id . '_' . md5($target_lang); set_transient($key, $new_page_id, 10 * MINUTE_IN_SECONDS); return 'cache_hit:' . $key; } $api = AITRFOEL_OpenAI_API::get_instance(); $proxy_job_id = $api->submit_batch_async($to_send, $target_lang, $source); if (is_wp_error($proxy_job_id)) { return $proxy_job_id; } set_transient('aitrfoel_async_' . $proxy_job_id, $context, HOUR_IN_SECONDS); AITRFOEL_Logger::log( sprintf('Async page submit: page=%d lang=%s job=%s uncached=%d cached=%d', $page_id, $target_lang, $proxy_job_id, count($to_send), count($cached_map)), 'info', $page_id ); return $proxy_job_id; } /** * Async Phase 2: Check proxy job status; apply translations and create page when done. * * @param string $proxy_job_id Job ID returned by submit_page_async() * @return string|int|WP_Error * 'pending' — job still running; call again in a few seconds * int — new_page_id on success * WP_Error — on failure */ public function poll_page_async($proxy_job_id) { // Elementor async jobs use a multi-job manifest stored in the context transient; // route them to the dedicated poller before the single-job logic below. $maybe_ctx = get_transient('aitrfoel_async_' . $proxy_job_id); if (is_array($maybe_ctx) && ($maybe_ctx['content_type'] ?? '') === AITRFOEL_Content_Detector::TYPE_ELEMENTOR && !empty($maybe_ctx['jobs'])) { return $this->poll_elementor_async($proxy_job_id, $maybe_ctx); } $api = AITRFOEL_OpenAI_API::get_instance(); $status = $api->get_async_job_status($proxy_job_id); if (is_wp_error($status)) { return $status; } if (in_array($status['status'], ['pending', 'processing'], true)) { return 'pending'; } if ($status['status'] === 'error') { return new WP_Error( 'proxy_job_error', $status['error'] ?? __('Translation job failed on server.', 'ai-translator-for-elementor-and-polylang') ); } // status === 'done' $context = get_transient('aitrfoel_async_' . $proxy_job_id); if (!$context) { return new WP_Error( 'context_expired', __('Translation context expired. Please retry the translation.', 'ai-translator-for-elementor-and-polylang') ); } // Proxy returns translations as an array ordered the same as $context['to_send'] $proxy_translations = $status['translations'] ?? []; $full_map = $context['cached_map']; foreach ($context['to_send'] as $i => $orig) { if (isset($proxy_translations[$i])) { $translated = method_exists($api, 'sanitize_chatty_response') ? $api->sanitize_chatty_response($orig, $proxy_translations[$i]) : $proxy_translations[$i]; $full_map[$orig] = $translated; } } $new_page_id = $this->apply_async_page($context, $full_map); if (!is_wp_error($new_page_id)) { delete_transient('aitrfoel_async_' . $proxy_job_id); // Cache freshly translated strings for future re-use if ($this->cache_enabled) { $cache = AITRFOEL_Cache_Manager::get_instance(); foreach ($context['to_send'] as $i => $orig) { if (!empty($full_map[$orig])) { $cache->set_to_cache($orig, $full_map[$orig], $context['target_lang'], $context['source_lang'], 'gpt-4o-mini'); } } } } return $new_page_id; } /** * Elementor async Phase 1: parse the page, extract placeholders, chunk large * fields, and submit all prepared pieces to the proxy async endpoint as one or * more jobs (<=90 texts each, under the server's 100-text cap). A manifest mapping * every submitted text back to its Elementor path + chunk position is stored in a * context transient keyed by the first job id (the returned poll handle). * * @return string|WP_Error first job id (poll handle), or WP_Error to signal the * caller to fall back to the synchronous path. */ private function submit_elementor_async($page_id, $target_lang, $source_lang = 'auto') { $raw = get_post_meta($page_id, '_elementor_data', true); if (empty($raw)) { return new WP_Error('no_elementor_data', __('No Elementor data found for this page.', 'ai-translator-for-elementor-and-polylang')); } $data = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return new WP_Error('json_decode_error', __('Failed to decode Elementor data.', 'ai-translator-for-elementor-and-polylang')); } $parser = AITRFOEL_Widget_Parser::get_instance(); $map = $parser->parse_for_translation($data); $strings = array_values($map); if (empty($strings)) { return new WP_Error('no_translatable_content', __('No translatable content found in this page.', 'ai-translator-for-elementor-and-polylang')); } // Resolve a source language for caching/locale (the server still auto-detects). $detected_lang = $source_lang; if ($source_lang === 'auto') { $cached_lang = get_transient('aitrfoel_detected_lang_' . $page_id . '_' . $target_lang); $detected_lang = $cached_lang ?: 'auto'; } // Build ordered units: one prepared (placeholder-extracted) text piece per chunk. $max = AITRFOEL_OpenAI_API::LARGE_TEXT_THRESHOLD; $units = []; // [ ['path'=>, 'chunk_index'=>, 'n_chunks'=>, 'prepared'=>], ... ] $ph_maps = []; // path => placeholder map $originals = []; // path => original (for link restore) $prepared_full = []; // path => full prepared field (cache key, matches sync path) foreach ($strings as $row) { if (!is_array($row)) continue; $path = (string)($row['path'] ?? ''); $orig = (string)($row['original'] ?? ''); if ($path === '' || trim($orig) === '') continue; $prepared = $orig; $ph = []; if (method_exists($parser, 'i18n_extract_with_placeholders')) { list($prepared, $ph) = $parser->i18n_extract_with_placeholders($orig); } $ph_maps[$path] = is_array($ph) ? $ph : []; $originals[$path] = $orig; $prepared_full[$path] = $prepared; $pieces = (strlen($prepared) > $max) ? AITRFOEL_OpenAI_API::split_html_into_chunks($prepared, $max) : [$prepared]; if (empty($pieces)) { $pieces = [$prepared]; } $n = count($pieces); foreach ($pieces as $ci => $piece) { $units[] = ['path' => $path, 'chunk_index' => $ci, 'n_chunks' => $n, 'prepared' => $piece]; } } if (empty($units)) { return new WP_Error('no_translatable_content', __('No translatable content found in this page.', 'ai-translator-for-elementor-and-polylang')); } $api = AITRFOEL_OpenAI_API::get_instance(); // Submit in jobs of <=90 texts (server caps at 100). Global unit index == send index. $JOB_MAX = 90; $jobs = []; // ['job_id'=>, 'offset'=>, 'count'=>] $total = count($units); for ($offset = 0; $offset < $total; $offset += $JOB_MAX) { $slice = array_slice($units, $offset, $JOB_MAX); $texts = array_map(static function ($u) { return $u['prepared']; }, $slice); $job_id = $api->submit_batch_async($texts, $target_lang, $source_lang); if (is_wp_error($job_id)) { return $job_id; // caller falls back to sync } $jobs[] = ['job_id' => $job_id, 'offset' => $offset, 'count' => count($texts)]; } $handle = $jobs[0]['job_id']; $context = [ 'content_type' => AITRFOEL_Content_Detector::TYPE_ELEMENTOR, 'page_id' => $page_id, 'target_lang' => $target_lang, 'source_lang' => $source_lang, 'detected_lang' => $detected_lang, 'units' => $units, 'ph_maps' => $ph_maps, 'originals' => $originals, 'prepared_full' => $prepared_full, 'jobs' => $jobs, 'start_time' => microtime(true), ]; set_transient('aitrfoel_async_' . $handle, $context, HOUR_IN_SECONDS); AITRFOEL_Logger::log( sprintf('Elementor async submit: page=%d lang=%s handle=%s units=%d jobs=%d', $page_id, $target_lang, $handle, $total, count($jobs)), 'info', $page_id ); return $handle; } /** * Elementor async Phase 2: poll every job; once all are done, reassemble chunks * per path, restore placeholders, run the (bounded) gap-filler, apply to the * Elementor data, and save the translated page. * * @return string|int|WP_Error 'pending' | new_page_id | WP_Error */ private function poll_elementor_async($handle, $context) { $api = AITRFOEL_OpenAI_API::get_instance(); // Lay every job's translations into a global, send-ordered results array. $resolved = []; // global unit index => translated piece foreach ($context['jobs'] as $job) { $status = $api->get_async_job_status($job['job_id']); if (is_wp_error($status)) { return $status; } $st = $status['status'] ?? 'pending'; if (in_array($st, ['pending', 'processing'], true)) { return 'pending'; } if ($st === 'error') { return new WP_Error('proxy_job_error', $status['error'] ?? __('Translation job failed on server.', 'ai-translator-for-elementor-and-polylang')); } $job_translations = $status['translations'] ?? []; $offset = (int) $job['offset']; foreach ($job_translations as $k => $tr) { $resolved[$offset + $k] = $tr; } } $parser = AITRFOEL_Widget_Parser::get_instance(); $units = $context['units']; $ph_maps = $context['ph_maps']; $originals = $context['originals']; $prepared_full = $context['prepared_full'] ?? []; $target_lang = $context['target_lang']; $detected_lang = $context['detected_lang']; // Reassemble translated pieces per path (ordered by chunk_index via unit order). $assembled = []; // path => reassembled pre-restore translated text foreach ($units as $i => $u) { $path = $u['path']; $piece_tr = isset($resolved[$i]) ? (string) $resolved[$i] : $u['prepared']; // fallback: keep source piece $assembled[$path] = ($assembled[$path] ?? '') . $piece_tr; } // Restore placeholders + cleanup per path → final translation map (by path). $translations = []; foreach ($assembled as $path => $tr_prepared) { $final = $tr_prepared; $pmap = $ph_maps[$path] ?? []; if (!empty($pmap) && method_exists($parser, 'i18n_restore_placeholders')) { $final = $parser->i18n_restore_placeholders($final, $pmap); } if (method_exists($parser, 'i18n_cleanup_artifacts')) { $final = $parser->i18n_cleanup_artifacts($final); } if (method_exists($parser, 'restore_lost_links') && isset($originals[$path])) { $final = $parser->restore_lost_links($final, $originals[$path]); } $translations[$path] = $final; } // Gap-filler second pass (now bounded/fast) to fix missed/under-translated strings. if (class_exists('AITRFOEL_Gap_Filler')) { $gf_strings = []; foreach ($originals as $path => $orig) { $gf_strings[] = ['path' => $path, 'original' => $orig, 'type' => 'field']; } $opts = [ 'levenshtein_threshold' => (int) get_option('aitrfoel_gap_lev_threshold', 3), 'residue_patterns' => (string) get_option('aitrfoel_gap_residue_patterns', '/\\b(Read more|Číst dál|Читать далее)\\b/ui'), 'max_retry' => (int) get_option('aitrfoel_gap_max_retry', 1), 'enable_postprocessor' => (bool) get_option('aitrfoel_gap_enable_postprocessor', true), 'page_id' => $context['page_id'], ]; AITRFOEL_Gap_Filler::run($gf_strings, $translations, $target_lang, $detected_lang, $opts); } // Apply to a fresh copy of the Elementor data and save. $raw = get_post_meta($context['page_id'], '_elementor_data', true); $data = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return new WP_Error('json_decode_error', __('Failed to decode Elementor data.', 'ai-translator-for-elementor-and-polylang')); } $translated_data = $parser->apply_translations($data, $translations); // Deep cleanup of any escaped tags / orphan placeholders left in string fields. if (method_exists($parser, 'i18n_cleanup_artifacts')) { $walker = function (&$node) use (&$walker, $parser) { if (is_string($node)) { $node = $parser->i18n_cleanup_artifacts($node); return; } if (is_array($node)) { foreach ($node as &$v) { $walker($v); } unset($v); } }; $walker($translated_data); } if (!is_array($translated_data) || (method_exists($parser, 'validate_structure') && !$parser->validate_structure($translated_data))) { return new WP_Error('invalid_structure', __('Translation resulted in invalid Elementor structure.', 'ai-translator-for-elementor-and-polylang')); } // Same post-apply transforms as the sync path: link localization + menu/template swaps. try { $this->localize_internal_links($translated_data, $target_lang); } catch (Exception $e) { /* non-fatal */ } $menu_id_map = get_option('aitrfoel_menu_id_map', []); if (!empty($menu_id_map)) { $this->swap_menu_ids_in_elementor_data($translated_data, $menu_id_map, $target_lang); } if (method_exists($this, 'swap_elementor_template_refs_in_data')) { $this->swap_elementor_template_refs_in_data($translated_data, $target_lang); } if (method_exists($this, 'swap_form_refs_in_data')) { $this->swap_form_refs_in_data($translated_data, $target_lang); } // Translate the post title (single quick call). $orig_post = get_post($context['page_id']); $pre_title = null; if ($orig_post && !empty($orig_post->post_title)) { $t = $api->translate_title($orig_post->post_title, $target_lang, $context['source_lang']); if (!is_wp_error($t) && !empty($t)) { $pre_title = $t; } } $new_page_id = $this->create_translated_page($context['page_id'], $translated_data, $target_lang, $detected_lang, $pre_title, false); if (is_wp_error($new_page_id)) { return $new_page_id; } // Persist cache for future reuse (full prepared field => reassembled pre-restore // translated), matching the key/value scheme the sync large-text path uses. if ($this->cache_enabled) { $cache = AITRFOEL_Cache_Manager::get_instance(); $model = get_option('aitrfoel_openai_model', 'gpt-4o-mini'); foreach ($prepared_full as $path => $src_prep) { if (!empty($assembled[$path]) && !empty($src_prep)) { $cache->set_to_cache($src_prep, $assembled[$path], $target_lang, $detected_lang, $model); } } } if (class_exists('AITRFOEL_Translation_History')) { AITRFOEL_Translation_History::record([ 'original_post_id' => $context['page_id'], 'translated_post_id' => $new_page_id, 'source_lang' => $detected_lang, 'target_lang' => $target_lang, 'content_type' => 'elementor', 'word_count' => 0, 'status' => 'success', 'duration' => round(microtime(true) - ($context['start_time'] ?? microtime(true)), 2), ]); } delete_transient('aitrfoel_async_' . $handle); AITRFOEL_License_Manager_Server::get_instance()->clear_status_cache(); // Increment global successful translations counter for milestone review prompts $rev_count = (int) get_option('aitrfoel_successful_translations_count', 0); update_option('aitrfoel_successful_translations_count', $rev_count + 1, false); AITRFOEL_Logger::log( sprintf('Elementor async complete: page=%d → new_page=%d lang=%s', $context['page_id'], $new_page_id, $target_lang), 'success', $context['page_id'], $new_page_id ); return $new_page_id; } /** * Parse a non-Elementor page into a strings array for async submission. * Handles Classic Editor, Gutenberg, and WooCommerce content types. * * @param int $page_id Page ID * @param string $content_type One of AITRFOEL_Content_Detector::TYPE_* constants * @param string $source_lang Source language code or 'auto' * @return array|WP_Error {strings, detected_lang, extra} */ private function parse_page_content_for_async($page_id, $content_type, $source_lang) { switch ($content_type) { case AITRFOEL_Content_Detector::TYPE_CLASSIC: { $post = get_post($page_id); if (!$post || (empty($post->post_content) && empty(trim($post->post_title ?? '')))) { return new WP_Error('no_content', __('No content found in this page.', 'ai-translator-for-elementor-and-polylang')); } $parser = AITRFOEL_Classic_Parser::get_instance(); $map = !empty($post->post_content) ? $parser->parse_for_translation($page_id) : []; if (empty($map) && empty(trim($post->post_title ?? ''))) { return new WP_Error('no_translatable_content', __('No translatable content found in this page.', 'ai-translator-for-elementor-and-polylang')); } $strings = array_values($map); $title_in_content = false; foreach ($strings as $s) { if (trim(wp_strip_all_tags($s['original'] ?? '')) === trim($post->post_title ?? '')) { $title_in_content = true; break; } } if (!empty($post->post_title) && !$title_in_content) { $strings[] = ['original' => $post->post_title, 'path' => '__aitrfoel_post_title__', 'type' => 'title', 'widget_type' => 'classic_editor']; } $detected = $this->detect_source_language_from_content($strings, $source_lang, $page_id); return [ 'strings' => $strings, 'detected_lang'=> $detected, 'extra' => [ 'post_title' => $post->post_title, 'post_content' => $post->post_content ?? '', 'title_in_content' => $title_in_content, ], ]; } case AITRFOEL_Content_Detector::TYPE_GUTENBERG: { $post = get_post($page_id); if (!$post || (empty($post->post_content) && empty(trim($post->post_title ?? '')))) { return new WP_Error('no_content', __('No content found in this page.', 'ai-translator-for-elementor-and-polylang')); } $parser = AITRFOEL_Gutenberg_Parser::get_instance(); $map = !empty($post->post_content) ? $parser->parse_for_translation($page_id) : []; if (empty($map) && empty(trim($post->post_title ?? ''))) { return new WP_Error('no_translatable_content', __('No translatable content found in this page.', 'ai-translator-for-elementor-and-polylang')); } $strings = array_values($map); $title_in_content = false; foreach ($strings as $s) { if (trim(wp_strip_all_tags($s['original'] ?? '')) === trim($post->post_title ?? '')) { $title_in_content = true; break; } } if (!empty($post->post_title) && !$title_in_content) { $strings[] = ['original' => $post->post_title, 'path' => '__aitrfoel_post_title__', 'type' => 'title', 'widget_type' => 'gutenberg']; } $detected = $this->detect_source_language_from_content($strings, $source_lang, $page_id); return [ 'strings' => $strings, 'detected_lang'=> $detected, 'extra' => [ 'post_title' => $post->post_title, 'post_content' => $post->post_content ?? '', 'title_in_content' => $title_in_content, ], ]; } case AITRFOEL_Content_Detector::TYPE_WOOCOMMERCE: { $post = get_post($page_id); if (!$post) { return new WP_Error('no_content', __('Product not found.', 'ai-translator-for-elementor-and-polylang')); } $texts = []; $text_keys = []; if (!empty($post->post_title)) { $texts[] = $post->post_title; $text_keys[] = 'title'; } if (!empty(trim($post->post_excerpt))) { $texts[] = $post->post_excerpt; $text_keys[] = 'excerpt'; } if (!empty(trim($post->post_content))) { $texts[] = $post->post_content; $text_keys[] = 'content'; } if (empty($texts)) { return new WP_Error('no_translatable_content', __('This product has no translatable content.', 'ai-translator-for-elementor-and-polylang')); } $strings = []; foreach ($texts as $i => $t) { $strings[] = ['original' => $t, 'path' => $text_keys[$i], 'type' => 'text', 'widget_type' => 'woocommerce_product']; } $detected = $this->detect_source_language_from_content($strings, $source_lang, $page_id); return [ 'strings' => $strings, 'detected_lang'=> $detected, 'extra' => ['texts' => $texts, 'text_keys' => $text_keys], ]; } default: return new WP_Error('unsupported_content', __('Content type does not support async translation.', 'ai-translator-for-elementor-and-polylang')); } } /** * Apply a complete original→translated map to a page and create the translated copy. * Called by poll_page_async() once all translations are available (and for the all-cached case). * * @param array $context Context array stored by submit_page_async() * @param array $translations_map Additional original→translated pairs (from proxy result) * @return int|WP_Error new_page_id or WP_Error */ private function apply_async_page($context, $translations_map) { $page_id = $context['page_id']; $target_lang = $context['target_lang']; $source_lang = $context['source_lang']; $content_type = $context['content_type']; $extra = $context['extra']; $start_time = $context['start_time'] ?? microtime(true); $strings = $context['strings']; // Merge cached hits + proxy results into one complete map $full_map = array_merge($context['cached_map'] ?? [], $translations_map); switch ($content_type) { case AITRFOEL_Content_Detector::TYPE_CLASSIC: { $parser = AITRFOEL_Classic_Parser::get_instance(); $translated_content = $parser->apply_translations($extra['post_content'], $full_map); $translated_title = null; if (!$extra['title_in_content'] && isset($full_map[$extra['post_title']])) { $translated_title = $full_map[$extra['post_title']]; } elseif ($extra['title_in_content']) { foreach ($strings as $s) { if (trim(wp_strip_all_tags($s['original'] ?? '')) === trim($extra['post_title']) && isset($full_map[$s['original']])) { $translated_title = wp_strip_all_tags($full_map[$s['original']]); break; } } } $new_page_id = $this->create_translated_page_generic($page_id, $translated_content, $target_lang, $source_lang, 'classic', $translated_title); if (!is_wp_error($new_page_id)) { $this->track_translation_success($page_id, $new_page_id, $target_lang, $source_lang, $start_time, 'classic'); } return $new_page_id; } case AITRFOEL_Content_Detector::TYPE_GUTENBERG: { $parser = AITRFOEL_Gutenberg_Parser::get_instance(); $translated_content = $parser->apply_translations($extra['post_content'], $full_map); $translated_title = null; if (!$extra['title_in_content'] && isset($full_map[$extra['post_title']])) { $translated_title = $full_map[$extra['post_title']]; unset($full_map[$extra['post_title']]); } elseif ($extra['title_in_content']) { foreach ($strings as $s) { if (trim(wp_strip_all_tags($s['original'] ?? '')) === trim($extra['post_title']) && isset($full_map[$s['original']])) { $translated_title = wp_strip_all_tags($full_map[$s['original']]); break; } } } $new_page_id = $this->create_translated_page_generic($page_id, $translated_content, $target_lang, $source_lang, 'gutenberg', $translated_title); if (!is_wp_error($new_page_id)) { $this->track_translation_success($page_id, $new_page_id, $target_lang, $source_lang, $start_time, 'gutenberg'); } return $new_page_id; } case AITRFOEL_Content_Detector::TYPE_WOOCOMMERCE: { $texts = $extra['texts']; $text_keys = $extra['text_keys']; $translated_title = null; $translated_excerpt = null; $translated_content = null; foreach ($texts as $i => $orig) { $tr = $full_map[$orig] ?? null; if (!$tr) continue; if ($text_keys[$i] === 'title') { $translated_title = $tr; } if ($text_keys[$i] === 'excerpt') { $translated_excerpt = $tr; } if ($text_keys[$i] === 'content') { $translated_content = $tr; } } if (!$translated_title) { $post = get_post($page_id); $translated_title = ($post->post_title ?? '') . ' (' . strtoupper($target_lang) . ')'; } $new_page_id = $this->create_translated_page_generic($page_id, $translated_content ?: '', $target_lang, $source_lang, 'woocommerce', $translated_title); if (!is_wp_error($new_page_id)) { if ($translated_excerpt) { wp_update_post(['ID' => $new_page_id, 'post_excerpt' => $translated_excerpt]); } $this->track_translation_success($page_id, $new_page_id, $target_lang, $source_lang, $start_time, 'woocommerce'); } return $new_page_id; } default: return new WP_Error('unsupported_content', __('Cannot apply translations: unsupported content type.', 'ai-translator-for-elementor-and-polylang')); } } /** * Translate Elementor page (original functionality preserved) * * @param int $page_id Original page ID * @param string $target_lang Target language * @param string $source_lang Source language * @param array $strings_to_translate Selected strings for translation (optional) * @param float $translation_start_time Start time for analytics * @return int|WP_Error New page ID or error */ private function translate_elementor_page($page_id, $target_lang, $source_lang = 'auto', $strings_to_translate = [], $translation_start_time = null) { if (!$translation_start_time) { $translation_start_time = microtime(true); } // Get Elementor data $elementor_data = get_post_meta($page_id, '_elementor_data', true); if (empty($elementor_data)) { $this->track_translation_failure('no_elementor_data', 'No Elementor data found', $translation_start_time); return new WP_Error('no_elementor_data', __('No Elementor data found for this page.', 'ai-translator-for-elementor-and-polylang')); } $elementor_data_array = json_decode($elementor_data, true); if (json_last_error() !== JSON_ERROR_NONE) { $this->track_translation_failure('json_decode_error', 'Failed to decode Elementor data', $translation_start_time); return new WP_Error('json_decode_error', __('Failed to decode Elementor data.', 'ai-translator-for-elementor-and-polylang')); } // If no specific strings provided, parse all translatable content if (empty($strings_to_translate)) { $parser = AITRFOEL_Widget_Parser::get_instance(); $translation_map = $parser->parse_for_translation($elementor_data_array); $strings_to_translate = array_values($translation_map); } // ========================================== // WORD LIMIT & COOLDOWN ENFORCEMENT // ========================================== // Get current license status and usage $license_manager = AITRFOEL_License_Manager_Server::get_instance(); $license_status = $license_manager->get_license_status(); $plan = $license_status['plan']; $word_limit = $license_status['word_limit']; $words_used = $license_status['words_used']; $words_left = $license_status['words_left']; // Calculate estimated word count for this translation // Server counts both source + translated words, so apply 2× factor $source_words = 0; $debug_word_breakdown = []; // DEBUG: track per-string word counts foreach ($strings_to_translate as $string_data) { if (isset($string_data['original'])) { $text = wp_strip_all_tags($string_data['original']); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $wc = self::count_words($text); $source_words += $wc; $debug_word_breakdown[] = $wc . ':' . mb_substr($text, 0, 60); } } // DEBUG: Log per-string word counts AITRFOEL_Logger::log( sprintf('[WORD-DEBUG] Total strings: %d, Source words: %d, Breakdown: %s', count($strings_to_translate), $source_words, implode(' | ', $debug_word_breakdown)), 'info', $page_id ); $server_factor = defined('AITRFOEL_Translate_All::SERVER_USAGE_FACTOR') ? AITRFOEL_Translate_All::SERVER_USAGE_FACTOR : 1; // Refine the estimate by subtracting words for strings already in cache. // On retries, most strings are cached so the real cost is a small fraction // of the full page — without this, retries are blocked unnecessarily. $prelim_source_lang = $source_lang; if ($source_lang === 'auto') { $cached_lang_transient = get_transient('aitrfoel_detected_lang_' . $page_id . '_' . $target_lang); if ($cached_lang_transient) { $prelim_source_lang = $cached_lang_transient; } } AITRFOEL_Logger::log( sprintf('[WORD-PRECHECK] source_lang=%s, prelim_source_lang=%s, cache_enabled=%s', $source_lang, $prelim_source_lang, $this->cache_enabled ? 'yes' : 'no'), 'debug', $page_id ); $cache_precheck = AITRFOEL_Cache_Manager::get_instance(); $uncached_source_words = 0; foreach ($strings_to_translate as $string_data) { if (!isset($string_data['original'])) continue; $raw = (string) $string_data['original']; $stripped = html_entity_decode(wp_strip_all_tags($raw), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $wc = self::count_words($stripped); if ($wc === 0) continue; if (strlen($raw) > AITRFOEL_OpenAI_API::LARGE_TEXT_THRESHOLD) { // Large string: check regular cache first (set when translation completes fully). if ($this->cache_enabled) { $prepared_large = $raw; if (method_exists($parser, 'i18n_extract_with_placeholders')) { list($prepared_large,) = $parser->i18n_extract_with_placeholders($raw); } $large_cached = $cache_precheck->get_from_cache($prepared_large, $target_lang, $prelim_source_lang); AITRFOEL_Logger::log( sprintf('[WORD-PRECHECK] Large string (%d chars, %d words): regular cache %s (lang=%s→%s)', strlen($raw), $wc, $large_cached !== null ? 'HIT' : 'MISS', $prelim_source_lang, $target_lang), 'debug', $page_id ); if ($large_cached !== null) { continue; // fully translated and in regular cache } } // Not in regular cache: check per-chunk transients to gauge remaining cost. $total_approx = (int) ceil(strlen($raw) / AITRFOEL_OpenAI_API::LARGE_TEXT_THRESHOLD); $chunk_key_base = 'aitrfoel_chunk_' . md5($raw . $target_lang); $cached_chunks = 0; for ($ci = 0; $ci < $total_approx; $ci++) { if (get_transient($chunk_key_base . '_' . $ci) !== false) { $cached_chunks++; } } $uncached_ratio = $total_approx > 0 ? max(0.0, ($total_approx - $cached_chunks) / $total_approx) : 1.0; AITRFOEL_Logger::log( sprintf('[WORD-PRECHECK] Large string chunk transients: %d/%d cached, ratio_uncached=%.2f', $cached_chunks, $total_approx, $uncached_ratio), 'debug', $page_id ); $uncached_source_words += (int) ceil($wc * $uncached_ratio); continue; } // Regular string: check translation cache. if ($this->cache_enabled) { $prepared = $raw; if (method_exists($parser, 'i18n_extract_with_placeholders')) { list($prepared,) = $parser->i18n_extract_with_placeholders($raw); } if ($cache_precheck->get_from_cache($prepared, $target_lang, $prelim_source_lang) !== null) { continue; // already translated and cached — no words needed } } $uncached_source_words += $wc; } $estimated_words = $uncached_source_words * $server_factor; AITRFOEL_Logger::log( sprintf('Word limit check: Plan=%s, Limit=%d, Used=%d, Left=%d, TotalSourceWords=%d, UncachedWords=%d, EstimatedUsage=%d (×%d)', $plan, $word_limit, $words_used, $words_left, $source_words, $uncached_source_words, $estimated_words, $server_factor), 'info', $page_id ); // Check if user has enough words remaining if ($word_limit !== -1 && $estimated_words > $words_left) { // Check if user is on cooldown (Free plan only) if (strtolower($plan) === 'free') { $cooldown_end = get_option('aitrfoel_cooldown_end_date', 0); if ($cooldown_end > time()) { // Still in cooldown period $days_left = ceil(($cooldown_end - time()) / DAY_IN_SECONDS); AITRFOEL_Logger::log( sprintf('Translation blocked: Free plan in cooldown for %d more days', $days_left), 'warning', $page_id ); $tm_stats = class_exists('AITRFOEL_Translation_Memory') ? AITRFOEL_Translation_Memory::get_instance()->get_stats() : ['total_words_saved' => 0]; $tm_saved = isset($tm_stats['total_words_saved']) ? intval($tm_stats['total_words_saved']) : 0; $tm_msg = $tm_saved > 0 ? sprintf(__(' (You also saved +%s words for FREE via Smart Translation Memory!)', 'ai-translator-for-elementor-and-polylang'), number_format_i18n($tm_saved)) : ''; return new WP_Error( 'cooldown_active', sprintf( /* translators: 1: Word limit (formatted number), 2: Number of days remaining, 3: TM savings text */ __('You have reached your monthly limit of %1$s words%3$s. Free plan users must wait %2$d more days for the limit to reset. Upgrade to a paid plan for immediate access.', 'ai-translator-for-elementor-and-polylang'), number_format_i18n($word_limit), $days_left, $tm_msg ), [ 'type' => 'cooldown', 'words_limit' => $word_limit, 'words_used' => $words_used, 'words_saved_tm' => $tm_saved, 'days_left' => $days_left, ] ); } } // Not enough words for this translation AITRFOEL_Logger::log( sprintf('Translation blocked: Insufficient words (need %d, have %d)', $estimated_words, $words_left), 'warning', $page_id ); $tm_stats = class_exists('AITRFOEL_Translation_Memory') ? AITRFOEL_Translation_Memory::get_instance()->get_stats() : ['total_words_saved' => 0]; $tm_saved = isset($tm_stats['total_words_saved']) ? intval($tm_stats['total_words_saved']) : 0; $tm_msg = $tm_saved > 0 ? sprintf(__(' (Note: You already saved +%s words for FREE via Translation Memory!)', 'ai-translator-for-elementor-and-polylang'), number_format_i18n($tm_saved)) : ''; $upgrade_link = admin_url('admin.php?page=ai-translator-for-elementor-and-polylang-pricing'); return new WP_Error( 'insufficient_words', sprintf( /* translators: 1: Estimated words needed (formatted number), 2: Words remaining (formatted number), 3: Upgrade URL, 4: TM bonus text */ __('This translation requires approximately %1$s words, but you only have %2$s words remaining in your plan%4$s. Please upgrade your plan to complete your translation without waiting.', 'ai-translator-for-elementor-and-polylang'), number_format_i18n($estimated_words), number_format_i18n($words_left), esc_url($upgrade_link), $tm_msg ), [ 'type' => 'insufficient_words', 'words_needed' => $estimated_words, 'words_remaining' => $words_left, 'words_saved_tm' => $tm_saved, ] ); } // ========================================== // END WORD LIMIT CHECK // ========================================== // Language detection if auto AITRFOEL_Logger::log("Source language parameter: '$source_lang'", 'debug', $page_id); $detected_lang = $source_lang; if ($source_lang === 'auto') { AITRFOEL_Logger::log('Starting language auto-detection', 'debug', $page_id); // Log sample of text for debugging if (!empty($strings_to_translate)) { $sample_text = isset($strings_to_translate[0]['original']) ? $strings_to_translate[0]['original'] : ''; $sample_display = mb_substr($sample_text, 0, 100) . '...'; AITRFOEL_Logger::log("Sample text for detection: '$sample_display'", 'debug', $page_id); } $detected_lang = $this->detect_source_language($strings_to_translate, $page_id); if (!$detected_lang) { AITRFOEL_Logger::log('Auto-detection failed, using fallback: en', 'warning', $page_id); $detected_lang = 'en'; // sensible fallback } else { AITRFOEL_Logger::log("Language detected: '$detected_lang'", 'info', $page_id); } } // Persist the resolved language code so AJAX retries can send an explicit code // instead of 'auto'. This keeps the cache key (hash + target_lang + source_lang) // identical across all partial-save passes, preventing full retranslation. set_transient('aitrfoel_detected_lang_' . $page_id . '_' . $target_lang, $detected_lang, 15 * MINUTE_IN_SECONDS); // Enhanced logging for debugging AITRFOEL_Logger::log( sprintf('Translation summary: Page %d, %s → %s, %d strings to translate', $page_id, $detected_lang, $target_lang, count($strings_to_translate)), 'info', $page_id ); // Log translation start AITRFOEL_Logger::log( sprintf('Starting translation of page %d to %s', $page_id, $target_lang), 'info', $page_id ); // Initialize services based on selected provider (OpenAI only) $api_provider = 'openai'; // Simplified to OpenAI only AITRFOEL_Logger::log("Using API provider: '$api_provider'", 'debug', $page_id); $api = AITRFOEL_OpenAI_API::get_instance(); if (method_exists($api, 'reset_usage_session')) { $api->reset_usage_session(); } $cache = AITRFOEL_Cache_Manager::get_instance(); $parser = AITRFOEL_Widget_Parser::get_instance(); // Include post title in batch to avoid a separate API call $original_post = get_post($page_id); $title_batch_key = '__aitrfoel_post_title__'; $pre_translated_title = null; $title_already_in_batch = false; if ($original_post && !empty($original_post->post_title)) { // Check if title already exists in parsed content foreach ($strings_to_translate as $s) { $stripped = trim(wp_strip_all_tags($s['original'] ?? '')); if ($stripped === trim($original_post->post_title)) { $title_already_in_batch = true; break; } } if (!$title_already_in_batch) { $strings_to_translate[] = [ 'original' => $original_post->post_title, 'path' => $title_batch_key, 'type' => 'title', 'widget_type' => 'elementor', ]; } } // Process translations in batches $batch_start_time = microtime(true); AITRFOEL_Logger::log(sprintf('[TIMING] Starting batch processing: %d strings', count($strings_to_translate)), 'info', $page_id); $translations = []; $total_strings = count($strings_to_translate); $processed = 0; // Group strings by cache status - uncached strings get batched $cached_translations = []; $uncached_strings = []; $uncached_paths = []; $uncached_items = []; foreach ($strings_to_translate as $string_data) { // Validate string data structure if (!is_array($string_data) || !isset($string_data['original'])) { AITRFOEL_Logger::log('Invalid string data structure, skipping', 'warning', $page_id); continue; } $raw_original = (string)$string_data['original']; $path = $string_data['path'] ?? ''; // Extract inline placeholders using parser helper $ph_map = []; $prepared_text = $raw_original; if (method_exists($parser, 'i18n_extract_with_placeholders')) { list($prepared_text, $ph_map) = $parser->i18n_extract_with_placeholders($raw_original); // Diagnostics: log placeholder count per string if (is_array($ph_map) && !empty($ph_map)) { AITRFOEL_Logger::log(sprintf('Placeholders extracted: %d for path %s', count($ph_map), (string)($path ?? 'n/a')), 'debug', $page_id); } } // Normalize to NFC if possible if (class_exists('Normalizer') && is_string($prepared_text)) { $prepared_text = Normalizer::normalize($prepared_text, Normalizer::FORM_C); } // Skip empty or invalid text if (empty(trim((string)$prepared_text))) { continue; } // Check cache first (only if enabled) $cached = null; if ($this->cache_enabled) { $cached = $cache->get_from_cache($prepared_text, $target_lang, $detected_lang); // Validate cached entry: reject if translation is too similar to source // This prevents using stale cache from earlier failed translations if ($cached !== null) { $cached_clean = strtolower(trim(html_entity_decode(wp_strip_all_tags($cached), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); $source_clean = strtolower(trim(html_entity_decode(wp_strip_all_tags($prepared_text), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if ($cached_clean === $source_clean) { $cached = null; // Reject: cached translation is identical to source AITRFOEL_Logger::log(sprintf('Cache rejected (identical to source): "%s"', mb_substr($prepared_text, 0, 60)), 'debug', $page_id); } } } if ($cached) { // Restore placeholders before applying if (!empty($ph_map) && method_exists($parser, 'i18n_restore_placeholders')) { $restored = $parser->i18n_restore_placeholders($cached, $ph_map); if (method_exists($parser, 'i18n_cleanup_artifacts')) { $restored = $parser->i18n_cleanup_artifacts($restored); } // Try to restore lost links if (method_exists($parser, 'restore_lost_links')) { $restored = $parser->restore_lost_links($restored, $raw_original); } $cached_translations[$path] = $restored; } else { $tmp = $this->restore_html_formatting($cached, $raw_original); if (method_exists($parser, 'i18n_cleanup_artifacts')) { $tmp = $parser->i18n_cleanup_artifacts($tmp); } // Try to restore lost links if (method_exists($parser, 'restore_lost_links')) { $tmp = $parser->restore_lost_links($tmp, $raw_original); } $cached_translations[$path] = $tmp; } $processed++; } else { // Queue item for batch translation $uncached_items[] = [ 'src' => $prepared_text, 'path' => $path, 'original' => $raw_original, 'ph_map' => $ph_map, ]; } } // Self-imposed time budget per AJAX call. If the host has disabled set_time_limit, // PHP can run for 20+ minutes on a huge page and be killed with nothing saved. // We cap each call at 200 s, save whatever was translated, and return 'partial' so // the caller retries — the retry is fast because all strings are now in cache. $budget_seconds = (int) apply_filters('aitrfoel_translate_time_budget', 200); $budget_start = microtime(true); $timed_out = false; $word_limit_hit = false; // Propagate time budget to the API class so translate_large_text() can // honour the same deadline and cache completed chunks for fast retries. AITRFOEL_OpenAI_API::set_time_budget($budget_start, $budget_seconds); // Process uncached strings in batches if (!empty($uncached_items)) { $cache_elapsed = round((microtime(true) - $batch_start_time) * 1000); // Separate large strings from normal strings. Large strings are sent // directly to translate_text() (which chunks them) so they never block // the batch API and waste the time budget on a 60-second cURL timeout. $large_items = []; $normal_items = []; foreach ($uncached_items as $item) { if (strlen($item['src']) > AITRFOEL_OpenAI_API::LARGE_TEXT_THRESHOLD) { $large_items[] = $item; } else { $normal_items[] = $item; } } AITRFOEL_Logger::log(sprintf('[TIMING] Cache check done in %dms: %d cached, %d uncached (%d large, %d normal)', $cache_elapsed, count($cached_translations), count($uncached_items), count($large_items), count($normal_items)), 'info', $page_id); $batches = array_chunk($normal_items, $this->batch_size); foreach ($batches as $batch_index => $batch_items) { // Group key is now just "batch" since we're not grouping by widget $gkey = 'batch'; $batch_texts = array_map(function($it){ return $it['src']; }, $batch_items); $start = microtime(true); AITRFOEL_Logger::log(sprintf('[EAT] page=%d lang=%s group="%s" batch=%d size=%d', $page_id, $target_lang, (string)$gkey, $batch_index, count($batch_texts)), 'info', $page_id); // Use batch translation API (expects placeholderized texts) $batch_results = $api->translate_batch($batch_texts, $target_lang, $detected_lang); $elapsed_ms = (int)round((microtime(true) - $start) * 1000); if (!is_wp_error($batch_results)) { AITRFOEL_Logger::log(sprintf('[EAT] page=%d lang=%s group="%s" batch=%d results=%d time_ms=%d', $page_id, $target_lang, (string)$gkey, $batch_index, count((array)$batch_results), $elapsed_ms), 'info', $page_id); } if (is_wp_error($batch_results)) { $batch_error_msg = $batch_results->get_error_message(); AITRFOEL_Logger::log( 'Batch translation failed, falling back to individual: ' . $batch_error_msg, 'warning', $page_id ); // If batch failed due to word limit, abort immediately — don't try individual calls if ( strpos($batch_error_msg, 'Word limit') !== false || strpos($batch_error_msg, 'word limit') !== false || strpos($batch_error_msg, 'words remaining') !== false ) { return new WP_Error('word_limit_exceeded', $batch_error_msg); } // Fallback to individual translation $consecutive_limit_errors = 0; foreach ($batch_items as $item) { // Budget check inside fallback — each individual call can take up to 60 s; // without this guard 20 calls × 60 s = 1200 s blocks the time-budget save. if ((microtime(true) - $budget_start) >= $budget_seconds) { $timed_out = true; AITRFOEL_Logger::log( sprintf('[TIMING] Time budget of %ds reached inside individual fallback (batch %d). Saving partial page.', $budget_seconds, $batch_index), 'warning', $page_id ); break 2; } $text = $item['src']; $translated = $api->translate_text($text, $target_lang, $detected_lang); // CRITICAL DEBUG: Log what OpenAI returns for individual translations AITRFOEL_Logger::log("Individual Translation Debug - Original text (first 200 chars): " . substr($text, 0, 200), 'debug'); AITRFOEL_Logger::log("Individual Translation Debug - Translated text (first 200 chars): " . substr(is_wp_error($translated) ? 'ERROR' : $translated, 0, 200), 'debug'); AITRFOEL_Logger::log("Individual Translation Debug - Original contains placeholders: " . (strpos($text, '≪') !== false ? 'YES' : 'NO'), 'debug'); AITRFOEL_Logger::log("Individual Translation Debug - Translated contains placeholders: " . (!is_wp_error($translated) && strpos($translated, '≪') !== false ? 'YES' : 'NO'), 'debug'); if (!is_wp_error($translated)) { $path = $item['path']; $original = $item['original']; $ph_map = $item['ph_map']; // Cache the translation (only if enabled) if ($this->cache_enabled) { $cache->set_to_cache( $text, // cache keyed by placeholderized source $translated, // store raw model output $target_lang, $detected_lang, get_option('aitrfoel_openai_model', 'gpt-3.5-turbo') ); } // Residue retry: check for leftover phrases $needs_retry = false; foreach (['Read more','Číst dál','Читать далее'] as $marker) { if (is_string($translated) && mb_stripos($translated, $marker, 0, 'UTF-8') !== false) { $needs_retry = true; break; } } if ($needs_retry) { $retry = $api->translate_text($text, $target_lang, $detected_lang); if (!is_wp_error($retry) && is_string($retry) && $retry !== '') { $translated = $retry; } } // Restore placeholders if (!empty($ph_map) && method_exists($parser, 'i18n_restore_placeholders')) { $final = $parser->i18n_restore_placeholders($translated, $ph_map); } else { $final = $this->restore_html_formatting($translated, $original); } if (method_exists($parser, 'i18n_cleanup_artifacts')) { $final = $parser->i18n_cleanup_artifacts($final); } // Try to restore lost links if (method_exists($parser, 'restore_lost_links')) { $final = $parser->restore_lost_links($final, $original); } $translations[$path] = $final; } else { AITRFOEL_Logger::log( 'Individual translation failed for text: ' . substr($text, 0, 50) . '...', 'error', $page_id ); } $processed++; usleep($this->rate_limit_delay * 1000); // Rate limiting } } else { // Process successful batch results // NOTE: batch_results is keyed by SOURCE TEXT, not numeric index foreach ($batch_items as $index => $item) { $text = $item['src']; // Check if translation exists by source text key OR numeric index (for compatibility) $translated = null; if (isset($batch_results[$text])) { $translated = $batch_results[$text]; // Keyed by source text } elseif (isset($batch_results[$index])) { $translated = $batch_results[$index]; // Fallback to numeric index } if ($translated !== null) { // Guard: if batch returned non-string or empty, fallback to individual to avoid applying raw/empty if (!is_string($translated) || trim($translated) === '') { AITRFOEL_Logger::log('Empty/non-string batch item detected, using individual fallback for path: ' . ($item['path'] ?? 'n/a'), 'warning', $page_id); $translated = $api->translate_text($text, $target_lang, $detected_lang); } $path = $item['path']; $original = $item['original']; $ph_map = $item['ph_map']; // Cache the translation (only if enabled) // But DON'T cache if translation is identical to source (failed translation) if ($this->cache_enabled) { $tr_clean = strtolower(trim(html_entity_decode(wp_strip_all_tags($translated), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); $src_clean = strtolower(trim(html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if ($tr_clean !== $src_clean) { $cache->set_to_cache( $text, $translated, // raw model output (placeholders still present) $target_lang, $detected_lang, get_option('aitrfoel_openai_model', 'gpt-3.5-turbo') ); } } // Restore placeholders (primary) or formatting (fallback) if (!empty($ph_map) && method_exists($parser, 'i18n_restore_placeholders')) { $final = $parser->i18n_restore_placeholders($translated, $ph_map); } else { $final = $this->restore_html_formatting($translated, $original); } if (method_exists($parser, 'i18n_cleanup_artifacts')) { $final = $parser->i18n_cleanup_artifacts($final); } $translations[$path] = $final; } else { // No translation found in batch results - fallback to individual AITRFOEL_Logger::log('Batch result missing for text (fallback to individual): ' . substr($text, 0, 50) . '...', 'warning', $page_id); $translated = $api->translate_text($text, $target_lang, $detected_lang); if (!is_wp_error($translated) && is_string($translated) && trim($translated) !== '') { $path = $item['path']; $original = $item['original']; $ph_map = $item['ph_map']; if (!empty($ph_map) && method_exists($parser, 'i18n_restore_placeholders')) { $final = $parser->i18n_restore_placeholders($translated, $ph_map); } else { $final = $this->restore_html_formatting($translated, $original); } if (method_exists($parser, 'i18n_cleanup_artifacts')) { $final = $parser->i18n_cleanup_artifacts($final); } $translations[$path] = $final; } } $processed++; } // No delay between successful batch calls - the server handles rate limiting } // Update progress $progress = round(($processed / $total_strings) * 100); AITRFOEL_Logger::log( sprintf('Translation progress: %d%% (%d/%d strings processed)', $progress, $processed, $total_strings), 'info', $page_id ); // Time-budget check: break early so create_translated_page() still runs // and the partial result is saved to the DB before PHP is killed. if ((microtime(true) - $budget_start) >= $budget_seconds) { $timed_out = true; AITRFOEL_Logger::log( sprintf('[TIMING] Time budget of %ds reached after batch %d (%d/%d strings done). Saving partial page.', $budget_seconds, $batch_index, $processed, $total_strings), 'warning', $page_id ); break; } } // Process large strings individually — they bypass the batch API entirely // to avoid wasting the time budget on a 60-second cURL timeout. if (!empty($large_items) && !$timed_out) { AITRFOEL_Logger::log(sprintf('[TIMING] Processing %d large string(s) individually', count($large_items)), 'info', $page_id); foreach ($large_items as $item) { if ((microtime(true) - $budget_start) >= $budget_seconds) { $timed_out = true; AITRFOEL_Logger::log( sprintf('[TIMING] Time budget of %ds reached before large string processing. Saving partial page.', $budget_seconds), 'warning', $page_id ); break; } $text = $item['src']; $path = $item['path']; $original = $item['original']; $ph_map = $item['ph_map']; $translated = $api->translate_text($text, $target_lang, $detected_lang); if (is_wp_error($translated)) { $err = $translated->get_error_message(); $err_code = $translated->get_error_code(); // chunk_timeout / word_limit_exceeded: partial progress was cached; will retry next pass if ($err_code === 'word_limit_exceeded') { $timed_out = true; $word_limit_hit = true; // word limit exhausted — do not auto-retry } elseif ($err_code === 'chunk_timeout' || strpos($err, 'chunk_timeout') !== false || strpos($err, 'timed out') !== false) { $timed_out = true; } AITRFOEL_Logger::log('Large string translation failed: ' . $err, 'error', $page_id); } else { if ($this->cache_enabled) { $cache->set_to_cache($text, $translated, $target_lang, $detected_lang, get_option('aitrfoel_openai_model', 'gpt-3.5-turbo')); } if (!empty($ph_map) && method_exists($parser, 'i18n_restore_placeholders')) { $final = $parser->i18n_restore_placeholders($translated, $ph_map); } else { $final = $this->restore_html_formatting($translated, $original); } if (method_exists($parser, 'i18n_cleanup_artifacts')) { $final = $parser->i18n_cleanup_artifacts($final); } if (method_exists($parser, 'restore_lost_links')) { $final = $parser->restore_lost_links($final, $original); } $translations[$path] = $final; } $processed++; } } } // Final processing summary $cached_count = count($cached_translations); $translated_count = count($translations); $uncached_item_count = isset($uncached_items) ? count($uncached_items) : 0; $total_elements_processed = $cached_count + $translated_count; // Calculate statistics for this translation $word_count = 0; foreach ($strings_to_translate as $string_data) { if (isset($string_data['original'])) { $text = wp_strip_all_tags($string_data['original']); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $word_count += self::count_words($text); } } // Prepare estimated cost as fallback $usage_tracker = class_exists('AITRFOEL_Usage_Tracker') ? AITRFOEL_Usage_Tracker::get_instance() : null; // Simplified usage tracking - no cost estimation in basic version $estimated_cost = 0; // Actual usage summary from OpenAI API $usage_summary = method_exists($api, 'get_usage_summary') ? $api->get_usage_summary(true) : []; $prompt_tokens_actual = (int) ($usage_summary['prompt_tokens'] ?? 0); $completion_tokens_actual = (int) ($usage_summary['completion_tokens'] ?? 0); $total_tokens_actual = (int) ($usage_summary['total_tokens'] ?? ($prompt_tokens_actual + $completion_tokens_actual)); $usage_records = $usage_summary['records'] ?? []; $usage_records_count = (int) ($usage_summary['records_count'] ?? count($usage_records)); $has_actual_usage = ($total_tokens_actual > 0) || $usage_records_count > 0; $expected_usage = $uncached_item_count > 0; $input_tokens_final = 0; $output_tokens_final = 0; $total_tokens_final = 0; $cost_value = $estimated_cost; $cost_formatted = isset($estimated_cost_data['cost_formatted']) ? $estimated_cost_data['cost_formatted'] : ('$' . number_format($estimated_cost, 4)); $is_estimated_stats = true; if ($has_actual_usage) { $is_estimated_stats = false; $input_tokens_final = $prompt_tokens_actual; $output_tokens_final = $completion_tokens_actual; $total_tokens_final = $total_tokens_actual; // Simplified cost calculation - no detailed cost tracking in basic version $cost_value = 0; $cost_formatted = '$0.0000'; } elseif (!$expected_usage) { // All strings were served from cache $is_estimated_stats = false; $cost_value = 0; $cost_formatted = '$0.0000'; } else { // Fallback to estimation when usage metrics are missing if (!empty($estimated_cost_data)) { $input_tokens_final = $estimated_cost_data['input_tokens'] ?? 0; $output_tokens_final = $estimated_cost_data['output_tokens'] ?? 0; $total_tokens_final = $estimated_cost_data['total_tokens'] ?? ($input_tokens_final + $output_tokens_final); $cost_value = $estimated_cost_data['estimated_cost'] ?? $cost_value; $cost_formatted = $estimated_cost_data['cost_formatted'] ?? ('$' . number_format($cost_value, 4)); } } $usage_records_summary = []; if (!empty($usage_records)) { $usage_records_summary = array_map(function($record) { return [ 'operation' => $record['operation'] ?? 'unknown', 'attempt' => $record['attempt'] ?? 0, 'prompt_tokens' => $record['prompt_tokens'] ?? 0, 'completion_tokens' => $record['completion_tokens'] ?? 0, 'total_tokens' => $record['total_tokens'] ?? 0, ]; }, $usage_records); $encoded_records = function_exists('wp_json_encode') ? wp_json_encode($usage_records_summary) : json_encode($usage_records_summary); if (!empty($encoded_records)) { AITRFOEL_Logger::log('Usage breakdown (per call): ' . $encoded_records, 'debug', $page_id); } } // Get character and symbol counts from OpenAI usage records (more accurate) $character_count = 0; $symbol_count = 0; if (!empty($usage_records)) { // Sum up characters and symbols from all API calls foreach ($usage_records as $record) { $character_count += $record['input_characters'] ?? 0; $symbol_count += $record['input_symbols'] ?? 0; } } // Fallback: calculate from strings if not available from API records if ($character_count === 0 && class_exists('AITRFOEL_Usage_Tracker')) { $usage_tracker = AITRFOEL_Usage_Tracker::get_instance(); // Extract original text from strings_to_translate $all_original_texts = []; foreach ($strings_to_translate as $string_data) { if (isset($string_data['original'])) { $all_original_texts[] = $string_data['original']; } } // Basic character counting for simplified usage tracker $character_count = 0; $symbol_count = 0; foreach ($all_original_texts as $text) { $character_count += mb_strlen($text, 'UTF-8'); // Count symbols (non-alphanumeric, non-space characters) $symbol_count += preg_match_all('/[^\p{L}\p{N}\s]/u', $text); } if (class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log("Using fallback character counting: {$character_count} chars, {$symbol_count} symbols", 'debug'); } } else if (class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log("Using API record character counts: {$character_count} chars, {$symbol_count} symbols", 'debug'); } // Store statistics $this->last_translation_stats = [ 'elements_translated' => $total_elements_processed, 'word_count' => $word_count, 'character_count' => $character_count, 'symbol_count' => $symbol_count, 'input_tokens' => $input_tokens_final, 'output_tokens' => $output_tokens_final, 'total_tokens' => $total_tokens_final, 'estimated_cost' => $cost_value, 'cost_formatted' => $cost_formatted, 'is_estimated' => $is_estimated_stats, 'cached_strings' => $cached_count, 'uncached_strings' => $uncached_item_count, 'usage_records' => $usage_records_summary ]; $usage_mode_label = $is_estimated_stats ? 'estimated' : 'actual'; AITRFOEL_Logger::log( sprintf( 'Usage totals (%s): prompt=%d, completion=%d, total=%d, cost=%s, pages=%d', $usage_mode_label, $this->last_translation_stats['input_tokens'], $this->last_translation_stats['output_tokens'], $this->last_translation_stats['total_tokens'], $cost_formatted, 1 ), 'info', $page_id ); AITRFOEL_Logger::log( sprintf('Translation completed: %d cached, %d new translations, %d total', $cached_count, $translated_count, $total_elements_processed), 'info', $page_id ); $total_batch_elapsed = round((microtime(true) - $batch_start_time) * 1000); AITRFOEL_Logger::log(sprintf('[TIMING] Total batch_translate_strings: %dms', $total_batch_elapsed), 'info', $page_id); // Merge cached and newly translated results $translations = array_merge($cached_translations, $translations); // Extract pre-translated title BEFORE gap-filler and apply_translations // so the parser doesn't try to set __aitrfoel_post_title__ as an Elementor data path if ($original_post && !empty($original_post->post_title)) { if (isset($translations[$title_batch_key])) { $pre_translated_title = wp_strip_all_tags($translations[$title_batch_key]); unset($translations[$title_batch_key]); } elseif ($title_already_in_batch) { foreach ($strings_to_translate as $s) { $stripped = trim(wp_strip_all_tags($s['original'] ?? '')); if ($stripped === trim($original_post->post_title) && isset($translations[$s['path']])) { $pre_translated_title = wp_strip_all_tags($translations[$s['path']]); break; } } } } // Remove title entry from strings before gap-filler processes them $strings_to_translate = array_values(array_filter($strings_to_translate, function($s) use ($title_batch_key) { return ($s['path'] ?? '') !== $title_batch_key; })); // Gap-Filler second pass: fix missed/under-translated strings before applying // Skip on a timed-out pass to preserve the time budget; the retry call will run it. if (class_exists('AITRFOEL_Gap_Filler') && !$timed_out) { // Prepare minimal strings format expected by gap-filler $strings = $strings_to_translate; // Inject group_key heuristically (prefix path up to .settings) foreach ($strings as &$row) { if (is_array($row)) { $p = (string)($row['path'] ?? ''); $g = preg_replace('~(\.settings.*)$~', '', $p); if ($g === null) { $g = ''; } $row['group_key'] = $g; } } unset($row); $opts = [ 'levenshtein_threshold' => (int) get_option('aitrfoel_gap_lev_threshold', 3), 'residue_patterns' => (string) get_option('aitrfoel_gap_residue_patterns', '/\\b(Read more|Číst dál|Читать далее)\\b/ui'), 'max_retry' => (int) get_option('aitrfoel_gap_max_retry', 1), 'enable_postprocessor' => (bool) get_option('aitrfoel_gap_enable_postprocessor', true), 'page_id' => $page_id, ]; $metrics = AITRFOEL_Gap_Filler::run($strings, $translations, $target_lang, $detected_lang, $opts); AITRFOEL_Logger::log(sprintf('[EAT gap-filler] page=%d lang=%s retried=%d fixed=%d failed=%d', $page_id, $target_lang, $metrics['retried'] ?? 0, $metrics['fixed'] ?? 0, $metrics['failed'] ?? 0), 'info', $page_id); // Update cache with gap-filler corrections so future translations use good values if ($this->cache_enabled && ($metrics['fixed'] ?? 0) > 0) { $model = get_option('aitrfoel_openai_model', 'gpt-3.5-turbo'); foreach ($strings as $s) { $p = $s['path'] ?? ''; if (isset($translations[$p]) && !empty($translations[$p])) { $src = $s['original'] ?? ''; $tr = $translations[$p]; // Only update cache if translation is meaningfully different from source $src_c = strtolower(trim(html_entity_decode(wp_strip_all_tags($src), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); $tr_c = strtolower(trim(html_entity_decode(wp_strip_all_tags($tr), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if ($src_c !== $tr_c) { // Re-extract placeholders to get the cache key if (method_exists($parser, 'i18n_extract_with_placeholders')) { list($prepared_src, ) = $parser->i18n_extract_with_placeholders($src); } else { $prepared_src = $src; } $cache->set_to_cache($prepared_src, $tr, $target_lang, $detected_lang, $model); } } } } } // Apply translations back to Elementor data. // Reset the execution timer first: the translation + gap-filler passes may have consumed // most of max_execution_time, and we must not let the request die before the result is // saved (otherwise the words are spent but the page is never updated). if (function_exists('set_time_limit')) { @set_time_limit(120); } $translated_data = $parser->apply_translations($elementor_data_array, $translations); // Deep cleanup pass across all string fields to ensure no escaped tags/orphan placeholders remain $deepChanged = 0; $walker = function (&$node) use (&$walker, $parser, &$deepChanged) { if (is_string($node)) { $clean = $parser->i18n_cleanup_artifacts($node); if ($clean !== $node) { $node = $clean; $deepChanged++; } return; } if (is_array($node)) { foreach ($node as &$v) { $walker($v); } unset($v); } }; $tmp = $translated_data; $walker($tmp); if ($deepChanged > 0) { AITRFOEL_Logger::log("Deep cleanup adjusted $deepChanged fields before save", 'info', $page_id); $translated_data = $tmp; } // Enhanced validation: ensure counts match and quality check if (!is_array($translated_data) || !$parser->validate_structure($translated_data)) { AITRFOEL_Logger::log('Validation failed after translation apply (structure). Rolling back.', 'error', $page_id); return new WP_Error('invalid_structure', __('Translation resulted in invalid Elementor structure.', 'ai-translator-for-elementor-and-polylang')); } // Quality validation $quality_issues = $this->validate_translation_quality($translations, $target_lang); if (!empty($quality_issues)) { AITRFOEL_Logger::log('Translation quality issues detected: ' . implode(', ', $quality_issues), 'warning', $page_id); } // Localize internal links (href) to the target language before saving try { $this->localize_internal_links($translated_data, $target_lang); } catch (Exception $e) { AITRFOEL_Logger::log('URL localization failed: ' . $e->getMessage(), 'warning', $page_id); } // Swap nav_menu IDs in Elementor Nav Menu widgets to point to translated menus $menu_id_map = get_option('aitrfoel_menu_id_map', []); if (!empty($menu_id_map)) { $swapped = $this->swap_menu_ids_in_elementor_data($translated_data, $menu_id_map, $target_lang); if ($swapped > 0) { AITRFOEL_Logger::log("Swapped $swapped nav menu IDs to translated menus for $target_lang", 'info', $page_id); } } // Swap nested Elementor template references so translated headers/footers do not // keep pointing at source-language reusable templates/sections. $template_refs_swapped = $this->swap_elementor_template_refs_in_data($translated_data, $target_lang); if ($template_refs_swapped > 0) { AITRFOEL_Logger::log("Swapped $template_refs_swapped nested Elementor template references for $target_lang", 'info', $page_id); } $form_refs_swapped = $this->swap_form_refs_in_data($translated_data, $target_lang); if ($form_refs_swapped > 0) { AITRFOEL_Logger::log("Swapped $form_refs_swapped form references for $target_lang", 'info', $page_id); } // Create new page with translations $new_page_id = $this->create_translated_page($page_id, $translated_data, $target_lang, $detected_lang, $pre_translated_title, $timed_out); if (is_wp_error($new_page_id)) { AITRFOEL_Logger::log( 'Failed to create translated page: ' . $new_page_id->get_error_message(), 'error', $page_id ); return $new_page_id; } // Record history immediately after page creation — before usage/analytics which can fail. // Status is 'partial' when we ran out of time mid-batch; the retry (fast from cache) // will overwrite with 'success'. $translation_duration = microtime(true) - $translation_start_time; if (class_exists('AITRFOEL_Translation_History')) { AITRFOEL_Translation_History::record([ 'original_post_id' => $page_id, 'translated_post_id' => $new_page_id, 'source_lang' => $detected_lang, 'target_lang' => $target_lang, 'content_type' => 'elementor', 'word_count' => $this->last_translation_stats['word_count'] ?? 0, 'status' => $timed_out ? 'partial' : 'success', 'duration' => round($translation_duration, 2), ]); } // Tag partial pages so the AJAX handler can trigger an automatic retry. // If the partial was caused by word limit exhaustion, mark it separately so // the AJAX handler can return 'limit_reached' to the JS instead of retrying. if ($timed_out) { update_post_meta($new_page_id, '_aitrfoel_partial_translation', 1); if ($word_limit_hit) { update_post_meta($new_page_id, '_aitrfoel_word_limit_hit', 1); AITRFOEL_Logger::log( sprintf('Page %d partially translated — word limit exhausted, no auto-retry.', $new_page_id), 'warning', $page_id ); } else { delete_post_meta($new_page_id, '_aitrfoel_word_limit_hit'); AITRFOEL_Logger::log( sprintf('Page %d partially translated — retry will complete it from cache.', $new_page_id), 'warning', $page_id ); } } else { delete_post_meta($new_page_id, '_aitrfoel_partial_translation'); delete_post_meta($new_page_id, '_aitrfoel_word_limit_hit'); } // Log success AITRFOEL_Logger::log( sprintf('Successfully translated page %d to %s. New page ID: %d', $page_id, $target_lang, $new_page_id), 'success', $page_id, $new_page_id ); // All translations go through MeepTech's /translate endpoint which auto-counts words // server-side — no separate report-usage call is needed (it would double-count). // Just invalidate the cached license status so the next word-limit check is fresh. AITRFOEL_License_Manager_Server::get_instance()->clear_status_cache(); // Increment global successful translations counter for milestone review prompts $rev_count = (int) get_option('aitrfoel_successful_translations_count', 0); update_option('aitrfoel_successful_translations_count', $rev_count + 1, false); // Track successful translation with PostHog Analytics if (function_exists('aitrfoel_analytics')) { $translation_duration = microtime(true) - $translation_start_time; aitrfoel_analytics()->track_translation_completed([ 'duration' => round($translation_duration, 2), 'word_count' => $this->last_translation_stats['word_count'] ?? 0, 'widget_count' => $this->last_translation_stats['strings_count'] ?? 0, 'success' => true, 'mode' => 'single', 'model' => get_option('aitrfoel_ai_model', 'gpt-4o-mini'), 'source_lang' => $detected_lang, 'target_lang' => $target_lang, ]); } // Record in translation history — already done above, right after page creation. return $new_page_id; } /** * Translate Gutenberg (Block Editor) page * * @param int $page_id Original page ID * @param string $target_lang Target language * @param string $source_lang Source language * @param float $translation_start_time Start time for analytics * @return int|WP_Error New page ID or error */ private function translate_gutenberg_page($page_id, $target_lang, $source_lang = 'auto', $translation_start_time = null) { if (!$translation_start_time) { $translation_start_time = microtime(true); } AITRFOEL_Logger::log( sprintf('Starting Gutenberg translation for page %d to %s', $page_id, $target_lang), 'info', $page_id ); $post = get_post($page_id); if (!$post || (empty($post->post_content) && empty(trim($post->post_title ?? '')))) { $this->track_translation_failure('no_content', 'No content found in Gutenberg page', $translation_start_time); return new WP_Error('no_content', __('No content found in this page.', 'ai-translator-for-elementor-and-polylang')); } AITRFOEL_Logger::log( sprintf('Gutenberg page %d has %d characters of content', $page_id, strlen($post->post_content ?? '')), 'info', $page_id ); // Parse Gutenberg blocks $parser = AITRFOEL_Gutenberg_Parser::get_instance(); $translation_map = !empty($post->post_content) ? $parser->parse_for_translation($page_id) : []; AITRFOEL_Logger::log( sprintf('Gutenberg parser found %d translatable items', count($translation_map)), 'info', $page_id ); if (empty($translation_map)) { $has_title = !empty(trim($post->post_title ?? '')); if (!$has_title) { $this->track_translation_failure('no_translatable_content', 'No translatable content found in Gutenberg blocks or title', $translation_start_time); return new WP_Error('no_translatable_content', __('No translatable content found in this page.', 'ai-translator-for-elementor-and-polylang')); } } // Convert to strings array for word limit check $strings_to_translate = []; foreach ($translation_map as $key => $data) { $strings_to_translate[] = $data; } // Extract SureForms post meta (_srfm_submit_button_text, _srfm_form_confirmation) when translating a SureForms form $is_sureform = in_array($post->post_type, ['sureforms_form', 'sf_form'], true); $srfm_submit_text = ''; $srfm_confirmations = null; if ($is_sureform) { $srfm_submit_text = (string) get_post_meta($page_id, '_srfm_submit_button_text', true); if (!empty(trim($srfm_submit_text))) { $strings_to_translate[] = [ 'original' => $srfm_submit_text, 'path' => '__srfm_submit_button_text__', 'type' => 'attribute', 'widget_type' => 'sureforms_meta', ]; } $srfm_confirmations = get_post_meta($page_id, '_srfm_form_confirmation', true); if (is_array($srfm_confirmations)) { foreach ($srfm_confirmations as $c_idx => $conf) { if (is_array($conf) && !empty($conf['message']) && is_string($conf['message'])) { $strings_to_translate[] = [ 'original' => $conf['message'], 'path' => '__srfm_confirmation_' . $c_idx . '__', 'type' => 'html', 'widget_type' => 'sureforms_meta', ]; } } } } // Word limit check (reuse existing logic) $word_limit_check = $this->check_word_limit($strings_to_translate, $page_id); if (is_wp_error($word_limit_check)) { AITRFOEL_Logger::log( 'Word limit check failed: ' . $word_limit_check->get_error_message(), 'warning', $page_id ); return $word_limit_check; } // Detect source language $detected_lang = $this->detect_source_language_from_content($strings_to_translate, $source_lang, $page_id); AITRFOEL_Logger::log( sprintf('Gutenberg translation: Page %d, %s → %s, %d strings', $page_id, $detected_lang, $target_lang, count($strings_to_translate)), 'info', $page_id ); // Include post title in batch to avoid a separate API call $post_title = $post->post_title; $title_key = '__aitrfoel_post_title__'; $title_already_included = false; if (!empty($post_title)) { // Check if title already exists in parsed content (e.g. as H1) foreach ($strings_to_translate as $s) { $stripped = trim(wp_strip_all_tags($s['original'] ?? '')); if ($stripped === trim($post_title)) { $title_already_included = true; break; } } if (!$title_already_included) { $strings_to_translate[] = [ 'original' => $post_title, 'path' => $title_key, 'type' => 'title', 'widget_type' => 'gutenberg', ]; } } // Translate strings (title included in batch) $translations = $this->batch_translate_strings($strings_to_translate, $target_lang, $detected_lang, $page_id); if (is_wp_error($translations)) { AITRFOEL_Logger::log( 'Batch translation failed: ' . $translations->get_error_message(), 'error', $page_id ); return $translations; } if (empty($translations) && !empty($strings_to_translate)) { $this->track_translation_failure('translation_failed', 'Translation API returned no translations', $translation_start_time); return new WP_Error('translation_failed', __('Failed to translate page content.', 'ai-translator-for-elementor-and-polylang')); } // Extract translated title from batch results // batch_translate_strings keys results by original text, not by path $translated_title = null; if (!$title_already_included && isset($translations[$post_title])) { // Title was added as a separate batch entry — keyed by original text $translated_title = $translations[$post_title]; unset($translations[$post_title]); // Don't pass to apply_translations } elseif ($title_already_included) { // Title was already in content (e.g. as H1) — extract translated version foreach ($strings_to_translate as $s) { $stripped = trim(wp_strip_all_tags($s['original'] ?? '')); if ($stripped === trim($post_title) && isset($translations[$s['original']])) { $translated_title = wp_strip_all_tags($translations[$s['original']]); break; } } } AITRFOEL_Logger::log( sprintf('Received %d translations from API (translated_title: %s)', count($translations), $translated_title ? 'yes' : 'no'), 'info', $page_id ); // Apply translations to content $original_content = $post->post_content; $translated_content = $parser->apply_translations($original_content, $translations); // Create translated page (pass pre-translated title to skip separate API call) $new_page_id = $this->create_translated_page_generic($page_id, $translated_content, $target_lang, $detected_lang, 'gutenberg', $translated_title); if (is_wp_error($new_page_id)) { AITRFOEL_Logger::log( 'Failed to create translated page: ' . $new_page_id->get_error_message(), 'error', $page_id ); return $new_page_id; } // Track success // Save translated SureForms post meta (_srfm_submit_button_text & _srfm_form_confirmation) if (!empty($is_sureform)) { if (!empty(trim($srfm_submit_text)) && isset($translations[$srfm_submit_text])) { update_post_meta($new_page_id, '_srfm_submit_button_text', $translations[$srfm_submit_text]); } if (is_array($srfm_confirmations)) { $conf_changed = false; foreach ($srfm_confirmations as $c_idx => &$conf) { if (is_array($conf) && !empty($conf['message']) && isset($translations[$conf['message']])) { $conf['message'] = $translations[$conf['message']]; $conf_changed = true; } } unset($conf); if ($conf_changed) { update_post_meta($new_page_id, '_srfm_form_confirmation', $srfm_confirmations); } } } $this->track_translation_success($page_id, $new_page_id, $target_lang, $detected_lang, $translation_start_time, 'gutenberg'); AITRFOEL_Logger::log( sprintf('Gutenberg translation complete: Page %d -> %d', $page_id, $new_page_id), 'success', $page_id ); return $new_page_id; } /** * Translate Classic Editor page * * @param int $page_id Original page ID * @param string $target_lang Target language * @param string $source_lang Source language * @param float $translation_start_time Start time for analytics * @return int|WP_Error New page ID or error */ private function translate_classic_page($page_id, $target_lang, $source_lang = 'auto', $translation_start_time = null) { if (!$translation_start_time) { $translation_start_time = microtime(true); } $post = get_post($page_id); if (!$post || (empty($post->post_content) && empty(trim($post->post_title ?? '')))) { $this->track_translation_failure('no_content', 'Post not found or empty', $translation_start_time); return new WP_Error('no_content', __('No content found in this page.', 'ai-translator-for-elementor-and-polylang')); } // Parse Classic Editor content. Pages with media-only body or no text // are allowed through with an empty map — the title will be // translated and the content replicated. $parser = AITRFOEL_Classic_Parser::get_instance(); $has_body = !empty(trim($post->post_content ?? '')); $translation_map = $has_body ? $parser->parse_for_translation($page_id) : []; if (empty($translation_map)) { if (empty(trim($post->post_title ?? ''))) { $this->track_translation_failure('no_translatable_content', 'No translatable content found in Classic Editor or title', $translation_start_time); return new WP_Error('no_translatable_content', __('No translatable content found in this page.', 'ai-translator-for-elementor-and-polylang')); } } // Convert to strings array for word limit check $strings_to_translate = []; foreach ($translation_map as $key => $data) { $strings_to_translate[] = $data; } // Extract SureForms post meta (_srfm_submit_button_text, _srfm_form_confirmation) when translating a SureForms form $is_sureform = in_array($post->post_type, ['sureforms_form', 'sf_form'], true); $srfm_submit_text = ''; $srfm_confirmations = null; if ($is_sureform) { $srfm_submit_text = (string) get_post_meta($page_id, '_srfm_submit_button_text', true); if (!empty(trim($srfm_submit_text))) { $strings_to_translate[] = [ 'original' => $srfm_submit_text, 'path' => '__srfm_submit_button_text__', 'type' => 'attribute', 'widget_type' => 'sureforms_meta', ]; } $srfm_confirmations = get_post_meta($page_id, '_srfm_form_confirmation', true); if (is_array($srfm_confirmations)) { foreach ($srfm_confirmations as $c_idx => $conf) { if (is_array($conf) && !empty($conf['message']) && is_string($conf['message'])) { $strings_to_translate[] = [ 'original' => $conf['message'], 'path' => '__srfm_confirmation_' . $c_idx . '__', 'type' => 'html', 'widget_type' => 'sureforms_meta', ]; } } } } // Word limit check (reuse existing logic) $word_limit_check = $this->check_word_limit($strings_to_translate, $page_id); if (is_wp_error($word_limit_check)) { return $word_limit_check; } // Detect source language $detected_lang = $this->detect_source_language_from_content($strings_to_translate, $source_lang, $page_id); AITRFOEL_Logger::log( sprintf('Classic translation: Page %d, %s → %s, %d strings', $page_id, $detected_lang, $target_lang, count($strings_to_translate)), 'info', $page_id ); // Include post title in batch to avoid a separate API call $post_title = $post->post_title; $title_key = '__aitrfoel_post_title__'; if (!empty($post_title)) { // Check if title already exists in parsed content (e.g. as H1) $title_already_included = false; foreach ($strings_to_translate as $s) { $stripped = trim(wp_strip_all_tags($s['original'] ?? '')); if ($stripped === trim($post_title)) { $title_already_included = true; break; } } if (!$title_already_included) { $strings_to_translate[] = [ 'original' => $post_title, 'path' => $title_key, 'type' => 'title', 'widget_type' => 'classic_editor', ]; } } // Translate strings (title included in batch) $translations = $this->batch_translate_strings($strings_to_translate, $target_lang, $detected_lang, $page_id); if (is_wp_error($translations)) { return $translations; } if (empty($translations) && !empty($strings_to_translate)) { $this->track_translation_failure('translation_failed', 'Translation API returned no translations', $translation_start_time); return new WP_Error('translation_failed', __('Failed to translate page content.', 'ai-translator-for-elementor-and-polylang')); } // Extract translated title from batch results // batch_translate_strings() keys translations by original text, not path $translated_title = null; if (isset($translations[$post_title])) { $translated_title = $translations[$post_title]; } elseif (!empty($title_already_included)) { // Title was already in content (e.g. as H1) — extract translated version foreach ($strings_to_translate as $s) { $stripped = trim(wp_strip_all_tags($s['original'] ?? '')); if ($stripped === trim($post_title) && isset($translations[$s['original']])) { $translated_title = wp_strip_all_tags($translations[$s['original']]); break; } } } // Apply translations to content $original_content = $post->post_content; $translated_content = $parser->apply_translations($original_content, $translations); // Create translated page (pass pre-translated title to skip separate API call) $new_page_id = $this->create_translated_page_generic($page_id, $translated_content, $target_lang, $detected_lang, 'classic', $translated_title); if (is_wp_error($new_page_id)) { return $new_page_id; } // Track success $this->track_translation_success($page_id, $new_page_id, $target_lang, $detected_lang, $translation_start_time, 'classic'); return $new_page_id; } /** * Translate a WooCommerce product that has no traditional editor content. * Handles products with only title, short description, attributes, etc. * * @param int $page_id Original product ID * @param string $target_lang Target language * @param string $source_lang Source language * @param float $translation_start_time Start time for analytics * @return int|WP_Error New product ID or error */ private function translate_woocommerce_product($page_id, $target_lang, $source_lang = 'auto', $translation_start_time = null) { if (!$translation_start_time) { $translation_start_time = microtime(true); } $post = get_post($page_id); if (!$post) { $this->track_translation_failure('no_content', 'Product not found', $translation_start_time); return new WP_Error('no_content', __('Product not found.', 'ai-translator-for-elementor-and-polylang')); } // Collect translatable texts: title + short description + long description $texts_to_translate = []; $text_keys = []; if (!empty($post->post_title)) { $texts_to_translate[] = $post->post_title; $text_keys[] = 'title'; } if (!empty(trim($post->post_excerpt))) { $texts_to_translate[] = $post->post_excerpt; $text_keys[] = 'excerpt'; } if (!empty(trim($post->post_content))) { $texts_to_translate[] = $post->post_content; $text_keys[] = 'content'; } if (empty($texts_to_translate)) { $this->track_translation_failure('no_translatable_content', 'Product has no translatable text', $translation_start_time); return new WP_Error('no_translatable_content', __('This product has no translatable content (no title or short description).', 'ai-translator-for-elementor-and-polylang')); } // Word limit check $strings_for_check = []; foreach ($texts_to_translate as $i => $text) { $strings_for_check[] = [ 'original' => $text, 'path' => $text_keys[$i], 'type' => 'text', 'widget_type' => 'woocommerce_product', ]; } $word_limit_check = $this->check_word_limit($strings_for_check, $page_id); if (is_wp_error($word_limit_check)) { return $word_limit_check; } // Detect source language $detected_lang = $this->detect_source_language_from_content($strings_for_check, $source_lang, $page_id); AITRFOEL_Logger::log( sprintf('WooCommerce product translation: Product %d, %s → %s, %d texts', $page_id, $detected_lang, $target_lang, count($texts_to_translate)), 'info', $page_id ); // Batch-translate title and excerpt $api = AITRFOEL_OpenAI_API::get_instance(); $translated_texts = $api->translate_batch($texts_to_translate, $target_lang, $detected_lang); if (is_wp_error($translated_texts)) { $this->track_translation_failure('translation_failed', 'API translation failed', $translation_start_time); return $translated_texts; } // Map results back using original texts as keys // translate_batch returns [ original_text => translated_text ] $translated_title = null; $translated_excerpt = null; $translated_content = null; foreach ($texts_to_translate as $i => $original_text) { $translated = isset($translated_texts[$original_text]) ? $translated_texts[$original_text] : null; if (!$translated || is_wp_error($translated)) { continue; } if ($text_keys[$i] === 'title') { $translated_title = $translated; } elseif ($text_keys[$i] === 'excerpt') { $translated_excerpt = $translated; } elseif ($text_keys[$i] === 'content') { $translated_content = $translated; } } if (!$translated_title) { $translated_title = $post->post_title . ' (' . strtoupper($target_lang) . ')'; } // Create translated product using the generic path $new_page_id = $this->create_translated_page_generic( $page_id, $translated_content ?: '', // translated long description $target_lang, $detected_lang, 'woocommerce', $translated_title ); if (is_wp_error($new_page_id)) { return $new_page_id; } // Apply translated excerpt (the generic path's translate_post_meta_fields handles SEO/WC meta, // but the excerpt was already translated above via batch, so apply it directly) if ($translated_excerpt) { wp_update_post(['ID' => $new_page_id, 'post_excerpt' => $translated_excerpt]); } // Track success $this->track_translation_success($page_id, $new_page_id, $target_lang, $detected_lang, $translation_start_time, 'woocommerce'); return $new_page_id; } /** * Check word limit for translation * * @param array|int $strings_to_translate Strings to translate or word count * @param int $page_id Page ID for logging * @return true|WP_Error True if within limit, error otherwise */ public function check_word_limit($strings_to_translate, $page_id = 0) { $license_manager = AITRFOEL_License_Manager_Server::get_instance(); $license_status = $license_manager->get_license_status(); $plan = $license_status['plan']; $word_limit = $license_status['word_limit']; $words_left = $license_status['words_left']; // Calculate estimated word count $source_words = 0; if (is_numeric($strings_to_translate)) { $source_words = intval($strings_to_translate); } elseif (is_array($strings_to_translate)) { foreach ($strings_to_translate as $string_data) { if (isset($string_data['original'])) { $text = wp_strip_all_tags($string_data['original']); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $source_words += self::count_words($text); } } } $server_factor = defined('AITRFOEL_Translate_All::SERVER_USAGE_FACTOR') ? AITRFOEL_Translate_All::SERVER_USAGE_FACTOR : 1; $estimated_words = $source_words * $server_factor; if ($word_limit !== -1 && $estimated_words > $words_left) { // Check cooldown for free plan if (strtolower($plan) === 'free') { $cooldown_end = get_option('aitrfoel_cooldown_end_date', 0); if ($cooldown_end > time()) { $days_left = ceil(($cooldown_end - time()) / DAY_IN_SECONDS); return new WP_Error( 'cooldown_active', sprintf( __('You have reached your monthly limit. Free plan users must wait %d more days for the limit to reset.', 'ai-translator-for-elementor-and-polylang'), $days_left ) ); } } return new WP_Error( 'insufficient_words', sprintf( __('This translation requires approximately %s words, but you only have %s words remaining.', 'ai-translator-for-elementor-and-polylang'), number_format($estimated_words), number_format($words_left) ) ); } return true; } /** * Detect source language from content * * @param array $strings_to_translate Strings to analyze * @param string $source_lang Provided source language (or 'auto') * @param int $page_id Page ID for logging * @return string Detected language code */ private function detect_source_language_from_content($strings_to_translate, $source_lang, $page_id) { if ($source_lang !== 'auto') { return $source_lang; } $detected = $this->detect_source_language($strings_to_translate, $page_id); if ($detected) { return $detected; } // Content too short/empty to auto-detect (e.g. a meta-only JetEngine CPT whose // post body is empty). Fall back to the source post's assigned language, then // the site's default language, before the last-resort 'en'. Without this, a // Dutch-default site translates "nl → en" as "en → en" — a no-op that leaves // titles and custom fields untranslated. if (class_exists('AITRFOEL_Multilingual_Manager')) { $mm = AITRFOEL_Multilingual_Manager::get_instance(); if ($mm) { if (method_exists($mm, 'get_post_language')) { $assigned = $mm->get_post_language($page_id); if ($assigned) { return $assigned; } } if (method_exists($mm, 'get_default_language')) { $default = $mm->get_default_language(); if ($default) { return $default; } } } } return 'en'; } /** * Batch translate strings using the OpenAI API * * @param array $strings_to_translate Array of string data with 'original' key * @param string $target_lang Target language * @param string $source_lang Source language * @param int $page_id Page ID for logging * @return array|WP_Error Array of original => translated, or error */ private function batch_translate_strings($strings_to_translate, $target_lang, $source_lang, $page_id) { $api = AITRFOEL_OpenAI_API::get_instance(); $cache = AITRFOEL_Cache_Manager::get_instance(); $translations = []; $uncached_strings = []; AITRFOEL_Logger::log( sprintf('batch_translate_strings: Processing %d strings for translation', count($strings_to_translate)), 'info', $page_id ); // Check cache first foreach ($strings_to_translate as $data) { $original = isset($data['original']) ? $data['original'] : ''; if (empty(trim($original))) { continue; } $cached = null; if ($this->cache_enabled) { $cached = $cache->get_from_cache($original, $target_lang, $source_lang); } if ($cached) { $translations[$original] = $cached; } else { $uncached_strings[] = $original; } } AITRFOEL_Logger::log( sprintf('batch_translate_strings: %d cached, %d need translation', count($translations), count($uncached_strings)), 'info', $page_id ); // Batch translate uncached strings if (!empty($uncached_strings)) { // Split by word count (~1500 words per batch) to avoid API timeouts on large content $max_words_per_batch = 1500; $batches = []; $current_batch = []; $current_words = 0; foreach ($uncached_strings as $str) { $wc = self::count_words(wp_strip_all_tags($str)); if (!empty($current_batch) && ($current_words + $wc) > $max_words_per_batch) { $batches[] = $current_batch; $current_batch = []; $current_words = 0; } $current_batch[] = $str; $current_words += $wc; } if (!empty($current_batch)) { $batches[] = $current_batch; } AITRFOEL_Logger::log( sprintf('batch_translate_strings: %d batches (~%d words/batch)%s', count($batches), $max_words_per_batch, count($batches) > 1 ? ' — using PARALLEL requests' : ''), 'info', $page_id ); // Use parallel requests when multiple batches exist if (count($batches) > 1) { $parallel_result = $api->translate_batches_parallel($batches, $target_lang, $source_lang); if (!is_wp_error($parallel_result)) { foreach ($parallel_result as $original => $translated) { $translations[$original] = $translated; if ($this->cache_enabled && !empty($translated)) { $cache->set_to_cache($original, $translated, $target_lang, $source_lang, 'gpt-4o-mini'); } } } else { AITRFOEL_Logger::log('Parallel batch error: ' . $parallel_result->get_error_message(), 'error', $page_id); return $parallel_result; } } else { // Single batch — use normal sequential path $batch = $batches[0]; $batch_wc = 0; foreach ($batch as $s) { $batch_wc += self::count_words(wp_strip_all_tags($s)); } AITRFOEL_Logger::log( sprintf('batch_translate_strings: Processing 1 batch with %d strings (~%d words)', count($batch), $batch_wc), 'info', $page_id ); $result = $api->translate_batch($batch, $target_lang, $source_lang); if (!is_wp_error($result)) { foreach ($result as $original => $translated) { $translations[$original] = $translated; if ($this->cache_enabled && !empty($translated)) { $cache->set_to_cache($original, $translated, $target_lang, $source_lang, 'gpt-4o-mini'); } } } else { AITRFOEL_Logger::log('Batch translation error: ' . $result->get_error_message(), 'error', $page_id); return $result; } } } // Track word count $word_count = 0; foreach ($translations as $original => $translated) { $word_count += self::count_words(wp_strip_all_tags($original)); } $this->last_translation_stats['word_count'] = $word_count; $this->last_translation_stats['strings_count'] = count($translations); AITRFOEL_Logger::log( sprintf('batch_translate_strings: Complete. %d translations, %d words', count($translations), $word_count), 'info', $page_id ); return $translations; } /** * Create translated page for Gutenberg/Classic content * * @param int $original_page_id Original page ID * @param string $translated_content Translated post content * @param string $target_lang Target language * @param string $source_lang Source language * @param string $content_type Content type (gutenberg/classic) * @return int|WP_Error New page ID or error */ private function get_translated_parent_id($parent_id, $target_lang) { $parent_id = (int) $parent_id; if (!$parent_id) { return 0; } if (class_exists('AITRFOEL_Builtin_Integration') && AITRFOEL_Builtin_Integration::is_enabled()) { $translations = AITRFOEL_Builtin_Integration::get_instance()->get_post_translations($parent_id); if (!empty($translations[$target_lang])) { return (int) $translations[$target_lang]; } } if (function_exists('pll_get_post')) { $tp = pll_get_post($parent_id, $target_lang); if ($tp) { return (int) $tp; } } $wpml = apply_filters('wpml_object_id', $parent_id, get_post_type($parent_id), false, $target_lang); if ($wpml && (int) $wpml !== $parent_id) { return (int) $wpml; } return $parent_id; // no translated parent yet — keep under the source parent } private function create_translated_page_generic($original_page_id, $translated_content, $target_lang, $source_lang, $content_type, $pre_translated_title = null) { $original_page = get_post($original_page_id); if (!$original_page) { return new WP_Error('page_not_found', __('Original page not found.', 'ai-translator-for-elementor-and-polylang')); } // Check if a translation already exists for this language — update instead of duplicating $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); $existing_translations = $multilingual->is_active() ? $multilingual->get_post_translations($original_page_id) : []; // Normalize target_lang to code for lookup (translations are keyed by code) $target_code = $target_lang; if (strlen($target_lang) > 3 && class_exists('AITRFOEL_Language_Mapper')) { $resolved = AITRFOEL_Language_Mapper::get_code_from_language_name($target_lang); if ($resolved) { $target_code = $resolved; } } // Rewrite [elementor-template id="X"] and form shortcodes in the translated post_content // so each id points at the X-language translation. This makes // the translated post self-contained and immune to render paths that bypass // our pre_do_shortcode_tag runtime swap (which still acts as a safety net). if (is_string($translated_content) && $translated_content !== '') { $translated_content = $this->rewrite_elementor_template_shortcode_ids($translated_content, $target_code); $translated_content = $this->rewrite_form_shortcode_ids($translated_content, $target_code); } $existing_page_id = isset($existing_translations[$target_code]) ? (int) $existing_translations[$target_code] : 0; // Fallback: if the multilingual plugin didn't register the relationship (e.g. on a // partial-save retry), search for a previously created page by our own tracking meta. if (!$existing_page_id || !get_post($existing_page_id)) { $fallback_posts = get_posts([ 'post_type' => $original_page->post_type, 'post_status' => ['publish', 'draft', 'private', 'pending'], 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_query' => [[ 'key' => '_aitrfoel_translated_from', 'value' => $original_page_id . '_' . $target_code, 'compare' => '=', ]], ]); if (!empty($fallback_posts)) { $existing_page_id = (int) $fallback_posts[0]; AITRFOEL_Logger::log( sprintf('Fallback lookup: found existing %s translation (ID %d) for page %d → %s via tracking meta.', $content_type, $existing_page_id, $original_page_id, $target_lang), 'info', $original_page_id ); } } if ($existing_page_id && get_post($existing_page_id)) { AITRFOEL_Logger::log( sprintf('Existing %s translation found (ID %d) for page %d → %s. Updating.', $content_type, $existing_page_id, $original_page_id, $target_lang), 'info', $original_page_id ); // Translate title if (!empty($pre_translated_title) && is_string($pre_translated_title)) { $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = method_exists($api, 'clean_html_from_text') ? $api->clean_html_from_text($pre_translated_title) : wp_strip_all_tags($pre_translated_title); } else { $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = $api->translate_title($original_page->post_title, $target_lang, $source_lang); } // Update existing page $update_args = [ 'ID' => $existing_page_id, 'post_content' => $translated_content, ]; if (!is_wp_error($translated_title) && !empty($translated_title)) { $update_args['post_title'] = $translated_title; // Optionally update slug from translated title (unless preserve slug on retranslation is enabled). $preserve_slug = get_option('aitrfoel_preserve_slug_on_retranslate', true); if (!$preserve_slug && get_option('aitrfoel_translate_url_slugs', true)) { $translated_slug = sanitize_title($translated_title); if (!empty($translated_slug)) { $update_args['post_name'] = $translated_slug; } } } wp_update_post($update_args); // Copy meta $meta_keys_to_copy = [ '_wp_page_template', 'site-post-title', 'ast-title-bar-display', 'ast-featured-img', 'ast-site-content-layout', 'site-sidebar-layout', ]; foreach ($meta_keys_to_copy as $meta_key) { $meta_value = get_post_meta($original_page_id, $meta_key, true); if ($meta_value !== '') { update_post_meta($existing_page_id, $meta_key, $meta_value); } } update_post_meta($existing_page_id, '_aitrfoel_content_type', $content_type); // Translate SEO meta, excerpt, and WooCommerce fields $this->translate_post_meta_fields($original_page_id, $existing_page_id, $target_lang, $source_lang); // Assign translated WooCommerce product categories/tags $this->translate_product_taxonomies($original_page_id, $existing_page_id, $target_lang); // Translate WooCommerce product attributes $this->translate_product_attributes($original_page_id, $existing_page_id, $target_lang, $source_lang); // Translate product variations for variable products $this->translate_product_variations($original_page_id, $existing_page_id, $target_lang, $source_lang); // Translate product gallery image alt text/captions $this->translate_product_gallery_meta($original_page_id, $existing_page_id, $target_lang, $source_lang); // Translate and assign post categories/tags (non-WooCommerce) $this->translate_post_taxonomies($original_page_id, $existing_page_id, $target_lang); // If the source post is not Elementor, remove stale Elementor data/edit-mode so post_content renders if ($content_type !== 'elementor') { delete_post_meta($existing_page_id, '_elementor_edit_mode'); delete_post_meta($existing_page_id, '_elementor_data'); } // Invalidate post cache, Elementor CSS cache, and page cache delete_post_meta($existing_page_id, '_elementor_css'); clean_post_cache($existing_page_id); if (class_exists('\Elementor\Plugin')) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } if (function_exists('litespeed_purge_post')) { litespeed_purge_post($existing_page_id); } if (isset($GLOBALS['wp_fastest_cache']) && method_exists($GLOBALS['wp_fastest_cache'], 'singleDeleteCache')) { $GLOBALS['wp_fastest_cache']->singleDeleteCache(false, $existing_page_id); } return $existing_page_id; } // Use pre-translated title if provided (already included in batch), otherwise translate separately if (!empty($pre_translated_title) && is_string($pre_translated_title)) { // Clean HTML from title (same as translate_title does) $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = method_exists($api, 'clean_html_from_text') ? $api->clean_html_from_text($pre_translated_title) : wp_strip_all_tags($pre_translated_title); } else { // Fallback: translate title separately (adds ~2-5s) $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = $api->translate_title($original_page->post_title, $target_lang, $source_lang); } if (is_wp_error($translated_title) || empty($translated_title)) { $translated_title = $original_page->post_title . ' (' . strtoupper($target_lang) . ')'; } // Create new page $target_status = get_option('aitrfoel_translated_page_status', 'publish'); // If 'inherit', match the original post's status if ($target_status === 'inherit') { $target_status = ($original_page->post_status === 'publish') ? 'publish' : 'publish'; } // Optionally generate a translated slug; otherwise preserve source-style slug. if (get_option('aitrfoel_translate_url_slugs', true)) { $translated_slug = sanitize_title($translated_title); if (empty($translated_slug)) { $translated_slug = $original_page->post_name . '-' . $target_lang; } } else { $translated_slug = $original_page->post_name . '-' . $target_lang; } $new_page_args = [ 'post_title' => $translated_title, 'post_name' => $translated_slug, 'post_content' => $translated_content, 'post_status' => $target_status, 'post_type' => $original_page->post_type, 'post_parent' => $this->get_translated_parent_id($original_page->post_parent, $target_lang), 'menu_order' => $original_page->menu_order, 'post_date' => $original_page->post_date, 'post_date_gmt' => $original_page->post_date_gmt, 'post_excerpt' => $original_page->post_excerpt, ]; $new_page_id = wp_insert_post($new_page_args); if (is_wp_error($new_page_id)) { return $new_page_id; } // Reassert the requested status after insertion in case another hook/plugin // forced a different post_status during wp_insert_post(). if (get_post_status($new_page_id) !== $target_status) { wp_update_post([ 'ID' => $new_page_id, 'post_status' => $target_status, ]); clean_post_cache($new_page_id); } // Copy relevant meta $meta_keys_to_copy = [ '_wp_page_template', 'site-post-title', 'ast-title-bar-display', 'ast-featured-img', 'ast-site-content-layout', 'site-sidebar-layout', ]; foreach ($meta_keys_to_copy as $meta_key) { $meta_value = get_post_meta($original_page_id, $meta_key, true); if ($meta_value !== '') { update_post_meta($new_page_id, $meta_key, $meta_value); } } // Store content type for reference update_post_meta($new_page_id, '_aitrfoel_content_type', $content_type); update_post_meta($new_page_id, '_aitrfoel_source_page', $original_page_id); // Store source-page + language so we can find this page on partial-save retries // even when the multilingual plugin hasn't committed the relationship yet. update_post_meta($new_page_id, '_aitrfoel_translated_from', $original_page_id . '_' . $target_code); // Translate SEO meta, excerpt, and WooCommerce fields $this->translate_post_meta_fields($original_page_id, $new_page_id, $target_lang, $source_lang); // Setup multilingual relationship $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); if ($multilingual->is_active()) { $result = $multilingual->setup_translation_relationship($original_page_id, $new_page_id, $target_lang); if (is_wp_error($result)) { AITRFOEL_Logger::log( 'Multilingual setup failed: ' . $result->get_error_message(), 'warning', $original_page_id, $new_page_id ); } } // Assign translated WooCommerce product categories/tags $this->translate_product_taxonomies($original_page_id, $new_page_id, $target_lang); // Translate WooCommerce product attributes $this->translate_product_attributes($original_page_id, $new_page_id, $target_lang, $source_lang); // Translate product variations for variable products $this->translate_product_variations($original_page_id, $new_page_id, $target_lang, $source_lang); // Translate product gallery image alt text/captions $this->translate_product_gallery_meta($original_page_id, $new_page_id, $target_lang, $source_lang); // Translate and assign post categories/tags (non-WooCommerce) $this->translate_post_taxonomies($original_page_id, $new_page_id, $target_lang); AITRFOEL_Logger::log( sprintf('Created %s translated page: %d -> %d (%s)', $content_type, $original_page_id, $new_page_id, $target_lang), 'success', $original_page_id, $new_page_id ); return $new_page_id; } /** * Track translation success with analytics * * @param int $page_id Original page ID * @param int $new_page_id New page ID * @param string $target_lang Target language * @param string $source_lang Source language * @param float $translation_start_time Start time * @param string $content_type Content type */ private function track_translation_success($page_id, $new_page_id, $target_lang, $source_lang, $translation_start_time, $content_type) { AITRFOEL_Logger::log( sprintf('Successfully translated page %d to %s. New page ID: %d (type: %s)', $page_id, $target_lang, $new_page_id, $content_type), 'success', $page_id, $new_page_id ); // All translations go through MeepTech's /translate endpoint which auto-counts words. // Invalidate cached license status so next word-limit check fetches fresh data. AITRFOEL_License_Manager_Server::get_instance()->clear_status_cache(); // Increment global successful translations counter for milestone review prompts $rev_count = (int) get_option('aitrfoel_successful_translations_count', 0); update_option('aitrfoel_successful_translations_count', $rev_count + 1, false); // Record in translation history $translation_duration = microtime(true) - $translation_start_time; if (class_exists('AITRFOEL_Translation_History')) { AITRFOEL_Translation_History::record([ 'original_post_id' => $page_id, 'translated_post_id' => $new_page_id, 'source_lang' => $source_lang, 'target_lang' => $target_lang, 'content_type' => $content_type, 'word_count' => $this->last_translation_stats['word_count'] ?? 0, 'status' => 'success', 'duration' => round($translation_duration, 2), ]); } // Track with PostHog Analytics if (function_exists('aitrfoel_analytics')) { aitrfoel_analytics()->track_translation_completed([ 'duration' => round($translation_duration, 2), 'word_count' => $this->last_translation_stats['word_count'] ?? 0, 'widget_count' => $this->last_translation_stats['strings_count'] ?? 0, 'success' => true, 'mode' => 'single', 'content_type' => $content_type, 'source_lang' => $source_lang, 'target_lang' => $target_lang, ]); } } /** * Clean text for translation (remove HTML but preserve structure) * * @param string $text Original text with HTML * @return string Clean text */ private function clean_text_for_translation($text) { // Store original text for hash key $original_text = $text; // Ensure input is string if (!is_string($text)) { return ''; } // Skip if text is too short to be meaningful (use mb_strlen for UTF-8) if (mb_strlen(trim($text), 'UTF-8') < 2) { return ''; } // Preserve emojis and special Unicode characters // Convert to UTF-8 if needed if (!mb_check_encoding($text, 'UTF-8')) { $text = mb_convert_encoding($text, 'UTF-8', mb_detect_encoding($text)); } // Preserve important line breaks and spacing elements with placeholders $html_placeholders = []; $placeholder_counter = 0; // Preserve complete HTML elements while cleaning content // Process in order that preserves nested structures correctly // SIMPLIFIED APPROACH: Only preserve major structural elements, let OpenAI handle inline formatting $preserved_patterns = [ '/<(div)([^>]*)>(.*?)<\/\1>/s' => 'DIV', '/<(h[1-6])([^>]*)>(.*?)<\/\1>/s' => 'HDG', '/<(p)([^>]*)>(.*?)<\/\1>/s' => 'PGR', '/<(ul|ol)([^>]*)>(.*?)<\/\1>/s' => 'LST', '/<(li)([^>]*)>(.*?)<\/\1>/s' => 'ITM', ]; // Store HTML structure and replace with placeholders // Only for BLOCK elements - let inline elements pass through to OpenAI foreach ($preserved_patterns as $pattern => $placeholder_prefix) { AITRFOEL_Logger::log("Processing pattern for: $placeholder_prefix", 'debug'); $match_count = 0; $text = preg_replace_callback($pattern, function($matches) use (&$html_placeholders, &$placeholder_counter, $placeholder_prefix, &$match_count) { $match_count++; $tag = $matches[1]; $attributes = $matches[2]; $content = $matches[3]; // Create translation-resistant placeholder using random string $random_id = substr(md5(uniqid() . $placeholder_counter), 0, 8); $placeholder = $placeholder_prefix . '_' . $random_id; $placeholder_counter++; // Store the HTML structure with the base placeholder name $html_placeholders[$placeholder] = [ 'tag' => $tag, 'attributes' => $attributes, 'is_self_closing' => false ]; AITRFOEL_Logger::log("Match $match_count: Storing placeholder: $placeholder for tag: <$tag$attributes>", 'debug'); // For all block elements, use newlines return "\n≪" . $placeholder . "≫\n" . $content . "\n≪/" . $placeholder . "≫\n"; }, $text); AITRFOEL_Logger::log("Pattern $placeholder_prefix found $match_count matches", 'debug'); } // Handle self-closing tags with translation-resistant markers (inline, no newlines) // Case-insensitive, handles attributes like
and variants like
$text = preg_replace('//i', '≪BR≫', $text); // Strip HTML tags but preserve content and placeholders $text = wp_strip_all_tags($text); // Decode HTML entities (preserve Unicode) $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Clean up whitespace but preserve intentional line breaks and placeholders $text = preg_replace('/[ \t]+/u', ' ', $text); // Normalize spaces and tabs (with Unicode flag) $text = preg_replace('/\n\s*\n/u', "\n", $text); // Remove excess line breaks $text = trim($text); // Skip text that's only numbers, special characters, or whitespace // BUT allow Unicode letters (Cyrillic, Arabic, Chinese, etc.) if (!preg_match('/\p{L}/u', $text)) { return ''; } // Store the HTML placeholders for restoration using original text hash $text_hash = md5($original_text); $this->html_placeholders_storage[$text_hash] = $html_placeholders; AITRFOEL_Logger::log("Stored placeholders for hash: $text_hash (original text hash)", 'debug'); return $text; } /** * Restore HTML formatting after translation * * @param string $translated_text Clean translated text * @param string $original_text Original text with HTML * @return string Translated text with HTML restored */ private function restore_html_formatting($translated_text, $original_text) { // Ensure inputs are strings if (!is_string($translated_text) || !is_string($original_text)) { return is_string($translated_text) ? $translated_text : ''; } AITRFOEL_Logger::log("restore_html_formatting called", 'debug'); AITRFOEL_Logger::log("Original text: " . substr((string)$original_text, 0, 200) . (strlen((string)$original_text) > 200 ? '...' : ''), 'debug'); AITRFOEL_Logger::log("Translated text: " . substr((string)$translated_text, 0, 200) . (strlen((string)$translated_text) > 200 ? '...' : ''), 'debug'); // If no HTML in original, strip any HTML the AI added and return if ($original_text === wp_strip_all_tags($original_text)) { $cleaned = wp_strip_all_tags($translated_text); // Restore BR markers even in "no HTML" path (edge case: original was already cleaned) $cleaned = str_replace('≪BR≫', '
', $cleaned); if ($cleaned !== $translated_text) { AITRFOEL_Logger::log("No HTML in original, stripping AI-added HTML", 'debug'); } AITRFOEL_Logger::log("No HTML in original text, returning cleaned translation", 'debug'); return $cleaned; } // Original HAS HTML. Check if AI preserved the block-level structure. // Extract block-level tag sequence from original and translated. $block_tags = 'p|h[1-6]|ul|ol|li|div|blockquote|table|tr|td|th|thead|tbody|section|article|header|footer|figure|figcaption|pre'; preg_match_all('/<(\/?)(' . $block_tags . ')[\s>]/i', $original_text, $orig_blocks); preg_match_all('/<(\/?)(' . $block_tags . ')[\s>]/i', $translated_text, $tr_blocks); $orig_sequence = implode(',', array_map(function($close, $tag) { return ($close ? '/' : '') . strtolower($tag); }, $orig_blocks[1], $orig_blocks[2])); $tr_sequence = implode(',', array_map(function($close, $tag) { return ($close ? '/' : '') . strtolower($tag); }, $tr_blocks[1], $tr_blocks[2])); if (!empty($orig_sequence) && $orig_sequence === $tr_sequence) { // AI preserved the same block-level HTML structure — use AI's response directly AITRFOEL_Logger::log("AI preserved block-level HTML structure, using AI response directly", 'info'); // Still restore BR markers in case they survived in the AI response return str_replace('≪BR≫', '
', $translated_text); } // Block structure doesn't match — fall back to stripping and restoring $cleaned_translated = wp_strip_all_tags($translated_text); if ($cleaned_translated !== $translated_text) { AITRFOEL_Logger::log("WARNING: AI changed HTML structure, stripping it", 'warning'); AITRFOEL_Logger::log("Cleaned response: " . substr($cleaned_translated, 0, 200), 'debug'); } // If we have stored HTML placeholders, restore them $original_hash = md5($original_text); AITRFOEL_Logger::log("Looking for placeholders with hash: $original_hash", 'debug'); AITRFOEL_Logger::log("Available hashes: " . implode(', ', array_keys($this->html_placeholders_storage)), 'debug'); if (!empty($this->html_placeholders_storage[$original_hash])) { AITRFOEL_Logger::log("Using placeholder restoration with " . count($this->html_placeholders_storage[$original_hash]) . " placeholders for hash: $original_hash", 'debug'); $result = $this->restore_html_with_placeholders($cleaned_translated, $this->html_placeholders_storage[$original_hash]); // Check if placeholder restoration actually worked // If the result is the same as input, placeholder restoration didn't work because // the AI response doesn't contain the placeholder markers if ($result === $cleaned_translated) { AITRFOEL_Logger::log("Placeholder restoration had no effect (AI didn't return placeholders), falling back to pattern matching", 'warning'); $result = $this->restore_html_simple_patterns($cleaned_translated, $original_text); $result = str_replace('≪BR≫', '
', $result); return $result; } // Restore BR markers from placeholder path $result = str_replace('≪BR≫', '
', $result); return $result; } else { AITRFOEL_Logger::log("No placeholders found for hash: $original_hash", 'warning'); } // Fallback to simple pattern matching for backward compatibility AITRFOEL_Logger::log("Using fallback pattern matching", 'debug'); $result = $this->restore_html_simple_patterns($cleaned_translated, $original_text); // Always restore BR markers regardless of which path was taken $result = str_replace('≪BR≫', '
', $result); return $result; } /** * Restore HTML using stored placeholders */ private function restore_html_with_placeholders($translated_text, $html_placeholders) { AITRFOEL_Logger::log("Restoring HTML with placeholders. Text length: " . strlen($translated_text), 'debug'); AITRFOEL_Logger::log("Available placeholders: " . count($html_placeholders), 'debug'); // First restore complex HTML elements (BEFORE BR tag replacement) foreach ($html_placeholders as $placeholder => $html_info) { $start_marker = '≪' . $placeholder . '≫'; $end_marker = '≪/' . $placeholder . '≫'; AITRFOEL_Logger::log("Looking for placeholder: $placeholder", 'debug'); AITRFOEL_Logger::log("Start marker: $start_marker", 'debug'); AITRFOEL_Logger::log("End marker: $end_marker", 'debug'); // Check if markers exist in the text if (strpos($translated_text, $start_marker) !== false && strpos($translated_text, $end_marker) !== false) { AITRFOEL_Logger::log("Found markers for placeholder: $placeholder", 'debug'); // Find content between markers - handle inline vs block elements differently $is_inline = in_array($html_info['tag'], ['strong', 'b', 'em', 'i', 'a', 'span']); if ($is_inline) { // For inline elements, preserve space around markers $pattern = '/\s*' . preg_quote($start_marker, '/') . '\s+(.*?)\s+' . preg_quote($end_marker, '/') . '\s*/s'; } else { // For block elements, use the original pattern $pattern = '/\s*' . preg_quote($start_marker, '/') . '\s*(.*?)\s*' . preg_quote($end_marker, '/') . '\s*/s'; } $translated_text = preg_replace_callback($pattern, function($matches) use ($html_info, $placeholder, $is_inline) { $translated_content = trim($matches[1]); $tag = $html_info['tag']; $attributes = $html_info['attributes']; AITRFOEL_Logger::log("Restoring placeholder $placeholder: <$tag$attributes>$translated_content", 'debug'); $restored = "<{$tag}{$attributes}>{$translated_content}"; // For inline elements, add a space after to prevent concatenation if ($is_inline) { return $restored . ' '; } return $restored; }, $translated_text); } else { AITRFOEL_Logger::log("Markers not found for placeholder: $placeholder", 'warning'); } } // NOTE: BR marker restoration moved to restore_html_formatting() to cover all paths AITRFOEL_Logger::log("HTML restoration complete", 'debug'); return $translated_text; } /** * Simple pattern matching for HTML restoration (fallback) */ private function restore_html_simple_patterns($translated_text, $original_text) { AITRFOEL_Logger::log("Fallback restoration - Original: " . substr($original_text, 0, 200), 'debug'); AITRFOEL_Logger::log("Fallback restoration - Translated: " . substr($translated_text, 0, 200), 'debug'); // Strip any HTML that might have been added by the AI translation service $clean_translated = wp_strip_all_tags($translated_text); // STEP 1: Try to restore inline HTML elements using text matching $restored_with_inline = $this->restore_inline_html_fallback($clean_translated, $original_text); AITRFOEL_Logger::log("After inline HTML restoration: " . substr($restored_with_inline, 0, 200), 'debug'); // STEP 2: Handle structural restoration $trimmed_original = trim($original_text); // Count the number of top-level HTML elements in the original $element_count = preg_match_all('/<([a-zA-Z0-9]+)(?:[^>]*)>.*?<\/\1>/s', $trimmed_original, $matches); AITRFOEL_Logger::log("Fallback: Found $element_count top-level HTML elements", 'debug'); // If there's only ONE top-level element, we can safely restore it if ($element_count == 1) { // Handle nested elements FIRST (like

Title

) if (preg_match('/^<(h[1-6])([^>]*)><(strong|b|em|i)([^>]*)>(.*?)<\/\3><\/\1>$/s', $trimmed_original, $matches)) { $outer_tag = $matches[1]; $outer_attrs = $matches[2]; $inner_tag = $matches[3]; $inner_attrs = $matches[4]; $content = $matches[5]; $result = "<{$outer_tag}{$outer_attrs}><{$inner_tag}{$inner_attrs}>{$restored_with_inline}"; AITRFOEL_Logger::log("Fallback: Restored nested element: $result", 'debug'); return $result; } // Handle simple single element cases if (preg_match('/^<([a-zA-Z0-9]+)([^>]*)>(.*?)<\/\1>$/s', $trimmed_original, $matches)) { $tag = $matches[1]; $attrs = $matches[2]; $content = $matches[3]; $result = "<{$tag}{$attrs}>{$restored_with_inline}"; AITRFOEL_Logger::log("Fallback: Restored single element <$tag>: $result", 'debug'); return $result; } } // If we have MULTIPLE elements or complex structure, DO NOT attempt automatic splitting // This prevents the over-splitting issue you're experiencing if ($element_count > 1) { AITRFOEL_Logger::log("Fallback: Multiple elements detected - returning original structure with translated text inserted in first element only", 'warning'); // Find the first paragraph or main content element and replace its content $result = preg_replace_callback('/<(p|h[1-6]|div)([^>]*)>(.*?)<\/\1>/s', function($matches) use ($restored_with_inline) { $tag = $matches[1]; $attrs = $matches[2]; $original_content = $matches[3]; // Only replace the first match, preserve the rest as-is static $first_replacement = true; if ($first_replacement) { $first_replacement = false; AITRFOEL_Logger::log("Fallback: Replacing content in first <$tag> element", 'debug'); return "<{$tag}{$attrs}>{$restored_with_inline}"; } // Keep subsequent elements unchanged return $matches[0]; }, $trimmed_original, 1); // Limit to 1 replacement if ($result !== $trimmed_original) { AITRFOEL_Logger::log("Fallback: Partial restoration completed", 'debug'); return $result; } } // Last resort: return the translated text with any inline HTML we managed to restore AITRFOEL_Logger::log("Fallback: Could not restore HTML structure - returning text with inline HTML", 'warning'); return $restored_with_inline; } /** * Try to restore inline HTML elements by matching text content with flexible patterns */ private function restore_inline_html_fallback($translated_text, $original_text) { // Strategy 1: Extract all inline HTML elements and their text content $inline_patterns = [ '/]*>(.*?)<\/strong>/i' => 'strong', '/]*>(.*?)<\/em>/i' => 'em', '/]*href[^>]*>(.*?)<\/a>/i' => 'a', '/]*>(.*?)<\/span>/i' => 'span', '/]*>(.*?)<\/b>/i' => 'b', '/]*>(.*?)<\/i>/i' => 'i' ]; $found_elements = []; foreach ($inline_patterns as $pattern => $tag_type) { if (preg_match_all($pattern, $original_text, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $full_tag = $match[0]; $text_content = trim(wp_strip_all_tags($match[1])); if (!empty($text_content)) { $found_elements[] = [ 'full_tag' => $full_tag, 'text' => $text_content, 'type' => $tag_type ]; AITRFOEL_Logger::log("Found $tag_type: '" . $text_content . "' in " . substr($full_tag, 0, 50), 'debug'); } } } } AITRFOEL_Logger::log("Found " . count($found_elements) . " inline HTML elements for flexible restoration", 'debug'); $restored = $translated_text; $restorations = 0; // Strategy 2: Flexible pattern matching for different element types foreach ($found_elements as $element) { $original_text_clean = $element['text']; $tag_type = $element['type']; $full_original_tag = $element['full_tag']; if ($tag_type === 'strong' || $tag_type === 'b') { // Look for numeric patterns in strong tags if (preg_match('/(\d+(?:[.,]\d+)*)\s*(million|thousand|individuals|foreigners|%)/i', $original_text_clean, $orig_matches)) { // Try multiple patterns for different languages $patterns_to_try = [ '/(\d+[.,]?\d*)\s*%/', // Percentage '/(\d+[.,\s]?\d*)\s*(milion[ůy]?|tisíc)/ui', // Million/thousand in Czech '/(\d+[.,\s]?\d*)\s*(jednotlivců|osob|lidí)/ui', // Individuals in Czech '/(\d+[.,\s]?\d*)\s*(cizinců|cizích)/ui', // Foreigners in Czech ]; foreach ($patterns_to_try as $pattern) { if (preg_match($pattern, $restored, $trans_matches)) { $translated_match = trim($trans_matches[0]); // Check if it's not already wrapped if (strpos($restored, "<$tag_type>$translated_match") === false) { $restored = str_replace($translated_match, "<$tag_type>$translated_match", $restored); AITRFOEL_Logger::log("Restored $tag_type around: '$translated_match'", 'debug'); $restorations++; break; // Stop after first successful match } } } } } else if ($tag_type === 'a') { // For links, look for common link text patterns $link_patterns = [ '/(číst více|čtěte více)/ui', '/(read more)/i', '/(více informací|více info)/ui', '/(pokračovat|continue)/ui', '/(zjistit více|learn more)/ui' ]; foreach ($link_patterns as $pattern) { if (preg_match($pattern, $restored, $trans_matches)) { $translated_link_text = $trans_matches[0]; // Extract href from original if (preg_match('/href=(["\'])([^"\']+)\1/i', $full_original_tag, $href_match)) { $href = $href_match[2]; $new_link = "$translated_link_text"; // Only replace if not already a link if (strpos($restored, $new_link) === false) { $restored = str_replace($translated_link_text, $new_link, $restored); AITRFOEL_Logger::log("Restored link: '$translated_link_text'", 'debug'); $restorations++; break; } } } } } } AITRFOEL_Logger::log("Total flexible restorations made: $restorations", 'debug'); return $restored; } /** * Normalize a language value (name or code) to multilingual plugin language code. */ private function normalize_lang_code($lang) { $code = $lang; if (is_string($lang) && strlen($lang) > 2 && class_exists('AITRFOEL_Multilingual_Manager')) { $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); $mapped = $multilingual->get_language_code($lang); if ($mapped) { $code = $mapped; } } return $code; } /** * Determine if a URL is internal to this site. */ private function is_internal_url($url) { if (!is_string($url) || $url === '') return false; // Skip special schemes if (preg_match('~^(#|mailto:|tel:|javascript:)~i', $url)) return false; $home = home_url('/'); $home_parts = wp_parse_url($home); $u = wp_parse_url($url); // Relative URL => internal if ($u === false || !isset($u['host'])) return true; $is_internal = isset($home_parts['host']) && strcasecmp($home_parts['host'], $u['host']) === 0; // Support staging/production domain mismatch during local development if (!$is_internal && isset($home_parts['host'])) { $path = isset($u['path']) ? $u['path'] : ''; if ($path !== '' && $path !== '/' && !preg_match('/\\.(jpg|jpeg|png|gif|css|js|pdf|zip|svg)$/i', $path)) { $local_url = str_replace($u['host'], $home_parts['host'], $url); if (url_to_postid($local_url) > 0) { return true; } } } return $is_internal; } /** * Build absolute URL for a (possibly relative) href. */ private function to_absolute_url($href) { if (!is_string($href) || $href === '') return $href; $u = wp_parse_url($href); if ($u === false) return $href; if (isset($u['scheme']) && isset($u['host'])) return $href; // already absolute $path = isset($u['path']) ? $u['path'] : ''; if (strpos($path, '/') !== 0) { // Relative path - resolve relative to home_url (simplistic fallback) $path = '/' . $path; } return home_url($path . (isset($u['query']) ? ('?' . $u['query']) : '') . (isset($u['fragment']) ? ('#' . $u['fragment']) : '')); } /** * Localize a single URL to the target language using Polylang mappings when possible. */ private function localize_single_url($href, $target_lang) { if (!is_string($href) || $href === '') return $href; if (!$this->is_internal_url($href)) return $href; $target_code = $this->normalize_lang_code($target_lang); $abs = $this->to_absolute_url($href); // Separate query/fragment $parts = wp_parse_url($abs); if ($parts === false) return $href; $base_no_qf = (isset($parts['scheme']) ? $parts['scheme'] . '://' : '') . (isset($parts['host']) ? $parts['host'] : '') . (isset($parts['port']) ? (':' . $parts['port']) : '') . (isset($parts['path']) ? $parts['path'] : ''); $suffix = (isset($parts['query']) ? ('?' . $parts['query']) : '') . (isset($parts['fragment']) ? ('#' . $parts['fragment']) : ''); // 1) Best-effort: map post ID to its translation $post_id = url_to_postid($base_no_qf); if (!$post_id && isset($parts['host'])) { $local_host = wp_parse_url(home_url(), PHP_URL_HOST); if ($local_host && strcasecmp($parts['host'], $local_host) !== 0) { $local_base = str_replace($parts['host'], $local_host, $base_no_qf); $post_id = url_to_postid($local_base); } } // Fallback: Resolve by slug (last path segment) if url_to_postid failed. // This handles shortened URLs, legacy/redirect links, or nested pages // whose static links in the database don't reflect the full current nested path. if (!$post_id && isset($parts['path'])) { $path_trimmed = trim($parts['path'], '/'); if ($path_trimmed !== '') { $path_segments = explode('/', $path_trimmed); $slug = end($path_segments); if ($slug !== '') { global $wpdb; $pt_allowed = ['page', 'post', 'product']; $pt_allowed = apply_filters('aitrfoel_link_resolution_post_types', $pt_allowed); $in_clause = "'" . implode("','", array_map('esc_sql', $pt_allowed)) . "'"; $resolved_id = (int) $wpdb->get_var($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type IN ($in_clause) AND post_status = 'publish' LIMIT 1", $slug )); if ($resolved_id > 0) { $post_id = $resolved_id; } } } } if ($post_id) { $multilingual = class_exists('AITRFOEL_Multilingual_Manager') ? AITRFOEL_Multilingual_Manager::get_instance() : null; if ($multilingual && $multilingual->is_active()) { $translations = $multilingual->get_post_translations($post_id); if (isset($translations[$target_code])) { $translated_id = (int) $translations[$target_code]; if ($translated_id > 0) { $new = get_permalink($translated_id); if (is_string($new) && $new !== '') { // Restore original host/scheme if mismatch (local dev / production database sync support) if (isset($parts['host'])) { $new_parts = wp_parse_url($new); if ($new_parts && isset($new_parts['host'])) { $new = str_replace($new_parts['host'], $parts['host'], $new); if (isset($parts['scheme']) && isset($new_parts['scheme'])) { $new = str_replace($new_parts['scheme'] . '://', $parts['scheme'] . '://', $new); } } } return $new . $suffix; } } } } elseif (function_exists('pll_get_post') && $target_code) { $translated_id = pll_get_post($post_id, $target_code); if ($translated_id) { $new = get_permalink($translated_id); if (is_string($new) && $new !== '') { return $new . $suffix; } } } } // 2) Fallback: swap language home base, keep path // Use multilingual manager if available, fallback to Polylang functions for backward compatibility $multilingual = class_exists('AITRFOEL_Multilingual_Manager') ? AITRFOEL_Multilingual_Manager::get_instance() : null; $default_lang = $multilingual ? $multilingual->get_default_language() : (function_exists('pll_default_language') ? pll_default_language() : null); // Get home URLs - Polylang has pll_home_url, WPML uses filters if (function_exists('pll_home_url')) { $home_default = pll_home_url($default_lang) ?: home_url('/'); $home_target = pll_home_url($target_code) ?: home_url('/'); } elseif (function_exists('icl_get_home_url')) { // WPML home URL function $home_default = icl_get_home_url() ?: home_url('/'); $home_target = $target_code ? apply_filters('wpml_home_url', home_url('/'), $target_code) : home_url('/'); } else { $home_default = home_url('/'); $home_target = home_url('/'); } // Normalize trailing slashes $home_default = trailingslashit($home_default); $home_target = trailingslashit($home_target); if (stripos($abs, $home_default) === 0) { $tail = substr($abs, strlen($home_default)); return $home_target . $tail; // suffix already included in $abs } // Also try plain home_url as base $home_plain = trailingslashit(home_url('/')); if (stripos($abs, $home_plain) === 0) { $tail = substr($abs, strlen($home_plain)); return $home_target . $tail; } return $href; // unchanged } /** * Rewrite href attributes inside an HTML blob to the target language. */ public function localize_hrefs_in_html($html, $target_lang) { if (!is_string($html) || stripos($html, ']*href=([\"\'])([^\"\']+)\1[^>]*>/i', $cb, $html); } /** * Swap nav menu IDs in Elementor data to point to translated menus. * Handles Elementor Nav Menu widgets which store 'menu' as a term_id in settings. * * @param array &$data Elementor data array (modified in place). * @param array $menu_id_map Map of source_menu_id => [lang => translated_menu_id]. * @param string $target_lang Target language code. * @return int Number of menu IDs swapped. */ private function swap_menu_ids_in_elementor_data(&$data, $menu_id_map, $target_lang) { $swapped = 0; foreach ($data as &$element) { if (!is_array($element)) continue; // Check if this is a nav-menu widget $widget_type = isset($element['widgetType']) ? $element['widgetType'] : ''; if ($widget_type === 'nav-menu' && isset($element['settings']['menu'])) { $source_menu_id = (int) $element['settings']['menu']; if ($source_menu_id > 0 && isset($menu_id_map[$source_menu_id][$target_lang])) { $element['settings']['menu'] = (string) $menu_id_map[$source_menu_id][$target_lang]; $swapped++; } } // Recurse into child elements if (isset($element['elements']) && is_array($element['elements'])) { $swapped += $this->swap_menu_ids_in_elementor_data($element['elements'], $menu_id_map, $target_lang); } } unset($element); return $swapped; } /** * Swap nested Elementor template references to translated template IDs. * * Covers common reusable-element patterns such as the Elementor Template widget * (`settings.template_id`) and Global Widget references (`templateID`). * * @param array &$data Elementor data array (modified in place). * @param string $target_lang Target language code. * @return int Number of template references swapped. */ private function swap_elementor_template_refs_in_data(&$data, $target_lang) { $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); if (!$multilingual->is_active()) { return 0; } $swapped = 0; foreach ($data as &$element) { if (!is_array($element)) continue; $widget_type = isset($element['widgetType']) ? $element['widgetType'] : ''; if ($widget_type === 'template' && isset($element['settings']['template_id'])) { $source_template_id = (int) $element['settings']['template_id']; $translated_template_id = $this->get_translated_elementor_template_id($source_template_id, $target_lang, $multilingual); if ($translated_template_id > 0) { $element['settings']['template_id'] = (string) $translated_template_id; $swapped++; } } if (isset($element['templateID'])) { $source_template_id = (int) $element['templateID']; $translated_template_id = $this->get_translated_elementor_template_id($source_template_id, $target_lang, $multilingual); if ($translated_template_id > 0) { $element['templateID'] = $translated_template_id; $swapped++; } } if (isset($element['elements']) && is_array($element['elements'])) { $swapped += $this->swap_elementor_template_refs_in_data($element['elements'], $target_lang); } } unset($element); return $swapped; } /** * Resolve the translated Elementor template ID for a target language. * * @param int $template_id Source template ID. * @param string $target_lang Target language code. * @param AITRFOEL_Multilingual_Manager $multilingual Multilingual manager instance. * @return int */ private function get_translated_elementor_template_id($template_id, $target_lang, $multilingual) { if ($template_id <= 0 || get_post_type($template_id) !== 'elementor_library') { return 0; } $translations = $multilingual->get_post_translations($template_id); if (!isset($translations[$target_lang])) { return 0; } $translated_template_id = (int) $translations[$target_lang]; if ($translated_template_id <= 0 || get_post_status($translated_template_id) !== 'publish') { return 0; } return $translated_template_id; } /** * Walk Elementor translated data and localize internal links for target language. */ public function localize_internal_links(&$data, $target_lang) { $walker = function (&$node, $keyPath = []) use (&$walker, $target_lang) { if (is_array($node)) { // Special-case: Elementor setting 'link' => ['url' => ...] if (isset($node['url']) && is_string($node['url'])) { // Heuristic: if parent path ends with '.settings.link' or contains '.link.' treat as link url $pathStr = implode('.', $keyPath); if (preg_match('~(^|\.)link(\.|$)~', $pathStr)) { $new = $this->localize_single_url($node['url'], $target_lang); if ($new !== $node['url']) { $node['url'] = $new; } } } foreach ($node as $k => &$v) { $walker($v, array_merge($keyPath, [$k])); } unset($v); return; } if (is_string($node)) { // Only touch strings that look like HTML with anchors if (stripos($node, 'is_active()) { return $content; } return preg_replace_callback( '/\[elementor-template\b([^\]]*)\]/i', function ($m) use ($multilingual, $target_lang) { $attrs = $m[1]; if (!preg_match('/\bid\s*=\s*(["\']?)(\d+)\1/i', $attrs, $idm)) { return $m[0]; } $orig_id = (int) $idm[2]; if ($orig_id <= 0 || get_post_type($orig_id) !== 'elementor_library') { return $m[0]; } $translations = $multilingual->get_post_translations($orig_id); if (!isset($translations[$target_lang])) { return $m[0]; } $new_id = (int) $translations[$target_lang]; if ($new_id <= 0 || $new_id === $orig_id || get_post_status($new_id) !== 'publish') { return $m[0]; } $new_attrs = preg_replace( '/\bid\s*=\s*(["\']?)(\d+)\1/i', 'id=' . ($idm[1] !== '' ? $idm[1] : '"') . $new_id . ($idm[1] !== '' ? $idm[1] : '"'), $attrs, 1 ); return '[elementor-template' . $new_attrs . ']'; }, $content ); } /** * Swap form references and shortcodes inside Elementor data structure. * * @param array &$data Elementor data array (modified in place). * @param string $target_lang Target language code. * @return int Number of form references swapped. */ private function swap_form_refs_in_data(&$data, $target_lang) { $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); if (!$multilingual || !$multilingual->is_active()) { return 0; } $swapped = 0; foreach ($data as &$element) { if (!is_array($element)) continue; $widget_type = isset($element['widgetType']) ? $element['widgetType'] : ''; // 1. Rewrite shortcode widget or any string setting containing form shortcodes if (isset($element['settings']) && is_array($element['settings'])) { foreach ($element['settings'] as $sk => &$sv) { if (is_string($sv) && (stripos($sv, '[contact-form-7') !== false || stripos($sv, '[sureforms') !== false || stripos($sv, '[srfm_form') !== false || stripos($sv, '[wpforms') !== false)) { $rewritten = $this->rewrite_form_shortcode_ids($sv, $target_lang); if ($rewritten !== $sv) { $sv = $rewritten; $swapped++; } } } unset($sv); // 2. WPForms widget settings: form_id // 2. WPForms & SureForms widget settings if (in_array($widget_type, ['sureforms_form', 'srfm-form', 'sureforms'], true)) { foreach (['srfm_form_block', 'form_id', 'sureforms_form_id', 'id'] as $sf_key) { if (!empty($element['settings'][$sf_key]) && is_numeric($element['settings'][$sf_key])) { $orig_fid = (int) $element['settings'][$sf_key]; if ($orig_fid > 0) { $translations = $multilingual->get_post_translations($orig_fid); if (!empty($translations[$target_lang])) { $element['settings'][$sf_key] = (string) $translations[$target_lang]; $swapped++; } } } } } if ($widget_type === 'wpforms' && isset($element['settings']['form_id'])) { $orig_fid = (int) $element['settings']['form_id']; if ($orig_fid > 0) { $translations = $multilingual->get_post_translations($orig_fid); if (!empty($translations[$target_lang])) { $element['settings']['form_id'] = (string) $translations[$target_lang]; $swapped++; } } } } if (isset($element['elements']) && is_array($element['elements'])) { $swapped += $this->swap_form_refs_in_data($element['elements'], $target_lang); } } unset($element); return $swapped; } /** * Rewrite form shortcode and block IDs in translated post_content. * Covers: * - Contact Form 7: [contact-form-7 id="123" ...] and * - SureForms: [sureforms id="123"] and * - WPForms: [wpforms id="123"] and * * @param string $content Post content. * @param string $target_lang Target language code. * @return string */ public function rewrite_form_shortcode_ids($content, $target_lang) { if (!is_string($content) || $content === '') { return $content; } $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); if (!$multilingual || !$multilingual->is_active()) { return $content; } $get_trans_id = function($orig_id) use ($multilingual, $target_lang) { $orig_id = (int) $orig_id; if ($orig_id <= 0) return 0; $translations = $multilingual->get_post_translations($orig_id); if (!isset($translations[$target_lang])) return 0; $tid = (int) $translations[$target_lang]; return ($tid > 0 && $tid !== $orig_id && get_post_status($tid) === 'publish') ? $tid : 0; }; // 1. Contact Form 7 shortcodes: [contact-form-7 id="123" ...] if (stripos($content, '[contact-form-7') !== false) { $content = preg_replace_callback( '/\[contact-form-7\b([^\]]*)\]/i', function ($m) use ($get_trans_id) { $attrs = $m[1]; if (!preg_match('/\bid\s*=\s*(["\']?)(\d+)\1/i', $attrs, $idm)) { return $m[0]; } $new_id = $get_trans_id($idm[2]); if (!$new_id) return $m[0]; $new_attrs = preg_replace('/\bid\s*=\s*(["\']?)(\d+)\1/i', 'id=' . ($idm[1] ?: '"') . $new_id . ($idm[1] ?: '"'), $attrs, 1); return '[contact-form-7' . $new_attrs . ']'; }, $content ); } // 2. SureForms shortcodes: [sureforms id="123"] if (stripos($content, '[sureforms') !== false || stripos($content, '[srfm_form') !== false) { $content = preg_replace_callback( '/\[(sureforms|srfm_form)\b([^\]]*)\]/i', function ($m) use ($get_trans_id) { $tag = $m[1]; $attrs = $m[2]; if (!preg_match('/\bid\s*=\s*(["\']?)(\d+)\1/i', $attrs, $idm)) { return $m[0]; } $new_id = $get_trans_id($idm[2]); if (!$new_id) return $m[0]; $new_attrs = preg_replace('/\bid\s*=\s*(["\']?)(\d+)\1/i', 'id=' . ($idm[1] ?: '"') . $new_id . ($idm[1] ?: '"'), $attrs, 1); return '[' . $tag . $new_attrs . ']'; }, $content ); } if (false && stripos($content, '[sureforms') !== false) { $content = preg_replace_callback( '/\[sureforms\b([^\]]*)\]/i', function ($m) use ($get_trans_id) { $attrs = $m[1]; if (!preg_match('/\bid\s*=\s*(["\']?)(\d+)\1/i', $attrs, $idm)) { return $m[0]; } $new_id = $get_trans_id($idm[2]); if (!$new_id) return $m[0]; $new_attrs = preg_replace('/\bid\s*=\s*(["\']?)(\d+)\1/i', 'id=' . ($idm[1] ?: '"') . $new_id . ($idm[1] ?: '"'), $attrs, 1); return '[sureforms' . $new_attrs . ']'; }, $content ); } // 3. WPForms shortcodes: [wpforms id="123" ...] if (stripos($content, '[wpforms') !== false) { $content = preg_replace_callback( '/\[wpforms\b([^\]]*)\]/i', function ($m) use ($get_trans_id) { $attrs = $m[1]; if (!preg_match('/\bid\s*=\s*(["\']?)(\d+)\1/i', $attrs, $idm)) { return $m[0]; } $new_id = $get_trans_id($idm[2]); if (!$new_id) return $m[0]; $new_attrs = preg_replace('/\bid\s*=\s*(["\']?)(\d+)\1/i', 'id=' . ($idm[1] ?: '"') . $new_id . ($idm[1] ?: '"'), $attrs, 1); return '[wpforms' . $new_attrs . ']'; }, $content ); } // 4. Gutenberg block comments: // SureForms block: if (strpos($content, 'wp:sureforms/form') !== false || strpos($content, 'wp:srfm/form') !== false) { $content = preg_replace_callback( '/|-->)/s', function ($m) use ($get_trans_id) { $bname = $m[1]; $json = json_decode($m[2], true); $close = $m[3]; if (is_array($json)) { $id_key = !empty($json['id']) ? 'id' : (!empty($json['formId']) ? 'formId' : ''); if ($id_key) { $new_id = $get_trans_id($json[$id_key]); if ($new_id) { $json[$id_key] = $new_id; return '/s', function ($m) use ($get_trans_id) { $json = json_decode($m[1], true); if (is_array($json) && !empty($json['formId'])) { $new_id = $get_trans_id($json['formId']); if ($new_id) { $json['formId'] = $new_id; return ''; } } return $m[0]; }, $content ); } // CF7 block: if (strpos($content, 'wp:contact-form-7') !== false) { $content = preg_replace_callback( '//s', function ($m) use ($get_trans_id) { $json = json_decode($m[1], true); if (is_array($json) && !empty($json['id'])) { $new_id = $get_trans_id($json['id']); if ($new_id) { $json['id'] = $new_id; return ''; } } return $m[0]; }, $content ); } return $content; } /** * Translate a Contact Form 7 form post. * Translates post_title, form tags (labels, placeholders, submit text, choice options), * _messages array meta, and _mail subject meta. * * @param int $page_id * @param string $target_lang * @param string $source_lang * @param float $translation_start_time * @return int|WP_Error */ public function translate_contact_form_7($page_id, $target_lang, $source_lang = 'auto', $translation_start_time = null) { if (!$translation_start_time) { $translation_start_time = microtime(true); } $post = get_post($page_id); if (!$post || empty($post->post_title)) { $this->track_translation_failure('no_content', 'Contact Form 7 post not found', $translation_start_time); return new WP_Error('no_content', __('Contact Form 7 post not found.', 'ai-translator-for-elementor-and-polylang')); } // Collect translatable strings $strings_to_translate = []; $strings_to_translate[] = [ 'original' => $post->post_title, 'type' => 'title', 'widget_type' => 'wpcf7_contact_form', 'path' => 'title', ]; // 1. Extract translatable strings from CF7 form content (post_content) $raw_content = (string) $post->post_content; // Submit button labels: [submit "Label"] or [submit 'Label'] if (preg_match_all('/\[submit\s+["\']([^"\']+)["\']/i', $raw_content, $sm)) { foreach ($sm[1] as $s_text) { if (strlen(trim($s_text)) > 0) { $strings_to_translate[] = ['original' => $s_text, 'type' => 'submit_button', 'widget_type' => 'wpcf7_contact_form']; } } } // Placeholders: placeholder "Label" or placeholder 'Label' if (preg_match_all('/placeholder\s+["\']([^"\']+)["\']/i', $raw_content, $pm)) { foreach ($pm[1] as $p_text) { if (strlen(trim($p_text)) > 0) { $strings_to_translate[] = ['original' => $p_text, 'type' => 'placeholder', 'widget_type' => 'wpcf7_contact_form']; } } } // Select/checkbox/radio option strings inside quotes: e.g. [select ... "Option 1" "Option 2"] if (preg_match_all('/\[(select|checkbox|radio)\b[^\]]*\]/i', $raw_content, $opt_tags)) { foreach ($opt_tags[0] as $tag_str) { if (preg_match_all('/"([^"]+)"|\'([^\']+)\'/i', $tag_str, $quoted)) { $all_opts = array_filter(array_merge($quoted[1], $quoted[2])); foreach ($all_opts as $opt_text) { if (strlen(trim($opt_text)) > 0 && !preg_match('/^[a-z0-9_-]+$/i', $opt_text)) { $strings_to_translate[] = ['original' => $opt_text, 'type' => 'choice', 'widget_type' => 'wpcf7_contact_form']; } elseif (strlen(trim($opt_text)) > 1) { $strings_to_translate[] = ['original' => $opt_text, 'type' => 'choice', 'widget_type' => 'wpcf7_contact_form']; } } } } } // Label/paragraph texts outside of CF7 bracket tags [ ... ] $content_without_tags = preg_replace('/\[[^\]]+\]/', '', $raw_content); $classic_parser = AITRFOEL_Classic_Parser::get_instance(); $label_strings = $classic_parser->extract_text_from_html($content_without_tags); foreach ($label_strings as $ls) { $strings_to_translate[] = ['original' => $ls, 'type' => 'label_html', 'widget_type' => 'wpcf7_contact_form']; } // 2. Extract _messages meta $messages = get_post_meta($page_id, '_messages', true); if (is_array($messages)) { foreach ($messages as $m_key => $m_val) { if (is_string($m_val) && strlen(trim($m_val)) > 1) { $strings_to_translate[] = ['original' => $m_val, 'type' => 'cf7_message', 'widget_type' => 'wpcf7_contact_form']; } } } // 3. Extract _mail meta subject $mail = get_post_meta($page_id, '_mail', true); if (is_array($mail) && !empty($mail['subject']) && is_string($mail['subject'])) { $strings_to_translate[] = ['original' => $mail['subject'], 'type' => 'cf7_mail_subject', 'widget_type' => 'wpcf7_contact_form']; } // Word limit check $word_limit_check = $this->check_word_limit($strings_to_translate, $page_id); if (is_wp_error($word_limit_check)) { return $word_limit_check; } // Detect source language $detected_lang = $this->detect_source_language_from_content($strings_to_translate, $source_lang, $page_id); // Batch translate strings $translations = $this->batch_translate_strings($strings_to_translate, $target_lang, $detected_lang, $page_id); if (is_wp_error($translations)) { $this->track_translation_failure('translation_failed', $translations->get_error_message(), $translation_start_time); return $translations; } // Translate title $translated_title = isset($translations[$post->post_title]) ? $translations[$post->post_title] : ($post->post_title . ' (' . strtoupper($target_lang) . ')'); // Apply translations to post_content $translated_content = $raw_content; uksort($translations, function($a, $b) { return strlen($b) - strlen($a); }); foreach ($translations as $orig => $trans) { if ($orig === $post->post_title || empty($orig) || empty($trans) || $orig === $trans) { continue; } $translated_content = str_replace($orig, $trans, $translated_content); } // Target code $target_code = $target_lang; if (strlen($target_lang) > 3 && class_exists('AITRFOEL_Language_Mapper')) { $resolved = AITRFOEL_Language_Mapper::get_code_from_language_name($target_lang); if ($resolved) { $target_code = $resolved; } } // Check if translated form already exists $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); $existing_translations = $multilingual->is_active() ? $multilingual->get_post_translations($page_id) : []; $existing_page_id = isset($existing_translations[$target_code]) ? (int) $existing_translations[$target_code] : 0; $target_status = get_option('aitrfoel_translated_page_status', 'publish'); if ($target_status === 'inherit') { $target_status = $post->post_status; } $translated_slug = sanitize_title($translated_title); if (empty($translated_slug)) { $translated_slug = $post->post_name . '-' . $target_code; } $post_data = [ 'post_title' => $translated_title, 'post_name' => $translated_slug, 'post_content' => $translated_content, 'post_status' => $target_status, 'post_type' => 'wpcf7_contact_form', ]; if ($existing_page_id && get_post($existing_page_id)) { $post_data['ID'] = $existing_page_id; $new_page_id = wp_update_post($post_data); } else { $new_page_id = wp_insert_post($post_data); } if (is_wp_error($new_page_id) || !$new_page_id) { $this->track_translation_failure('insert_failed', 'Failed to create translated CF7 form post', $translation_start_time); return is_wp_error($new_page_id) ? $new_page_id : new WP_Error('insert_failed', __('Failed to create translated CF7 form.', 'ai-translator-for-elementor-and-polylang')); } // Save CF7 meta fields update_post_meta($new_page_id, '_form', $translated_content); // Translated messages if (is_array($messages)) { $trans_messages = []; foreach ($messages as $mk => $mv) { $trans_messages[$mk] = isset($translations[$mv]) ? $translations[$mv] : $mv; } update_post_meta($new_page_id, '_messages', $trans_messages); } // Translated mail settings if (is_array($mail)) { $trans_mail = $mail; if (!empty($mail['subject']) && isset($translations[$mail['subject']])) { $trans_mail['subject'] = $translations[$mail['subject']]; } update_post_meta($new_page_id, '_mail', $trans_mail); } $mail_2 = get_post_meta($page_id, '_mail_2', true); if (is_array($mail_2)) { update_post_meta($new_page_id, '_mail_2', $mail_2); } $add_settings = get_post_meta($page_id, '_additional_settings', true); if ($add_settings !== '') { update_post_meta($new_page_id, '_additional_settings', $add_settings); } // Set tracking metadata update_post_meta($new_page_id, '_aitrfoel_content_type', 'wpcf7_contact_form'); update_post_meta($new_page_id, '_aitrfoel_source_page', $page_id); update_post_meta($new_page_id, '_aitrfoel_translated_from', $page_id . '_' . $target_code); // Setup multilingual relationship if ($multilingual->is_active()) { $multilingual->setup_translation_relationship($page_id, $new_page_id, $target_lang); } $this->track_translation_success($page_id, $new_page_id, $target_lang, $detected_lang, $translation_start_time, 'wpcf7_contact_form'); return $new_page_id; } /** * Split translated content into parts matching the number of original elements */ private function split_translated_content($translated_text, $expected_parts) { AITRFOEL_Logger::log("Splitting translated content into $expected_parts parts", 'debug'); // Try different splitting strategies // Strategy 1: Split by double newlines (paragraph breaks) $parts = preg_split('/\n\s*\n/', trim($translated_text)); $parts = array_filter(array_map('trim', $parts)); // Remove empty parts if (count($parts) == $expected_parts) { AITRFOEL_Logger::log("Split by paragraph breaks successful", 'debug'); return array_values($parts); } // Strategy 2: Split by single newlines if (count($parts) < $expected_parts) { $parts = preg_split('/\n/', trim($translated_text)); $parts = array_filter(array_map('trim', $parts)); if (count($parts) == $expected_parts) { AITRFOEL_Logger::log("Split by single newlines successful", 'debug'); return array_values($parts); } } // Strategy 3: Split by sentences (periods followed by space/newline) if (count($parts) < $expected_parts) { $parts = preg_split('/\.\s*(?=[A-Z])|\n/', trim($translated_text)); $parts = array_filter(array_map(function($part) { $part = trim($part); // Re-add period if it was removed by splitting if (!empty($part) && !preg_match('/[.!?]$/', $part)) { $part .= '.'; } return $part; }, $parts)); if (count($parts) >= $expected_parts) { AITRFOEL_Logger::log("Split by sentences successful", 'debug'); return array_slice(array_values($parts), 0, $expected_parts); } } // Strategy 4: If we have more parts than expected, try to merge some if (count($parts) > $expected_parts) { $merged_parts = []; $parts_per_expected = ceil(count($parts) / $expected_parts); for ($i = 0; $i < $expected_parts; $i++) { $start = $i * $parts_per_expected; $end = min(($i + 1) * $parts_per_expected, count($parts)); $merged_parts[] = implode(' ', array_slice($parts, $start, $end - $start)); } AITRFOEL_Logger::log("Merged parts strategy used", 'debug'); return $merged_parts; } // Fallback: Just return the whole text as a single part for the first element AITRFOEL_Logger::log("Using fallback strategy - single part", 'debug'); return [$translated_text]; } /** * Create a new page with translated content * * @param int $original_page_id Original page ID * @param array $translated_data Translated Elementor data * @param string $target_lang Target language * @param string $detected_lang Detected source language * @return int|WP_Error New page ID or error */ private function create_translated_page($original_page_id, $translated_data, $target_lang, $detected_lang = 'en', $pre_translated_title = null, $is_partial = false) { $original_page = get_post($original_page_id); if (!$original_page) { return new WP_Error('page_not_found', __('Original page not found.', 'ai-translator-for-elementor-and-polylang')); } // Check if a translation already exists for this language — update it instead of creating a duplicate $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); $existing_translations = $multilingual->is_active() ? $multilingual->get_post_translations($original_page_id) : []; // Normalize target_lang to code for lookup (translations are keyed by code) $target_code = $target_lang; if (strlen($target_lang) > 3 && class_exists('AITRFOEL_Language_Mapper')) { $resolved = AITRFOEL_Language_Mapper::get_code_from_language_name($target_lang); if ($resolved) { $target_code = $resolved; } } $existing_page_id = isset($existing_translations[$target_code]) ? (int) $existing_translations[$target_code] : 0; // Fallback: if the multilingual plugin didn't register the relationship (e.g. the // first pass was a partial save that returned before Polylang committed the link), // locate the previously created page via our own tracking meta. if (!$existing_page_id || !get_post($existing_page_id)) { $fallback_posts = get_posts([ 'post_type' => $original_page->post_type, 'post_status' => ['publish', 'draft', 'private', 'pending'], 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_query' => [[ 'key' => '_aitrfoel_translated_from', 'value' => $original_page_id . '_' . $target_code, 'compare' => '=', ]], ]); if (!empty($fallback_posts)) { $existing_page_id = (int) $fallback_posts[0]; AITRFOEL_Logger::log( sprintf('Fallback lookup: found existing translation (ID %d) via tracking meta for page %d → %s.', $existing_page_id, $original_page_id, $target_lang), 'info', $original_page_id ); } } if ($existing_page_id && get_post($existing_page_id)) { AITRFOEL_Logger::log( sprintf('Existing translation found (ID %d) for page %d → %s. Updating instead of creating new.', $existing_page_id, $original_page_id, $target_lang), 'info', $original_page_id ); // Update existing translated page's Elementor data update_post_meta($existing_page_id, '_elementor_data', wp_slash(json_encode($translated_data))); // Update existing page's post_content with localized links $content_local = $this->localize_hrefs_in_html($original_page->post_content, $target_lang); wp_update_post([ 'ID' => $existing_page_id, 'post_content' => $content_local, ]); // Translated templates must NEVER have display conditions — ensure they are removed // even if a previous plugin version accidentally copied them. if (get_post_type($original_page_id) === 'elementor_library') { delete_post_meta($existing_page_id, '_elementor_conditions'); // Also strip conditions from _elementor_page_settings to prevent Elementor Pro // re-adding _elementor_conditions via its updated_post_meta hook. $page_settings = get_post_meta($existing_page_id, '_elementor_page_settings', true); if (is_array($page_settings) && isset($page_settings['conditions'])) { unset($page_settings['conditions']); update_post_meta($existing_page_id, '_elementor_page_settings', $page_settings); } // Synchronize all template taxonomies, metadata, and compile CSS stylesheet $this->sync_elementor_template_meta_and_taxonomies($original_page_id, $existing_page_id); } // Use pre-translated title if available (from batch), otherwise translate separately $translated_title = $pre_translated_title; if (!$translated_title && !$is_partial) { $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = $api->translate_title($original_page->post_title, $target_lang, $detected_lang); if (is_wp_error($translated_title)) { $translated_title = null; } } if ($translated_title) { $update_args = [ 'ID' => $existing_page_id, 'post_title' => $translated_title, ]; // Optionally update slug from translated title (unless preserve slug on retranslation is enabled). $preserve_slug = get_option('aitrfoel_preserve_slug_on_retranslate', true); if (!$preserve_slug && get_option('aitrfoel_translate_url_slugs', true)) { $translated_slug = sanitize_title($translated_title); if (!empty($translated_slug)) { $update_args['post_name'] = $translated_slug; } } wp_update_post($update_args); } // Copy theme/layout meta from original to keep in sync $theme_meta_keys = [ '_wp_page_template', 'site-post-title', 'ast-title-bar-display', 'ast-featured-img', 'ast-site-content-layout', 'site-sidebar-layout', 'ast-main-header-display', 'footer-sml-layout', 'theme-transparent-header-meta' ]; foreach ($theme_meta_keys as $meta_key) { $meta_value = get_post_meta($original_page_id, $meta_key, true); if ($meta_value !== '') { update_post_meta($existing_page_id, $meta_key, $meta_value); } } // Track word usage for updates too $word_count = 0; foreach ($translated_data as $element) { $word_count += $this->count_element_words($element); } if ($word_count > 0 && class_exists('AITRFOEL_License_Manager_Server')) { $license_manager = AITRFOEL_License_Manager_Server::get_instance(); if (method_exists($license_manager, 'report_word_usage')) { $license_manager->report_word_usage($word_count, $target_lang); } } // Translate SEO meta, excerpt, and WooCommerce fields (skip for partial saves) if (!$is_partial) { $this->translate_post_meta_fields($original_page_id, $existing_page_id, $target_lang, $detected_lang); } // Assign translated WooCommerce product categories/tags $this->translate_product_taxonomies($original_page_id, $existing_page_id, $target_lang); // Translate WooCommerce product attributes $this->translate_product_attributes($original_page_id, $existing_page_id, $target_lang, $detected_lang); // Translate product variations for variable products $this->translate_product_variations($original_page_id, $existing_page_id, $target_lang, $detected_lang); // Translate product gallery image alt text/captions $this->translate_product_gallery_meta($original_page_id, $existing_page_id, $target_lang, $detected_lang); // Translate and assign post categories/tags (non-WooCommerce) $this->translate_post_taxonomies($original_page_id, $existing_page_id, $target_lang); // Invalidate post cache, Elementor CSS cache, and page cache delete_post_meta($existing_page_id, '_elementor_css'); clean_post_cache($existing_page_id); if (class_exists('\Elementor\Plugin')) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } if (function_exists('litespeed_purge_post')) { litespeed_purge_post($existing_page_id); } if (isset($GLOBALS['wp_fastest_cache']) && method_exists($GLOBALS['wp_fastest_cache'], 'singleDeleteCache')) { $GLOBALS['wp_fastest_cache']->singleDeleteCache(false, $existing_page_id); } AITRFOEL_Logger::log( sprintf('Successfully updated existing translation (ID %d)', $existing_page_id), 'info', $original_page_id ); return $existing_page_id; } // Use pre-translated title if available (from batch), otherwise translate separately $translated_title = $pre_translated_title; if (!$translated_title && !$is_partial) { $api = AITRFOEL_OpenAI_API::get_instance(); $translated_title = $api->translate_title($original_page->post_title, $target_lang, $detected_lang); } if (is_wp_error($translated_title) || !$translated_title) { // Fallback: use original title with language code suffix $translated_title = $original_page->post_title . ' (' . strtoupper($target_lang) . ')'; } // Create new page with intelligent status selection $target_status = get_option('aitrfoel_translated_page_status', 'publish'); // If 'inherit', match the original post's status if ($target_status === 'inherit') { $target_status = ($original_page->post_status === 'publish') ? 'publish' : 'publish'; } // Optionally generate a translated slug; otherwise preserve source-style slug. if (get_option('aitrfoel_translate_url_slugs', true)) { $translated_slug = sanitize_title($translated_title); if (empty($translated_slug)) { $translated_slug = $original_page->post_name . '-' . $target_lang; } } else { $translated_slug = $original_page->post_name . '-' . $target_lang; } $content_local = $this->localize_hrefs_in_html($original_page->post_content, $target_lang); $new_page_args = [ 'post_title' => $translated_title, 'post_name' => $translated_slug, 'post_content' => $content_local, 'post_status' => $target_status, 'post_type' => $original_page->post_type, 'post_parent' => $this->get_translated_parent_id($original_page->post_parent, $target_lang), 'menu_order' => $original_page->menu_order, 'post_excerpt' => $original_page->post_excerpt, 'post_date' => $original_page->post_date, 'post_date_gmt' => $original_page->post_date_gmt, ]; $new_page_id = wp_insert_post($new_page_args); if (is_wp_error($new_page_id)) { return $new_page_id; } // Store the source-page + language so we can find this page on partial-save retries // even when the multilingual plugin hasn't committed the translation relationship yet. update_post_meta($new_page_id, '_aitrfoel_translated_from', $original_page_id . '_' . $target_code); update_post_meta($new_page_id, '_aitrfoel_source_page', $original_page_id); // Reassert the requested status after insertion in case another hook/plugin // forced a different post_status during wp_insert_post(). if (get_post_status($new_page_id) !== $target_status) { wp_update_post([ 'ID' => $new_page_id, 'post_status' => $target_status, ]); clean_post_cache($new_page_id); } // Update Elementor data update_post_meta($new_page_id, '_elementor_data', wp_slash(json_encode($translated_data))); update_post_meta($new_page_id, '_elementor_edit_mode', 'builder'); // Copy other Elementor meta $elementor_meta_keys = [ '_elementor_template_type', '_elementor_template_sub_type', '_elementor_version', '_elementor_pro_version', '_elementor_page_settings', '_elementor_controls_usage', '_elementor_page_assets', '_elementor_screenshot', ]; // Copy Astra theme meta fields to preserve page layout settings $astra_meta_keys = [ '_wp_page_template', // The base page template (e.g., 'default') 'site-post-title', // Controls page title visibility ('disabled') 'ast-title-bar-display', // Disables the title bar area ('disabled') 'ast-featured-img', // Disables the featured image ('disabled') 'ast-site-content-layout', // **CRITICAL**: Sets the page layout (e.g., 'full-width-container') 'site-sidebar-layout', // **CRITICAL**: Sets the sidebar layout ('no-sidebar') 'ast-main-header-display', // Header display setting 'footer-sml-layout', // Footer layout 'theme-transparent-header-meta' // Transparent header setting ]; // Combine all meta keys to copy $all_meta_keys = array_merge($elementor_meta_keys, $astra_meta_keys); $is_elementor_template = get_post_type($original_page_id) === 'elementor_library'; foreach ($all_meta_keys as $meta_key) { $meta_value = get_post_meta($original_page_id, $meta_key, true); if ($meta_value !== '') { // Copy even if false/0, but not if completely empty // Strip display conditions from _elementor_page_settings for translated templates. // Elementor Pro reads the 'conditions' key inside this meta and re-writes // _elementor_conditions via an updated_post_meta hook, undoing our delete below. if ($is_elementor_template && $meta_key === '_elementor_page_settings' && is_array($meta_value)) { unset($meta_value['conditions']); } update_post_meta($new_page_id, $meta_key, $meta_value); } } // Translated templates must NEVER have display conditions. Elementor's location resolver // uses conditions to build its candidates list — if translated templates have conditions // they compete with the original and cause wrong-language templates to appear. // Our filter_elementor_location_templates() injects translated templates into the // candidate list at runtime by swapping originals, so conditions are not needed. if ($is_elementor_template) { delete_post_meta($new_page_id, '_elementor_conditions'); $this->sync_elementor_template_meta_and_taxonomies($original_page_id, $new_page_id); } // Handle multilingual plugin (Polylang or WPML) via abstraction layer $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); if ($multilingual->is_active()) { $plugin_name = $multilingual->get_plugin_display_name(); AITRFOEL_Logger::log( sprintf('Calling %s integration for %d -> %d, target lang: %s', $plugin_name, $original_page_id, $new_page_id, $target_lang), 'debug', $original_page_id ); // Pass the 2-letter language code directly $result = $multilingual->setup_translation_relationship($original_page_id, $new_page_id, $target_lang); if (is_wp_error($result)) { AITRFOEL_Logger::log( $plugin_name . ' setup FAILED: ' . $result->get_error_message() . ' (Code: ' . $result->get_error_code() . ')', 'error', $original_page_id, $new_page_id ); } else { AITRFOEL_Logger::log( sprintf('✓ %s integration successful for pages %d -> %d', $plugin_name, $original_page_id, $new_page_id), 'success', $original_page_id, $new_page_id ); } } else { AITRFOEL_Logger::log('No multilingual plugin is active, skipping translation relationship setup', 'info', $original_page_id); } // Translate SEO meta, excerpt, and WooCommerce fields (skip for partial saves) if (!$is_partial) { $this->translate_post_meta_fields($original_page_id, $new_page_id, $target_lang, $detected_lang); } // Assign translated WooCommerce product categories/tags $this->translate_product_taxonomies($original_page_id, $new_page_id, $target_lang); // Translate WooCommerce product attributes $this->translate_product_attributes($original_page_id, $new_page_id, $target_lang, $detected_lang); // Translate product variations for variable products $this->translate_product_variations($original_page_id, $new_page_id, $target_lang, $detected_lang); // Translate product gallery image alt text/captions $this->translate_product_gallery_meta($original_page_id, $new_page_id, $target_lang, $detected_lang); // Translate and assign post categories/tags (non-WooCommerce) $this->translate_post_taxonomies($original_page_id, $new_page_id, $target_lang); // Clear Elementor cache if (class_exists('\Elementor\Plugin')) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } // Post-creation processing for proper LiteSpeed integration $this->post_creation_processing($new_page_id); return $new_page_id; } /** * Translate SEO metadata (Yoast SEO, Rank Math), post excerpt, * and copy WooCommerce product fields for a translated post. * * @param int $original_id Source post ID * @param int $target_id Translated post ID * @param string $target_lang Target language code * @param string $source_lang Source language code */ /** * Whether JetEngine is active on this site. * * @return bool */ private function is_jetengine_active() { return defined('JET_ENGINE_VERSION') || class_exists('Jet_Engine'); } /** * Decode a JetEngine option that may be stored as a JSON string or a PHP array. * * @param mixed $val * @return array */ private function jet_maybe_decode($val) { if (is_string($val)) { $decoded = json_decode($val, true); return (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) ? $decoded : []; } return is_array($val) ? $val : []; } /** * Flatten a JetEngine "meta_fields" definition array into [ meta_key => type ]. * * @param mixed $meta_fields * @param array $out Accumulator passed by reference. */ private function jet_collect_fields($meta_fields, &$out) { if (!is_array($meta_fields)) { return; } foreach ($meta_fields as $field) { $field = (array) $field; $name = isset($field['name']) ? trim((string) $field['name']) : ''; if ($name === '') { continue; } $type = isset($field['type']) ? (string) $field['type'] : 'text'; $out[$name] = $type; } } /** * Map of JetEngine custom fields registered for a post type: [ meta_key => type ]. * Reads both JetEngine-registered CPTs (jet_post_types) and standalone meta boxes * (jet_engine_meta_boxes). Cached per request. Returns [] when JetEngine is inactive. * * @param string $post_type * @return array */ private function get_jetengine_fields($post_type) { static $cache = []; if (isset($cache[$post_type])) { return $cache[$post_type]; } $fields = []; if (!$this->is_jetengine_active() || $post_type === '') { return $cache[$post_type] = $fields; } // 1) Custom post types registered through JetEngine. $cpts = $this->jet_maybe_decode(get_option('jet_post_types', [])); foreach ($cpts as $cpt) { $cpt = (array) $cpt; $slug = isset($cpt['slug']) ? $cpt['slug'] : (isset($cpt['args']['slug']) ? $cpt['args']['slug'] : ''); if ($slug !== $post_type) { continue; } $this->jet_collect_fields(isset($cpt['meta_fields']) ? $cpt['meta_fields'] : [], $fields); } // 2) Standalone JetEngine meta boxes attached to this post type. $boxes = $this->jet_maybe_decode(get_option('jet_engine_meta_boxes', [])); foreach ($boxes as $box) { $box = (array) $box; $args = isset($box['args']) ? (array) $box['args'] : []; $allowed = []; if (isset($args['allowed_post_type'])) { $allowed = (array) $args['allowed_post_type']; } elseif (isset($args['post_type'])) { $allowed = (array) $args['post_type']; } if (!in_array($post_type, $allowed, true)) { continue; } $this->jet_collect_fields(isset($box['meta_fields']) ? $box['meta_fields'] : [], $fields); } return $cache[$post_type] = $fields; } /** * Meta keys of JetEngine text-type fields (text / textarea / wysiwyg / html) for * a post type — the fields whose values should be translated. * * @param string $post_type * @return string[] */ private function get_jetengine_text_meta_keys($post_type) { $text_types = ['text', 'textarea', 'wysiwyg', 'html']; $keys = []; foreach ($this->get_jetengine_fields($post_type) as $key => $type) { if (in_array($type, $text_types, true)) { $keys[] = $key; } } return $keys; } /** * Copy every JetEngine custom field value from the source post to the translation, * verbatim. Ensures non-text fields (media, dates, numbers, relations) are present * on the translation; text fields are translated afterwards by the caller. No-op * when JetEngine is inactive or the post type has no JetEngine fields. * * @param int $original_id * @param int $target_id */ private function copy_jetengine_meta($original_id, $target_id) { $fields = $this->get_jetengine_fields(get_post_type($original_id)); if (empty($fields)) { return; } $copied = 0; foreach ($fields as $key => $type) { $val = get_post_meta($original_id, $key, true); if ($val === '' || $val === null) { continue; } update_post_meta($target_id, $key, $val); $copied++; } if ($copied > 0 && class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log( sprintf('Copied %d JetEngine field value(s) for post %d → %d', $copied, $original_id, $target_id), 'info', $original_id, $target_id ); } } /** * Whether a meta key should be skipped when copying custom fields to a * translation. Excludes WordPress internals, our own tracking meta, Elementor * data (the translation has its own translated copy), and SEO/featured-image * keys that are handled explicitly elsewhere in translate_post_meta_fields(). * * @param string $key * @return bool */ private function is_meta_copy_excluded($key) { if (!is_string($key) || $key === '') { return true; } // Internal / separately-handled keys, by prefix. $blocked_prefixes = [ '_elementor', '_aitrfoel', '_edit_', '_wp_old', '_wp_page_template', '_yoast_wpseo', '_seopress', '_oembed_', '_wp_trash', '_menu_item', '_pingme', '_encloseme', '_thumbnail_id', ]; $excluded = false; foreach ($blocked_prefixes as $bp) { if (strpos($key, $bp) === 0) { $excluded = true; break; } } // SEO (Rank Math) is translated separately. if (!$excluded && strpos($key, 'rank_math') === 0) { $excluded = true; } /** * Filter whether a meta key is excluded from being copied to a translation. * Return true to skip a key, or false to force-copy one that is excluded by * default. * * @param bool $excluded * @param string $key */ return (bool) apply_filters('aitrfoel_exclude_meta_from_copy', $excluded, $key); } /** * Copy every (non-excluded) custom-field value from the source post to the * translation, verbatim. Generic across JetEngine, ACF, Meta Box, Pods and plain * custom fields — ensures galleries, media, dates, numbers, relations and * repeaters are present on the translation. Text fields are translated afterwards * by the caller. Internal/handled keys (Elementor data, SEO, thumbnails, our * tracking meta) are skipped. * * @param int $original_id * @param int $target_id */ private function copy_all_custom_meta($original_id, $target_id) { if (!get_option('aitrfoel_copy_custom_media_fields', true)) { return; } $all = get_post_meta($original_id); if (!is_array($all) || empty($all)) { return; } $copied = 0; foreach ($all as $key => $values) { if ($this->is_meta_copy_excluded($key) || !is_array($values)) { continue; } // Protected (underscore) keys: // Allow: // 1. ACF field references (values starting with 'field_') // 2. Any media, gallery, image, photo, attachment, slider, or portfolio field // 3. Custom plugin/theme metadata (e.g. JetEngine, Meta Box, Pods, theme metaboxes) if ($key[0] === '_') { $first = isset($values[0]) ? $values[0] : ''; $is_acf = (is_string($first) && strpos($first, 'field_') === 0); $is_media_or_gallery = ( stripos($key, 'gallery') !== false || stripos($key, 'image') !== false || stripos($key, 'media') !== false || stripos($key, 'photo') !== false || stripos($key, 'slide') !== false || stripos($key, 'attach') !== false || stripos($key, 'thumb') !== false || stripos($key, 'carousel') !== false || stripos($key, 'picture') !== false || stripos($key, 'portfolio') !== false || stripos($key, 'banner') !== false || stripos($key, 'case') !== false ); $is_custom_plugin_meta = ( strpos($key, '_wp_') === false && strpos($key, '_edit_') === false && strpos($key, '_elementor') === false && strpos($key, '_aitrfoel') === false ); if (!$is_acf && !$is_media_or_gallery && !$is_custom_plugin_meta) { continue; } } // Replace any existing values for this key on the translation, then add // each stored value (handles multi-value meta) in its original form. delete_post_meta($target_id, $key); foreach ($values as $raw) { $value = maybe_unserialize($raw); add_post_meta($target_id, $key, wp_slash($value)); $copied++; } } // Also ensure _product_image_gallery (WooCommerce / Custom) is directly synchronized if present $wc_gallery = get_post_meta($original_id, '_product_image_gallery', true); if (!empty($wc_gallery)) { update_post_meta($target_id, '_product_image_gallery', $wc_gallery); } // Copy page template if defined on source $page_template = get_post_meta($original_id, '_wp_page_template', true); if (!empty($page_template) && $page_template !== 'default') { update_post_meta($target_id, '_wp_page_template', $page_template); } if ($copied > 0 && class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log( sprintf('Copied %d custom field value(s) for post %d → %d', $copied, $original_id, $target_id), 'info', $original_id, $target_id ); } } /** * Meta keys of ACF text-type fields (text / textarea / wysiwyg) attached to a * post, whose values should be translated. Returns [] when ACF is inactive. * * @param int $post_id * @return string[] */ private function get_acf_text_meta_keys($post_id) { if (!function_exists('get_field_objects')) { return []; } $objects = get_field_objects($post_id, false, false); if (!is_array($objects) || empty($objects)) { return []; } $text_types = ['text', 'textarea', 'wysiwyg']; $keys = []; foreach ($objects as $name => $obj) { $type = is_array($obj) && isset($obj['type']) ? $obj['type'] : ''; if (in_array($type, $text_types, true)) { $keys[] = is_string($name) ? $name : (isset($obj['name']) ? $obj['name'] : ''); } } return array_values(array_filter($keys)); } /** * Remap relational custom fields (ACF relationship / post_object / page_link, and * any plain meta whose value is a post ID) on the translation so they point at the * target-language translations of the referenced posts instead of the source ones. * No-op when no multilingual backend or no relational fields are found. * * @param int $original_id * @param int $target_id * @param string $target_lang */ private function remap_relational_meta_fields($original_id, $target_id, $target_lang) { $mm = class_exists('AITRFOEL_Multilingual_Manager') ? AITRFOEL_Multilingual_Manager::get_instance() : null; if (!$mm || !method_exists($mm, 'get_post_translations')) { return; } // Collect relational ACF field meta keys (if ACF is present). $relational_keys = []; if (function_exists('get_field_objects')) { $objects = get_field_objects($original_id, false, false); if (is_array($objects)) { $relational_types = ['relationship', 'post_object', 'page_link']; foreach ($objects as $name => $obj) { $type = is_array($obj) && isset($obj['type']) ? $obj['type'] : ''; if (in_array($type, $relational_types, true)) { $relational_keys[] = is_string($name) ? $name : (isset($obj['name']) ? $obj['name'] : ''); } } } } /** * Filter the meta keys treated as post-ID references to remap to translations. * Useful for JetEngine relation fields, Meta Box post fields, or plain ID meta. * * @param string[] $relational_keys * @param int $original_id * @param string $target_lang */ $relational_keys = apply_filters('aitrfoel_relational_meta_keys', $relational_keys, $original_id, $target_lang); $relational_keys = array_values(array_unique(array_filter((array) $relational_keys))); if (empty($relational_keys)) { return; } $remapped = 0; foreach ($relational_keys as $key) { if (!is_string($key) || $key === '') { continue; } $value = get_post_meta($target_id, $key, true); // copied source value $new = $this->remap_relational_value($value, $target_lang, $mm, $changed); if (!empty($changed)) { update_post_meta($target_id, $key, $new); $remapped++; } } if ($remapped > 0 && class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log( sprintf('Remapped %d relational field(s) to %s translations for post %d', $remapped, $target_lang, $target_id), 'info', $original_id, $target_id ); } } /** * Remap a relational field value (scalar ID or array of IDs) to the target-language * translations. Sets $changed by reference. Non-ID values (e.g. page_link URLs) are * returned unchanged. * * @param mixed $value * @param string $target_lang * @param AITRFOEL_Multilingual_Manager $mm * @param bool|null $changed * @return mixed */ private function remap_relational_value($value, $target_lang, $mm, &$changed = false) { $changed = false; if (is_array($value)) { $out = []; foreach ($value as $item) { $out[] = $this->remap_single_post_id($item, $target_lang, $mm, $changed); } return $out; } return $this->remap_single_post_id($value, $target_lang, $mm, $changed); } /** * If $id is a post ID with a translation in $target_lang, return the translation's * ID (as the same scalar type), flipping $changed to true; otherwise return $id. * * @param mixed $id * @param string $target_lang * @param AITRFOEL_Multilingual_Manager $mm * @param bool $changed * @return mixed */ private function remap_single_post_id($id, $target_lang, $mm, &$changed) { if (!is_numeric($id) || (int) $id <= 0) { return $id; } $translations = $mm->get_post_translations((int) $id); if (is_array($translations) && isset($translations[$target_lang])) { $translated = (int) $translations[$target_lang]; if ($translated > 0 && $translated !== (int) $id) { $changed = true; return is_string($id) ? (string) $translated : $translated; } } return $id; } /** * Heuristically detect custom-field meta keys whose values are human-readable * text worth translating, by inspecting the source post's actual meta. Used as a * fallback when field-framework definitions can't be read. Conservative: only * multi-word, non-token, non-URL, non-serialized, non-numeric string values * qualify, so slugs, CSS classes (e.g. "jet-engine-cpt-1"), IDs, dates, colors * and option keys are left alone. * * @param int $post_id * @return string[] */ private function get_heuristic_text_meta_keys($post_id) { $all = get_post_meta($post_id); if (!is_array($all) || empty($all)) { return []; } $keys = []; foreach ($all as $key => $values) { if (!is_string($key) || $key === '' || $key[0] === '_') { continue; // private/internal keys are not user content } if ($this->is_meta_copy_excluded($key)) { continue; } $raw = isset($values[0]) ? $values[0] : ''; if ($this->is_probably_translatable_text($raw)) { $keys[] = $key; } } return $keys; } /** * Whether a meta value looks like translatable human text (multi-word prose), * as opposed to a slug, id, class name, URL, email, number, date, color or * serialized structure. * * @param mixed $value * @return bool */ private function is_probably_translatable_text($value) { if (!is_string($value)) { return false; } $t = trim($value); if (strlen($t) < 3) { return false; } if (is_numeric($t) || is_serialized($value)) { return false; } // JSON object/array. if (($t[0] === '{' || $t[0] === '[') && json_decode($t) !== null) { return false; } // URL or email. if (stripos($t, 'http://') === 0 || stripos($t, 'https://') === 0 || strpos($t, '://') !== false) { return false; } if (is_email($t)) { return false; } // Must contain a letter. if (!preg_match('/[A-Za-z\x80-\xff]/', $t)) { return false; } // Require multiple words (a space between word characters). Single tokens — // slugs, classes, enums, hex colors, ids — are skipped. Strip tags first so // wysiwyg/HTML still counts its inner words. $plain = trim(wp_strip_all_tags($t)); if (strpos($plain, ' ') === false) { return false; } return true; } /** * Translatable custom-field text values for a post (JetEngine + ACF + heuristic), * after the translatable-keys filter. Public so the word-count estimator can include * custom-field text in quota calculations. * * @param int $post_id * @return string[] */ public function get_translatable_meta_texts($post_id) { return array_values($this->get_translatable_meta_map($post_id)); } /** * Translatable custom-field text for a post as [ meta_key => value ]. Used by the * review editor to present custom fields as editable segments, and by the word * estimator. Covers JetEngine + ACF + heuristic detection, after the filter. * * @param int $post_id * @return array */ public function get_translatable_meta_map($post_id) { $keys = array_values(array_unique(array_merge( $this->get_jetengine_text_meta_keys(get_post_type($post_id)), $this->get_acf_text_meta_keys($post_id), $this->get_heuristic_text_meta_keys($post_id) ))); $keys = apply_filters('aitrfoel_translatable_meta_keys', $keys, $post_id, 0, ''); if (!is_array($keys)) { return []; } $map = []; foreach ($keys as $key) { if (!is_string($key) || $key === '') { continue; } $val = get_post_meta($post_id, $key, true); if (is_string($val) && trim($val) !== '') { $map[$key] = $val; } } return $map; } private function translate_post_meta_fields($original_id, $target_id, $target_lang, $source_lang = 'auto') { // Copy all custom-field values (galleries, media, dates, numbers, relations, // repeaters and text) from the source to the translation first, so nothing is // missing on the translation. This covers JetEngine, ACF, Meta Box, Pods and // plain custom fields generically. Text-type fields are overwritten with their // translations further down. Internal/handled keys are excluded. $this->copy_all_custom_meta($original_id, $target_id); // Keep featured image in sync for translated posts/pages/products. $thumbnail_id = get_post_thumbnail_id($original_id); if ($thumbnail_id) { set_post_thumbnail($target_id, (int) $thumbnail_id); update_post_meta($target_id, '_thumbnail_id', (int) $thumbnail_id); } else { delete_post_thumbnail($target_id); } // Remap media attachments if duplicate media attachments is enabled $this->remap_post_media_attachments($target_id, $original_id, $target_lang, $source_lang); $seo_translate_keys = []; $seo_enabled = get_option('aitrfoel_seo_translation_enabled', true); $seo_og_enabled = get_option('aitrfoel_seo_translate_og', true); $seo_twitter_enabled = get_option('aitrfoel_seo_translate_twitter', true); // Yoast SEO if ($seo_enabled && (defined('WPSEO_VERSION') || class_exists('WPSEO_Meta'))) { $seo_translate_keys[] = '_yoast_wpseo_title'; $seo_translate_keys[] = '_yoast_wpseo_metadesc'; if ($seo_og_enabled) { $seo_translate_keys[] = '_yoast_wpseo_opengraph-title'; $seo_translate_keys[] = '_yoast_wpseo_opengraph-description'; } if ($seo_twitter_enabled) { $seo_translate_keys[] = '_yoast_wpseo_twitter-title'; $seo_translate_keys[] = '_yoast_wpseo_twitter-description'; } $focus_kw = get_post_meta($original_id, '_yoast_wpseo_focuskw', true); if ($focus_kw !== '') { update_post_meta($target_id, '_yoast_wpseo_focuskw', $focus_kw); } } // Rank Math if ($seo_enabled && (class_exists('RankMath') || defined('RANK_MATH_VERSION'))) { $seo_translate_keys[] = 'rank_math_title'; $seo_translate_keys[] = 'rank_math_description'; if ($seo_og_enabled) { $seo_translate_keys[] = 'rank_math_og_title'; $seo_translate_keys[] = 'rank_math_og_description'; } if ($seo_twitter_enabled) { $seo_translate_keys[] = 'rank_math_twitter_title'; $seo_translate_keys[] = 'rank_math_twitter_description'; } $rm_kw = get_post_meta($original_id, 'rank_math_focus_keyword', true); if ($rm_kw !== '') { update_post_meta($target_id, 'rank_math_focus_keyword', $rm_kw); } } // SEOPress if ($seo_enabled && (defined('SEOPRESS_VERSION') || function_exists('seopress_init'))) { $seo_translate_keys[] = '_seopress_titles_title'; $seo_translate_keys[] = '_seopress_titles_desc'; if ($seo_og_enabled) { $seo_translate_keys[] = '_seopress_social_og_title'; $seo_translate_keys[] = '_seopress_social_og_desc'; } if ($seo_twitter_enabled) { $seo_translate_keys[] = '_seopress_social_twitter_title'; $seo_translate_keys[] = '_seopress_social_twitter_desc'; } } // Collect non-empty values for batch translation $to_translate = []; foreach ($seo_translate_keys as $key) { $val = get_post_meta($original_id, $key, true); if (!empty($val)) { $to_translate[$key] = $val; } } // Translate post excerpt (also serves as WooCommerce short description). // Primary source is the original post's excerpt. Fallback: if the source // excerpt is empty but the TARGET carries an untranslated excerpt (common // for older translations where the source-language text was copied onto // the translation and never translated), translate that text in place. $original_post = get_post($original_id); $original_excerpt = $original_post ? trim((string) $original_post->post_excerpt) : ''; if ($original_excerpt !== '') { $to_translate['__excerpt__'] = $original_excerpt; } else { $target_post = get_post($target_id); $target_excerpt = $target_post ? trim((string) $target_post->post_excerpt) : ''; if ($target_excerpt !== '') { $to_translate['__excerpt__'] = $target_excerpt; } } // Custom-field text: many CPTs store their real content in text/textarea/ // wysiwyg meta fields (rendered via JetEngine Listings, ACF blocks, dynamic // tags), leaving post_content empty. Translate those text fields so the // content — not just the title — gets translated. Non-text fields were copied // verbatim above. Covers JetEngine and ACF field definitions. $custom_text_keys = array_merge( $this->get_jetengine_text_meta_keys(get_post_type($original_id)), $this->get_acf_text_meta_keys($original_id), // Heuristic fallback: detect human-readable text in any custom field even // when the framework's field definitions can't be read. Covers JetEngine // setups where the meta-box config isn't in the expected option, plus Pods, // Meta Box, and plain custom fields. $this->get_heuristic_text_meta_keys($original_id) ); $custom_text_keys = array_values(array_unique($custom_text_keys)); /** * Filter which custom-field meta keys are translated for a post. Add keys the * auto-detection missed, or remove keys that should stay in the source language. * * @param string[] $custom_text_keys * @param int $original_id * @param int $target_id * @param string $target_lang */ $custom_text_keys = apply_filters('aitrfoel_translatable_meta_keys', $custom_text_keys, $original_id, $target_id, $target_lang); if (!is_array($custom_text_keys)) { $custom_text_keys = []; } foreach ($custom_text_keys as $custom_key) { if (!is_string($custom_key) || $custom_key === '' || isset($to_translate[$custom_key])) { continue; } $custom_val = get_post_meta($original_id, $custom_key, true); if (is_string($custom_val) && trim($custom_val) !== '') { $to_translate[$custom_key] = $custom_val; } } if (!empty($to_translate)) { $api = AITRFOEL_OpenAI_API::get_instance(); $texts = array_values($to_translate); $keys = array_keys($to_translate); $translated = $api->translate_batch($texts, $target_lang, $source_lang); if (!is_wp_error($translated) && is_array($translated)) { // translate_batch() returns an associative map [ original_text => translation ], // NOT a positional array — so look up each field's translation by its source // value, not by index. (A positional lookup silently writes nothing.) $written = []; foreach ($keys as $idx => $key) { $original = isset($texts[$idx]) ? $texts[$idx] : null; if ($original === null || !isset($translated[$original])) { continue; } $translated_text = $translated[$original]; if (is_wp_error($translated_text) || $translated_text === '' || $translated_text === null) { continue; } if ($key === '__excerpt__') { wp_update_post(['ID' => $target_id, 'post_excerpt' => $translated_text]); } else { update_post_meta($target_id, $key, $translated_text); } $written[] = $key; } AITRFOEL_Logger::log( sprintf('Translated %d SEO/meta field(s) for post %d → %d (%s): %s', count($written), $original_id, $target_id, $target_lang, implode(', ', $written)), 'info', $original_id, $target_id ); } } // ACF (and similar) relational fields store post IDs pointing at the source- // language items. Remap them to the target-language translation so e.g. a // "related projects" field links to translated posts instead of the originals. $this->remap_relational_meta_fields($original_id, $target_id, $target_lang); // WooCommerce: copy non-translatable product meta if (get_post_type($original_id) === 'product' && get_option('aitrfoel_woocommerce_support', true)) { $wc_copy_keys = [ '_regular_price', '_sale_price', '_price', '_sku', '_stock_status', '_manage_stock', '_stock', '_weight', '_length', '_width', '_height', '_virtual', '_downloadable', '_product_image_gallery', '_thumbnail_id', 'total_sales', ]; foreach ($wc_copy_keys as $key) { $val = get_post_meta($original_id, $key, true); if ($val !== '') { update_post_meta($target_id, $key, $val); } } } } /** * Public entry point for the SEO meta patch tool. * Re-translates SEO fields (Yoast / Rank Math title, description, OG, Twitter) * and the post excerpt for an existing translation pair. * * @param int $original_id Source (default-language) post ID * @param int $target_id Translated post ID * @param string $target_lang Target language code (e.g. 'en', 'de') * @param string $source_lang Source language code or 'auto' */ public function patch_seo_meta_fields($original_id, $target_id, $target_lang, $source_lang = 'auto') { $this->translate_post_meta_fields($original_id, $target_id, $target_lang, $source_lang); } /** * Assign translated WooCommerce product categories and tags to a translated product. * Uses Polylang/WPML term translation lookup to find equivalent terms. * For the built-in language system, assigns original terms and translates their names. * * @param int $original_id Source product ID * @param int $translated_id Translated product ID * @param string $target_lang Target language code (2-letter) */ private function translate_product_taxonomies($original_id, $translated_id, $target_lang) { if (get_post_type($original_id) !== 'product') { return; } $woo_enabled = get_option('aitrfoel_woocommerce_support', true); if (!$woo_enabled) { return; } $taxonomies = []; if (get_option('aitrfoel_woo_translate_categories', true)) { $taxonomies[] = 'product_cat'; } if (get_option('aitrfoel_woo_translate_tags', true)) { $taxonomies[] = 'product_tag'; } if (empty($taxonomies)) { return; } $this->assign_and_translate_terms($original_id, $translated_id, $target_lang, $taxonomies); } /** * Assign and translate taxonomy terms (categories/tags) for any post type. * Handles Polylang/WPML term lookups, and for the built-in language system * assigns the original terms and stores translated names as term meta. * * @param int $original_id Source post ID * @param int $translated_id Translated post ID * @param string $target_lang Target language code (2-letter) * @param array $taxonomies List of taxonomy slugs to process */ private function assign_and_translate_terms($original_id, $translated_id, $target_lang, $taxonomies) { $terms_to_translate = []; // Collect term names for batch translation foreach ($taxonomies as $taxonomy) { $terms = wp_get_post_terms($original_id, $taxonomy, ['fields' => 'all']); if (empty($terms) || is_wp_error($terms)) { continue; } $translated_term_ids = []; foreach ($terms as $term) { $translated_term_id = null; // Polylang if (function_exists('pll_get_term')) { $translated_term_id = pll_get_term($term->term_id, $target_lang); } // WPML fallback if (!$translated_term_id && function_exists('apply_filters')) { $trid = apply_filters('wpml_element_trid', null, $term->term_taxonomy_id, 'tax_' . $taxonomy); if ($trid) { $translations = apply_filters('wpml_get_element_translations', null, $trid, 'tax_' . $taxonomy); if (isset($translations[$target_lang])) { $translated_term_id = $translations[$target_lang]->term_id; } } } // Built-in language system fallback: assign original term if (!$translated_term_id) { $translated_term_id = $term->term_id; // Queue term name for translation if not already translated $existing = get_term_meta($term->term_id, '_aitrfoel_name_' . $target_lang, true); if (empty($existing) && get_option('aitrfoel_translate_taxonomy_names', true)) { $terms_to_translate[$term->term_id] = $term->name; } } if ($translated_term_id) { $translated_term_ids[] = (int) $translated_term_id; } } if (!empty($translated_term_ids)) { wp_set_post_terms($translated_id, $translated_term_ids, $taxonomy); AITRFOEL_Logger::log( sprintf('Assigned %d %s terms to post %d', count($translated_term_ids), $taxonomy, $translated_id), 'info', $original_id, $translated_id ); } } // Batch-translate collected term names if (!empty($terms_to_translate)) { $this->translate_term_names_batch($terms_to_translate, $target_lang); } } /** * Translate term names via API and store as term meta. * * @param array $terms_map [term_id => term_name] pairs * @param string $target_lang Target language code */ private function translate_term_names_batch($terms_map, $target_lang) { $api = AITRFOEL_OpenAI_API::get_instance(); $names = array_values($terms_map); $term_ids = array_keys($terms_map); $translated = $api->translate_batch($names, $target_lang); if (is_wp_error($translated) || !is_array($translated)) { AITRFOEL_Logger::log( 'Failed to translate taxonomy term names: ' . (is_wp_error($translated) ? $translated->get_error_message() : 'unknown error'), 'warning' ); return; } // translate_batch returns [original => translated] mapping foreach ($names as $i => $original_name) { if (!isset($term_ids[$i])) continue; $term_id = $term_ids[$i]; $translated_name = isset($translated[$original_name]) ? $translated[$original_name] : null; if ($translated_name && !is_wp_error($translated_name) && $translated_name !== $original_name) { update_term_meta($term_id, '_aitrfoel_name_' . $target_lang, sanitize_text_field($translated_name)); AITRFOEL_Logger::log( sprintf('Translated term "%s" → "%s" (%s, term ID %d)', $original_name, $translated_name, $target_lang, $term_id), 'info' ); } } } /** * Synchronize Elementor template taxonomies, metadata, and compile CSS for translated templates. * Ensures translated templates stay categorized under Saved Templates (container, section, page) * and do not get misplaced into Theme Builder or lose styling. * * @param int $original_page_id Source template post ID. * @param int $target_page_id Translated template post ID. */ public function sync_elementor_template_meta_and_taxonomies($original_page_id, $target_page_id) { $post_type = get_post_type($original_page_id); if ($post_type !== 'elementor_library') { return; } // 1. Copy & synchronize elementor_library_type taxonomy terms $types = wp_get_object_terms($original_page_id, 'elementor_library_type', ['fields' => 'slugs']); if (!empty($types) && !is_wp_error($types)) { wp_set_object_terms($target_page_id, $types, 'elementor_library_type'); } else { // Fallback to _elementor_template_type post meta if taxonomy terms were not set on source $source_type = get_post_meta($original_page_id, '_elementor_template_type', true); if (!empty($source_type)) { wp_set_object_terms($target_page_id, (string) $source_type, 'elementor_library_type'); } } // 2. Copy & synchronize elementor_library_category taxonomy terms $cats = wp_get_object_terms($original_page_id, 'elementor_library_category', ['fields' => 'slugs']); if (!empty($cats) && !is_wp_error($cats)) { wp_set_object_terms($target_page_id, $cats, 'elementor_library_category'); } // 3. Synchronize all Elementor template meta fields $elementor_meta_keys = [ '_elementor_template_type', '_elementor_template_sub_type', '_elementor_version', '_elementor_pro_version', '_elementor_page_settings', '_elementor_controls_usage', '_elementor_page_assets', '_elementor_screenshot', ]; foreach ($elementor_meta_keys as $meta_key) { $meta_value = get_post_meta($original_page_id, $meta_key, true); if ($meta_value !== '') { // Strip display conditions from _elementor_page_settings for translated templates. if ($meta_key === '_elementor_page_settings' && is_array($meta_value)) { unset($meta_value['conditions']); } update_post_meta($target_page_id, $meta_key, $meta_value); } } update_post_meta($target_page_id, '_elementor_edit_mode', 'builder'); // Translated templates must never inherit display conditions delete_post_meta($target_page_id, '_elementor_conditions'); // 4. Invalidate and compile Elementor CSS stylesheet for translated template delete_post_meta($target_page_id, '_elementor_css'); clean_post_cache($target_page_id); if (class_exists('\Elementor\Core\Files\CSS\Post')) { try { $css_file = \Elementor\Core\Files\CSS\Post::create($target_page_id); if ($css_file) { $css_file->update(); } } catch (\Throwable $e) { // Graceful fallback } } if (class_exists('\Elementor\Plugin')) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } AITRFOEL_Logger::log( sprintf('Synchronized Elementor template taxonomies & CSS for #%d -> #%d', $original_page_id, $target_page_id), 'info', $original_page_id, $target_page_id ); } /** * Translate and assign regular post categories and tags. * Called for non-WooCommerce posts during translation. * * @param int $original_id Source post ID * @param int $translated_id Translated post ID * @param string $target_lang Target language code */ private function translate_post_taxonomies($original_id, $translated_id, $target_lang) { $post_type = get_post_type($original_id); // Skip WooCommerce products (handled by translate_product_taxonomies) if ($post_type === 'product') { return; } // Elementor library templates require internal taxonomy synchronization if ($post_type === 'elementor_library') { $this->sync_elementor_template_meta_and_taxonomies($original_id, $translated_id); return; } $taxonomies = get_object_taxonomies($post_type, 'names'); // Filter to only public, non-internal taxonomies $translatable = []; foreach ($taxonomies as $tax) { $tax_obj = get_taxonomy($tax); if ($tax_obj && $tax_obj->public && $tax_obj->show_ui && $tax !== 'aitrfoel_language') { $translatable[] = $tax; } } if (empty($translatable)) { return; } $this->assign_and_translate_terms($original_id, $translated_id, $target_lang, $translatable); } /** * Translate WooCommerce product attributes (_product_attributes meta). * Handles both custom (local) attributes and global taxonomy (pa_*) attributes. * * @param int $original_id Source product ID * @param int $translated_id Translated product ID * @param string $target_lang Target language code (2-letter) * @param string $source_lang Source language code */ private function translate_product_attributes($original_id, $translated_id, $target_lang, $source_lang = 'auto') { if (get_post_type($original_id) !== 'product') { return; } if (!get_option('aitrfoel_woocommerce_support', true) || !get_option('aitrfoel_woo_translate_attributes', true)) { return; } $attributes = get_post_meta($original_id, '_product_attributes', true); if (empty($attributes) || !is_array($attributes)) { return; } $api = AITRFOEL_OpenAI_API::get_instance(); $translated_attributes = []; $texts_to_translate = []; $text_keys = []; // Track which attribute+field each text belongs to foreach ($attributes as $attr_key => $attr_data) { $translated_attributes[$attr_key] = $attr_data; if (!empty($attr_data['is_taxonomy']) && taxonomy_exists($attr_key)) { // Global taxonomy attribute (pa_color, pa_size, etc.) // Assign translated terms via Polylang/WPML $terms = wp_get_post_terms($original_id, $attr_key, ['fields' => 'all']); if (!empty($terms) && !is_wp_error($terms)) { $translated_term_ids = []; foreach ($terms as $term) { $translated_term_id = null; if (function_exists('pll_get_term')) { $translated_term_id = pll_get_term($term->term_id, $target_lang); } if (!$translated_term_id) { $trid = apply_filters('wpml_element_trid', null, $term->term_taxonomy_id, 'tax_' . $attr_key); if ($trid) { $translations = apply_filters('wpml_get_element_translations', null, $trid, 'tax_' . $attr_key); if (isset($translations[$target_lang])) { $translated_term_id = $translations[$target_lang]->term_id; } } } if ($translated_term_id) { $translated_term_ids[] = (int) $translated_term_id; } else { // Fallback: keep original term $translated_term_ids[] = $term->term_id; } } wp_set_post_terms($translated_id, $translated_term_ids, $attr_key); // Update the value field with translated term slugs $translated_terms_obj = array_map('get_term', $translated_term_ids); $slugs = wp_list_pluck(array_filter($translated_terms_obj), 'slug'); $translated_attributes[$attr_key]['value'] = implode(' | ', $slugs); } } else { // Custom (local) attribute — translate the name and pipe-delimited values if (!empty($attr_data['name'])) { $texts_to_translate[] = $attr_data['name']; $text_keys[] = ['attr' => $attr_key, 'field' => 'name']; } if (!empty($attr_data['value'])) { $texts_to_translate[] = $attr_data['value']; $text_keys[] = ['attr' => $attr_key, 'field' => 'value']; } } } // Batch-translate custom attribute names and values if (!empty($texts_to_translate)) { $translated_texts = $api->translate_batch($texts_to_translate, $target_lang, $source_lang); if (!is_wp_error($translated_texts) && is_array($translated_texts)) { foreach ($translated_texts as $i => $translated_text) { if (!isset($text_keys[$i]) || is_wp_error($translated_text)) { continue; } $attr = $text_keys[$i]['attr']; $field = $text_keys[$i]['field']; $translated_attributes[$attr][$field] = $translated_text; } } } update_post_meta($translated_id, '_product_attributes', $translated_attributes); AITRFOEL_Logger::log( sprintf('Translated %d product attributes for product %d → %d', count($attributes), $original_id, $translated_id), 'info', $original_id, $translated_id ); } /** * Translate WooCommerce product variations for variable products. * Creates translated variation posts with proper parent-child relationships. * * @param int $original_id Source product ID * @param int $translated_id Translated product ID * @param string $target_lang Target language code (2-letter) * @param string $source_lang Source language code */ private function translate_product_variations($original_id, $translated_id, $target_lang, $source_lang = 'auto') { if (get_post_type($original_id) !== 'product') { return; } if (!get_option('aitrfoel_woocommerce_support', true) || !get_option('aitrfoel_woo_translate_variations', true)) { return; } // Only process variable products $product_type = wp_get_post_terms($original_id, 'product_type', ['fields' => 'slugs']); if (empty($product_type) || !in_array('variable', $product_type, true)) { return; } // Set the translated product as variable type too wp_set_object_terms($translated_id, 'variable', 'product_type'); // Get source variations $variations = get_posts([ 'post_type' => 'product_variation', 'post_parent' => $original_id, 'numberposts' => -1, 'post_status' => 'any', 'orderby' => 'menu_order', 'order' => 'ASC', ]); if (empty($variations)) { return; } // Delete existing variations on the translated product to avoid duplicates on re-translation $existing_variations = get_posts([ 'post_type' => 'product_variation', 'post_parent' => $translated_id, 'numberposts' => -1, 'post_status' => 'any', 'fields' => 'ids', ]); foreach ($existing_variations as $ev_id) { wp_delete_post($ev_id, true); } // Collect translatable variation descriptions for batch translation $descriptions_to_translate = []; $desc_variation_indices = []; foreach ($variations as $idx => $variation) { $desc = $variation->post_excerpt; if (!empty(trim($desc))) { $descriptions_to_translate[] = $desc; $desc_variation_indices[] = $idx; } } // Batch-translate descriptions $translated_descriptions = []; if (!empty($descriptions_to_translate)) { $api = AITRFOEL_OpenAI_API::get_instance(); $result = $api->translate_batch($descriptions_to_translate, $target_lang, $source_lang); if (!is_wp_error($result) && is_array($result)) { foreach ($result as $i => $td) { if (isset($desc_variation_indices[$i]) && !is_wp_error($td)) { $translated_descriptions[$desc_variation_indices[$i]] = $td; } } } } // Meta keys to copy directly (non-translatable variation data) $variation_copy_keys = [ '_regular_price', '_sale_price', '_price', '_sku', '_stock_status', '_manage_stock', '_stock', '_weight', '_length', '_width', '_height', '_virtual', '_downloadable', '_download_limit', '_download_expiry', '_thumbnail_id', '_variation_description', ]; foreach ($variations as $idx => $variation) { // Create the translated variation $new_variation_id = wp_insert_post([ 'post_type' => 'product_variation', 'post_parent' => $translated_id, 'post_status' => $variation->post_status, 'post_title' => $variation->post_title, 'post_excerpt' => isset($translated_descriptions[$idx]) ? $translated_descriptions[$idx] : $variation->post_excerpt, 'menu_order' => $variation->menu_order, ]); if (is_wp_error($new_variation_id) || !$new_variation_id) { continue; } // Copy variation meta foreach ($variation_copy_keys as $key) { $val = get_post_meta($variation->ID, $key, true); if ($val !== '') { update_post_meta($new_variation_id, $key, $val); } } // Copy attribute assignment meta (attribute_pa_color, attribute_pa_size, etc.) $all_meta = get_post_meta($variation->ID); foreach ($all_meta as $meta_key => $meta_values) { if (strpos($meta_key, 'attribute_') === 0) { $attr_value = $meta_values[0]; $taxonomy = str_replace('attribute_', '', $meta_key); // For taxonomy attributes, try to find translated term slug if (taxonomy_exists($taxonomy) && !empty($attr_value)) { $term = get_term_by('slug', $attr_value, $taxonomy); if ($term) { $translated_term_id = null; if (function_exists('pll_get_term')) { $translated_term_id = pll_get_term($term->term_id, $target_lang); } if (!$translated_term_id) { $trid = apply_filters('wpml_element_trid', null, $term->term_taxonomy_id, 'tax_' . $taxonomy); if ($trid) { $translations = apply_filters('wpml_get_element_translations', null, $trid, 'tax_' . $taxonomy); if (isset($translations[$target_lang])) { $translated_term_id = $translations[$target_lang]->term_id; } } } if ($translated_term_id) { $translated_term = get_term($translated_term_id, $taxonomy); if ($translated_term && !is_wp_error($translated_term)) { $attr_value = $translated_term->slug; } } } } update_post_meta($new_variation_id, $meta_key, $attr_value); } } } // Copy variable product transient data $default_attributes = get_post_meta($original_id, '_default_attributes', true); if (!empty($default_attributes) && is_array($default_attributes)) { $translated_defaults = []; foreach ($default_attributes as $taxonomy => $slug) { if (taxonomy_exists($taxonomy) && !empty($slug)) { $term = get_term_by('slug', $slug, $taxonomy); if ($term) { $translated_term_id = null; if (function_exists('pll_get_term')) { $translated_term_id = pll_get_term($term->term_id, $target_lang); } if ($translated_term_id) { $translated_term = get_term($translated_term_id, $taxonomy); if ($translated_term && !is_wp_error($translated_term)) { $translated_defaults[$taxonomy] = $translated_term->slug; continue; } } } } $translated_defaults[$taxonomy] = $slug; } update_post_meta($translated_id, '_default_attributes', $translated_defaults); } // Clean transients so WooCommerce rebuilds variation data delete_transient('wc_product_children_' . $translated_id); delete_transient('wc_var_prices_' . $translated_id); AITRFOEL_Logger::log( sprintf('Created %d translated variations for variable product %d → %d', count($variations), $original_id, $translated_id), 'info', $original_id, $translated_id ); } /** * Translate product gallery and featured image alt text, captions, and descriptions. * * @param int $original_id Source product ID * @param int $translated_id Translated product ID * @param string $target_lang Target language code (2-letter) * @param string $source_lang Source language code */ private function translate_product_gallery_meta($original_id, $translated_id, $target_lang, $source_lang = 'auto') { if (get_post_type($original_id) !== 'product') { return; } if (!get_option('aitrfoel_woocommerce_support', true) || !get_option('aitrfoel_woo_translate_gallery_alt', true)) { return; } // Collect all image IDs: featured image + gallery images $image_ids = []; $thumbnail_id = get_post_meta($original_id, '_thumbnail_id', true); if ($thumbnail_id) { $image_ids[] = (int) $thumbnail_id; } $gallery_ids = get_post_meta($original_id, '_product_image_gallery', true); if (!empty($gallery_ids)) { $gallery_arr = array_map('intval', array_filter(explode(',', $gallery_ids))); $image_ids = array_merge($image_ids, $gallery_arr); } $image_ids = array_unique($image_ids); if (empty($image_ids)) { return; } // Collect translatable text from images $texts_to_translate = []; $text_keys = []; // [image_id, field] foreach ($image_ids as $img_id) { $alt = get_post_meta($img_id, '_wp_attachment_image_alt', true); if (!empty(trim($alt))) { $texts_to_translate[] = $alt; $text_keys[] = ['id' => $img_id, 'field' => 'alt']; } $attachment = get_post($img_id); if ($attachment) { if (!empty(trim($attachment->post_excerpt))) { $texts_to_translate[] = $attachment->post_excerpt; $text_keys[] = ['id' => $img_id, 'field' => 'caption']; } if (!empty(trim($attachment->post_content))) { $texts_to_translate[] = $attachment->post_content; $text_keys[] = ['id' => $img_id, 'field' => 'description']; } } } if (empty($texts_to_translate)) { return; } // Batch-translate all image text $api = AITRFOEL_OpenAI_API::get_instance(); $translated_texts = $api->translate_batch($texts_to_translate, $target_lang, $source_lang); if (is_wp_error($translated_texts) || !is_array($translated_texts)) { return; } // Apply translations — we store translated alt/caption/description as post meta on the translated product // This avoids modifying the shared attachment posts (which are shared across languages) $translated_image_meta = []; foreach ($translated_texts as $i => $translated_text) { if (!isset($text_keys[$i]) || is_wp_error($translated_text)) { continue; } $img_id = $text_keys[$i]['id']; $field = $text_keys[$i]['field']; if (!isset($translated_image_meta[$img_id])) { $translated_image_meta[$img_id] = []; } $translated_image_meta[$img_id][$field] = $translated_text; } // Store as structured meta on the translated product for theme/frontend retrieval update_post_meta($translated_id, '_aitrfoel_translated_image_meta', $translated_image_meta); // Also update the alt text directly on attachment posts if they don't have language-specific versions // This is safe because alt text meta can be overwritten and is the most impactful for SEO foreach ($translated_image_meta as $img_id => $fields) { if (isset($fields['alt'])) { // Store translated alt with a language-specific key to avoid overwriting the original update_post_meta($img_id, '_wp_attachment_image_alt_' . $target_lang, sanitize_text_field($fields['alt'])); } } AITRFOEL_Logger::log( sprintf('Translated image meta for %d images on product %d → %d', count($translated_image_meta), $original_id, $translated_id), 'info', $original_id, $translated_id ); } /** * Localize a media attachment by creating a linked attachment record in the * WordPress database for the target language (sharing the same physical file on disk). * * @param int $source_attachment_id * @param string $target_lang * @param string $source_lang * @return int Localized attachment ID (or original ID on failure) */ public function localize_attachment($source_attachment_id, $target_lang, $source_lang = 'auto') { $source_attachment_id = (int) $source_attachment_id; if ($source_attachment_id <= 0) { return 0; } // 1. Check if a localized attachment already exists $existing = get_posts([ 'post_type' => 'attachment', 'post_status' => 'inherit', 'meta_key' => '_aitrfoel_source_attachment', 'meta_value' => (string) $source_attachment_id, 'posts_per_page' => -1, 'suppress_filters' => true, ]); if (!empty($existing)) { $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); foreach ($existing as $p) { $lang = $multilingual->get_post_language($p->ID); if ($lang === $target_lang) { return (int) $p->ID; } $saved_target = get_post_meta($p->ID, '_aitrfoel_attachment_target_lang', true); if ($saved_target === $target_lang) { return (int) $p->ID; } } } // 2. Fetch original attachment post $src_post = get_post($source_attachment_id); if (!$src_post || $src_post->post_type !== 'attachment') { return $source_attachment_id; } // 3. Prepare translatable text (Title, Caption, Description, Alt text) $orig_title = $src_post->post_title; $orig_caption = $src_post->post_excerpt; $orig_desc = $src_post->post_content; $orig_alt = get_post_meta($source_attachment_id, '_wp_attachment_image_alt', true); $translatable = []; $keys = []; if (!empty(trim($orig_title))) { $translatable[] = $orig_title; $keys[] = 'title'; } if (!empty(trim($orig_caption))) { $translatable[] = $orig_caption; $keys[] = 'caption'; } if (!empty(trim($orig_desc))) { $translatable[] = $orig_desc; $keys[] = 'desc'; } if (!empty(trim($orig_alt))) { $translatable[] = $orig_alt; $keys[] = 'alt'; } $trans_map = [ 'title' => $orig_title, 'caption' => $orig_caption, 'desc' => $orig_desc, 'alt' => $orig_alt, ]; if (!empty($translatable)) { $api = AITRFOEL_OpenAI_API::get_instance(); $results = $api->translate_batch($translatable, $target_lang, $source_lang); if (is_array($results) && !is_wp_error($results)) { foreach ($keys as $idx => $k) { if (isset($results[$idx]) && !is_wp_error($results[$idx])) { $trans_map[$k] = $results[$idx]; } } } } // 4. Create new attachment post referencing the exact same physical file $target_attachment_id = wp_insert_post([ 'post_title' => $trans_map['title'], 'post_excerpt' => $trans_map['caption'], 'post_content' => $trans_map['desc'], 'post_status' => 'inherit', 'post_type' => 'attachment', 'post_mime_type' => $src_post->post_mime_type, 'guid' => $src_post->guid, ]); if (is_wp_error($target_attachment_id) || !$target_attachment_id) { return $source_attachment_id; } // 5. Copy attachment file path & thumbnail metadata from source $attached_file = get_post_meta($source_attachment_id, '_wp_attached_file', true); if (!empty($attached_file)) { update_post_meta($target_attachment_id, '_wp_attached_file', $attached_file); } $attach_meta = get_post_meta($source_attachment_id, '_wp_attachment_metadata', true); if (!empty($attach_meta)) { update_post_meta($target_attachment_id, '_wp_attachment_metadata', $attach_meta); } if (!empty($trans_map['alt'])) { update_post_meta($target_attachment_id, '_wp_attachment_image_alt', sanitize_text_field($trans_map['alt'])); } // 6. Set tracking metadata update_post_meta($target_attachment_id, '_aitrfoel_source_attachment', $source_attachment_id); update_post_meta($target_attachment_id, '_aitrfoel_attachment_target_lang', $target_lang); update_post_meta($target_attachment_id, '_aitrfoel_translated_from', $source_attachment_id . '_' . $target_lang); // 7. Register language in Polylang / WPML / Built-in $multilingual = AITRFOEL_Multilingual_Manager::get_instance(); $multilingual->set_post_language($target_attachment_id, $target_lang); $multilingual->setup_translation_relationship($source_attachment_id, $target_attachment_id, $target_lang); if (class_exists('AITRFOEL_Logger')) { AITRFOEL_Logger::log( sprintf('Localized media attachment %d → %d for [%s]', $source_attachment_id, $target_attachment_id, $target_lang), 'info', $source_attachment_id, $target_attachment_id ); } return (int) $target_attachment_id; } /** * Remap all media attachments (featured image, galleries, ACF fields, JetEngine fields, * and Elementor data) on a translated post to their localized attachment counterparts. * * @param int $target_id * @param int $original_id * @param string $target_lang * @param string $source_lang */ public function remap_post_media_attachments($target_id, $original_id, $target_lang, $source_lang = 'auto') { if (!get_option('aitrfoel_duplicate_media_attachments', false)) { return; } // 1. Remap featured image $orig_thumb = get_post_thumbnail_id($original_id); if ($orig_thumb) { $new_thumb = $this->localize_attachment($orig_thumb, $target_lang, $source_lang); if ($new_thumb && $new_thumb !== $orig_thumb) { set_post_thumbnail($target_id, $new_thumb); update_post_meta($target_id, '_thumbnail_id', $new_thumb); } } // 2. Remap custom meta fields (galleries, ACF, JetEngine, WooCommerce) $all_meta = get_post_meta($original_id); if (is_array($all_meta)) { foreach ($all_meta as $key => $values) { if (empty($values) || !is_array($values)) continue; $is_media_field = ( stripos($key, 'gallery') !== false || stripos($key, 'image') !== false || stripos($key, 'media') !== false || stripos($key, 'photo') !== false || stripos($key, 'slide') !== false || stripos($key, 'picture') !== false || $key === '_product_image_gallery' ); if (!$is_media_field) continue; foreach ($values as $raw) { $val = maybe_unserialize($raw); if (is_numeric($val) && (int)$val > 0) { $loc_id = $this->localize_attachment((int)$val, $target_lang, $source_lang); if ($loc_id) { update_post_meta($target_id, $key, $loc_id); } } elseif (is_array($val)) { $remapped = []; foreach ($val as $k => $item) { if (is_numeric($item) && (int)$item > 0) { $remapped[$k] = $this->localize_attachment((int)$item, $target_lang, $source_lang); } else { $remapped[$k] = $item; } } update_post_meta($target_id, $key, $remapped); } elseif (is_string($val) && strpos($val, ',') !== false) { $ids = array_map('trim', explode(',', $val)); $all_num = true; foreach ($ids as $id_str) { if (!is_numeric($id_str)) { $all_num = false; break; } } if ($all_num && !empty($ids)) { $new_ids = []; foreach ($ids as $id_num) { $new_ids[] = $this->localize_attachment((int)$id_num, $target_lang, $source_lang); } update_post_meta($target_id, $key, implode(',', $new_ids)); } } } } } // 3. Remap attachment IDs inside Elementor data $elementor_raw = get_post_meta($target_id, '_elementor_data', true); if (!empty($elementor_raw)) { $data = is_array($elementor_raw) ? $elementor_raw : json_decode($elementor_raw, true); if (is_array($data)) { $modified = false; $this->remap_elementor_media_ids($data, $target_lang, $source_lang, $modified); if ($modified) { update_post_meta($target_id, '_elementor_data', wp_slash(json_encode($data))); } } } } /** * Recursively remap media attachment IDs inside Elementor data structure */ private function remap_elementor_media_ids(&$elements, $target_lang, $source_lang, &$modified) { if (!is_array($elements)) { return; } foreach ($elements as &$el) { if (isset($el['settings']) && is_array($el['settings'])) { // Check single image widget settings if (isset($el['settings']['image']['id']) && is_numeric($el['settings']['image']['id']) && (int)$el['settings']['image']['id'] > 0) { $orig = (int) $el['settings']['image']['id']; $loc = $this->localize_attachment($orig, $target_lang, $source_lang); if ($loc && $loc !== $orig) { $el['settings']['image']['id'] = $loc; $modified = true; } } // Check gallery / wp_gallery widget settings if (isset($el['settings']['wp_gallery']) && is_array($el['settings']['wp_gallery'])) { foreach ($el['settings']['wp_gallery'] as &$g_item) { if (isset($g_item['id']) && is_numeric($g_item['id']) && (int)$g_item['id'] > 0) { $orig = (int) $g_item['id']; $loc = $this->localize_attachment($orig, $target_lang, $source_lang); if ($loc && $loc !== $orig) { $g_item['id'] = $loc; $modified = true; } } } } // Check background image settings if (isset($el['settings']['background_image']['id']) && is_numeric($el['settings']['background_image']['id']) && (int)$el['settings']['background_image']['id'] > 0) { $orig = (int) $el['settings']['background_image']['id']; $loc = $this->localize_attachment($orig, $target_lang, $source_lang); if ($loc && $loc !== $orig) { $el['settings']['background_image']['id'] = $loc; $modified = true; } } } if (isset($el['elements']) && is_array($el['elements'])) { $this->remap_elementor_media_ids($el['elements'], $target_lang, $source_lang, $modified); } } } /** * Post-creation processing for translated pages * Handles cache warming, HTML regeneration, and LiteSpeed optimizations * * @param int $page_id Translated page ID * @return void */ public function post_creation_processing($page_id) { AITRFOEL_Logger::log("Starting post-creation processing for page $page_id", 'info', $page_id); // Step 1: Force Elementor to regenerate HTML content if (class_exists('\Elementor\Plugin')) { try { // Get the document for this page $document = \Elementor\Plugin::$instance->documents->get($page_id); if ($document && !is_wp_error($document)) { AITRFOEL_Logger::log("Regenerating Elementor HTML for page $page_id", 'info', $page_id); // Force save to regenerate HTML - this updates post_content. // Buffer it: Elementor can print regenerated CSS (atomic container // variables, etc.) during save, which would otherwise leak into an // AJAX response and corrupt the JSON ("request failed"). ob_start(); $document->save([]); ob_end_clean(); // Alternative method for older Elementor versions if (method_exists($document, 'get_main_post')) { $post = $document->get_main_post(); if ($post) { // Get the latest Elementor data and regenerate HTML $elementor_data = get_post_meta($page_id, '_elementor_data', true); if ($elementor_data) { // Update post content with fresh HTML $updated_content = $document->get_content(); wp_update_post([ 'ID' => $page_id, 'post_content' => $updated_content ]); AITRFOEL_Logger::log("Updated post_content with regenerated HTML", 'info', $page_id); } } } } else { AITRFOEL_Logger::log("Could not get Elementor document for page $page_id", 'warning', $page_id); } } catch (Exception $e) { AITRFOEL_Logger::log("Elementor regeneration failed: " . $e->getMessage(), 'error', $page_id); } } // Step 2: Clear all relevant caches AITRFOEL_Logger::log("Clearing caches for page $page_id", 'info', $page_id); // Clear Elementor cache if (class_exists('\Elementor\Plugin')) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } // Clear LiteSpeed cache if available if (defined('LSCWP_V') && class_exists('LiteSpeed\Purge')) { try { // Purge specific page // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party LiteSpeed Cache hook do_action('litespeed_purge_single_post', $page_id); AITRFOEL_Logger::log("LiteSpeed cache purged for page $page_id", 'info', $page_id); } catch (Exception $e) { AITRFOEL_Logger::log("LiteSpeed cache purge failed: " . $e->getMessage(), 'warning', $page_id); } } // Step 3: REMOVED sleep(2) for performance - database consistency is instant // Note: Removed 2-second delay that was slowing down translations // Step 4: REMOVED cache warming for performance (saves 3-5 seconds per translation) // Reason: wp_remote_get() triggers new PHP process → Freemius initialization → 3-5s delay // Impact: LiteSpeed will cache page on first real visitor, which is acceptable // Note: Cache warming was adding 5-10% overhead to translation time AITRFOEL_Logger::log("Skipping cache warm-up for performance (cache will build on first visitor)", 'info', $page_id); // Step 5: Additional LiteSpeed-specific optimizations if (defined('LSCWP_V')) { AITRFOEL_Logger::log("Triggering LiteSpeed post-processing hooks", 'info', $page_id); // Trigger LiteSpeed's optimization pipeline // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party LiteSpeed Cache hook do_action('litespeed_cache_finalize'); // Force image optimization check // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party LiteSpeed Cache hook do_action('litespeed_media_finalize', $page_id); } AITRFOEL_Logger::log("Post-creation processing completed for page $page_id", 'success', $page_id); } /** * Detect source language from strings * * @param array $strings Array of strings to analyze * @return string|null Detected language code or null on failure */ private function detect_source_language($strings, $page_id = null) { // Get the API instance (OpenAI only now) $api = AITRFOEL_OpenAI_API::get_instance(); $sample_parts = []; $total_len = 0; foreach ($strings as $item) { if (!isset($item['original'])) continue; $t = wp_strip_all_tags($item['original']); if ($t === '') continue; $sample_parts[] = $t; $total_len += strlen($t); if ($total_len > 800) break; // enough sample } if (empty($sample_parts)) return null; $sample_text = implode("\n", $sample_parts); $detected = $api->detect_language($sample_text); if (is_wp_error($detected)) { AITRFOEL_Logger::log('Language auto-detect failed: ' . $detected->get_error_message(), 'warning', $page_id); return null; } AITRFOEL_Logger::log('Detected source language: ' . $detected, 'info', $page_id); return $detected; } /** * Get last translation statistics * * @return array Translation statistics */ public function get_last_translation_stats() { return $this->last_translation_stats ?? [ 'elements_translated' => 0, 'word_count' => 0, 'input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'estimated_cost' => 0, 'cost_formatted' => '$0.0000', 'is_estimated' => true, 'cached_strings' => 0, 'uncached_strings' => 0, 'usage_records' => [] ]; } /** * Count words in an Elementor element recursively * * @param array $element Element data * @return int Word count */ private function count_element_words($element) { $words = 0; if (is_array($element)) { if (isset($element['settings'])) { foreach ($element['settings'] as $value) { if (is_string($value)) { $words += self::count_words(wp_strip_all_tags($value)); } } } if (isset($element['elements']) && is_array($element['elements'])) { foreach ($element['elements'] as $child) { $words += $this->count_element_words($child); } } } return $words; } /** * Validate translation quality * * @param array $translations Array of translations * @param string $target_lang Target language * @return array Array of quality issues */ private function validate_translation_quality($translations, $target_lang) { $issues = []; $suspicious_count = 0; $total_count = count($translations); // Character set validation for different languages $charset_patterns = [ 'ru' => '/[а-яё]/ui', // Cyrillic for Russian 'cs' => '/[ěščřžýáíéúů]/ui', // Czech specific characters 'pt' => '/[ãõçáéíóúâêôà]/ui', // Portuguese specific characters 'zh' => '/[\x{4e00}-\x{9fff}]/u', // Chinese characters 'ar' => '/[\x{0600}-\x{06ff}]/u', // Arabic characters 'ja' => '/[\x{3040}-\x{309f}\x{30a0}-\x{30ff}\x{4e00}-\x{9faf}]/u', // Japanese 'ko' => '/[\x{ac00}-\x{d7af}]/u', // Korean Hangul 'th' => '/[\x{0e00}-\x{0e7f}]/u', // Thai 'hi' => '/[\x{0900}-\x{097f}]/u', // Hindi Devanagari 'uk' => '/[а-яєіїґё]/ui', // Ukrainian Cyrillic 'bg' => '/[а-яьъ]/ui', // Bulgarian Cyrillic 'sr' => '/[а-яљњћџ]/ui', // Serbian Cyrillic 'el' => '/[α-ωάέήίόύώ]/ui', // Greek 'he' => '/[\x{0590}-\x{05ff}]/u', // Hebrew ]; // Language similarity patterns to detect non-translations $similarity_check = [ 'es' => ['en' => 0.7], // Spanish-English similarity threshold 'pt' => ['es' => 0.8, 'en' => 0.6], // Portuguese similarities 'it' => ['es' => 0.7, 'en' => 0.6], // Italian similarities 'fr' => ['en' => 0.6], // French-English ]; foreach ($translations as $path => $translated_text) { if (empty($translated_text) || !is_string($translated_text)) { $issues[] = "Empty translation at path: $path"; $suspicious_count++; continue; } // Check if translation is too similar to original (possible untranslated) // This would require original text comparison - skip for now // Check for appropriate character set if (isset($charset_patterns[$target_lang])) { $pattern = $charset_patterns[$target_lang]; $clean_text = wp_strip_all_tags($translated_text); if (strlen($clean_text) > 3 && !preg_match($pattern, $clean_text)) { $issues[] = "Translation may not be in target language ($target_lang) at path: $path"; $suspicious_count++; } } // Check for common translation failures if (strpos($translated_text, 'I cannot translate') !== false || strpos($translated_text, 'Unable to translate') !== false || strpos($translated_text, 'Error:') !== false || strpos($translated_text, 'Sorry, I can\'t') !== false || strpos($translated_text, 'Translation not available') !== false || preg_match('/\b(error|failed|unable)\b/i', $translated_text)) { $issues[] = "Translation error response at path: $path"; $suspicious_count++; } // Check for obvious non-translations (text too short after translation) $clean_original_estimate = strlen(wp_strip_all_tags($translated_text)); if ($clean_original_estimate < 3 && strlen($translated_text) > 10) { $issues[] = "Suspiciously short translation at path: $path"; $suspicious_count++; } // Check for HTML-only content (translations that lost all text) if (strlen(wp_strip_all_tags($translated_text)) === 0 && strlen($translated_text) > 0) { $issues[] = "Translation contains only HTML tags at path: $path"; $suspicious_count++; } } // Overall quality check if ($total_count > 0) { $failure_rate = ($suspicious_count / $total_count) * 100; if ($failure_rate > 30) { $issues[] = "High failure rate: {$failure_rate}% of translations may be problematic"; } elseif ($failure_rate > 10) { $issues[] = "Moderate failure rate: {$failure_rate}% of translations flagged for review"; } // Log quality summary if (empty($issues)) { AITRFOEL_Logger::log("Translation quality: EXCELLENT (no issues detected)", 'success'); } elseif ($failure_rate < 10) { AITRFOEL_Logger::log("Translation quality: GOOD (minor issues: {$failure_rate}%)", 'info'); } elseif ($failure_rate < 30) { AITRFOEL_Logger::log("Translation quality: FAIR (issues: {$failure_rate}%)", 'warning'); } else { AITRFOEL_Logger::log("Translation quality: POOR (major issues: {$failure_rate}%)", 'error'); } } return $issues; } /** * Public test method for debugging HTML restoration */ public function test_html_restoration($original_html, $translated_text = null) { AITRFOEL_Logger::log("=== HTML Restoration Test Started ===", 'debug'); AITRFOEL_Logger::log("Original HTML: " . $original_html, 'debug'); // Clean the text first $cleaned = $this->clean_text_for_translation($original_html); AITRFOEL_Logger::log("Cleaned text: " . $cleaned, 'debug'); // If no translated text provided, use the cleaned text as mock translation if ($translated_text === null) { $translated_text = $cleaned; } AITRFOEL_Logger::log("Translated text: " . $translated_text, 'debug'); // Restore HTML (now works with hash-based storage) $restored = $this->restore_html_formatting($translated_text, $original_html); AITRFOEL_Logger::log("Restored HTML: " . $restored, 'debug'); AITRFOEL_Logger::log("=== HTML Restoration Test Ended ===", 'debug'); return [ 'original' => $original_html, 'cleaned' => $cleaned, 'translated' => $translated_text, 'restored' => $restored ]; } /** * Track translation failure with analytics * * @param string $error_code Error code * @param string $error_message Error message * @param float|null $start_time Translation start time for duration calculation */ private function track_translation_failure($error_code, $error_message, $start_time = null) { if (!function_exists('aitrfoel_analytics')) { return; } $duration = $start_time ? round(microtime(true) - $start_time, 2) : 0; aitrfoel_analytics()->track_translation_failed($error_code, $error_message); aitrfoel_analytics()->track_translation_completed([ 'duration' => $duration, 'word_count' => 0, 'widget_count' => 0, 'success' => false, 'error_type' => $error_code, 'mode' => 'single', ]); } }