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$tag>", 'debug');
$restored = "<{$tag}{$attributes}>{$translated_content}{$tag}>";
// 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