settings = $settings; $trp = Translangated_Plugin::get_translangated_instance(); if ( ! $this->machine_translator_logger ) { $this->machine_translator_logger = $trp->get_component('machine_translator_logger'); } if ( ! $this->translangated_languages ) { $this->translangated_languages = $trp->get_component('languages'); } $this->machine_translation_codes = $this->translangated_languages->get_iso_codes($this->settings['translation-languages']); add_filter( 'translangated_exclude_words_from_automatic_translation', array( $this, 'sort_exclude_words_from_automatic_translation_array' ), 99999, 1 ); add_filter( 'translangated_exclude_words_from_automatic_translation', array( $this, 'exclude_special_symbol_from_translation' ), 9999, 2 ); $this->schedule_machine_translation_lock_cleanup(); } /** * Schedule the recurring cleanup of the locks left behind by requests killed mid translation. */ protected function schedule_machine_translation_lock_cleanup(){ if ( self::$lock_cleanup_hooked ){ return; } self::$lock_cleanup_hooked = true; add_action( 'translangated_machine_translation_lock_cleanup', array( $this, 'cleanup_machine_translation_locks_cron' ) ); if ( ! wp_next_scheduled( 'translangated_machine_translation_lock_cleanup' ) ){ wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'translangated_machine_translation_lock_cleanup' ); } } /** * Cron callback clearing abandoned machine translation locks. */ public function cleanup_machine_translation_locks_cron(){ $this->get_translangated_query()->cleanup_machine_translation_locks( $this->get_machine_translation_lock_timeout() ); } /** * Whether automatic translation is available. * * @param array $languages * @return bool */ public function is_available( $languages = array() ) { /** * Return false in case it was directly called from parent and not from a derived class (DeepL / Google / TPAI) * Calling this method on the parent class means it was called too early and machine translation is not available at this point */ if ( get_class( $this ) === __CLASS__ ) return false; $settings = $this->settings['translangated_machine_translation_settings'] ?? array(); $enabled = ( $settings['machine-translation'] ?? '' ) === 'yes'; $is_available = false; if ( $enabled ) { $engine = $settings['translation-engine'] ?? null; if ( empty( $languages ) ) { $is_available = true; } else { $is_available = $this->check_languages_availability( $languages ); } } return apply_filters( 'translangated_machine_translator_is_available', $is_available, $languages, $settings ); } public function check_languages_availability( $languages, $force_recheck = false ){ if ( !method_exists( $this, 'get_supported_languages' ) || !method_exists( $this, 'get_engine_specific_language_codes' )){ return true; } $force_recheck = ( current_user_can('manage_options') && !empty( $_GET['translangated_recheck_supported_languages']) && $_GET['translangated_recheck_supported_languages'] === '1' && wp_verify_nonce( sanitize_text_field( $_GET['translangated_recheck_supported_languages_nonce'] ), 'translangated_recheck_supported_languages' ) ) ? true : $force_recheck; //phpcs:ignore $data = get_option('translangated_db_stored_data', array() ); if ( isset( $_GET['translangated_recheck_supported_languages'] )) { unset($_GET['translangated_recheck_supported_languages'] ); } // if supported languages are not stored, fetch them and update option if ( empty( $data['translangated_mt_supported_languages'][$this->settings['translangated_machine_translation_settings']['translation-engine']]['last-checked'] ) || $force_recheck || ( method_exists($this,'check_formality') && !isset($data['translangated_mt_supported_languages'][$this->settings['translangated_machine_translation_settings']['translation-engine']]['formality-supported-languages']))){ if ( empty( $data['translangated_mt_supported_languages'] ) ) { $data['translangated_mt_supported_languages'] = array(); } if ( empty( $data['translangated_mt_supported_languages'][ $this->settings['translangated_machine_translation_settings']['translation-engine'] ] ) ) { $data['translangated_mt_supported_languages'][ $this->settings['translangated_machine_translation_settings']['translation-engine'] ] = array( 'languages' => array() ); } $data['translangated_mt_supported_languages'][ $this->settings['translangated_machine_translation_settings']['translation-engine'] ]['languages'] = $this->get_supported_languages(); if (method_exists($this, 'check_formality')) { $data['translangated_mt_supported_languages'][ $this->settings['translangated_machine_translation_settings']['translation-engine'] ]['formality-supported-languages'] = $this->check_formality(); } $data['translangated_mt_supported_languages'][$this->settings['translangated_machine_translation_settings']['translation-engine']]['last-checked'] = gmdate("Y-m-d H:i:s" ); update_option('translangated_db_stored_data', $data ); } $languages_iso_to_check = $this->get_engine_specific_language_codes( $languages ); $all_are_available = !array_diff($languages_iso_to_check, $data['translangated_mt_supported_languages'][$this->settings['translangated_machine_translation_settings']['translation-engine']]['languages']); return apply_filters('translangated_mt_available_supported_languages', $all_are_available, $languages, $this->settings ); } public function get_last_checked_supported_languages(){ $data = get_option('translangated_db_stored_data', array() ); if ( empty( $data['translangated_mt_supported_languages'][$this->settings['translangated_machine_translation_settings']['translation-engine']]['last-checked'] ) ){ $this->check_languages_availability( $this->settings['translation-languages'], true); } return $data['translangated_mt_supported_languages'][$this->settings['translangated_machine_translation_settings']['translation-engine']]['last-checked']; } /** * Output an SVG based on translation engine and error flag. * * @param bool $show_errors true to show an error SVG, false if not. */ public function automatic_translation_svg_output( $show_errors ) { if ( method_exists( $this, 'automatic_translate_error_check' ) ) { if ( $show_errors ) { translangated_output_svg( 'error' ); } else { translangated_output_svg( 'check' ); } } } /** * * @deprecated * Check the automatic translation API keys for Google Translate and DeepL * * @param Translangated_Plugin $machine_translator Machine translator instance. * @param string $translation_engine The translation engine (can be google_translate_v2 and deepl). * @param string $api_key The API key to check. * * @return array [ (string) $message, (bool) $error ]. */ public function automatic_translate_error_check( $machine_translator, $translation_engine, $api_key ) { $is_error = false; $return_message = ''; switch ( $translation_engine ) { case 'google_translate_v2': if ( empty( $api_key ) ) { $is_error = true; $return_message = __( 'Please enter your Google Translate key.', 'translangated' ); } else { // Perform test. $response = $machine_translator->test_request(); $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { $is_error = true; $translate_response = translangated_gt_response_codes( $code ); $return_message = $translate_response['message']; } } break; case 'deepl': if ( empty( $api_key ) ) { $is_error = true; $return_message = __( 'Please enter your DeepL API key.', 'translangated' ); } else { // Perform test. $is_error= false; $response = $machine_translator->test_request(); $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code && ( method_exists( 'Translangated_DeepL', 'deepl_response_codes' ) || method_exists( 'Translangated_IN_DeepL', 'deepl_response_codes' ) ) ) { // Test whether the old deepL add-on or the new repackaging model is used if ( method_exists( 'Translangated_DeepL', 'deepl_response_codes' ) ) { $translate_response = Translangated_DeepL::deepl_response_codes( $code ); } else { $translate_response = Translangated_IN_DeepL::deepl_response_codes( $code ); } $is_error = true; $return_message = $translate_response['message']; } } break; default: break; } $this->correct_api_key=array( 'message' => $return_message, 'error' => $is_error, ); return $this->correct_api_key; } // checking if the api_key is correct in order to display unsupported languages public function is_correct_api_key(){ if(method_exists($this, 'check_api_key_validity')){ $verification = $this->check_api_key_validity(); }else { //we only need this values for automatic translate error check function for backwards compatibility $machine_translator = $this; $translation_engine = $this->settings['translangated_machine_translation_settings']['translation-engine']; $api_key = $this->get_api_key(); $verification = $this->automatic_translate_error_check( $machine_translator, $translation_engine, $api_key ); } if($verification['error']== false) { return true; } return false; } /** * Return site referer * * @return string */ public function get_referer(){ if( ! $this->referer ) { if( ! $this->url_converter ) { $trp = Translangated_Plugin::get_translangated_instance(); $this->url_converter = $trp->get_component( 'url_converter' ); } $this->referer = $this->url_converter->get_abs_home(); } return apply_filters( 'translangated_machine_translator_referer', $this->referer ); } /** * Verifies that the machine translation request is valid * @deprecated since TP 1.6.0 (only here to support Deepl Add-on version 1.0.0) * * @param string $to_language language we're looking to translate to * @return bool */ public function verify_request( $to_language ){ if( empty( $this->get_api_key() ) || empty( $to_language ) || $to_language == $this->settings['default-language'] || empty( $this->machine_translation_codes[$this->settings['default-language']] ) ) return false; // Method that can be extended in the child class to add extra validation if( !$this->extra_request_validations( $to_language ) ) return false; // Check if crawlers are blocked if( !empty( $this->settings['translangated_machine_translation_settings']['block-crawlers'] ) && $this->settings['translangated_machine_translation_settings']['block-crawlers'] == 'yes' && $this->is_crawler() ) return false; // Check if daily quota is met if( $this->machine_translator_logger->quota_exceeded() ) return false; return true; } /** * Verifies that the machine translation request is valid * * @param string $target_language_code language we're looking to translate to * @param string $source_language_code language we're looking to translate from * @return bool */ public function verify_request_parameters($target_language_code, $source_language_code){ if( empty( $this->get_api_key() ) || empty( $target_language_code ) || empty( $source_language_code ) || empty( $this->machine_translation_codes[$target_language_code] ) || empty( $this->machine_translation_codes[$source_language_code] ) || $this->machine_translation_codes[$target_language_code] == $this->machine_translation_codes[$source_language_code] ) return false; // Method that can be extended in the child class to add extra validation if( !$this->extra_request_validations( $target_language_code ) ) return false; // Check if crawlers are blocked if( !empty( $this->settings['translangated_machine_translation_settings']['block-crawlers'] ) && $this->settings['translangated_machine_translation_settings']['block-crawlers'] == 'yes' && $this->is_crawler() ) return false; // Check if daily quota is met if( $this->machine_translator_logger->quota_exceeded() ) return false; return true; } /** * Verifies user agent to check if the request is being made by a crawler * * @return boolean */ private function is_crawler(){ if( !isset( $_SERVER['HTTP_USER_AGENT'] ) ) return false; $crawlers = apply_filters( 'translangated_machine_translator_crawlers', 'rambler|abacho|acoi|accona|aspseek|altavista|estyle|scrubby|lycos|geona|ia_archiver|alexa|sogou|skype|facebook|twitter|pinterest|linkedin|naver|bing|google|yahoo|duckduckgo|yandex|baidu|teoma|xing|java\/1.7.0_45|bot|crawl|slurp|spider|mediapartners|\sask\s|\saol\s' ); return preg_match( '/'. $crawlers .'/i', sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ); } private function get_placeholders( $count ){ $placeholders = array(); for( $i = 1 ; $i <= $count; $i++ ){ $placeholders[] = '1TP' . $i . 'T'; } return $placeholders; } /** * Check if a string should be sent for translation based on minimum length and content criteria * * @param string $string The string to check * @return bool True if the string should be translated, false otherwise */ private function should_translate_string( $string ) { // Reject single strings that exceed the smallest engine body limit if ( strlen( $string ) > 99400 ) { return false; } // Trim whitespace for accurate length check $trimmed = trim( $string ); // Check if string is empty after trimming if ( empty( $trimmed ) ) { return false; } // Get minimum length (default: 2 characters) // Allow customization via filter $min_length = apply_filters( 'translangated_minimum_translation_length', 2 ); // Check minimum length if ( mb_strlen( $trimmed, 'UTF-8' ) < $min_length ) { return false; } // Check if string is only punctuation/special characters (optional, can be disabled via filter) $skip_punctuation_only = apply_filters( 'translangated_skip_punctuation_only_strings', true ); if ( $skip_punctuation_only && preg_match( '/^[[:punct:][:space:]]+$/u', $trimmed ) ) { return false; } // Base64-encoded strings — require at least one digit or `=` padding so letter-only words // separated by `/` (e.g. "Certifikater/Batchstyring", "TCP/IP-style paths") aren't matched. if ( preg_match( '/^[A-Za-z0-9+\/]{20,}={0,2}$/', $trimmed ) && preg_match( '/[0-9=]/', $trimmed ) ) { return false; } // Pure numbers, dates, dimensions (no letters at all) if ( preg_match( '/^[\d\s.,;:x×\-–—+\/()%°\'\"]+$/', $trimmed ) ) { return false; } // ISO 8601 datetimes (e.g. 2026-01-06T16:50:29+00:00, ...Z, with optional fractional seconds) if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:?\d{2}|Z)?$/', $trimmed ) ) { return false; } // Standalone email addresses if ( preg_match( '/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/', $trimmed ) ) { return false; } // Bare URLs or srcset-style URL lists (https://... 300w, https://... 1024w) if ( preg_match( '/^https?:\/\//', $trimmed ) && ! preg_match( '/[a-zA-ZÀ-ž]{2,}\s+[a-zA-ZÀ-ž]{2,}/', preg_replace( '/https?:\/\/\S+/', '', $trimmed ) ) ) { return false; } // Standalone media file references with no scheme: a bare filename ("photo.png"), a relative // path ("wp-content/uploads/2026/06/img.webp"), a protocol-relative URL ("//cdn.com/a.jpg"), // or a srcset-style comma list of these with width/density descriptors ("a.png 300w, b.png 600w"). // The https:// rule above misses these because they have no scheme, so image/media file names // would otherwise be sent for machine translation. Anchored to the whole string so real prose // that merely mentions a file (e.g. "Download report.pdf now") is left translatable. $media_ext = 'png|jpe?g|gif|bmp|webp|avif|svg|ico|tiff?|mp4|m4v|webm|ogv|mov|avi|mp3|ogg|wav|m4a|flac|pdf'; $media_ref = '\S+\.(?:' . $media_ext . ')(?:\?\S*)?'; if ( preg_match( '/^' . $media_ref . '(?:\s+\d+(?:\.\d+)?[wx])?(?:\s*,\s*' . $media_ref . '(?:\s+\d+(?:\.\d+)?[wx])?)*$/i', $trimmed ) ) { return false; } // HTML media/void tags that are purely markup (no meaningful translatable text outside attributes) if ( preg_match( '/^<(img|source|video|picture|input|br|hr|meta|link)\s/i', $trimmed ) && ! preg_match( '/>([^<]{10,})]*\/>\s*)+$/i', $trimmed ) && ! preg_match( '/>([^<]{10,})]*>/i', $trimmed ) && ! preg_match( '/[a-zA-ZÀ-ž]/', preg_replace( '/<[^>]+>/', '', $trimmed ) ) ) { return false; } // WordPress plugin/theme file paths (wp-content/plugins/...:123) if ( preg_match( '/^wp-content\/(plugins|themes)\/\S+\.(php|js|css)(:\d+)?$/', $trimmed ) ) { return false; } // Divi/shortcode builder markup (starts with [ shortcode tag and is mostly attributes) if ( preg_match( '/^\[?\/?et_pb_/', $trimmed ) || preg_match( '/^\[\/et_pb_/', $trimmed ) ) { return false; } // JavaScript code blocks. // Inline/minified scripts (GTM, gtag, analytics, etc.) sometimes reach this point, often // split into arbitrary ~2KB chunks that start mid-statement. The patterns below are loose // on purpose and matched anywhere in the string (not anchored to the start) so a chunk that // begins in the middle of an expression is still recognised. They look for syntax that does // not occur in natural-language content. Designed against real machine_translation_log data. // A "prose run" = 3+ consecutive natural-language words (3+ letters each, separated only by // spaces/common punctuation). Minified JS has no such runs; help text and UI labels do. It // is used below to exempt only the loosest JS signals from matching genuine content. $has_prose_run = preg_match( '/(?:\b\p{L}{3,}\b[\s,.\'’]+){2,}\b\p{L}{3,}\b/u', $trimmed ); // Structural keywords: anchored function/var declarations, function expressions, for/var // declarations, `new Foo(`, control flow and operator keywords. These never occur in prose. if ( preg_match( '/^\(\s*function\s*\(/', $trimmed ) || preg_match( '/=\s*function\s*\(/', $trimmed ) || preg_match( '/^(var|let|const|function)\s+\w+\s*[=(]/', $trimmed ) || preg_match( '/\bfor\s*\(\s*var\s/', $trimmed ) || preg_match( '/;\s*var\s+[\w$]/', $trimmed ) || preg_match( '/=\s*new\s+[A-Z]\w*\(/', $trimmed ) || preg_match( '/\belse\s+if\s*\(/', $trimmed ) || preg_match( '/;\s*case\s/', $trimmed ) || preg_match( '/\b(typeof|instanceof)\b/', $trimmed ) ) { return false; } // Object/member idioms: prototype chains, the `void 0` minifier idiom, BigInt(), and the // common built-in method calls minifiers emit (e.g. `.push(`, `.charCodeAt(`). if ( preg_match( '/\.prototype\./', $trimmed ) || preg_match( '/\bvoid 0\b/', $trimmed ) || preg_match( '/\bBigInt\s*\(/', $trimmed ) || preg_match( '/\.(push|pop|shift|unshift|charCodeAt|charAt|substring|substr|indexOf|lastIndexOf|slice|splice|concat|toString|hasOwnProperty|call|apply|replace|split|join|sort|filter|map|forEach)\s*\(/', $trimmed ) ) { return false; } // Minified operator "soup" (strict members): `.length` access, pre/post increment inside a // loop, ternaries like `)?1:0` and array indexing like `=a[b`. if ( preg_match( '/\b\w+\.length\b/', $trimmed ) || preg_match( '/\+\+[a-z]\)|[a-z]\+\+\)/', $trimmed ) || preg_match( '/\)\?[\w\'"]+:/', $trimmed ) || preg_match( '/=\w+\[\w/', $trimmed ) ) { return false; } // Base64/URL-safe charset table emitted by consent-string builders (e.g. GTM). The full // ordered alphabet run never appears in natural language. if ( preg_match( '/0123456789abcdefghijklmnopqrstuvwxyz/i', $trimmed ) ) { return false; } // Loose JS signals: a minified `function x(a,b){`, bitwise shifts (`<>`), and logical // operators (`&&`/`||`). These can appear in genuine content (CTAs ending in `>>`, help text // quoting code, URLs/search titles with `||`), so they only fire when there is NO prose run. if ( ! $has_prose_run && ( preg_match( '/\bfunction\s*[\w$]*\s*\([\w$,\s]*\)\s*\{/', $trimmed ) || preg_match( '/<<\w|\w>>>?/', $trimmed ) || preg_match( '/&&|\|\|/', $trimmed ) ) ) { return false; } // PDF internals if ( preg_match( '/endobj\s*\n?\s*xref/', $trimmed ) ) { return false; } // XML declarations and SVG markup if ( preg_match( '/^<\?xml\s/', $trimmed ) || preg_match( '/^]/i', $trimmed ) ) { return false; } // Data URIs (base64-encoded images, fonts, SVG+XML, etc.) if ( preg_match( '/^data:[a-z]+\/[a-z0-9+.\-]+(;[a-z0-9=\-]+)*,/i', $trimmed ) ) { return false; } // Mustache/Handlebars/Vue template tags with no translatable text outside the tags if ( preg_match( '/\{\{[^{}]*\}\}/', $trimmed ) && ! preg_match( '/[a-zA-ZÀ-ž]/', preg_replace( '/\{\{+[^{}]*\}+\}/', '', $trimmed ) ) ) { return false; } // CSS fragments — custom property declarations, at-rules, or url(data:...) constructs. // Loose by design, but guarded: only skip if the string doesn't contain real HTML content // tags (so HTML pages with inline `style="--foo: ..."` attributes aren't rejected). if ( ( preg_match( '/--[a-zA-Z][a-zA-Z0-9_-]*\s*:\s*\S/', $trimmed ) || preg_match( '/^\s*@(media|import|font-face|keyframes|supports|charset|page|namespace|layer)\b/i', $trimmed ) || preg_match( '/\burl\s*\(\s*[\'"]?data:/i', $trimmed ) ) && ! preg_match( '/<(p|div|span|a|h[1-6]|article|section|ul|ol|li|main|header|footer|nav|figure|figcaption|blockquote|table|tr|td|th|trp-post-container)\b/i', $trimmed ) ) { return false; } // Binary/mojibake data — three or more clusters of 2+ consecutive '?' or U+FFFD // characters. Real prose almost never repeats '?' or replacement chars; binary // data captured as text (PDF/font/image bytes, broken encodings) does so heavily. if ( preg_match_all( '/[?\x{FFFD}]{2,}/u', $trimmed ) >= 3 ) { return false; } return true; } /** * Function to be used externally * * Strings locked by a concurrent request ( in flight or recently translated ) are dropped from the * return array. Locks claimed here are held until the caller saves the translations and calls * release_locks( true ). * * @param array $strings * @param string $target_language_code * @param string|null $source_language_code * @param string $dictionary_context Dictionary the caller saves to: 'regular' or 'gettext'. * @return array */ public function translate($strings, $target_language_code, $source_language_code = null, $dictionary_context = 'regular' ){ if ( !empty($strings) && is_array($strings) && method_exists( $this, 'translate_array' ) && apply_filters( 'translangated_disable_automatic_translations_due_to_error', false ) === false ) { /* google has a problem translating this characters ( '%', '$', '#' )...for some reasons it puts spaces after them so we need to 'encode' them and decode them back. hopefully it won't break anything important */ /* we put '%s' before '%' because google seems to transform %s into % in strings for some languages which causes a 500 Fatal Error in PHP 8*/ $imploded_strings = implode(" ", $strings); $translangated_exclude_words_from_automatic_translation = apply_filters('translangated_exclude_words_from_automatic_translation', array('%s', '%d', '%', '$', '#'), $imploded_strings, $target_language_code, $source_language_code); $placeholders = $this->get_placeholders(count($translangated_exclude_words_from_automatic_translation)); $shortcode_tags_to_execute = apply_filters( 'translangated_do_these_shortcodes_before_automatic_translation', array('translangated_language', 'translangated-include', 'translangated-exclude') ); $strings = array_unique($strings); $original_strings = $strings; // Filter out strings that are too short to translate $strings_to_skip = array(); $strings_to_translate = array(); foreach ($strings as $key => $string) { if ( !$this->should_translate_string($string) ) { $strings_to_skip[$key] = $string; } else { $strings_to_translate[$key] = $string; } } // If all strings are too short, return them as-is. Keyed by the original string, the same // way the regular return below is: callers look the result up by original string // ( isset( $machine_strings[ $string ] ) ), so returning the incoming index-keyed array // here meant nothing was ever matched. The translations were then saved against the array // index instead of the original ( rows with original = "10" ), the real string stayed // NOT TRANSLATED, and it was collected and re-processed again on every single page load. if ( empty($strings_to_translate) ) { $skipped_strings_return_array = array(); foreach ( $original_strings as $original_string ) { $skipped_strings_return_array[ $original_string ] = $original_string; } return $skipped_strings_return_array; } // Continue with only the strings that meet the minimum length $strings = $strings_to_translate; // claim the strings so concurrent requests don't send the same string to the engine twice $strings = $this->claim_strings_for_machine_translation( $strings, $target_language_code, $source_language_code, $dictionary_context ); $machine_strings = array(); if ( !empty( $strings ) ) { foreach ($strings as $key => $string) { /* html_entity_decode is needed before replacing the character "#" from the list because characters like “ (8220 utf8) * will get an extra space after '&' which will break the character, rendering it like this: & #8220; */ $strings[$key] = str_replace($translangated_exclude_words_from_automatic_translation, $placeholders, html_entity_decode( $string )); $strings[$key] = translangated_do_these_shortcodes( $strings[$key], $shortcode_tags_to_execute ); } try { $machine_strings = $this->translate_array($strings, $target_language_code, $source_language_code); } finally { if ( empty( $machine_strings ) ) { // nothing to save, free the locks now $this->release_locks(); } } } $machine_strings_return_array = array(); if (!empty($machine_strings)) { foreach ($machine_strings as $key => $machine_string) { // An engine is expected to return the keys it was given. If it re-indexed them we // have no original string to key the result by, and using the bare key would save // the translation against a meaningless original. Skip instead, so the string is // simply left untranslated for this request. if ( !isset( $original_strings[$key] ) ) { continue; } // Restore placeholders to original excluded words (or to user-driven replacements via filter) $translangated_replace_placeholders_with = apply_filters( 'translangated_replace_placeholders_with', $translangated_exclude_words_from_automatic_translation, $translangated_exclude_words_from_automatic_translation, $placeholders, $machine_string, $original_strings[$key], $target_language_code, $source_language_code ); if ( !is_array( $translangated_replace_placeholders_with ) || count( $translangated_replace_placeholders_with ) !== count( $placeholders ) ) { $translangated_replace_placeholders_with = $translangated_exclude_words_from_automatic_translation; } $processed_string = str_ireplace( $placeholders, $translangated_replace_placeholders_with, $machine_string ); // Restore quote patterns (use $strings which is decoded, not $original_strings with HTML entities) $processed_string = $this->restore_translation_quotes($strings[$key], $processed_string); // Restore punctuation and spacing patterns (use $strings which is decoded, not $original_strings with HTML entities) $processed_string = $this->restore_punctuation_patterns($strings[$key], $processed_string); $machine_strings_return_array[$original_strings[$key]] = $processed_string; } } // Add skipped strings back to the return array with their original values foreach ($strings_to_skip as $key => $skipped_string) { $machine_strings_return_array[$original_strings[$key]] = $original_strings[$key]; } return $machine_strings_return_array; }else { return array(); } } /** * Claim each string in the machine translation locks table, returning only the ones this request may translate. * * Drops the strings another request is translating right now or translated in the last lock timeout * seconds ( locks are kept after a save as recently translated markers ). Also defers strings when * lock contention persists after retries. These strings are handled on a following page load. * The claimed locks stay in place after returning: they must * also cover the dictionary save in the caller. * * @param array $strings Strings to translate, keys are preserved. * @param string $target_language_code * @param string|null $source_language_code * @param string $dictionary_context 'regular' or 'gettext', see translate(). * @return array The strings this request should translate. All of them when * locking is disabled via filter or unavailable. */ protected function claim_strings_for_machine_translation( $strings, $target_language_code, $source_language_code, $dictionary_context ){ if ( empty( $strings ) || !is_array( $strings ) ){ return array(); } if ( !apply_filters( 'translangated_prevent_concurrent_machine_translation', true, $strings, $target_language_code, $source_language_code ) ){ return $strings; } $translangated_query = $this->get_translangated_query(); // if the locks table can't be created, don't hold up translations because of it if ( !$translangated_query->check_machine_translation_lock_table() ){ return $strings; } // the context keeps regular and gettext locks for the same text apart $string_hashes = array(); foreach( $strings as $key => $string ){ $string_hashes[$key] = sha1( $dictionary_context . '|' . $source_language_code . '|' . $target_language_code . '|' . $string ); } $lock_owner = wp_generate_uuid4(); $claimed_hashes = $translangated_query->claim_machine_translation_locks( array_values( $string_hashes ), $lock_owner, $this->get_machine_translation_lock_timeout() ); // Don't send an unprotected batch to the engine when another request prevents claiming it. if ( $claimed_hashes === false ){ $translangated_query->delete_machine_translation_locks( array( $lock_owner ) ); $this->remember_lock_skipped_strings( $strings ); return array(); } // locking is unavailable, translate everything rather than nothing if ( $claimed_hashes === null ){ $translangated_query->delete_machine_translation_locks( array( $lock_owner ) ); return $strings; } // every string is locked by concurrent requests, nothing was claimed if ( empty( $claimed_hashes ) ){ $this->remember_lock_skipped_strings( $strings ); return array(); } $this->hold_lock_owner( $lock_owner ); $claimed_hashes = array_flip( $claimed_hashes ); $claimed_strings = array(); foreach( $string_hashes as $key => $string_hash ){ if ( isset( $claimed_hashes[$string_hash] ) ){ $claimed_strings[$key] = $strings[$key]; } } $this->remember_lock_skipped_strings( array_diff_key( $strings, $claimed_strings ) ); return $claimed_strings; } /** * Remember strings skipped because they are locked or lock contention prevented claiming them. */ protected function remember_lock_skipped_strings( $strings ){ foreach( $strings as $string ){ $this->lock_skipped_strings[ $string ] = true; } } /** * Strings this request dropped because a concurrent request holds their lock, keyed by string. * * The lock holder inserts these into the dictionary when it saves, so callers must not register * them as untranslated strings themselves: that would duplicate the holder's rows. * * @return array */ public function get_lock_skipped_strings(){ return $this->lock_skipped_strings; } /** * Remember a lock owner token until its locks are released. */ protected function hold_lock_owner( $lock_owner ){ if ( empty( $this->held_lock_owners ) ){ // PHP_INT_MAX so the hook still runs when registered during shutdown ( gettext translates on shutdown ) add_action( 'shutdown', array( $this, 'release_locks' ), PHP_INT_MAX, 0 ); } $this->held_lock_owners[] = $lock_owner; } /** * Stop tracking every machine translation lock this instance still holds. * * Callers of translate() must call this after saving the returned translations, with * $translations_saved = true: the locks then stay in place until the lock timeout as "recently * translated" markers, blocking concurrent requests whose dictionary read predates the save from * re-translating the same strings. Otherwise the locks are deleted so the strings can be retried. * * @param bool $translations_saved Whether the translations were saved to the dictionary. */ public function release_locks( $translations_saved = false ){ if ( empty( $this->held_lock_owners ) ){ return; } $lock_owners = $this->held_lock_owners; $this->held_lock_owners = array(); if ( $translations_saved ){ $this->get_translangated_query()->refresh_machine_translation_locks( $lock_owners ); } else { $this->get_translangated_query()->delete_machine_translation_locks( $lock_owners ); } } /** * Seconds after which a machine translation lock is considered abandoned and can be taken over. * * Must exceed the engine call plus the dictionary save — every engine sends its HTTP request with * a 45s timeout, so a shorter value lets a concurrent request take over a lock whose engine call * is still in flight and re-bill the same strings. Must also stay above * translangated_machine_translation_time_budget, so locks kept after a save outlive any concurrent request * still working off a dictionary read that predates the save. * * @return int */ public function get_machine_translation_lock_timeout(){ return (int) apply_filters( 'translangated_machine_translation_lock_timeout', 50 ); // seconds } /** * @return Translangated_Query */ protected function get_translangated_query(){ if ( !$this->translangated_query ){ $trp = Translangated_Plugin::get_translangated_instance(); $this->translangated_query = $trp->get_component( 'query' ); } return $this->translangated_query; } /** * Maximum number of strings the engine sends to the API in a single request. * * Frontend callers (regular DOM + gettext) chunk by this size and save each chunk to the * database before requesting the next one, so an aborted/overlapping page load never re-sends * (and re-bills) an already-saved chunk. Engines override this with their own API limit. * * @return int */ public function get_chunk_size() { return 50; } /** * @param $translangated_exclude_words_from_automatic_translation * @return mixed * * We need to sort the $translangated_exclude_words_from_automatic_translation array descending because we risk to not translate excluded multiple words when one * is repeated ( example: Facebook, Facebook Store, Facebook View, because Facebook was the first one in the array it was replaced with a code and the * other words group ( Store, View) were translated) */ public function sort_exclude_words_from_automatic_translation_array($translangated_exclude_words_from_automatic_translation){ usort($translangated_exclude_words_from_automatic_translation, array($this,"sort_array")); return $translangated_exclude_words_from_automatic_translation; } public function sort_array($a, $b){ return strlen($b)-strlen($a); } public function test_request(){} public function get_api_key(){ return false; } public function extra_request_validations( $to_language ){ return true; } public function exclude_special_symbol_from_translation($array, $strings){ $float_array_symbols = array('d', 's', 'e', 'E', 'f', 'F', 'g', 'G', 'h', 'H'); foreach ($float_array_symbols as $float_array_symbol){ for($i= 1; $i<=10; $i++) { $symbol = '%'.$i .'$'.$float_array_symbol; if ( strpos( $strings, $symbol ) !== false ) { $array[] = '%' . $i . '$' . $float_array_symbol; } } } return $array; } /** * Restore and normalize quotes in translated strings to match the original * * @param string $original_string The original untranslated string * @param string $translated_string The translated string from the API * @return string The translated string with quotes restored and normalized */ public function restore_translation_quotes($original_string, $translated_string) { // Allow disabling this functionality via filter if ( apply_filters( 'translangated_disable_restore_translation_quotes', false ) ) { return $translated_string; } // Check if original string is empty or translated string is empty if ( empty($original_string) || empty($translated_string) ) { return $translated_string; } // Define all quote characters to check for $quote_chars = [ // Straight quotes "'", // U+0027 Apostrophe / single quote '"', // U+0022 Double quote // Curly / typographic quotes '‘', // U+2018 Left single curly quote '’', // U+2019 Right single curly quote (also apostrophe) '“', // U+201C Left double curly quote '”', // U+201D Right double curly quote // Common international quotes '„', // U+201E Low double quote (German, Polish) '‚', // U+201A Low single quote '«', // U+00AB Left double angle quote (French, Italian, etc.) '»', // U+00BB Right double angle quote ]; // Step 1: Restore boundary quotes if they were stripped $original_first_char = mb_substr($original_string, 0, 1, 'UTF-8'); $original_last_char = mb_substr($original_string, -1, 1, 'UTF-8'); $translated_first_char = mb_substr($translated_string, 0, 1, 'UTF-8'); $translated_last_char = mb_substr($translated_string, -1, 1, 'UTF-8'); // Check if original starts with a quote character and translated doesn't if ( in_array($original_first_char, $quote_chars, true) && !in_array($translated_first_char, $quote_chars, true) ) { $translated_string = $original_first_char . $translated_string; } // Check if original ends with a quote character and translated doesn't // Need to recalculate last char if we added a quote at the beginning if ( in_array($original_last_char, $quote_chars, true) && !in_array($translated_last_char, $quote_chars, true) ) { $translated_string = $translated_string . $original_last_char; } // Step 2: Normalize ALL quotes - collect all quotes from original (excluding apostrophes) $original_quotes = array(); $original_len = mb_strlen($original_string, 'UTF-8'); for ($i = 0; $i < $original_len; $i++) { $char = mb_substr($original_string, $i, 1, 'UTF-8'); if ( in_array($char, $quote_chars, true) ) { // Check if this is an apostrophe (letter-apostrophe-letter pattern) $prev_char = ($i > 0) ? mb_substr($original_string, $i - 1, 1, 'UTF-8') : ''; $next_char = ($i < $original_len - 1) ? mb_substr($original_string, $i + 1, 1, 'UTF-8') : ''; $is_apostrophe = ($char === "'" || $char === '’') && preg_match('/^\p{L}$/u', $prev_char) && preg_match('/^\p{L}$/u', $next_char); if ( !$is_apostrophe ) { $original_quotes[] = $char; } } } // If we found quotes in the original, replace them in order in the translation if ( !empty($original_quotes) ) { $quote_index = 0; $translated_len = mb_strlen($translated_string, 'UTF-8'); $translated_chars = array(); // Convert translation to array of characters for ($i = 0; $i < $translated_len; $i++) { $translated_chars[] = mb_substr($translated_string, $i, 1, 'UTF-8'); } // Replace all quotes in translation in order (excluding apostrophes) for ($i = 0; $i < $translated_len; $i++) { $char = $translated_chars[$i]; if ( in_array($char, $quote_chars, true) ) { // Check if this is an apostrophe (letter-apostrophe-letter pattern) $prev_char = ($i > 0) ? $translated_chars[$i - 1] : ''; $next_char = ($i < $translated_len - 1) ? $translated_chars[$i + 1] : ''; $is_apostrophe = ($char === "'" || $char === '’') && preg_match('/^\p{L}$/u', $prev_char) && preg_match('/^\p{L}$/u', $next_char); if ( !$is_apostrophe && $quote_index < count($original_quotes) ) { $translated_chars[$i] = $original_quotes[$quote_index]; $quote_index++; } } } $translated_string = implode('', $translated_chars); } return $translated_string; } /** * Restore punctuation and spacing patterns at string boundaries * * Handles patterns at the beginning or end of strings: * - Two-character: comma, period or semicolon with a space before or after it * - Single character: "," (comma), "." (period), ";" (semicolon), " " (space) * Existing boundary punctuation is preserved even when its spacing differs. * * @param string $original_string The original untranslated string * @param string $translated_string The translated string from the API * @return string The translated string with punctuation patterns restored */ public function restore_punctuation_patterns($original_string, $translated_string) { // Allow disabling this functionality via filter if ( apply_filters( 'translangated_disable_restore_punctuation_patterns', false ) ) { return $translated_string; } // Check if original string is empty or translated string is empty if ( empty($original_string) || empty($translated_string) ) { return $translated_string; } // Define patterns to check (longer patterns first to avoid partial matches) // leading or trailing spaces are trimmed by translangated_full_trim() inside translate_page but we still check here as "space+comma" at the end is valid for example $patterns = [', ', '. ', '; ', ' ,', ' .', ' ;', ',', '.', ';', ' ']; // Check all patterns and restore at both leading and trailing positions foreach ($patterns as $pattern) { $pattern_len = mb_strlen($pattern, 'UTF-8'); $punctuation = trim($pattern); // Check and restore leading pattern $original_start = mb_substr($original_string, 0, $pattern_len, 'UTF-8'); $translated_start = mb_substr($translated_string, 0, $pattern_len, 'UTF-8'); // Spacing differences must not cause an existing punctuation mark to be duplicated. if ($original_start === $pattern && $translated_start !== $pattern && ($punctuation === '' || mb_substr(ltrim($translated_string), 0, 1, 'UTF-8') !== $punctuation)) { $translated_string = $pattern . $translated_string; } // Check and restore trailing pattern $original_end = mb_substr($original_string, -$pattern_len, $pattern_len, 'UTF-8'); $translated_end = mb_substr($translated_string, -$pattern_len, $pattern_len, 'UTF-8'); if ($original_end === $pattern && $translated_end !== $pattern && ($punctuation === '' || mb_substr(rtrim($translated_string), -1, 1, 'UTF-8') !== $punctuation)) { $translated_string = $translated_string . $pattern; } } return $translated_string; } }