Published : August 5, 2026

CVE-2026-18510: TranslatePress <= 3.2.6 Unauthenticated Stored Cross-Site Scripting via Comment Content PoC, Patch Analysis & Rule

Severity High (CVSS 7.2)
CWE 79
Vulnerable Version 3.2.6
Patched Version 3.3
Disclosed August 4, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-18510: This vulnerability is a Stored Cross-Site Scripting (XSS) issue in the TranslatePress plugin for WordPress, affecting all versions up to and including 3.2.6. The flaw exists in the `remove_trp_html_tags` function within `includes/class-translation-render.php`. An unauthenticated attacker can exploit this by submitting a comment containing malicious HTML, which is then rendered unsafely by the plugin, allowing arbitrary script execution when a page is accessed. The high CVSS score of 7.2 reflects the potential for full site compromise if an administrator visits an affected page.

The root cause is an unsafe regular expression within the `remove_trp_html_tags` function at line 1564 of `includes/class-translation-render.php`. The original pattern for removing encoded gettext markers, `%23%21trpst%23trp-gettext(.*?)%23%21trpen%23`, uses a non-greedy but unbounded wildcard (`.?`), which can match across attribute delimiters. When the WordPress comment sanitizer (`wp_kses`) processes a comment with percent-encoded markers and permitted tags, this flawed regex can incorrectly merge two HTML attributes. For example, an opening marker in an `href` attribute and a closing marker in a `title` attribute on the same tag will cause the regex to match the text in between, splicing the attributes together. This can transform a benign attribute like `href` into a dangerous one, such as `href=”javascript:alert(1)”`, before the plugin outputs it.

Exploitation can be executed by any unauthenticated user capable of posting a comment on a WordPress site. The attacker crafts a comment that includes a tag such as ``, embedding the encoded gettext markers (`%23%21trpst%23trp-gettext`) and a `javascript:` URI. The comment also must include WP-permitted attributes like `title` or `href` that contain the closing marker (`%23%21trpen%23`). Because the entire payload uses percent-encoded characters and standard HTML tags, it passes through the WordPress `wp_kses` sanitization unauthorised. When the comment is displayed, TranslatePress’s regex processing merges these attributes, resulting in a `javascript:` URL being injected into the page. The stored XSS then executes whenever a user, such as an admin, views the post containing the comment.

The patch modifies the insecure regular expressions in `includes/class-translation-render.php`. The updated regex for encoded markers now constrains the matched span to exclude the raw attribute delimiters `'”`, preventing the regex from bridging across different HTML attributes. This change ensures that a start marker in one attribute cannot connect to an end marker in another, thereby breaking the attack vector. The patch also separates the handling of the real “ tag from the encoded form, applying the restrictive pattern only to the attacker-reachable encoded variant.

The impact of a successful attack is the execution of arbitrary JavaScript within the context of the victim’s browser session. For an administrator, this can lead to full site compromise, including the theft of cookies, session hijacking, and the ability to create rogue admin accounts. Because the payload is persistent, the script executes every time any user views the page, meaning even a single injected comment can inflict widespread damage before detection.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/translatepress-multilingual/class-translate-press.php
+++ b/translatepress-multilingual/class-translate-press.php
@@ -82,7 +82,7 @@
         define( 'TRP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
         define( 'TRP_PLUGIN_BASE', plugin_basename( __DIR__ . '/index.php' ) );
         define( 'TRP_PLUGIN_SLUG', 'translatepress-multilingual' );
-        define( 'TRP_PLUGIN_VERSION', '3.2.6' );
+        define( 'TRP_PLUGIN_VERSION', '3.3' );

 	    wp_cache_add_non_persistent_groups(array('trp'));

@@ -382,6 +382,7 @@

 	    $this->loader->add_action( 'admin_menu', $this->upgrade, 'register_menu_page' );
         $this->loader->add_action( 'admin_init', $this->upgrade, 'show_admin_error_message' );
+        $this->loader->add_action( 'admin_init', $this->upgrade, 'maybe_start_gettext_tables_optimization_from_notice' );
 	    $this->loader->add_action( 'admin_init', $this->upgrade, 'show_admin_notice' );
 	    $this->loader->add_action( 'admin_init', $this->upgrade, 'show_notification_about_add_ons_removal' );
         $this->loader->add_action( 'admin_init', $this->upgrade, 'trp_prepare_options_for_database_optimization' );
--- a/translatepress-multilingual/includes/advanced-settings/remove-duplicates-from-db.php
+++ b/translatepress-multilingual/includes/advanced-settings/remove-duplicates-from-db.php
@@ -5,6 +5,42 @@

 add_filter( 'trp_register_advanced_settings', 'trp_register_remove_duplicate_entries_from_db', 530 );
 function trp_register_remove_duplicate_entries_from_db( $settings_array ){
+    $gettext_optimization_pending = get_option( 'trp_updated_database_gettext_tables_optimization', 'yes' ) === 'no';
+    $gettext_batch_status         = get_option( 'trp_gettext_tables_optimization_330', 'is not set' );
+
+    if ( $gettext_optimization_pending && ! in_array( $gettext_batch_status, array( 'no', 'failed' ), true ) ) {
+        $start_url = wp_nonce_url(
+            add_query_arg(
+                array(
+                    'page'                                      => 'trp_advanced_page',
+                    'tab'                                       => 'troubleshooting',
+                    'trp_start_gettext_tables_optimization'      => '1',
+                ),
+                admin_url( 'admin.php' )
+            ),
+            'trp_start_gettext_tables_optimization'
+        );
+
+        $settings_array[] = array(
+            'name'        => 'pending_gettext_database_optimization',
+            'type'        => 'text',
+            'label'       => esc_html__( 'Pending gettext database optimization', 'translatepress-multilingual' ),
+            'description' => sprintf(
+                wp_kses(
+                    __( 'TranslatePress needs to optimize its gettext database tables. Back up the database, then <a href="%s">start the optimization</a>.', 'translatepress-multilingual' ),
+                    array(
+                        'a' => array(
+                            'href' => array(),
+                        ),
+                    )
+                ),
+                esc_url( $start_url )
+            ),
+            'id'          => 'debug',
+            'container'   => 'debug',
+        );
+    }
+
     $settings_array[] = array(
         'name'          => 'remove_duplicate_entries_from_db',
         'type'          => 'text',
--- a/translatepress-multilingual/includes/class-editor-api-gettext-strings.php
+++ b/translatepress-multilingual/includes/class-editor-api-gettext-strings.php
@@ -89,12 +89,15 @@
 						$update_strings[ $language ] = array();
 						foreach( $language_strings as $string ) {
 							if ( isset( $string->id ) && is_numeric( $string->id ) ) {
+								$translated = trp_sanitize_string( $string->translated );
+								$status     = ! empty( $translated ) ? TRP_Query::HUMAN_REVIEWED : TRP_Query::NOT_TRANSLATED;
+
 								array_push($update_strings[ $language ], array(
 									'id' => (int)$string->id,
                                     'original' => trp_sanitize_string( $string->original, false ),
-									'translated' => trp_sanitize_string( $string->translated ),
+									'translated' => $translated,
 									'domain' => sanitize_text_field( $string->domain ),
-									'status' => (int)$string->status,
+									'status' => $status,
 									'plural_form' => (int)$string->plural_form,
 									'context' => $string->context
 								));
--- a/translatepress-multilingual/includes/class-plugin-notices.php
+++ b/translatepress-multilingual/includes/class-plugin-notices.php
@@ -48,7 +48,7 @@
             // Check that the user hasn't already clicked to ignore the message
             if ( ! get_user_meta($user_id, $this->notificationId.'_dismiss_notification' ) || $this->force_show  ) {//ignore the dismissal if we have force_show
                 add_filter('safe_style_css', array( $this, 'allow_z_index_in_wp_kses'));
-                echo wp_kses( apply_filters($this->notificationId.'_notification_message','<div class="'. $this->notificationClass .'" style="position:relative;'  . ((strpos($this->notificationClass, 'trp-narrow')!==false ) ? 'max-width: 825px;' : '') . '" >'.$this->notificationMessage.'</div>', $this->notificationMessage), [ 'div' => [ 'class' => [],'style' => [] ], 'p' => ['style' => [], 'class' => []], 'a' => ['href' => [], 'type'=> [], 'class'=> [], 'style'=>[], 'title'=>[],'target'=>[]], 'span' => ['class'=> []], 'strong' => [], 'img' => [ 'src' => [], 'style' => [] ], 'br' => [] ]  );
+                echo wp_kses( apply_filters($this->notificationId.'_notification_message','<div class="'. $this->notificationClass .'" style="position:relative;'  . ((strpos($this->notificationClass, 'trp-narrow')!==false ) ? 'max-width: 825px;' : '') . '" >'.$this->notificationMessage.'</div>', $this->notificationMessage), [ 'div' => [ 'class' => [],'style' => [] ], 'p' => ['style' => [], 'class' => []], 'a' => ['href' => [], 'type'=> [], 'class'=> [], 'style'=>[], 'title'=>[],'target'=>[]], 'span' => ['class'=> [], 'style' => []], 'strong' => [], 'img' => [ 'src' => [], 'style' => [] ], 'br' => [] ]  );
                 remove_filter('safe_style_css', array( $this, 'allow_z_index_in_wp_kses'));
             }
             do_action( $this->notificationId.'_notification_displayed', $current_user, $pagenow );
@@ -59,6 +59,12 @@
     function allow_z_index_in_wp_kses( $styles ) {
         $styles[] = 'z-index';
         $styles[] = 'position';
+        $styles[] = 'display';
+        $styles[] = 'align-items';
+        $styles[] = 'gap';
+        $styles[] = 'float';
+        $styles[] = 'margin';
+        $styles[] = 'visibility';
         return $styles;
     }

--- a/translatepress-multilingual/includes/class-plugin-optin.php
+++ b/translatepress-multilingual/includes/class-plugin-optin.php
@@ -485,6 +485,7 @@
             'trp_updated_database_gettext_original_id_cleanup',
             'trp_updated_database_gettext_original_id_insert',
             'trp_updated_database_gettext_original_id_update',
+            'trp_updated_database_gettext_runtime_status_update',
             'trp_were_old_slug_tables_found',
             'trp_add_ons_settings',
         ];
--- a/translatepress-multilingual/includes/class-translation-manager.php
+++ b/translatepress-multilingual/includes/class-translation-manager.php
@@ -182,6 +182,7 @@
                 //human or machine translation tooltips
                 'human_translation'                 => esc_html__('Human Translation', 'translatepress-multilingual'),
                 'machine_translation'               => esc_html__('Machine Translation', 'translatepress-multilingual'),
+                'language_file_translation'         => esc_html__('Gettext File Translation', 'translatepress-multilingual'),
                 'percentage_bar'                    => array(
                     'tooltip_text_default' => esc_html__( 'Text on this page is %s% translated into all languages.', 'translatepress-multilingual'),
                     'tooltip_text_general' => esc_html__( '%1$s% of text on this page is translated into %2$s.', 'translatepress-multilingual'),
@@ -1010,13 +1011,18 @@
 			$url = add_query_arg( array(
 				'page'                      => 'trp_update_database',
 			), site_url('wp-admin/admin.php') );
+			$database_update_confirmation_message = sprintf(
+				"%sn%s",
+				__( 'IMPORTANT: It is strongly recommended to first backup the database!', 'translatepress-multilingual' ),
+				__( 'Are you sure you want to continue?', 'translatepress-multilingual' )
+			);

 			// maybe change notice color to blue #28B1FF
 			$html = "<div class='trp-notice trp-notice-warning'>";
 			$html .= '<p><strong>' . esc_html__( 'TranslatePress data update', 'translatepress-multilingual' ) . '</strong> – ' . esc_html__( 'We need to update your translations database to the latest version.', 'translatepress-multilingual' ) . '</p>';
 			$html .= '<p>' . esc_html__( 'Updating will allow editing translations of localized text from plugins and theme. Existing translation will still work as expected.', 'translatepress-multilingual' ) . '</p>';

-			$html .= '<p><a class="trp-button-primary" target="_blank" href="' . esc_url( $url ) . '" onclick="return confirm( '' . __( 'IMPORTANT: It is strongly recommended to first backup the database!nAre you sure you want to continue?', 'translatepress-multilingual' ) . '');" class="button-primary">' . esc_html__( 'Run the updater', 'translatepress-multilingual' ) . '</a></p>';
+			$html .= '<p><a class="trp-button-primary" target="_blank" href="' . esc_url( $url ) . '" onclick="return confirm( ' . esc_attr( wp_json_encode( $database_update_confirmation_message ) ) . ' );" class="button-primary">' . esc_html__( 'Run the updater', 'translatepress-multilingual' ) . '</a></p>';
 			$html .= '</div>';

 			$trp_editor_notices = $html;
@@ -1030,12 +1036,17 @@
             $url = add_query_arg( array(
                 'page'                      => 'trp_update_database',
             ), site_url('wp-admin/admin.php') );
+            $database_update_confirmation_message = sprintf(
+                "%sn%s",
+                __( 'IMPORTANT: It is strongly recommended to first backup the database!', 'translatepress-multilingual' ),
+                __( 'Are you sure you want to continue?', 'translatepress-multilingual' )
+            );

             $html = "<div class='trp-notice trp-notice-warning'>";
             $html .= '<p><strong>' . esc_html__( 'TranslatePress data update', 'translatepress-multilingual' ) . '</strong> – ' . esc_html__( 'We need to update your translations database to the latest version.', 'translatepress-multilingual' ) . '</p>';
             $html .= '<p>' . esc_html__( 'Updating will allow editing translations of slugs. Existing translation will still work as expected.', 'translatepress-multilingual' ) . '</p>';

-            $html .= '<p><a class="trp-button-primary" target="_blank" href="' . esc_url( $url ) . '" onclick="return confirm( '' . __( 'IMPORTANT: It is strongly recommended to first backup the database!nAre you sure you want to continue?', 'translatepress-multilingual' ) . '');" class="button-primary">' . esc_html__( 'Run the updater', 'translatepress-multilingual' ) . '</a></p>';
+            $html .= '<p><a class="trp-button-primary" target="_blank" href="' . esc_url( $url ) . '" onclick="return confirm( ' . esc_attr( wp_json_encode( $database_update_confirmation_message ) ) . ' );" class="button-primary">' . esc_html__( 'Run the updater', 'translatepress-multilingual' ) . '</a></p>';
             $html .= '</div>';

             $trp_editor_notices = $html;
--- a/translatepress-multilingual/includes/class-translation-render.php
+++ b/translatepress-multilingual/includes/class-translation-render.php
@@ -1564,25 +1564,48 @@

     /**
      * function that removes any unwanted leftover <trp-gettext> tags
+     *
+     * Security ( CU-869eddnvm ): the opening-tag removals below used an unbounded inner match ( .*? ) that
+     * could bridge across html attribute/tag delimiters. TP emits these wrappers as real tags, but the same
+     * marker can also appear ENCODED ( percent-encoded, or html-entity escaped: %23%21trpst%23trp-gettext,
+     * <trp-gettext ) inside an attribute value that survived wp_kses, because the marker text contains no
+     * html-special characters. A .*? there let a start marker inside one attribute ( href ) reach an end
+     * marker inside another ( title ) and collapse everything between them, splicing the two attributes into
+     * e.g. href="javascript:..." from an otherwise kses-clean, unauthenticated comment ( stored XSS ).
+     *
+     * The real-tag form ( <trp-gettext ...> ) is never attacker-reachable: wp_kses strips a literal <trp-*>
+     * tag from user input, so it stays permissive ( bounded only by the real angle brackets ). Only the
+     * encoded/entity form is attacker-reachable, so there the captured span excludes the raw attribute
+     * delimiters "'<> and can no longer leave a single attribute value / text node. Legitimately escaped
+     * wrappers carry "/' rather than raw quotes, so they are still removed.
+     *
      * @param $string
      * @return string|string[]|null
      */
     function remove_trp_html_tags( $string ){
-        $string = preg_replace( '/(<|<)trp-gettext (.*?)(>|>)/i', '', $string );
+        // trp-gettext opening tag: the real form ( <...> ) and the entity form ( <...> ) both carry an
+        // unquoted attribute ( data-trpgettextoriginal=123 ), so a single delimiter-constrained match is safe.
+        $string = preg_replace( '/(<|<)trp-gettext ([^"'<>]*?)(>|>)/i', '', $string );
         $string = preg_replace( '/(<|<)(\\)*/trp-gettext(>|>)/i', '', $string );

         // In case we have a gettext string which was run through rawurlencode(). See more details on iss6563
-        $string = preg_replace( '/%23%21trpst%23trp-gettext(.*?)%23%21trpen%23/i', '', $string );
+        $string = preg_replace( '/%23%21trpst%23trp-gettext([^"'<>]*?)%23%21trpen%23/i', '', $string );
         $string = preg_replace( '/%23%21trpst%23%2Ftrp-gettext%23%21trpen%23/i', '', $string );
         $string = preg_replace( '/%23%21trpst%23%5C%2Ftrp-gettext%23%21trpen%23/i', '', $string );

         if (!isset($_REQUEST['trp-edit-translation']) || $_REQUEST['trp-edit-translation'] != 'preview') {
-            $string = preg_replace('/(<|<)trp-wrap (.*?)(>|>)/i', '', $string);
+            // Real trp-wrap tag carries a double-quoted attribute ( class="trp-wrap" ), so the real form stays
+            // permissive; only the attacker-reachable entity form is delimiter-constrained.
+            $string = preg_replace('/<trp-wrap [^<>]*?>/i', '', $string);
+            $string = preg_replace('/<trp-wrap ([^"'<>]*?)>/i', '', $string);
             $string = preg_replace('/(<|<)(\\)*/trp-wrap(>|>)/i', '', $string);
         }

         //remove post containers before outputting
-        $string = preg_replace( '/(<|<)trp-post-container (.*?)(>|>)/i', '', $string );
+        // Real trp-post-container carries a single-quoted attribute ( data-trp-post-id='123' ), so the real
+        // form stays permissive; only the attacker-reachable entity form is delimiter-constrained.
+        $string = preg_replace( '/<trp-post-container [^<>]*?>/i', '', $string );
+        $string = preg_replace( '/<trp-post-container ([^"'<>]*?)>/i', '', $string );
         $string = preg_replace( '/(<|<)(\\)*/trp-post-container(>|>)/i', '', $string );

         return $string;
--- a/translatepress-multilingual/includes/class-upgrade.php
+++ b/translatepress-multilingual/includes/class-upgrade.php
@@ -65,6 +65,23 @@
                 }
             }

+            if ( get_option( 'trp_updated_database_gettext_original_lookup_hash', 'is not set' ) === 'is not set' ) {
+                $originals_table = $this->trp_query->get_table_name_for_gettext_original_strings();
+                $lookup_hash_is_ready = (
+                    ! $this->trp_query->table_exists( $originals_table ) ||
+                    (
+                        $this->trp_query->table_column_exists( $originals_table, 'lookup_hash' ) &&
+                        $this->trp_query->table_index_exists( $originals_table, 'gettext_lookup_hash_unique' )
+                    )
+                );
+
+                update_option( 'trp_updated_database_gettext_original_lookup_hash', $lookup_hash_is_ready ? 'yes' : 'no' );
+            }
+
+            if ( get_option( 'trp_updated_database_gettext_tables_optimization', 'is not set' ) === 'is not set' ) {
+                update_option( 'trp_updated_database_gettext_tables_optimization', $this->gettext_tables_optimization_is_ready() ? 'yes' : 'no' );
+            }
+
             // Updates that can be done right way. They should take very little time.
             if ( version_compare( $stored_database_version, '1.3.0', '<=' ) ) {
                 $this->trp_query->check_for_block_type_column();
@@ -223,6 +240,7 @@
             'gettext_original_id_insert'                    => __('Inserting gettext original strings for language %s...', 'translatepress-multilingual' ),
             'gettext_original_id_cleanup'                   => __('Cleaning gettext original strings table for language %s...', 'translatepress-multilingual' ),
             'gettext_original_id_update'                    => __('Updating gettext original string ids for language %s...', 'translatepress-multilingual' ),
+            'gettext_tables_optimization'                   => __('Starting gettext database optimization in the background...', 'translatepress-multilingual' ),
             'migrate_old_slugs_to_the_new_translate_table_structure_post_type_and_tax_284' => __( 'Migrating taxonomy and post type base slugs to new table structure...', 'translatepress-multilingual' ),
             'migrate_old_slugs_to_the_new_translate_table_structure_post_meta_284'         => __( 'Migrating post slugs to new table structure for language %s...', 'translatepress-multilingual' ),
             'migrate_old_slugs_to_the_new_translate_table_structure_term_meta_284'         => __( 'Migrating term slugs to new table structure for language %s...', 'translatepress-multilingual' ),
@@ -338,6 +356,14 @@
                     'batch_size'        => 5000,
                     'message_initial'   => '',
                 ),
+                'gettext_tables_optimization' => array(
+                    'version'           => '3.3',
+                    'option_name'       => 'trp_updated_database_gettext_tables_optimization',
+                    'callback'          => array( $this,'trp_start_gettext_tables_optimization'),
+                    'batch_size'        => 1,
+                    'message_initial'   => '',
+                    'execute_only_once' => true,
+                ),
                 'migrate_old_slugs_to_the_new_translate_table_structure_post_type_and_tax_284' => array(
                     'version'            => '0',
                     'option_name'        => 'trp_migrate_old_slug_to_new_parent_and_translate_slug_table_post_type_and_tax_284',
@@ -387,9 +413,12 @@
             }
             $updates_needed          = $this->get_updates_details();
             $option_db_error_message = get_option( $updates_needed['show_error_db_message']['option_name'] );
-            foreach ( $updates_needed as $update ) {
+                foreach ( $updates_needed as $update_key => $update ) {
                 $option = get_option( $update['option_name'], 'is not set' );
                 if ( $option === 'no' && $option_db_error_message !== 'no' ) {
+                        if ( $update_key === 'gettext_tables_optimization' && $this->gettext_tables_optimization_batch_has_started() ) {
+                            continue;
+                        }
                     add_action( 'admin_notices', array( $this, 'admin_notice_update_database' ) );
                     break;
                 }
@@ -401,6 +430,26 @@
 	 * Print admin notice message
 	 */
 	public function admin_notice_update_database() {
+        $database_update_confirmation_message = sprintf(
+            "%sn%s",
+            __( 'IMPORTANT: It is strongly recommended to first backup the database!', 'translatepress-multilingual' ),
+            __( 'Are you sure you want to continue?', 'translatepress-multilingual' )
+        );
+
+        if ( $this->only_gettext_tables_optimization_is_pending() ) {
+            $url = wp_nonce_url(
+                add_query_arg( array( 'trp_start_gettext_tables_optimization' => '1' ) ),
+                'trp_start_gettext_tables_optimization'
+            );
+
+            $html = '<div id="message" class="notice notice-warning">';
+            $html .= '<p><strong>' . esc_html__( 'TranslatePress data update', 'translatepress-multilingual' ) . '</strong> – ' . esc_html__( 'TranslatePress needs to optimize gettext database tables. Translations continue to work while this runs in the background.', 'translatepress-multilingual' ) . '</p>';
+            $html .= '<p>' . esc_html__( 'Before starting, we strongly recommend creating a database backup.', 'translatepress-multilingual' ) . '</p>';
+            $html .= '<p class="submit"><a href="' . esc_url( $url ) . '" onclick="return confirm( ' . esc_attr( wp_json_encode( $database_update_confirmation_message ) ) . ' );" class="button-primary">' . esc_html__( 'Start optimization', 'translatepress-multilingual' ) . '</a></p>';
+            $html .= '</div>';
+            echo $html;//phpcs:ignore
+            return;
+        }

 		$url = add_query_arg( array(
 			'page'                      => 'trp_update_database',
@@ -409,17 +458,128 @@
 		// maybe change notice color to blue #28B1FF
 		$html = '<div id="message" class="updated">';
 		$html .= '<p><strong>' . esc_html__( 'TranslatePress data update', 'translatepress-multilingual' ) . '</strong> – ' . esc_html__( 'We need to update your translations database to the latest version.', 'translatepress-multilingual' ) . '</p>';
-		$html .= '<p class="submit"><a href="' . esc_url( $url ) . '" onclick="return confirm( '' . __( 'IMPORTANT: It is strongly recommended to first backup the database!nAre you sure you want to continue?', 'translatepress-multilingual' ) . '');" class="button-primary">' . esc_html__( 'Run the updater', 'translatepress-multilingual' ) . '</a></p>';
+        $html .= '<p class="submit"><a href="' . esc_url( $url ) . '" onclick="return confirm( ' . esc_attr( wp_json_encode( $database_update_confirmation_message ) ) . ' );" class="button-primary">' . esc_html__( 'Run the updater', 'translatepress-multilingual' ) . '</a></p>';
 		$html .= '</div>';
 		echo $html;//phpcs:ignore
 	}

+    /**
+     * Start gettext table optimization directly from the admin notice.
+     */
+    public function maybe_start_gettext_tables_optimization_from_notice() {
+        if ( empty( $_GET['trp_start_gettext_tables_optimization'] ) ) {
+            return;
+        }
+
+        if ( ! current_user_can( apply_filters( 'trp_update_database_capability', 'manage_options' ) ) ) {
+            return;
+        }
+
+        $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
+        if ( ! wp_verify_nonce( $nonce, 'trp_start_gettext_tables_optimization' ) ) {
+            return;
+        }
+
+        $this->trp_start_gettext_tables_optimization();
+
+        wp_safe_redirect( remove_query_arg( array( 'trp_start_gettext_tables_optimization', '_wpnonce' ) ) );
+        exit;
+    }
+
+    /**
+     * Return whether gettext table optimization is the only pending DB updater item.
+     *
+     * @return bool
+     */
+    protected function only_gettext_tables_optimization_is_pending() {
+        $updates_needed = $this->get_updates_details();
+        $pending_keys   = array();
+
+        foreach ( $updates_needed as $key => $update ) {
+            if ( get_option( $update['option_name'], 'is not set' ) === 'no' ) {
+                $pending_keys[] = $key;
+            }
+        }
+
+        return count( $pending_keys ) === 1 && $pending_keys[0] === 'gettext_tables_optimization';
+    }
+
+    /**
+     * Return whether the background gettext optimizer task has already started.
+     *
+     * @return bool
+     */
+    protected function gettext_tables_optimization_batch_has_started() {
+        return in_array( get_option( 'trp_gettext_tables_optimization_330', 'is not set' ), array( 'no', 'failed' ), true );
+    }
+
     public function trp_successfully_run_database_optimization($language_code= null, $inferior_size = null, $batch_size = null){
         delete_option('trp_show_error_db_message');

         return true;
     }

+        /**
+         * Schedule gettext database optimization in the background.
+         *
+         * @return bool
+         */
+    public function trp_start_gettext_tables_optimization() {
+        $trp = TRP_Translate_Press::get_trp_instance();
+        $batch_processor = $trp->get_component( 'batch_processor' );
+
+        if ( $batch_processor && $batch_processor->start_task( 'trp_gettext_tables_optimization_330', true ) ) {
+            return true;
+        }
+
+        $this->stop_and_print_error( __( 'Update aborted! Could not schedule gettext database optimization.', 'translatepress-multilingual' ) );
+    }
+
+        /**
+         * Check if gettext tables already have the final optimized schema.
+         *
+         * @return bool
+         */
+    protected function gettext_tables_optimization_is_ready() {
+        if ( ! $this->trp_query ) {
+            $trp = TRP_Translate_Press::get_trp_instance();
+            /* @var TRP_Query */
+            $this->trp_query = $trp->get_component( 'query' );
+        }
+
+        $originals_table = $this->trp_query->get_table_name_for_gettext_original_strings();
+
+        if ( ! $this->trp_query->table_exists( $originals_table ) ) {
+            return true;
+        }
+
+        if (
+            ! $this->trp_query->table_column_exists( $originals_table, 'lookup_hash' ) ||
+            ! $this->trp_query->table_index_exists( $originals_table, 'gettext_lookup_hash_unique' ) ||
+            ! $this->trp_query->table_index_exists( $originals_table, 'gettext_lookup_original_domain_context' )
+        ) {
+            return false;
+        }
+
+        foreach ( $this->trp_query->get_all_gettext_table_names() as $table_name ) {
+            $table_name = sanitize_text_field( $table_name );
+
+            if ( ! $this->trp_query->table_exists( $table_name ) ) {
+                continue;
+            }
+
+            if ( ! $this->trp_query->table_column_exists( $table_name, 'original_id' ) || ! $this->trp_query->table_column_exists( $table_name, 'plural_form' ) ) {
+                return false;
+            }
+
+            if ( ! $this->trp_query->table_index_exists( $table_name, 'gettext_original_plural_unique' ) ) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+

     public function show_admin_error_message(){
         if ( ( isset( $_GET[ 'page'] ) && $_GET['page'] == 'trp_update_database' ) ){
@@ -1043,6 +1203,206 @@
         }
     }

+	    /**
+	     * Run the gettext originals lookup hash migration through the dedicated migration class.
+	     *
+     * @param string $language_code Unused. Kept for updater callback signature.
+     * @param int    $inferior_limit Unused. This migration resumes by DB state.
+     * @param int    $batch_size Number of rows/groups/map entries per batch.
+     * @param array  $extra_params Migration phase state.
+     *
+     * @return array|bool
+     * @throws Exception
+     */
+    public function trp_updated_database_gettext_original_lookup_hash( $language_code, $inferior_limit, $batch_size, $extra_params = array() ) {
+        if ( ! $this->trp_query ) {
+            $trp = TRP_Translate_Press::get_trp_instance();
+            /* @var TRP_Query */
+            $this->trp_query = $trp->get_component( 'query' );
+        }
+
+        require_once TRP_PLUGIN_DIR . 'includes/upgrade/gettext-optimization/class-gettext-originals-lookup-hash-migration.php';
+
+        $migration = new TRP_Gettext_Originals_Lookup_Hash_Migration( $this->trp_query );
+
+        return $migration->run( $language_code, $inferior_limit, $batch_size, $extra_params );
+    }
+
+    /**
+     * Classify gettext rows into language-file or runtime gettext statuses.
+     *
+     * Rows imported from .po/.mo files use status 4 when their stored
+     * translation matches the active language-file translation. Rows that must
+     * override runtime gettext output use the human-reviewed status 2. Machine
+     * translated rows keep their existing status semantics.
+     *
+     * @param string $language_code
+     * @param int    $inferior_limit
+     * @param int    $batch_size
+     * @param string $table_name Optional discovered gettext table name.
+     *
+     * @return bool
+     */
+    public function trp_updated_database_gettext_runtime_status_update( $language_code, $inferior_limit, $batch_size, $table_name = '' ) {
+        if ( ! $this->trp_query ) {
+            $trp = TRP_Translate_Press::get_trp_instance();
+            /* @var TRP_Query */
+            $this->trp_query = $trp->get_component( 'query' );
+        }
+
+        $table_name      = is_string( $table_name ) && $table_name !== '' ? sanitize_text_field( $table_name ) : sanitize_text_field( $this->trp_query->get_gettext_table_name( $language_code ) );
+        $originals_table = sanitize_text_field( $this->trp_query->get_table_name_for_gettext_original_strings() );
+
+        if ( ! $this->trp_query->table_exists( $table_name ) ) {
+            return true;
+        }
+
+        $rows = $this->db->get_results(
+            $this->db->prepare(
+                "SELECT tt.id, tt.translated, tt.status, tt.original AS tt_original, tt.domain AS tt_domain, tt.plural_form, ot.original, ot.domain, ot.context, ot.original_plural
+                FROM `$table_name` AS tt
+                LEFT JOIN `$originals_table` AS ot ON tt.original_id = ot.id
+                ORDER BY tt.id
+                LIMIT %d, %d",
+                $inferior_limit,
+                $batch_size
+            ),
+            ARRAY_A
+        );
+
+        if ( empty( $rows ) ) {
+            return true;
+        }
+
+        $current_locale          = determine_locale();
+        $switched                = switch_to_locale( $language_code );
+        $language_file_available = $switched || $current_locale === $language_code;
+
+        $ids_to_human_reviewed_status = array();
+        $ids_to_language_file_status = array();
+
+        foreach ( $rows as $row ) {
+            $new_status = $this->get_migrated_gettext_status( $row, $language_file_available );
+
+            if ( $new_status === TRP_Query::HUMAN_REVIEWED ) {
+                $ids_to_human_reviewed_status[] = (int) $row['id'];
+            } elseif ( $new_status === TRP_Query::GETTEXT_TRANSLATED_IN_LANGUAGE_FILE ) {
+                $ids_to_language_file_status[] = (int) $row['id'];
+            }
+        }
+
+        if ( $switched ) {
+            restore_previous_locale();
+        }
+
+        $this->update_gettext_statuses( $table_name, $ids_to_human_reviewed_status, TRP_Query::HUMAN_REVIEWED );
+        $this->update_gettext_statuses( $table_name, $ids_to_language_file_status, TRP_Query::GETTEXT_TRANSLATED_IN_LANGUAGE_FILE );
+
+        $is_finished = count( $rows ) < $batch_size;
+
+        return $is_finished;
+    }
+
+    /**
+     * Decide the migrated gettext status for an existing DB row.
+     *
+     * Empty/untranslated rows and machine translated rows keep their existing
+     * status semantics. Human/file-based rows use status 4 when they match the
+     * active language-file output and status 2 when they must override it.
+     *
+     * @param array  $row                     Gettext row joined with the original gettext table.
+     * @param bool   $language_file_available Whether WordPress loaded language files for the target locale.
+     *
+     * @return int|null
+     */
+    protected function get_migrated_gettext_status( $row, $language_file_available = true ) {
+        if ( empty( $row['translated'] ) || (int) $row['status'] === TRP_Query::NOT_TRANSLATED ) {
+            return null;
+        }
+
+        if ( (int) $row['status'] === TRP_Query::MACHINE_TRANSLATED ) {
+            return TRP_Query::MACHINE_TRANSLATED;
+        }
+
+        $original        = ! empty( $row['original'] ) ? $row['original'] : $row['tt_original'];
+        $domain          = ! empty( $row['domain'] ) ? $row['domain'] : $row['tt_domain'];
+        $context         = ! empty( $row['context'] ) ? $row['context'] : 'trp_context';
+        $plural_form     = isset( $row['plural_form'] ) ? (int) $row['plural_form'] : 0;
+        $original_plural = ! empty( $row['original_plural'] ) ? $row['original_plural'] : null;
+
+        if ( empty( $original ) || empty( $domain ) ) {
+            return TRP_Query::HUMAN_REVIEWED;
+        }
+
+        if ( ! $language_file_available ) {
+            $source_translation = ( ! empty( $original_plural ) && (int) $plural_form > 0 ) ? $original_plural : $original;
+
+            return ( $row['translated'] !== $source_translation ) ? TRP_Query::HUMAN_REVIEWED : TRP_Query::GETTEXT_TRANSLATED_IN_LANGUAGE_FILE;
+        }
+
+        $mo_translation = $this->get_gettext_language_file_translation( $original, $domain, $context, $plural_form, $original_plural );
+
+        return ( $row['translated'] !== $mo_translation ) ? TRP_Query::HUMAN_REVIEWED : TRP_Query::GETTEXT_TRANSLATED_IN_LANGUAGE_FILE;
+    }
+
+    /**
+     * Return the translation WordPress would provide from loaded language files.
+     *
+     * The caller is responsible for switching to the target locale before calling
+     * this method. If no matching entry exists in the language files, WordPress
+     * returns the source string.
+     *
+     * @param string      $original        Original gettext string.
+     * @param string      $domain          Text domain.
+     * @param string      $context         Gettext context or trp_context placeholder.
+     * @param int         $plural_form     Plural form index stored by TranslatePress.
+     * @param string|null $original_plural Original plural string, when available.
+     *
+     * @return string
+     */
+    protected function get_gettext_language_file_translation( $original, $domain, $context, $plural_form, $original_plural ) {
+        $translations = get_translations_for_domain( $domain );
+        $context      = ( $context === 'trp_context' ) ? null : $context;
+
+        if ( ! empty( $original_plural ) ) {
+            $plural_forms = new TRP_Plural_Forms( $this->settings );
+
+            if ( method_exists( $translations, 'translate_entry' ) ) {
+                return $plural_forms->translate_plural( $original, $original_plural, $plural_form, $context, $translations );
+            }
+
+            return (int) $plural_form === 0 ? $original : $original_plural;
+        }
+
+        return $translations->translate( $original, $context );
+    }
+
+    /**
+     * Bulk update gettext status for a list of gettext row ids.
+     *
+     * @param string $table_name     Gettext table name.
+     * @param array  $ids            Row ids to update.
+     * @param int    $status         New gettext status value.
+     *
+     * @return void
+     */
+    protected function update_gettext_statuses( $table_name, $ids, $status ) {
+        if ( empty( $ids ) ) {
+            return;
+        }
+
+        $ids = array_map( 'intval', $ids );
+        $ids = array_filter( $ids );
+
+        if ( empty( $ids ) ) {
+            return;
+        }
+
+        $this->db->query(
+            "UPDATE `$table_name` SET status = " . (int) $status . " WHERE id IN (" . implode( ',', $ids ) . ")"
+        );
+    }
+
     /**
      *
      * Hooked to admin_init
--- a/translatepress-multilingual/includes/compatibility-functions.php
+++ b/translatepress-multilingual/includes/compatibility-functions.php
@@ -42,6 +42,57 @@
 add_filter( 'trp_allow_tp_to_run', 'trp_missing_mbstrings_library' );

 /**
+ * robots.txt should never be handled by TranslatePress.
+ *
+ * Without this, the robots.txt file is processed by TranslatePress on secondary
+ * languages: it becomes accessible (and translated) on language URLs such as
+ * /es/robots.txt, and when "Use subdirectory for default language" is enabled the
+ * default robots.txt gets redirected to the language slug URL. Since the resulting
+ * file no longer matches the canonical one, this causes indexing issues.
+ *
+ * The check is based on the request URI (instead of is_robots()) because these
+ * filters run on 'plugins_loaded', before the query is parsed and conditional tags
+ * are available.
+ *
+ * @see https://app.clickup.com/t/qtc0c2
+ *
+ * @param string $url Optional URL to check. Defaults to the current request URI.
+ * @return bool        Whether the current request targets a robots.txt file.
+ */
+function trp_is_robots_txt_request( $url = '' ){
+    if ( empty( $url ) ) {
+        $url = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore
+    }
+    if ( empty( $url ) || ! is_string( $url ) ) {
+        return false;
+    }
+    // Only look at the path, ignoring any query string or fragment.
+    $path = wp_parse_url( $url, PHP_URL_PATH );
+    if ( empty( $path ) ) {
+        return false;
+    }
+    return (bool) preg_match( '#(^|/)robots.txt$#i', $path );
+}
+
+// Don't run TranslatePress (no translation, no output buffering) on robots.txt requests.
+function trp_stop_running_on_robots_txt( $allow_to_run ){
+    if ( trp_is_robots_txt_request() ) {
+        return false;
+    }
+    return $allow_to_run;
+}
+add_filter( 'trp_allow_tp_to_run', 'trp_stop_running_on_robots_txt' );
+
+// Don't redirect robots.txt to a language URL (e.g. /robots.txt -> /en/robots.txt).
+function trp_stop_redirect_on_robots_txt( $allow_redirect, $needed_language, $current_page_url ){
+    if ( trp_is_robots_txt_request( $current_page_url ) || trp_is_robots_txt_request() ) {
+        return false;
+    }
+    return $allow_redirect;
+}
+add_filter( 'trp_allow_language_redirect', 'trp_stop_redirect_on_robots_txt', 10, 3 );
+
+/**
  * Don't have html inside menu title tags. Some themes just put in the title the content of the link without striping HTML
  */
 add_filter( 'nav_menu_link_attributes', 'trp_remove_html_from_menu_title', 10, 3);
@@ -829,7 +880,10 @@
 	function trp_woo_fix_product_remove_from_cart_notice($message, $cart_item){
 		$product = wc_get_product( $cart_item['product_id'] );
 		if ($product){
-			$message =  sprintf( _x( '“ %s ”', 'Item name in quotes', 'woocommerce' ), $product->get_name() ); //phpcs:ignore
+			$trp                = TRP_Translate_Press::get_trp_instance();
+			$translation_render = $trp->get_component( 'translation_render' );
+			$product_name       = $translation_render->translate_page( $product->get_name() );
+			$message            = sprintf( _x( '“ %s ”', 'Item name in quotes', 'woocommerce' ), $product_name ); //phpcs:ignore
 		}
 		return $message;
 	}
@@ -2202,6 +2256,50 @@
 }


+/**
+ * Compatibility with Elementor when editing the static front page while "Use a subdirectory for the
+ * default language" is enabled.
+ *
+ * Elementor builds the preview URL from get_permalink(). For the static front page that permalink is
+ * the bare home URL (no ?page_id=), and TP does not add the language subdirectory on admin requests,
+ * so the preview URL ends up as e.g. https://example.com/?elementor-preview=ID . On the front end that
+ * bare-home request no longer resolves to the front page (it now lives under /<default-language>/), so
+ * it returns a 404 and the Elementor editor hangs on the loading screen.
+ *
+ * Regular pages are not affected because their preview URL carries ?page_id=ID, which resolves fine.
+ *
+ * We fix it at the source by adding the default-language subdirectory to the front-page preview URL
+ * (e.g. https://example.com/<default-language>/?elementor-preview=ID), which resolves correctly (200)
+ * and lets the editor finish loading.
+ */
+add_filter( 'elementor/document/urls/preview', 'trp_elementor_front_page_preview_url_subdirectory', 10, 2 );
+function trp_elementor_front_page_preview_url_subdirectory( $url, $document ) {
+
+    $trp      = TRP_Translate_Press::get_trp_instance();
+    $settings = $trp->get_component( 'settings' )->get_settings();
+
+    // Only when the default language uses a subdirectory.
+    if ( ( isset( $settings['add-subdirectory-to-default-language'] ) ? $settings['add-subdirectory-to-default-language'] : 'no' ) !== 'yes' ) {
+        return $url;
+    }
+
+    // Only for the configured static front page.
+    if ( get_option( 'show_on_front' ) !== 'page' ) {
+        return $url;
+    }
+
+    $front_page_id = (int) get_option( 'page_on_front' );
+    if ( $front_page_id === 0 || ! is_object( $document ) || (int) $document->get_main_id() !== $front_page_id ) {
+        return $url;
+    }
+
+    $url_converter = $trp->get_component( 'url_converter' );
+
+    // Pass an empty processed marker so the URL is not suffixed with #TRPLINKPROCESSED.
+    return $url_converter->get_url_for_language( $settings['default-language'], $url, '' );
+}
+
+
 /**
  * Compatibility with Give WP plugin.
  *
--- a/translatepress-multilingual/includes/gettext/class-gettext-manager.php
+++ b/translatepress-multilingual/includes/gettext/class-gettext-manager.php
@@ -56,7 +56,11 @@
 					$this->trp_query = $trp->get_component( 'query' );
 				}

-				$strings = $this->trp_query->get_all_gettext_strings( $language );
+				if ( $this->is_translation_editor_preview() || ! $this->gettext_runtime_status_migration_is_complete() ) {
+					$strings = $this->trp_query->get_all_gettext_strings( $language );
+				} else {
+					$strings = $this->trp_query->get_runtime_gettext_strings( $language );
+				}
 				if ( ! empty( $strings ) ) {
 					$trp_translated_gettext_texts = $strings;
 					$trp_strings                  = array();
@@ -115,6 +119,24 @@
 		$this->call_gettext_filters( 'woocommerce_' );
 	}

+	protected function is_translation_editor_preview() {
+		return isset( $_REQUEST['trp-edit-translation'] ) && $_REQUEST['trp-edit-translation'] === 'preview';
+	}
+
+	/**
+	 * Return whether the gettext runtime status migration has completed.
+	 *
+	 * Upgraded sites temporarily fall back to loading all gettext rows until the
+	 * full gettext optimization task has finished, including runtime-status
+	 * classification.
+	 *
+	 * @return bool
+	 */
+	protected function gettext_runtime_status_migration_is_complete() {
+		return get_option( 'trp_updated_database_gettext_tables_optimization', 'yes' ) === 'yes' &&
+		       get_option( 'trp_updated_database_gettext_runtime_status_update', 'yes' ) !== 'no';
+	}
+
     public function processing_gettext_is_needed() {
         global $pagenow;

@@ -306,88 +328,285 @@
 	 * function that machine translates gettext strings
 	 */
 	public function machine_translate_gettext() {
+		$this->flush_deferred_gettext_storage_and_mt();
+
 		/* @todo  set the original language to detect and also decide if we automatically translate for the default language */
 		global $TRP_LANGUAGE, $trp_gettext_strings_for_machine_translation;
 		if ( ! empty( $trp_gettext_strings_for_machine_translation ) ) {
-			if ( ! $this->machine_translator ) {
-				$trp                      = TRP_Translate_Press::get_trp_instance();
-				$this->machine_translator = $trp->get_component( 'machine_translator' );
+			$this->machine_translate_gettext_queue( $trp_gettext_strings_for_machine_translation, $TRP_LANGUAGE );
+			$trp_gettext_strings_for_machine_translation = array();
+		}
+	}
+
+	/**
+	 * Resolve all gettext rows observed during the request and process deferred MT.
+	 *
+	 * Runtime misses are collected in memory and flushed in batches at shutdown
+	 * to avoid one DB query per gettext string.
+	 *
+	 * @return void
+	 */
+	protected function flush_deferred_gettext_storage_and_mt() {
+		$pending_storage       = $this->process_gettext->get_pending_gettext_storage();
+		$pending_mt_candidates = $this->process_gettext->get_pending_gettext_mt_candidates();
+
+		if ( empty( $pending_storage ) && empty( $pending_mt_candidates ) ) {
+			return;
+		}
+
+		if ( ! $this->trp_query ) {
+			$trp             = TRP_Translate_Press::get_trp_instance();
+			$this->trp_query = $trp->get_component( 'query' );
+		}
+
+		$chunk_size            = max( 1, (int) apply_filters( 'trp_gettext_pending_storage_chunk_size', 250 ) );
+		$gettext_insert_update = $this->trp_query->get_query_component( 'gettext_insert_update' );
+
+		foreach ( $pending_storage as $language => $items ) {
+			if ( empty( $items ) || ! in_array( $language, $this->settings['translation-languages'] ) ) {
+				continue;
+			}
+
+			$resolved_rows = $this->resolve_pending_gettext_rows( $language, $items, $chunk_size );
+			$missing      = array_diff_key( $items, $resolved_rows );
+
+			if ( ! empty( $missing ) ) {
+				foreach ( array_chunk( $missing, $chunk_size, true ) as $missing_chunk ) {
+					$gettext_insert_update->insert_gettext_strings( array_values( $missing_chunk ), $language );
+				}
+
+				$inserted_rows  = $this->resolve_pending_gettext_rows( $language, $missing, $chunk_size );
+				$resolved_rows = array_replace( $resolved_rows, $inserted_rows );
+			}
+
+			$this->update_existing_gettext_rows_from_observed_translations( $language, $items, $resolved_rows );
+			$language_mt_candidates = isset( $pending_mt_candidates[ $language ] ) ? $pending_mt_candidates[ $language ] : array();
+			$mt_queue               = $this->build_deferred_gettext_mt_queue( $language, $language_mt_candidates, $resolved_rows );
+
+			if ( ! empty( $mt_queue ) ) {
+				$this->machine_translate_gettext_queue( $mt_queue, $language );
 			}
+		}

-			// Gettext strings are considered by default to be in the English language
-			$source_language = apply_filters( 'trp_gettext_source_language', 'en_US', $TRP_LANGUAGE, array(), $trp_gettext_strings_for_machine_translation );
-			// machine translate new strings
-			if ( $this->machine_translator->is_available( array( $source_language, $TRP_LANGUAGE ) ) ) {
+		$this->process_gettext->clear_pending_gettext_buffers();
+	}

-				/* Transform associative array into ordered numeric array. We need to keep keys numeric and ordered because $new_strings and $machine_strings depend on it.
-				 * Array was constructed as associative with db ids as keys to avoid duplication.
+	/**
+	 * Resolve observed gettext items to existing DB rows in bounded chunks.
+	 *
+	 * @param string $language   Target language code.
+	 * @param array  $items      Observed gettext items keyed by storage key.
+	 * @param int    $chunk_size Number of items to resolve per DB query.
+	 *
+	 * @return array
 				 */
-				$trp_gettext_strings_for_machine_translation = array_values( $trp_gettext_strings_for_machine_translation );
+	protected function resolve_pending_gettext_rows( $language, $items, $chunk_size ) {
+		$resolved_rows = array();
+
+		foreach ( array_chunk( $items, $chunk_size, true ) as $items_chunk ) {
+			$rows = $this->trp_query->get_gettext_rows_by_composite_keys( $language, array_values( $items_chunk ) );
+
+			foreach ( $rows as $row ) {
+				$key = $this->get_gettext_row_storage_key( $language, $row );
+
+				if ( $key ) {
+					if ( isset( $resolved_rows[ $key ] ) && ! empty( $resolved_rows[ $key ]['translated'] ) ) {
+						continue;
+					}
+
+					$resolved_rows[ $key ] = $row;
+				}
+			}
+		}
+
+		return $resolved_rows;
+	}
+
+	/**
+	 * Build the request-local storage key for a gettext DB row.
+	 *
+	 * @param string $language Target language code.
+	 * @param array  $row      Gettext row joined with the original gettext table.
+	 *
+	 * @return string
+	 */
+	protected function get_gettext_row_storage_key( $language, $row ) {
+		$original    = ! empty( $row['original'] ) ? $row['original'] : $row['tt_original'];
+		$domain      = ! empty( $row['domain'] ) ? $row['domain'] : $row['tt_domain'];
+		$context     = ! empty( $row['context'] ) ? $row['context'] : 'trp_context';
+		$plural_form = isset( $row['plural_form'] ) ? (int) $row['plural_form'] : 0;
+
+		if ( empty( $original ) || empty( $domain ) ) {
+			return '';
+		}
+
+		return $this->process_gettext->get_gettext_storage_key( $language, $context, $plural_form, $domain, $original );
+	}
+
+	/**
+	 * Persist observed language-file translations into existing untranslated rows.
+	 *
+	 * This updates storage/status only. Language-file translations stay on the
+	 * non-runtime gettext status because they should not override themselves.
+	 *
+	 * @param string $language      Target language code.
+	 * @param array  $items         Observed gettext items keyed by storage key.
+	 * @param array  $resolved_rows Existing DB rows keyed by storage key.
+	 *
+	 * @return void
+	 */
+	protected function update_existing_gettext_rows_from_observed_translations( $language, $items, &$resolved_rows ) {
+		$updates = array();
+
+		foreach ( $items as $key => $item ) {
+			if ( empty( $resolved_rows[ $key ]['id'] ) || empty( $item['translated'] ) || ! empty( $resolved_rows[ $key ]['translated'] ) ) {
+				continue;
+			}
+
+			$updates[] = array(
+				'id'         => (int) $resolved_rows[ $key ]['id'],
+				'translated' => $item['translated'],
+				'status'     => $this->trp_query->get_constant_gettext_translated_in_language_file(),
+			);
+
+			$resolved_rows[ $key ]['translated'] = $item['translated'];
+			$resolved_rows[ $key ]['status']     = $this->trp_query->get_constant_gettext_translated_in_language_file();
+		}
+
+		if ( ! empty( $updates ) ) {
+			$gettext_insert_update = $this->trp_query->get_query_component( 'gettext_insert_update' );
+			$gettext_insert_update->update_gettext_strings( $updates, $language, array( 'id', 'translated', 'status' ) );
+		}
+	}
+
+	/**
+	 * Build an id-backed gettext machine translation queue after storage resolution.
+	 *
+	 * @param string $language      Target language code.
+	 * @param array  $mt_candidates Observed MT candidates keyed by storage key.
+	 * @param array  $resolved_rows Existing DB rows keyed by storage key.
+	 *
+	 * @return array
+	 */
+	protected function build_deferred_gettext_mt_queue( $language, $mt_candidates, $resolved_rows ) {
+		if ( empty( $mt_candidates ) ) {
+			return array();
+		}
+
+		$mt_queue = array();
+
+		foreach ( $mt_candidates as $key => $candidate ) {
+			if ( empty( $resolved_rows[ $key ]['id'] ) || ! empty( $resolved_rows[ $key ]['translated'] ) ) {
+				continue;
+			}

+			$db_id = (int) $resolved_rows[ $key ]['id'];
+			if ( isset( $mt_queue[ $db_id ] ) ) {
+				continue;
+			}
+
+			$mt_queue[ $db_id ] = array(
+				'id'              => $db_id,
+				'original'        => $candidate['original'],
+				'translated'      => '',
+				'domain'          => $candidate['domain'],
+				'status'          => $this->trp_query->get_constant_machine_translated(),
+				'context'         => $candidate['context'],
+				'plural_form'     => $candidate['plural_form'],
+				'original_plural' => $candidate['original_plural'],
+			);
+		}
+
+		return $mt_queue;
+	}
+
+	/**
+	 * Machine translate gettext rows and persist successful MT translations.
+	 *
+	 * @param array  $gettext_queue Queue of gettext rows keyed by DB id or numeric index.
+	 * @param string $language      Target language code.
+	 *
+	 * @return void
+	 */
+	protected function machine_translate_gettext_queue( $gettext_queue, $language ) {
+		if ( empty( $gettext_queue ) || empty( $language ) ) {
+			return;
+		}
+
+		if ( ! $this->machine_translator ) {
+			$trp                      = TRP_Translate_Press::get_trp_instance();
+			$this->machine_translator = $trp->get_component( 'machine_translator' );
+		}
+
+		// Gettext strings are considered by default to be in the English language.
+		$source_language = apply_filters( 'trp_gettext_source_language', 'en_US', $language, array(), $gettext_queue );
+		if ( ! $this->machine_translator->is_available( array( $source_language, $language ) ) ) {
+			return;
+		}
+
+		$gettext_queue = array_values( $gettext_queue );
 				$new_strings = array();
-				foreach ( $trp_gettext_strings_for_machine_translation as $trp_gettext_string_for_machine_translation ) {
-					$new_strings[] = ( $trp_gettext_string_for_machine_translation['original_plural'] && (int)$trp_gettext_string_for_machine_translation['plural_form'] > 0 ) ? $trp_gettext_string_for_machine_translation['original_plural'] : $trp_gettext_string_for_machine_translation['original'];
+		foreach ( $gettext_queue as $gettext_string ) {
+			$new_strings[] = ( $gettext_string['original_plural'] && (int)$gettext_string['plural_form'] > 0 ) ? $gettext_string['original_plural'] : $gettext_string['original'];
 				}

 				if ( ! $this->trp_query ) {
 					$trp             = TRP_Translate_Press::get_trp_instance();
 					$this->trp_query = $trp->get_component( 'query' );
 				}
-				$gettext_insert_update = $this->trp_query->get_query_component( 'gettext_insert_update' );

-				if ( apply_filters( 'trp_gettext_allow_machine_translation', true, $source_language, $TRP_LANGUAGE, $new_strings, $trp_gettext_strings_for_machine_translation ) ) {
+		$gettext_insert_update = $this->trp_query->get_query_component( 'gettext_insert_update' );

-					// Translate in chunks and save each chunk to the gettext table before requesting the
-					// next one, so an aborted or overlapping page load never re-sends (and re-bills) a
-					// chunk that was already translated and saved.
-					//
-					// Request-wide time budget shared with the regular/DOM path through the same global:
-					// regular strings (translated while the page renders) use up the budget before
-					// gettext strings (translated here, on shutdown), so regular strings are prioritised.
-					// Whatever is left is translated on future page loads.
+		if ( apply_filters( 'trp_gettext_allow_machine_translation', true, $source_language, $language, $new_strings, $gettext_queue ) ) {
 					global $trp_machine_translation_deadline;
 					if ( ! isset( $trp_machine_translation_deadline ) ) {
 						$trp_machine_translation_deadline = microtime( true ) + apply_filters( 'trp_machine_translation_time_budget', 10 );
 					}

-					// Deduplicate before chunking so a repeated string is sent to the engine, and billed,
-					// only once.
-					foreach ( array_chunk( array_unique( $new_strings ), $this->machine_translator->get_chunk_size(), true ) as $strings_chunk ) {
+			$unique_strings = array_values( array_unique( $new_strings ) );
+			foreach ( array_chunk( $unique_strings, $this->machine_translator->get_chunk_size() ) as $strings_chunk ) {
 						if ( microtime( true ) > $trp_machine_translation_deadline ) {
 							break;
 						}

-						$chunk_machine_strings = $this->machine_translator->translate( $strings_chunk, $TRP_LANGUAGE, $source_language );
-						if ( empty( $chunk_machine_strings ) ) {
+				$machine_strings = $this->machine_translator->translate( $strings_chunk, $language, $source_language );
+				if ( empty( $machine_strings ) ) {
 							continue;
 						}

-						// map this chunk's translations back onto the gettext strings and save them
 						$strings_to_save = array();
-						foreach ( $strings_chunk as $key => $new_string ) {
-							if ( isset( $chunk_machine_strings[ $new_string ] ) && isset( $trp_gettext_strings_for_machine_translation[ $key ] ) ) {
-								$trp_gettext_strings_for_machine_translation[ $key ]['translated'] = $chunk_machine_strings[ $new_string ];
-								$strings_to_save[] = $trp_gettext_strings_for_machine_translation[ $key ];
+				foreach ( $new_strings as $key => $new_string ) {
+					if ( isset( $machine_strings[ $new_string ] ) ) {
+						$gettext_queue[ $key ]['translated'] = $machine_strings[ $new_string ];
+						$strings_to_save[]                   = $gettext_queue[ $key ];
 							}
 						}
+
 						if ( ! empty( $strings_to_save ) ) {
-							$gettext_insert_update->update_gettext_strings( $strings_to_save, $TRP_LANGUAGE );
+					$gettext_insert_update->update_gettext_strings( $strings_to_save, $language, array( 'id', 'original', 'translated', 'domain', 'status', 'plural_form' ) );
 						}
 					}

-				} else {
-					// Custom engine via filter: not chunked here, so save once.
-					$machine_strings = apply_filters( 'trp_gettext_machine_translate_strings', array(), $new_strings, $TRP_LANGUAGE, $trp_gettext_strings_for_machine_translation );
-					if ( ! empty( $machine_strings ) ) {
-						foreach ( $new_strings as $key => $new_string ) {
-							if ( isset( $machine_strings[ $new_string ] ) && isset( $trp_gettext_strings_for_machine_translation[ $key ] ) ) {
-								$trp_gettext_strings_for_machine_translation[ $key ]['translated'] = $machine_strings[ $new_string ];
-							}
+			return;
 						}
-						$gettext_insert_update->update_gettext_strings( $trp_gettext_strings_for_machine_translation, $TRP_LANGUAGE );
+
+		$machine_strings = apply_filters( 'trp_gettext_machine_translate_strings', array(), $new_strings, $language, $gettext_queue );
+		if ( empty( $machine_strings ) ) {
+			return;
 					}
+
+		foreach ( $new_strings as $key => $new_string ) {
+			if ( isset( $machine_strings[ $new_string ] ) ) {
+				$gettext_queue[ $key ]['translated'] = $machine_strings[ $new_string ];
 				}
 			}
+
+		$gettext_queue = array_filter( $gettext_queue, function( $gettext_string ) {
+			return ! empty( $gettext_string['translated'] );
+		} );
+
+		if ( ! empty( $gettext_queue ) ) {
+			$gettext_insert_update->update_gettext_strings( $gettext_queue, $language, array( 'id', 'original', 'translated', 'domain', 'status', 'plural_form' ) );
 		}
 	}

@@ -601,10 +820,10 @@
 							$translated = $trp_plural_forms->translate_plural( $current_string['original'], $current_string['original_plural'], $plural_form_i, $context, $translations );

 							if ( $translated && $translated != $current_string['original'] && $translated != $current_string['original_plural'] ) {
-								$status = 2;
+								$status = $this->trp_query->get_constant_gettext_translated_in_language_file();
 							}else {
 								$translated = '';
-								$status = 0;
+								$status = $this->trp_query->get_constant_not_translated();
 							}
 							if ( $plural_form_id_translation_table ) {
 								if ( $translated ) {
@@ -635,15 +854,15 @@
 					}
 				} else {
 					if ( $current_string['status'] == 0 && empty( $current_string['translated'] ) ) {
-						$translated = $translations->translate( $current_string['original'] );
+							$translated = $translations->translate( $current_string['original'], $context );
 					}
 				}
 				if ( $current_string['status'] == 0 && empty( $current_string['translated'] ) ) {
 					if ( $translated && $translated != $current_string['original'] && $translated != $current_string['original_plural'] ) {
-						$status = 2;
+						$status = $this->trp_query->get_constant_gettext_translated_in_language_file();
 					} else {
 						$translated = '';
-						$status     = 0;
+						$status     = $this->trp_query->get_constant_not_translated();
 					}

 					if ( $current_string['id'] ) {
@@ -651,7 +870,7 @@
 							$update_gettext_strings[] = array(
 								'id'         => $current_string['id'],
 								'translated' => $translated,
-								'status'     => 2
+								'status'     => $this->trp_query->get_constant_gettext_translated_in_language_file()
 							);
 						}
 

Frequently Asked Questions

Trusted by Developers & Organizations

Trusted by Developers
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.