Commit 0fd1dc67d31 for woocommerce

commit 0fd1dc67d3107d3a6c10c319bdd4f420641cd865
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Tue Aug 11 15:24:21 2026 +0300

    Regenerate missing combined translation file for WooCommerce Admin scripts on demand (#67548)

    * Regenerate missing combined translation file for WooCommerce Admin scripts on demand

    * Attempt combined translation file regeneration at most once per request

    * Publish combined translation file atomically via temporary file

    * Use non-overwriting move when publishing a missing combined translation file

    * Replace combined translation file atomically with rename on direct filesystems

    * Skip combined translation file rebuild when the languages directory is not writable

diff --git a/plugins/woocommerce/changelog/fix-51088-analytics-translations-combined-file b/plugins/woocommerce/changelog/fix-51088-analytics-translations-combined-file
new file mode 100644
index 00000000000..707183d1175
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-51088-analytics-translations-combined-file
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Regenerate the missing combined translation file for WooCommerce Admin scripts on demand, so Analytics and other admin screens no longer show untranslated strings when the file is absent.
diff --git a/plugins/woocommerce/src/Internal/Admin/Translations.php b/plugins/woocommerce/src/Internal/Admin/Translations.php
index 5058f5360f0..6ee3cbdc7c1 100644
--- a/plugins/woocommerce/src/Internal/Admin/Translations.php
+++ b/plugins/woocommerce/src/Internal/Admin/Translations.php
@@ -27,6 +27,14 @@ class Translations {
 	 */
 	private static $plugin_domain = 'woocommerce';

+	/**
+	 * Whether an on-demand regeneration of the combined translation file has
+	 * already been attempted during this request.
+	 *
+	 * @var bool
+	 */
+	private $regeneration_attempted = false;
+
 	/**
 	 * Get class instance.
 	 */
@@ -237,8 +245,29 @@ class Translations {
 		$cache_filename          = $this->get_combined_translation_filename( $plugin_domain, $locale );
 		$chunk_translations_json = wp_json_encode( $translations_from_chunks );

-		// Cache combined translations strings to a file.
-		$wp_filesystem->put_contents( $language_dir . $cache_filename, $chunk_translations_json );
+		// Publish via a temp file so readers never observe a partial write.
+		$temp_path  = $language_dir . $cache_filename . '.' . wp_generate_password( 12, false ) . '.tmp';
+		$cache_path = $language_dir . $cache_filename;
+
+		if ( ! $wp_filesystem->put_contents( $temp_path, $chunk_translations_json ) ) {
+			$wp_filesystem->delete( $temp_path );
+			return;
+		}
+
+		// Not WP_Filesystem_Direct::move(): it deletes the destination before renaming.
+		if ( 'direct' === get_filesystem_method() ) {
+			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace; failure handled below.
+			if ( ! @rename( $temp_path, $cache_path ) ) {
+				$wp_filesystem->delete( $temp_path );
+			}
+			return;
+		}
+
+		// Remote filesystems: overwrite only when refreshing an existing file.
+		$overwrite = $wp_filesystem->exists( $cache_path );
+		if ( ! $wp_filesystem->move( $temp_path, $cache_path, $overwrite ) ) {
+			$wp_filesystem->delete( $temp_path );
+		}
 	}

 	/**
@@ -264,6 +293,10 @@ class Translations {

 		$access_type = get_filesystem_method();
 		if ( 'direct' === $access_type ) {
+			// Skip the combine work when the file could never be saved anyway.
+			if ( ! wp_is_writable( $lang_dir ) ) {
+				return;
+			}
 			\WP_Filesystem();
 			$this->build_and_save_translations( $lang_dir, self::$plugin_domain, $locale );
 		} else {
@@ -307,8 +340,20 @@ class Translations {

 		$locale         = determine_locale();
 		$cache_filename = $this->get_combined_translation_filename( $domain, $locale );
+		$cache_path     = WP_LANG_DIR . '/plugins/' . $cache_filename;
+
+		// Rebuild the combined file on demand, since it is only generated on language pack updates and plugin activation.
+		if ( ! is_readable( $cache_path ) && ! $this->regeneration_attempted ) {
+			$this->regeneration_attempted = true;
+			$this->generate_translation_strings();
+		}
+
+		// Fall back so the app's own translation file still loads.
+		if ( ! is_readable( $cache_path ) ) {
+			return $file;
+		}

-		return WP_LANG_DIR . '/plugins/' . $cache_filename;
+		return $cache_path;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
new file mode 100644
index 00000000000..a07ad03476d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\Translations;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Translations class.
+ */
+class TranslationsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Translations
+	 */
+	private $sut;
+
+	/**
+	 * Path to the language files directory.
+	 *
+	 * @var string
+	 */
+	private $lang_dir;
+
+	/**
+	 * Files created during the test, removed on teardown.
+	 *
+	 * @var string[]
+	 */
+	private $created_files = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut      = Translations::get_instance();
+		$this->lang_dir = WP_LANG_DIR . '/plugins/';
+
+		// The singleton keeps its once-per-request regeneration guard across tests.
+		$guard = new \ReflectionProperty( Translations::class, 'regeneration_attempted' );
+		$guard->setAccessible( true );
+		$guard->setValue( $this->sut, false );
+
+		wp_mkdir_p( $this->lang_dir );
+
+		add_filter( 'pre_determine_locale', array( $this, 'force_de_locale' ) );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'pre_determine_locale', array( $this, 'force_de_locale' ) );
+
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		if ( file_exists( $combined ) ) {
+			wp_delete_file( $combined );
+		}
+		foreach ( $this->created_files as $file ) {
+			if ( file_exists( $file ) ) {
+				wp_delete_file( $file );
+			}
+		}
+		$this->created_files = array();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Forces determine_locale() to return de_DE.
+	 *
+	 * @return string Locale.
+	 */
+	public function force_de_locale() {
+		return 'de_DE';
+	}
+
+	/**
+	 * Writes a chunk translation JSON file in the official language pack format.
+	 *
+	 * @param string $md5_name  Fake md5 part of the filename.
+	 * @param string $reference Reference file path stored in the JSON.
+	 * @param array  $messages  Translation messages.
+	 */
+	private function create_chunk_json( $md5_name, $reference, $messages ) {
+		$data = array(
+			'translation-revision-date' => '2026-01-01 00:00:00+0000',
+			'generator'                 => 'GlotPress',
+			'domain'                    => 'messages',
+			'locale_data'               => array(
+				'messages' => array_merge(
+					array(
+						'' => array(
+							'domain'       => 'messages',
+							'plural-forms' => 'nplurals=2; plural=n != 1;',
+							'lang'         => 'de',
+						),
+					),
+					$messages
+				),
+			),
+			'comment'                   => array( 'reference' => $reference ),
+		);
+
+		$path = $this->lang_dir . 'woocommerce-de_DE-' . $md5_name . '.json';
+		file_put_contents( $path, wp_json_encode( $data ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->created_files[] = $path;
+	}
+
+	/**
+	 * @testdox Should leave the translation file untouched for other script handles.
+	 */
+	public function test_ignores_other_script_handles(): void {
+		$result = $this->sut->load_script_translation_file( '/some/file.json', 'some-other-script', 'woocommerce' );
+
+		$this->assertSame( '/some/file.json', $result, 'Files for other handles should not be redirected' );
+	}
+
+	/**
+	 * @testdox Should leave the translation file untouched for other text domains.
+	 */
+	public function test_ignores_other_text_domains(): void {
+		$result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'some-other-domain' );
+
+		$this->assertSame( '/some/file.json', $result, 'Files for other domains should not be redirected' );
+	}
+
+	/**
+	 * @testdox Should return the combined translation file when it exists.
+	 */
+	public function test_returns_combined_file_when_present(): void {
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		file_put_contents( $combined, '{}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+
+		$result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'woocommerce' );
+
+		$this->assertSame( $combined, $result, 'The combined translation file should be served when present' );
+	}
+
+	/**
+	 * @testdox Should rebuild the combined translation file on demand from chunk files.
+	 */
+	public function test_rebuilds_missing_combined_file_from_chunks(): void {
+		$this->create_chunk_json(
+			'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+			WC_ADMIN_DIST_JS_FOLDER . 'chunks/analytics-report-products.js',
+			array( 'Compare Products' => array( 'Produkte vergleichen' ) )
+		);
+		$this->create_chunk_json(
+			'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+			WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+			array( 'Show' => array( 'Anzeigen' ) )
+		);
+
+		$result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'woocommerce' );
+
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		$this->assertSame( $combined, $result, 'The combined file should be rebuilt and served' );
+		$this->assertFileExists( $combined, 'The combined file should be written to disk' );
+
+		$messages = json_decode( file_get_contents( $combined ), true )['locale_data']['messages']; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$this->assertSame( array( 'Produkte vergleichen' ), $messages['Compare Products'], 'Chunk translations should be merged into the combined file' );
+		$this->assertSame( array( 'Anzeigen' ), $messages['Show'], 'App translations should be merged into the combined file' );
+		$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after the combined file is published' );
+	}
+
+	/**
+	 * @testdox Should replace an existing combined file when rebuilding.
+	 */
+	public function test_replaces_existing_combined_file_on_rebuild(): void {
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->create_chunk_json(
+			'cccccccccccccccccccccccccccccccc',
+			WC_ADMIN_DIST_JS_FOLDER . 'chunks/analytics-report-products.js',
+			array( 'Compare Products' => array( 'Produkte vergleichen' ) )
+		);
+
+		if ( ! function_exists( 'get_filesystem_method' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+		\WP_Filesystem();
+		$build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+		$build->setAccessible( true );
+		$build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+
+		$data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$this->assertArrayNotHasKey( 'stale', $data, 'The stale combined file should be replaced' );
+		$this->assertSame( array( 'Produkte vergleichen' ), $data['locale_data']['messages']['Compare Products'], 'The replacement should contain the rebuilt translations' );
+		$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after replacing the combined file' );
+	}
+
+	/**
+	 * @testdox Should fall back to the original file when nothing can be rebuilt.
+	 */
+	public function test_falls_back_to_original_file_when_rebuild_not_possible(): void {
+		$result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'woocommerce' );
+
+		$this->assertSame( '/some/file.json', $result, 'The original file should be used when no combined file can be generated' );
+	}
+}