From e83f5a1a602b86dbecbf8ee58d6b8761d53b6197 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 23 Jan 2024 13:32:34 +0000 Subject: [PATCH] I18N: Introduce a more performant localization library. This introduces a more lightweight library for loading `.mo` translation files which offers increased speed and lower memory usage. It also supports loading multiple locales at the same time, which makes locale switching faster too. For plugins interacting with the `$l10n` global variable in core, a shim is added to retain backward compatibility with the existing `pomo` library. In addition to that, this library supports translations contained in PHP files, avoiding a binary file format and leveraging OPCache if available. If an `.mo` translation file has a corresponding `.l10n.php` file, the latter will be loaded instead. This behavior can be adjusted using the new `translation_file_format` and `load_translation_file` filters. PHP translation files will be typically created by downloading language packs, but can also be generated by plugins. See https://make.wordpress.org/core/2023/11/08/merging-performant-translations-into-core/ for more context. Props dd32, swissspidy, flixos90, joemcgill, westonruter, akirk, SergeyBiryukov. Fixes #59656. git-svn-id: https://develop.svn.wordpress.org/trunk@57337 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/plugin.php | 1 + src/wp-includes/class-wp-locale-switcher.php | 2 + src/wp-includes/compat.php | 32 + src/wp-includes/functions.php | 2 +- src/wp-includes/l10n.php | 68 +- .../l10n/class-wp-translation-controller.php | 420 ++++++++++++ .../l10n/class-wp-translation-file-mo.php | 219 +++++++ .../l10n/class-wp-translation-file-php.php | 83 +++ .../l10n/class-wp-translation-file.php | 296 +++++++++ .../l10n/class-wp-translations.php | 157 +++++ src/wp-settings.php | 7 + tests/phpunit/data/l10n/example-simple.mo | Bin 0 -> 373 bytes tests/phpunit/data/l10n/example-simple.php | 10 + tests/phpunit/data/l10n/example-simple.po | 23 + tests/phpunit/data/l10n/fa_IR.mo | Bin 0 -> 541 bytes tests/phpunit/data/l10n/plural.mo | Bin 0 -> 268 bytes tests/phpunit/data/l10n/simple.mo | Bin 0 -> 202 bytes tests/phpunit/data/pomo/simple.l10n.php | 3 + .../tests/l10n/loadTextdomainJustInTime.php | 3 + tests/phpunit/tests/l10n/wpLocaleSwitcher.php | 21 +- .../tests/l10n/wpTranslationController.php | 356 +++++++++++ tests/phpunit/tests/l10n/wpTranslations.php | 292 +++++++++ .../tests/l10n/wpTranslationsConvert.php | 598 ++++++++++++++++++ 23 files changed, 2578 insertions(+), 15 deletions(-) create mode 100644 src/wp-includes/l10n/class-wp-translation-controller.php create mode 100644 src/wp-includes/l10n/class-wp-translation-file-mo.php create mode 100644 src/wp-includes/l10n/class-wp-translation-file-php.php create mode 100644 src/wp-includes/l10n/class-wp-translation-file.php create mode 100644 src/wp-includes/l10n/class-wp-translations.php create mode 100644 tests/phpunit/data/l10n/example-simple.mo create mode 100644 tests/phpunit/data/l10n/example-simple.php create mode 100644 tests/phpunit/data/l10n/example-simple.po create mode 100644 tests/phpunit/data/l10n/fa_IR.mo create mode 100644 tests/phpunit/data/l10n/plural.mo create mode 100644 tests/phpunit/data/l10n/simple.mo create mode 100644 tests/phpunit/data/pomo/simple.l10n.php create mode 100644 tests/phpunit/tests/l10n/wpTranslationController.php create mode 100644 tests/phpunit/tests/l10n/wpTranslations.php create mode 100644 tests/phpunit/tests/l10n/wpTranslationsConvert.php diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index f55bbd80eb..123c9d8f5f 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -1009,6 +1009,7 @@ function delete_plugins( $plugins, $deprecated = '' ) { foreach ( $translations as $translation => $data ) { $wp_filesystem->delete( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '.po' ); $wp_filesystem->delete( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '.mo' ); + $wp_filesystem->delete( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '.l10n.php' ); $json_translation_files = glob( WP_LANG_DIR . '/plugins/' . $plugin_slug . '-' . $translation . '-*.json' ); if ( $json_translation_files ) { diff --git a/src/wp-includes/class-wp-locale-switcher.php b/src/wp-includes/class-wp-locale-switcher.php index d07490f107..b3e163014a 100644 --- a/src/wp-includes/class-wp-locale-switcher.php +++ b/src/wp-includes/class-wp-locale-switcher.php @@ -283,6 +283,8 @@ class WP_Locale_Switcher { $wp_locale = new WP_Locale(); + WP_Translation_Controller::instance()->set_locale( $locale ); + /** * Fires when the locale is switched to or restored. * diff --git a/src/wp-includes/compat.php b/src/wp-includes/compat.php index 5bfdbc23d6..429c5f92e7 100644 --- a/src/wp-includes/compat.php +++ b/src/wp-includes/compat.php @@ -420,6 +420,38 @@ if ( ! function_exists( 'array_key_last' ) ) { } } +if ( ! function_exists( 'array_is_list' ) ) { + /** + * Polyfill for `array_is_list()` function added in PHP 8.1. + * + * Determines if the given array is a list. + * + * An array is considered a list if its keys consist of consecutive numbers from 0 to count($array)-1. + * + * @see https://github.com/symfony/polyfill-php81/tree/main + * + * @since 6.5.0 + * + * @param array $arr The array being evaluated. + * @return bool True if array is a list, false otherwise. + */ + function array_is_list( $arr ) { + if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) { + return true; + } + + $next_key = -1; + + foreach ( $arr as $k => $v ) { + if ( ++$next_key !== $k ) { + return false; + } + } + + return true; + } +} + if ( ! function_exists( 'str_contains' ) ) { /** * Polyfill for `str_contains()` function added in PHP 8.0. diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index ff55251d7d..3b321fc0a6 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -6550,7 +6550,7 @@ function wp_timezone_choice( $selected_zone, $locale = null ) { if ( ! $mo_loaded || $locale !== $locale_loaded ) { $locale_loaded = $locale ? $locale : get_locale(); $mofile = WP_LANG_DIR . '/continents-cities-' . $locale_loaded . '.mo'; - unload_textdomain( 'continents-cities' ); + unload_textdomain( 'continents-cities', true ); load_textdomain( 'continents-cities', $mofile, $locale_loaded ); $mo_loaded = true; } diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 726e3da1a5..4f3ec8bfed 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -797,22 +797,65 @@ function load_textdomain( $domain, $mofile, $locale = null ) { $locale = determine_locale(); } - $mo = new MO(); - if ( ! $mo->import_from_file( $mofile ) ) { - $wp_textdomain_registry->set( $domain, $locale, false ); + $i18n_controller = WP_Translation_Controller::instance(); - return false; + // Ensures the correct locale is set as the current one, in case it was filtered. + $i18n_controller->set_locale( $locale ); + + /** + * Filters the preferred file format for translation files. + * + * Can be used to disable the use of PHP files for translations. + * + * @since 6.5.0 + * + * @param string $preferred_format Preferred file format. Possible values: 'php', 'mo'. Default: 'php'. + * @param string $domain The text domain. + */ + $preferred_format = apply_filters( 'translation_file_format', 'php', $domain ); + if ( ! in_array( $preferred_format, array( 'php', 'mo' ), true ) ) { + $preferred_format = 'php'; } - if ( isset( $l10n[ $domain ] ) ) { - $mo->merge_with( $l10n[ $domain ] ); + $translation_files = array( $mofile ); + if ( 'mo' !== $preferred_format ) { + array_unshift( + $translation_files, + substr_replace( $mofile, '.l10n.', - strlen( $preferred_format ) ) + ); } - unset( $l10n_unloaded[ $domain ] ); + foreach ( $translation_files as $file ) { + /** + * Filters the file path for loading translations for the given text domain. + * + * Similar to the {@see 'load_textdomain_mofile'} filter with the difference that + * the file path could be for an MO or PHP file. + * + * @since 6.5.0 + * + * @param string $file Path to the translation file to load. + * @param string $domain The text domain. + */ + $file = (string) apply_filters( 'load_translation_file', $file, $domain ); - $l10n[ $domain ] = &$mo; + $success = $i18n_controller->load_file( $file, $domain, $locale ); - $wp_textdomain_registry->set( $domain, $locale, dirname( $mofile ) ); + if ( $success ) { + if ( isset( $l10n[ $domain ] ) && $l10n[ $domain ] instanceof MO ) { + $i18n_controller->load_file( $l10n[ $domain ]->get_filename(), $domain, $locale ); + } + + // Unset NOOP_Translations reference in get_translations_for_domain(). + unset( $l10n[ $domain ] ); + + $l10n[ $domain ] = new WP_Translations( $i18n_controller, $domain ); + + $wp_textdomain_registry->set( $domain, $locale, dirname( $file ) ); + + return true; + } + } return true; } @@ -866,6 +909,11 @@ function unload_textdomain( $domain, $reloadable = false ) { */ do_action( 'unload_textdomain', $domain, $reloadable ); + // Since multiple locales are supported, reloadable text domains don't actually need to be unloaded. + if ( ! $reloadable ) { + WP_Translation_Controller::instance()->unload_textdomain( $domain ); + } + if ( isset( $l10n[ $domain ] ) ) { if ( $l10n[ $domain ] instanceof NOOP_Translations ) { unset( $l10n[ $domain ] ); @@ -904,7 +952,7 @@ function load_default_textdomain( $locale = null ) { } // Unload previously loaded strings so we can switch translations. - unload_textdomain( 'default' ); + unload_textdomain( 'default', true ); $return = load_textdomain( 'default', WP_LANG_DIR . "/$locale.mo", $locale ); diff --git a/src/wp-includes/l10n/class-wp-translation-controller.php b/src/wp-includes/l10n/class-wp-translation-controller.php new file mode 100644 index 0000000000..fbe5fa7d0c --- /dev/null +++ b/src/wp-includes/l10n/class-wp-translation-controller.php @@ -0,0 +1,420 @@ + [ Textdomain => [ ..., ... ] ] ] + * + * @since 6.5.0 + * @var array> + */ + protected $loaded_translations = array(); + + /** + * List of loaded translation files. + * + * [ Filename => [ Locale => [ Textdomain => WP_Translation_File ] ] ] + * + * @since 6.5.0 + * @var array>> + */ + protected $loaded_files = array(); + + /** + * Returns the WP_Translation_Controller singleton. + * + * @since 6.5.0 + * + * @return WP_Translation_Controller + */ + public static function instance(): WP_Translation_Controller { + static $instance; + + if ( ! $instance ) { + $instance = new self(); + } + + return $instance; + } + + /** + * Returns the current locale. + * + * @since 6.5.0 + * + * @return string Locale. + */ + public function get_locale(): string { + return $this->current_locale; + } + + /** + * Sets the current locale. + * + * @since 6.5.0 + * + * @param string $locale Locale. + */ + public function set_locale( string $locale ) { + $this->current_locale = $locale; + } + + /** + * Loads a translation file for a given text domain. + * + * @since 6.5.0 + * + * @param string $translation_file Translation file. + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Default current locale. + * @return bool True on success, false otherwise. + */ + public function load_file( string $translation_file, string $textdomain = 'default', string $locale = null ): bool { + if ( null === $locale ) { + $locale = $this->current_locale; + } + + $translation_file = realpath( $translation_file ); + + if ( false === $translation_file ) { + return false; + } + + if ( + isset( $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] ) && + false !== $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] + ) { + return null === $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ]->error(); + } + + if ( + isset( $this->loaded_files[ $translation_file ][ $locale ] ) && + array() !== $this->loaded_files[ $translation_file ][ $locale ] + ) { + $moe = reset( $this->loaded_files[ $translation_file ][ $locale ] ); + } else { + $moe = WP_Translation_File::create( $translation_file ); + if ( false === $moe || null !== $moe->error() ) { + $moe = false; + } + } + + $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] = $moe; + + if ( ! $moe instanceof WP_Translation_File ) { + return false; + } + + if ( ! isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) { + $this->loaded_translations[ $locale ][ $textdomain ] = array(); + } + + $this->loaded_translations[ $locale ][ $textdomain ][] = $moe; + + return true; + } + + /** + * Unloads a translation file for a given text domain. + * + * @since 6.5.0 + * + * @param WP_Translation_File|string $file Translation file instance or file name. + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Defaults to all locales. + * @return bool True on success, false otherwise. + */ + public function unload_file( $file, string $textdomain = 'default', string $locale = null ): bool { + if ( is_string( $file ) ) { + $file = realpath( $file ); + } + + if ( null !== $locale ) { + foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $i => $moe ) { + if ( $file === $moe || $file === $moe->get_file() ) { + unset( $this->loaded_translations[ $locale ][ $textdomain ][ $i ] ); + unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] ); + return true; + } + } + + return true; + } + + foreach ( $this->loaded_translations as $l => $domains ) { + foreach ( $domains[ $textdomain ] as $i => $moe ) { + if ( $file === $moe || $file === $moe->get_file() ) { + unset( $this->loaded_translations[ $l ][ $textdomain ][ $i ] ); + unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] ); + return true; + } + } + } + + return false; + } + + /** + * Unloads all translation files for a given text domain. + * + * @since 6.5.0 + * + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Defaults to all locales. + * @return bool True on success, false otherwise. + */ + public function unload_textdomain( string $textdomain = 'default', string $locale = null ): bool { + if ( null !== $locale ) { + foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $moe ) { + unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] ); + } + + unset( $this->loaded_translations[ $locale ][ $textdomain ] ); + + return true; + } + + $unloaded = false; + + foreach ( $this->loaded_translations as $l => $domains ) { + if ( ! isset( $domains[ $textdomain ] ) ) { + continue; + } + + $unloaded = true; + + foreach ( $domains[ $textdomain ] as $moe ) { + unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] ); + } + + unset( $this->loaded_translations[ $l ][ $textdomain ] ); + } + + return $unloaded; + } + + /** + * Determines whether translations are loaded for a given text domain. + * + * @since 6.5.0 + * + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Default current locale. + * @return bool True if there are any loaded translations, false otherwise. + */ + public function is_textdomain_loaded( string $textdomain = 'default', string $locale = null ): bool { + if ( null === $locale ) { + $locale = $this->current_locale; + } + + return isset( $this->loaded_translations[ $locale ][ $textdomain ] ) && + array() !== $this->loaded_translations[ $locale ][ $textdomain ]; + } + + /** + * Translates a singular string. + * + * @since 6.5.0 + * + * @param string $text Text to translate. + * @param string $context Optional. Context for the string. Default empty string. + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Default current locale. + * @return string|false Translation on success, false otherwise. + */ + public function translate( string $text, string $context = '', string $textdomain = 'default', string $locale = null ) { + if ( '' !== $context ) { + $context .= "\4"; + } + + $translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale ); + + if ( false === $translation ) { + return false; + } + + return $translation['entries'][0]; + } + + /** + * Translates plurals. + * + * Checks both singular+plural combinations as well as just singulars, + * in case the translation file does not store the plural. + * + * @since 6.5.0 + * + * @param array{0: string, 1: string} $plurals { + * Pair of singular and plural translations. + * + * @type string $0 Singular translation. + * @type string $1 Plural translation. + * } + * @param int $number Number of items. + * @param string $context Optional. Context for the string. Default empty string. + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Default current locale. + * @return string|false Translation on success, false otherwise. + */ + public function translate_plural( array $plurals, int $number, string $context = '', string $textdomain = 'default', string $locale = null ) { + if ( '' !== $context ) { + $context .= "\4"; + } + + $text = implode( "\0", $plurals ); + $translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale ); + + if ( false === $translation ) { + $text = $plurals[0]; + $translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale ); + + if ( false === $translation ) { + return false; + } + } + + /** @var WP_Translation_File $source */ + $source = $translation['source']; + $num = $source->get_plural_form( $number ); + + // See \Translations::translate_plural(). + return $translation['entries'][ $num ] ?? $translation['entries'][0]; + } + + /** + * Returns all existing headers for a given text domain. + * + * @since 6.5.0 + * + * @param string $textdomain Optional. Text domain. Default 'default'. + * @return array Headers. + */ + public function get_headers( string $textdomain = 'default' ): array { + if ( array() === $this->loaded_translations ) { + return array(); + } + + $headers = array(); + + foreach ( $this->get_files( $textdomain ) as $moe ) { + foreach ( $moe->headers() as $header => $value ) { + $headers[ $this->normalize_header( $header ) ] = $value; + } + } + + return $headers; + } + + /** + * Normalizes header names to be capitalized. + * + * @since 6.5.0 + * + * @param string $header Header name. + * @return string Normalized header name. + */ + protected function normalize_header( string $header ): string { + $parts = explode( '-', $header ); + $parts = array_map( 'ucfirst', $parts ); + return implode( '-', $parts ); + } + + /** + * Returns all entries for a given text domain. + * + * @since 6.5.0 + * + * @param string $textdomain Optional. Text domain. Default 'default'. + * @return array Entries. + */ + public function get_entries( string $textdomain = 'default' ): array { + if ( array() === $this->loaded_translations ) { + return array(); + } + + $entries = array(); + + foreach ( $this->get_files( $textdomain ) as $moe ) { + $entries = array_merge( $entries, $moe->entries() ); + } + + return $entries; + } + + /** + * Locates translation for a given string and text domain. + * + * @since 6.5.0 + * + * @param string $singular Singular translation. + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Default current locale. + * @return array{source: WP_Translation_File, entries: string[]}|false { + * Translations on success, false otherwise. + * + * @type WP_Translation_File $source Translation file instance. + * @type string[] $entries Array of translation entries. + * } + */ + protected function locate_translation( string $singular, string $textdomain = 'default', string $locale = null ) { + if ( array() === $this->loaded_translations ) { + return false; + } + + // Find the translation in all loaded files for this text domain. + foreach ( $this->get_files( $textdomain, $locale ) as $moe ) { + $translation = $moe->translate( $singular ); + if ( false !== $translation ) { + return array( + 'entries' => explode( "\0", $translation ), + 'source' => $moe, + ); + } + if ( null !== $moe->error() ) { + // Unload this file, something is wrong. + $this->unload_file( $moe, $textdomain, $locale ); + } + } + + // Nothing could be found. + return false; + } + + /** + * Returns all translation files for a given text domain. + * + * @since 6.5.0 + * + * @param string $textdomain Optional. Text domain. Default 'default'. + * @param string $locale Optional. Locale. Default current locale. + * @return WP_Translation_File[] List of translation files. + */ + protected function get_files( string $textdomain = 'default', string $locale = null ): array { + if ( null === $locale ) { + $locale = $this->current_locale; + } + + return $this->loaded_translations[ $locale ][ $textdomain ] ?? array(); + } +} diff --git a/src/wp-includes/l10n/class-wp-translation-file-mo.php b/src/wp-includes/l10n/class-wp-translation-file-mo.php new file mode 100644 index 0000000000..225b48a836 --- /dev/null +++ b/src/wp-includes/l10n/class-wp-translation-file-mo.php @@ -0,0 +1,219 @@ +error = 'Magic marker does not exist'; + return false; + } + + /** + * Parses the file. + * + * @since 6.5.0 + * + * @return bool True on success, false otherwise. + */ + protected function parse_file(): bool { + $this->parsed = true; + + $file_contents = file_get_contents( $this->file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + + if ( false === $file_contents ) { + return false; + } + + $file_length = strlen( $file_contents ); + + if ( $file_length < 24 ) { + $this->error = 'Invalid data'; + return false; + } + + $this->uint32 = $this->detect_endian_and_validate_file( substr( $file_contents, 0, 4 ) ); + + if ( false === $this->uint32 ) { + return false; + } + + $offsets = substr( $file_contents, 4, 24 ); + + if ( false === $offsets ) { + return false; + } + + $offsets = unpack( "{$this->uint32}rev/{$this->uint32}total/{$this->uint32}originals_addr/{$this->uint32}translations_addr/{$this->uint32}hash_length/{$this->uint32}hash_addr", $offsets ); + + if ( false === $offsets ) { + return false; + } + + $offsets['originals_length'] = $offsets['translations_addr'] - $offsets['originals_addr']; + $offsets['translations_length'] = $offsets['hash_addr'] - $offsets['translations_addr']; + + if ( $offsets['rev'] > 0 ) { + $this->error = 'Unsupported revision'; + return false; + } + + if ( $offsets['translations_addr'] > $file_length || $offsets['originals_addr'] > $file_length ) { + $this->error = 'Invalid data'; + return false; + } + + // Load the Originals. + $original_data = str_split( substr( $file_contents, $offsets['originals_addr'], $offsets['originals_length'] ), 8 ); + $translations_data = str_split( substr( $file_contents, $offsets['translations_addr'], $offsets['translations_length'] ), 8 ); + + foreach ( array_keys( $original_data ) as $i ) { + $o = unpack( "{$this->uint32}length/{$this->uint32}pos", $original_data[ $i ] ); + $t = unpack( "{$this->uint32}length/{$this->uint32}pos", $translations_data[ $i ] ); + + if ( false === $o || false === $t ) { + continue; + } + + $original = substr( $file_contents, $o['pos'], $o['length'] ); + $translation = substr( $file_contents, $t['pos'], $t['length'] ); + // GlotPress bug. + $translation = rtrim( $translation, "\0" ); + + // Metadata about the MO file is stored in the first translation entry. + if ( '' === $original ) { + foreach ( explode( "\n", $translation ) as $meta_line ) { + if ( '' === $meta_line ) { + continue; + } + + list( $name, $value ) = array_map( 'trim', explode( ':', $meta_line, 2 ) ); + + $this->headers[ strtolower( $name ) ] = $value; + } + } else { + $this->entries[ (string) $original ] = $translation; + } + } + + return true; + } + + /** + * Exports translation contents as a string. + * + * @since 6.5.0 + * + * @return string Translation file contents. + */ + public function export(): string { + // Prefix the headers as the first key. + $headers_string = ''; + foreach ( $this->headers as $header => $value ) { + $headers_string .= "{$header}: $value\n"; + } + $entries = array_merge( array( '' => $headers_string ), $this->entries ); + $entry_count = count( $entries ); + + if ( false === $this->uint32 ) { + $this->uint32 = 'V'; + } + + $bytes_for_entries = $entry_count * 4 * 2; + // Pair of 32bit ints per entry. + $originals_addr = 28; /* header */ + $translations_addr = $originals_addr + $bytes_for_entries; + $hash_addr = $translations_addr + $bytes_for_entries; + $entry_offsets = $hash_addr; + + $file_header = pack( $this->uint32 . '*', self::MAGIC_MARKER, 0 /* rev */, $entry_count, $originals_addr, $translations_addr, 0 /* hash_length */, $hash_addr ); + + $o_entries = ''; + $t_entries = ''; + $o_addr = ''; + $t_addr = ''; + + foreach ( array_keys( $entries ) as $original ) { + $o_addr .= pack( $this->uint32 . '*', strlen( $original ), $entry_offsets ); + $entry_offsets += strlen( $original ) + 1; + $o_entries .= $original . "\0"; + } + + foreach ( $entries as $translations ) { + $t_addr .= pack( $this->uint32 . '*', strlen( $translations ), $entry_offsets ); + $entry_offsets += strlen( $translations ) + 1; + $t_entries .= $translations . "\0"; + } + + return $file_header . $o_addr . $t_addr . $o_entries . $t_entries; + } +} diff --git a/src/wp-includes/l10n/class-wp-translation-file-php.php b/src/wp-includes/l10n/class-wp-translation-file-php.php new file mode 100644 index 0000000000..9f5b5abd98 --- /dev/null +++ b/src/wp-includes/l10n/class-wp-translation-file-php.php @@ -0,0 +1,83 @@ +parsed = true; + + $result = include $this->file; + if ( ! $result || ! is_array( $result ) ) { + $this->error = 'Invalid data'; + return; + } + + if ( isset( $result['messages'] ) && is_array( $result['messages'] ) ) { + foreach ( $result['messages'] as $singular => $translations ) { + if ( is_array( $translations ) ) { + $this->entries[ $singular ] = implode( "\0", $translations ); + } elseif ( is_string( $translations ) ) { + $this->entries[ $singular ] = $translations; + } + } + unset( $result['messages'] ); + } + + $this->headers = array_change_key_case( $result ); + } + + /** + * Exports translation contents as a string. + * + * @since 6.5.0 + * + * @return string Translation file contents. + */ + public function export(): string { + $data = array_merge( $this->headers, array( 'messages' => $this->entries ) ); + + return 'var_export( $data ) . ';' . PHP_EOL; + } + + /** + * Outputs or returns a parsable string representation of a variable. + * + * Like {@see var_export()} but "minified", using short array syntax + * and no newlines. + * + * @since 6.5.0 + * + * @param mixed $value The variable you want to export. + * @return string The variable representation. + */ + private function var_export( $value ): string { + if ( ! is_array( $value ) ) { + return var_export( $value, true ); + } + + $entries = array(); + + $is_list = array_is_list( $value ); + + foreach ( $value as $key => $val ) { + $entries[] = $is_list ? $this->var_export( $val ) : var_export( $key, true ) . '=>' . $this->var_export( $val ); + } + + return '[' . implode( ',', $entries ) . ']'; + } +} diff --git a/src/wp-includes/l10n/class-wp-translation-file.php b/src/wp-includes/l10n/class-wp-translation-file.php new file mode 100644 index 0000000000..61efd98270 --- /dev/null +++ b/src/wp-includes/l10n/class-wp-translation-file.php @@ -0,0 +1,296 @@ + + */ + protected $headers = array(); + + /** + * Whether file has been parsed. + * + * @since 6.5.0 + * @var bool + */ + protected $parsed = false; + + /** + * Error information. + * + * @since 6.5.0 + * @var string|null Error message or null if no error. + */ + protected $error; + + /** + * File name. + * + * @since 6.5.0 + * @var string + */ + protected $file = ''; + + /** + * Translation entries. + * + * @since 6.5.0 + * @var array + */ + protected $entries = array(); + + /** + * Plural forms function. + * + * @since 6.5.0 + * @var callable|null Plural forms. + */ + protected $plural_forms = null; + + /** + * Constructor. + * + * @since 6.5.0 + * + * @param string $file File to load. + */ + protected function __construct( string $file ) { + $this->file = $file; + } + + /** + * Creates a new WP_Translation_File instance for a given file. + * + * @since 6.5.0 + * + * @param string $file File name. + * @param string|null $filetype Optional. File type. Default inferred from file name. + * @return false|WP_Translation_File + */ + public static function create( string $file, string $filetype = null ) { + if ( ! is_readable( $file ) ) { + return false; + } + + if ( null === $filetype ) { + $pos = strrpos( $file, '.' ); + if ( false !== $pos ) { + $filetype = substr( $file, $pos + 1 ); + } + } + + switch ( $filetype ) { + case 'mo': + return new WP_Translation_File_MO( $file ); + case 'php': + return new WP_Translation_File_PHP( $file ); + default: + return false; + } + } + + /** + * Creates a new WP_Translation_File instance for a given file. + * + * @since 6.5.0 + * + * @param string $file Source file name. + * @param string $filetype Desired target file type. + * @return string|false Transformed translation file contents on success, false otherwise. + */ + public static function transform( string $file, string $filetype ) { + $source = self::create( $file ); + + if ( false === $source ) { + return false; + } + + switch ( $filetype ) { + case 'mo': + $destination = new WP_Translation_File_MO( '' ); + break; + case 'php': + $destination = new WP_Translation_File_PHP( '' ); + break; + default: + return false; + } + + $success = $destination->import( $source ); + + if ( ! $success ) { + return false; + } + + return $destination->export(); + } + + /** + * Returns all headers. + * + * @since 6.5.0 + * + * @return array Headers. + */ + public function headers(): array { + if ( ! $this->parsed ) { + $this->parse_file(); + } + return $this->headers; + } + + /** + * Returns all entries. + * + * @since 6.5.0 + * + * @return array Entries. + */ + public function entries(): array { + if ( ! $this->parsed ) { + $this->parse_file(); + } + + return $this->entries; + } + + /** + * Returns the current error information. + * + * @since 6.5.0 + * + * @return string|null Error message or null if no error. + */ + public function error() { + return $this->error; + } + + /** + * Returns the file name. + * + * @since 6.5.0 + * + * @return string File name. + */ + public function get_file(): string { + return $this->file; + } + + /** + * Translates a given string. + * + * @since 6.5.0 + * + * @param string $text String to translate. + * @return false|string Translation(s) on success, false otherwise. + */ + public function translate( string $text ) { + if ( ! $this->parsed ) { + $this->parse_file(); + } + + return $this->entries[ $text ] ?? false; + } + + /** + * Returns the plural form for a count. + * + * @since 6.5.0 + * + * @param int $number Count. + * @return int Plural form. + */ + public function get_plural_form( int $number ): int { + if ( ! $this->parsed ) { + $this->parse_file(); + } + + // In case a plural form is specified as a header, but no function included, build one. + if ( null === $this->plural_forms && isset( $this->headers['plural-forms'] ) ) { + $this->plural_forms = $this->make_plural_form_function( $this->headers['plural-forms'] ); + } + + if ( is_callable( $this->plural_forms ) ) { + /** + * Plural form. + * + * @var int $result Plural form. + */ + $result = call_user_func( $this->plural_forms, $number ); + return $result; + } + + // Default plural form matches English, only "One" is considered singular. + return ( 1 === $number ? 0 : 1 ); + } + + /** + * Makes a function, which will return the right translation index, according to the + * plural forms header. + * + * @since 6.5.0 + * + * @param string $expression Plural form expression. + * @return callable(int $num): int Plural forms function. + */ + public function make_plural_form_function( string $expression ): callable { + try { + $handler = new Plural_Forms( rtrim( $expression, ';' ) ); + return array( $handler, 'get' ); + } catch ( Exception $e ) { + // Fall back to default plural-form function. + return $this->make_plural_form_function( 'n != 1' ); + } + } + + /** + * Imports translations from another file. + * + * @since 6.5.0 + * + * @param WP_Translation_File $source Source file. + * @return bool True on success, false otherwise. + */ + protected function import( WP_Translation_File $source ): bool { + if ( null !== $source->error() ) { + return false; + } + + $this->headers = $source->headers(); + $this->entries = $source->entries(); + $this->error = $source->error(); + + return null === $this->error; + } + + /** + * Parses the file. + * + * @since 6.5.0 + */ + abstract protected function parse_file(); + + + /** + * Exports translation contents as a string. + * + * @since 6.5.0 + * + * @return string Translation file contents. + */ + abstract public function export(); +} diff --git a/src/wp-includes/l10n/class-wp-translations.php b/src/wp-includes/l10n/class-wp-translations.php new file mode 100644 index 0000000000..c3f5b16a55 --- /dev/null +++ b/src/wp-includes/l10n/class-wp-translations.php @@ -0,0 +1,157 @@ + $headers + * @property-read array $entries + */ +class WP_Translations { + /** + * Text domain. + * + * @since 6.5.0 + * @var string + */ + protected $textdomain = 'default'; + + /** + * Translation controller instance. + * + * @since 6.5.0 + * @var WP_Translation_Controller + */ + protected $controller; + + /** + * Constructor. + * + * @since 6.5.0 + * + * @param WP_Translation_Controller $controller I18N controller. + * @param string $textdomain Optional. Text domain. Default 'default'. + */ + public function __construct( WP_Translation_Controller $controller, string $textdomain = 'default' ) { + $this->controller = $controller; + $this->textdomain = $textdomain; + } + + /** + * Magic getter for backward compatibility. + * + * @since 6.5.0 + * + * @param string $name Property name. + * @return mixed + */ + public function __get( string $name ) { + if ( 'entries' === $name ) { + $entries = $this->controller->get_entries( $this->textdomain ); + + $result = array(); + + foreach ( $entries as $original => $translations ) { + $result[] = $this->make_entry( $original, $translations ); + } + + return $result; + } + + if ( 'headers' === $name ) { + return $this->controller->get_headers( $this->textdomain ); + } + + return null; + } + + /** + * Builds a Translation_Entry from original string and translation strings. + * + * @see MO::make_entry() + * + * @since 6.5.0 + * + * @param string $original Original string to translate from MO file. Might contain + * 0x04 as context separator or 0x00 as singular/plural separator. + * @param string $translations Translation strings from MO file. + * @return Translation_Entry Entry instance. + */ + private function make_entry( $original, $translations ): Translation_Entry { + $entry = new Translation_Entry(); + + // Look for context, separated by \4. + $parts = explode( "\4", $original ); + if ( isset( $parts[1] ) ) { + $original = $parts[1]; + $entry->context = $parts[0]; + } + + // Look for plural original. + $parts = explode( "\0", $original ); + $entry->singular = $parts[0]; + if ( isset( $parts[1] ) ) { + $entry->is_plural = true; + $entry->plural = $parts[1]; + } + + $entry->translations = explode( "\0", $translations ); + return $entry; + } + + /** + * Translates a plural string. + * + * @since 6.5.0 + * + * @param string|null $singular Singular string. + * @param string|null $plural Plural string. + * @param int|float $count Count. Should be an integer, but some plugins pass floats. + * @param string|null $context Context. + * @return string|null Translation if it exists, or the unchanged singular string. + */ + public function translate_plural( $singular, $plural, $count = 1, $context = '' ) { + if ( null === $singular || null === $plural ) { + return $singular; + } + + $translation = $this->controller->translate_plural( array( $singular, $plural ), (int) $count, (string) $context, $this->textdomain ); + if ( false !== $translation ) { + return $translation; + } + + // Fall back to the original with English grammar rules. + return ( 1 === $count ? $singular : $plural ); + } + + /** + * Translates a singular string. + * + * @since 6.5.0 + * + * @param string|null $singular Singular string. + * @param string|null $context Context. + * @return string|null Translation if it exists, or the unchanged singular string + */ + public function translate( $singular, $context = '' ) { + if ( null === $singular ) { + return null; + } + + $translation = $this->controller->translate( $singular, (string) $context, $this->textdomain ); + if ( false !== $translation ) { + return $translation; + } + + // Fall back to the original. + return $singular; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index d9da4172ee..28bcdded99 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -115,6 +115,11 @@ require ABSPATH . WPINC . '/class-wp-matchesmapregex.php'; require ABSPATH . WPINC . '/class-wp.php'; require ABSPATH . WPINC . '/class-wp-error.php'; require ABSPATH . WPINC . '/pomo/mo.php'; +require ABSPATH . WPINC . '/l10n/class-wp-translation-controller.php'; +require ABSPATH . WPINC . '/l10n/class-wp-translations.php'; +require ABSPATH . WPINC . '/l10n/class-wp-translation-file.php'; +require ABSPATH . WPINC . '/l10n/class-wp-translation-file-mo.php'; +require ABSPATH . WPINC . '/l10n/class-wp-translation-file-php.php'; /** * @since 0.71 @@ -617,6 +622,8 @@ $GLOBALS['wp_locale'] = new WP_Locale(); $GLOBALS['wp_locale_switcher'] = new WP_Locale_Switcher(); $GLOBALS['wp_locale_switcher']->init(); +WP_Translation_Controller::instance()->set_locale( $locale ); + // Load the functions for the active theme, for both parent and child theme if applicable. foreach ( wp_get_active_and_valid_themes() as $theme ) { if ( file_exists( $theme . '/functions.php' ) ) { diff --git a/tests/phpunit/data/l10n/example-simple.mo b/tests/phpunit/data/l10n/example-simple.mo new file mode 100644 index 0000000000000000000000000000000000000000..73fb5f21ab3baaece60f8b000a54f947b51bda07 GIT binary patch literal 373 zcmZ9Gy$ZrG6h@;|5FG?3MTP7okhE5$bN7m?Q;?#8*h(9J-oe?&arF@#eHK5pO4yL}LG-?$^j9BGaNYp{E}#u=pa<^21rIO)%?2SpJc7G$3=iQIOx9Uy zlCyELTy0b;H9aY_rDgY@?)4#;npeYbQ&gM3Ii&njT98G|mdQ~SDoa@;O%hU%iy7mB o@qmhXI1NJIa|P$TLrgByLQ8|U)#+AHTgFOi8(E1xnA;aYUSkDiOaK4? literal 0 HcmV?d00001 diff --git a/tests/phpunit/data/l10n/example-simple.php b/tests/phpunit/data/l10n/example-simple.php new file mode 100644 index 0000000000..e9c2f73ad6 --- /dev/null +++ b/tests/phpunit/data/l10n/example-simple.php @@ -0,0 +1,10 @@ + + [ + 'original' => ['translation'], + 'contextoriginal with context' => ['translation with context'], + 'plural0' . "\0" . 'plural1' => ['translation0', 'translation1'], + 'contextplural0 with context' . "\0" . 'plural1 with context' => ['translation0 with context', 'translation1 with context'], + ], +]; diff --git a/tests/phpunit/data/l10n/example-simple.po b/tests/phpunit/data/l10n/example-simple.po new file mode 100644 index 0000000000..b40fcda69b --- /dev/null +++ b/tests/phpunit/data/l10n/example-simple.po @@ -0,0 +1,23 @@ +msgid "" +msgstr "" +"PO-Revision-Date: 2016-01-05 18:45:32+1000\n" + +msgid "original" +msgstr "translation" + +msgctxt "context" +msgid "original with context" +msgstr "translation with context" + +msgid "plural0" +msgid_plural "plural1" +msgstr[0] "translation0" +msgstr[1] "translation1" + +msgctxt "context" +msgid "plural0 with context" +msgid_plural "plural1 with context" +msgstr[0] "translation0 with context" +msgstr[1] "translation1 with context" + + diff --git a/tests/phpunit/data/l10n/fa_IR.mo b/tests/phpunit/data/l10n/fa_IR.mo new file mode 100644 index 0000000000000000000000000000000000000000..19f165658ea5cfe2930d9f923e38811053e93a0c GIT binary patch literal 541 zcmYk2K}#D!6vxLJDJ+FTp`LnpJqSXXbvG#`Y??zcDuQW4iER&F#_UU6+01Nb#;7+1 z(HwH^xksT0ON*C&iJoT_JoJ2{B_>hQ zfK(PxitQ9*pY@XzEAQeM-s3*&Xtj$y%hzN633WLg!Dpr8jz(jE!yCWmACR_igsI9( z53loq|9_gdgvAJ|LAAz%I(cWhE^c^6p_qCKeCuQkIHDHlVj7v_ae4?5^_q{aoqQN6lAcPsNEG zL{Q&OXyJ5Ei?6x$`umRJw88o~vd<`Y)>7pHlablt7hhF)kF2A0*{3>_n x#Tj!yi*a#UjNPUC@%Ly_oVjx-j@@s%Jb~hr+Bu!M&!}xL5H^D+FqKiwKgbtYvEPlMGS?pg+ZRQ#en zS{RD#3SQM-BWBxt&s(RpvV%z*J%SIsLSq#_M1%YcMfMo^2yQYC0+!BVz+A)ty$Hd| OTtc5RH3'WordPress 2.6-bleeding','report-msgid-bugs-to'=>'wp-polyglots@lists.automattic.com','messages'=>['baba'=>'dyado','kuku +ruku'=>'yes']]; diff --git a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php index 3b93d4a975..db6a825274 100644 --- a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php +++ b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php @@ -48,6 +48,9 @@ class Tests_L10n_LoadTextdomainJustInTime extends WP_UnitTestCase { $wp_textdomain_registry = new WP_Textdomain_Registry(); + unload_textdomain( 'internationalized-plugin' ); + unload_textdomain( 'internationalized-theme' ); + parent::tear_down(); } diff --git a/tests/phpunit/tests/l10n/wpLocaleSwitcher.php b/tests/phpunit/tests/l10n/wpLocaleSwitcher.php index 1b0b8f796d..ba12a432e4 100644 --- a/tests/phpunit/tests/l10n/wpLocaleSwitcher.php +++ b/tests/phpunit/tests/l10n/wpLocaleSwitcher.php @@ -21,6 +21,11 @@ class Tests_L10n_wpLocaleSwitcher extends WP_UnitTestCase { */ protected static $user_id; + /** + * @var WP_Locale_Switcher + */ + protected $orig_instance; + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$user_id = $factory->user->create( array( @@ -42,7 +47,11 @@ class Tests_L10n_wpLocaleSwitcher extends WP_UnitTestCase { $wp_textdomain_registry = new WP_Textdomain_Registry(); - remove_filter( 'locale', array( $wp_locale_switcher, 'filter_locale' ) ); + $this->orig_instance = $wp_locale_switcher; + + remove_all_filters( 'locale' ); + remove_all_filters( 'determine_locale' ); + $wp_locale_switcher = new WP_Locale_Switcher(); $wp_locale_switcher->init(); } @@ -58,9 +67,13 @@ class Tests_L10n_wpLocaleSwitcher extends WP_UnitTestCase { // before resetting $wp_locale_switcher. restore_current_locale(); - remove_filter( 'locale', array( $wp_locale_switcher, 'filter_locale' ) ); - $wp_locale_switcher = new WP_Locale_Switcher(); - $wp_locale_switcher->init(); + remove_all_filters( 'locale' ); + remove_all_filters( 'determine_locale' ); + + $wp_locale_switcher = $this->orig_instance; + + unload_textdomain( 'internationalized-plugin' ); + unload_textdomain( 'custom-internationalized-theme' ); parent::tear_down(); } diff --git a/tests/phpunit/tests/l10n/wpTranslationController.php b/tests/phpunit/tests/l10n/wpTranslationController.php new file mode 100644 index 0000000000..9e33a1f543 --- /dev/null +++ b/tests/phpunit/tests/l10n/wpTranslationController.php @@ -0,0 +1,356 @@ +is_textdomain_loaded( 'wp-tests-domain' ); + $headers = WP_Translation_Controller::instance()->get_headers( 'wp-tests-domain' ); + $entries = WP_Translation_Controller::instance()->get_entries( 'wp-tests-domain' ); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $loaded_after_unload = is_textdomain_loaded( 'wp-tests-domain' ); + + $this->assertFalse( $loaded_before_load, 'Text domain was already loaded at beginning of the test' ); + $this->assertTrue( $load_successful, 'Text domain not successfully loaded' ); + $this->assertTrue( $loaded_after_load, 'Text domain is not considered loaded' ); + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + $this->assertFalse( $loaded_after_unload, 'Text domain still considered loaded after unload' ); + $this->assertTrue( $is_loaded, 'Text domain not considered loaded' ); + $this->assertEqualSetsWithIndex( + array( + 'Project-Id-Version' => 'WordPress 2.6-bleeding', + 'Report-Msgid-Bugs-To' => 'wp-polyglots@lists.automattic.com', + ), + $headers, + 'Actual translation headers do not match expected ones' + ); + $this->assertEqualSetsWithIndex( + array( + 'baba' => 'dyado', + "kuku\nruku" => 'yes', + ), + $entries, + 'Actual translation entries do not match expected ones' + ); + } + + /** + * @covers ::load_textdomain + * @covers WP_Translation_Controller::get_entries + * @covers WP_Translation_Controller::get_headers + * @covers WP_Translation_Controller::normalize_header + * + * @return void + */ + public function test_load_textdomain_existing_override() { + add_filter( 'override_load_textdomain', '__return_true' ); + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $is_loaded_wp = is_textdomain_loaded( 'wp-tests-domain' ); + + $is_loaded = WP_Translation_Controller::instance()->is_textdomain_loaded( 'wp-tests-domain' ); + + remove_filter( 'override_load_textdomain', '__return_true' ); + + $this->assertFalse( $is_loaded_wp ); + $this->assertFalse( $is_loaded ); + } + + /** + * @covers ::load_textdomain + * + * @return void + */ + public function test_load_textdomain_php_files() { + $load_php_successful = load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.l10n.php' ); + + $unload_php_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertTrue( $load_php_successful, 'PHP file not successfully loaded' ); + $this->assertTrue( $unload_php_successful ); + } + + /** + * @covers ::load_textdomain + * + * @return void + */ + public function test_load_textdomain_reads_php_files_if_filtered_format_is_unsupported() { + add_filter( + 'translation_file_format', + static function () { + return 'unknown-format'; + } + ); + + $load_mo_successful = load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $unload_mo_successful = unload_textdomain( 'wp-tests-domain' ); + + $load_php_successful = load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.l10n.php' ); + + $unload_php_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertTrue( $load_mo_successful, 'MO file not successfully loaded' ); + $this->assertTrue( $unload_mo_successful ); + $this->assertTrue( $load_php_successful, 'PHP file not successfully loaded' ); + $this->assertTrue( $unload_php_successful ); + } + + /** + * @covers ::load_textdomain + * + * @return void + */ + public function test_load_textdomain_existing_translation_is_kept() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/context.mo' ); + + $mo = new MO(); + $mo->import_from_file( DIR_TESTDATA . '/pomo/context.mo' ); + $mo->merge_with( $l10n['wp-tests-domain'] ); + $l10n['wp-tests-domain'] = $mo; + + $simple = __( 'baba', 'wp-tests-domain' ); + $context = _x( 'one dragon', 'not so dragon', 'wp-tests-domain' ); + + $this->assertSame( 'dyado', $simple ); + $this->assertSame( 'oney dragoney', $context ); + $this->assertInstanceOf( Translations::class, $l10n['wp-tests-domain'] ); + } + + /** + * @covers ::load_textdomain + * + * @return void + */ + public function test_load_textdomain_loads_existing_translation() { + global $l10n; + + $mo = new MO(); + $mo->import_from_file( DIR_TESTDATA . '/pomo/simple.mo' ); + $l10n['wp-tests-domain'] = $mo; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/context.mo' ); + + $simple = __( 'baba', 'wp-tests-domain' ); + $context = _x( 'one dragon', 'not so dragon', 'wp-tests-domain' ); + + $this->assertSame( 'dyado', $simple ); + $this->assertSame( 'oney dragoney', $context ); + $this->assertInstanceOf( WP_Translations::class, $l10n['wp-tests-domain'] ); + } + + /** + * @covers ::load_textdomain + * + * @return void + */ + public function test_load_textdomain_loads_existing_translation_mo_files() { + global $l10n; + + add_filter( + 'translation_file_format', + static function () { + return 'mo'; + } + ); + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $mo = new MO(); + $mo->import_from_file( DIR_TESTDATA . '/pomo/simple.mo' ); + $l10n['wp-tests-domain'] = $mo; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/context.mo' ); + + $simple = __( 'baba', 'wp-tests-domain' ); + $context = _x( 'one dragon', 'not so dragon', 'wp-tests-domain' ); + + $this->assertSame( 'dyado', $simple ); + $this->assertSame( 'oney dragoney', $context ); + $this->assertInstanceOf( WP_Translations::class, $l10n['wp-tests-domain'] ); + } + + /** + * @covers ::load_textdomain + * + * @return void + */ + public function test_load_textdomain_loads_existing_translation_php_files() { + global $l10n; + + // Just to ensure the PHP files exist. + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/context.mo' ); + unload_textdomain( 'wp-tests-domain' ); + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $mo = new MO(); + $mo->import_from_file( DIR_TESTDATA . '/pomo/simple.mo' ); + $l10n['wp-tests-domain'] = $mo; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/context.mo' ); + + $simple = __( 'baba', 'wp-tests-domain' ); + $context = _x( 'one dragon', 'not so dragon', 'wp-tests-domain' ); + + $this->assertSame( 'dyado', $simple ); + $this->assertSame( 'oney dragoney', $context ); + $this->assertInstanceOf( WP_Translations::class, $l10n['wp-tests-domain'] ); + } + + /** + * @covers ::unload_textdomain + * @covers WP_Translation_Controller::get_entries + * @covers WP_Translation_Controller::get_headers + * @covers WP_Translation_Controller::normalize_header + * + * @return void + */ + public function test_unload_textdomain() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $loaded_after_unload = is_textdomain_loaded( 'wp-tests-domain' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $is_loaded = WP_Translation_Controller::instance()->is_textdomain_loaded( 'wp-tests-domain' ); + $headers = WP_Translation_Controller::instance()->get_headers( 'wp-tests-domain' ); + $entries = WP_Translation_Controller::instance()->get_entries( 'wp-tests-domain' ); + + $this->assertNull( $compat_instance, 'Compat instance was not removed' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + $this->assertFalse( $loaded_after_unload, 'Text domain still considered loaded after unload' ); + $this->assertFalse( $is_loaded, 'Text domain still considered loaded' ); + $this->assertEmpty( $headers, 'Actual translation headers are not empty' ); + $this->assertEmpty( $entries, 'Actual translation entries are not empty' ); + } + + /** + * @covers ::unload_textdomain + * + * @return void + */ + public function test_unload_textdomain_existing_override() { + add_filter( 'override_unload_textdomain', '__return_true' ); + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $is_loaded = WP_Translation_Controller::instance()->is_textdomain_loaded( 'wp-tests-domain' ); + + remove_filter( 'override_unload_textdomain', '__return_true' ); + + $unload_successful_after = unload_textdomain( 'wp-tests-domain' ); + + $is_loaded_after = WP_Translation_Controller::instance()->is_textdomain_loaded( 'wp-tests-domain' ); + + $this->assertTrue( $unload_successful ); + $this->assertTrue( $is_loaded ); + $this->assertTrue( $unload_successful_after ); + $this->assertFalse( $is_loaded_after ); + } + + /** + * @covers ::load_textdomain + * @covers ::unload_textdomain + * + * @return void + */ + public function test_switch_to_locale_translations_stay_loaded_default_textdomain() { + switch_to_locale( 'es_ES' ); + + $actual = __( 'Invalid parameter.' ); + + $this->assertTrue( WP_Translation_Controller::instance()->is_textdomain_loaded() ); + $this->assertTrue( WP_Translation_Controller::instance()->is_textdomain_loaded( 'default', 'es_ES' ) ); + + restore_previous_locale(); + + $actual_2 = __( 'Invalid parameter.' ); + + $this->assertTrue( WP_Translation_Controller::instance()->is_textdomain_loaded( 'default', 'es_ES' ) ); + + $this->assertSame( 'Parámetro no válido. ', $actual ); + $this->assertSame( 'Invalid parameter.', $actual_2 ); + } + + /** + * @covers ::load_textdomain + * @covers ::unload_textdomain + * @covers ::change_locale + * + * @return void + */ + public function test_switch_to_locale_translations_stay_loaded_custom_textdomain() { + $this->assertSame( 'en_US', WP_Translation_Controller::instance()->get_locale() ); + + require_once DIR_TESTDATA . '/plugins/internationalized-plugin.php'; + + $before = i18n_plugin_test(); + + switch_to_locale( 'es_ES' ); + + $actual = i18n_plugin_test(); + + $this->assertSame( 'es_ES', WP_Translation_Controller::instance()->get_locale() ); + $this->assertTrue( WP_Translation_Controller::instance()->is_textdomain_loaded( 'internationalized-plugin', 'es_ES' ) ); + $this->assertTrue( WP_Translation_Controller::instance()->is_textdomain_loaded( 'default', 'es_ES' ) ); + $this->assertFalse( WP_Translation_Controller::instance()->is_textdomain_loaded( 'foo-bar', 'es_ES' ) ); + + restore_previous_locale(); + + $after = i18n_plugin_test(); + + $this->assertTrue( WP_Translation_Controller::instance()->is_textdomain_loaded( 'internationalized-plugin', 'es_ES' ) ); + + $this->assertSame( 'This is a dummy plugin', $before ); + $this->assertSame( 'Este es un plugin dummy', $actual ); + $this->assertSame( 'This is a dummy plugin', $after ); + } +} diff --git a/tests/phpunit/tests/l10n/wpTranslations.php b/tests/phpunit/tests/l10n/wpTranslations.php new file mode 100644 index 0000000000..36d3716c8e --- /dev/null +++ b/tests/phpunit/tests/l10n/wpTranslations.php @@ -0,0 +1,292 @@ +entries : array(); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + $this->assertEqualSets( + array( + new Translation_Entry( + array( + 'singular' => 'baba', + 'translations' => array( 'dyado' ), + ) + ), + new Translation_Entry( + array( + 'singular' => "kuku\nruku", + 'translations' => array( 'yes' ), + ) + ), + ), + $entries, + 'Actual translation entries do not match expected ones' + ); + } + + /** + * @covers ::__get + * @covers ::make_entry + * + * @return void + */ + public function test_get_entries_plural() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/plural.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $entries = $compat_instance ? $compat_instance->entries : array(); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + $this->assertEqualSets( + array( + new Translation_Entry( + array( + 'singular' => 'one dragon', + 'plural' => '%d dragons', + 'translations' => array( + 'oney dragoney', + 'twoey dragoney', + 'manyey dragoney', + 'manyeyey dragoney', + 'manyeyeyey dragoney', + ), + ) + ), + ), + $entries, + 'Actual translation entries do not match expected ones' + ); + } + + + /** + * @covers ::__get + * @covers ::make_entry + * + * @return void + */ + public function test_get_entries_context() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/context.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $entries = $compat_instance ? $compat_instance->entries : array(); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + $this->assertEqualSets( + array( + new Translation_Entry( + array( + 'context' => 'not so dragon', + 'singular' => 'one dragon', + 'translations' => array( 'oney dragoney' ), + ) + ), + new Translation_Entry( + array( + 'is_plural' => true, + 'singular' => 'one dragon', + 'plural' => '%d dragons', + 'context' => 'dragonland', + 'translations' => array( + 'oney dragoney', + 'twoey dragoney', + 'manyey dragoney', + ), + ) + ), + ), + $entries, + 'Actual translation entries do not match expected ones' + ); + } + + /** + * @covers ::__get + * + * @return void + */ + public function test_get_headers() { + global $l10n; + + $load_successful = load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $headers = $compat_instance ? $compat_instance->headers : array(); + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertTrue( $load_successful, 'Text domain not successfully loaded' ); + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + $this->assertEqualSetsWithIndex( + array( + 'Project-Id-Version' => 'WordPress 2.6-bleeding', + 'Report-Msgid-Bugs-To' => 'wp-polyglots@lists.automattic.com', + ), + $headers, + 'Actual translation headers do not match expected ones' + ); + } + + /** + * @covers ::__get + * + * @return void + */ + public function test_getter_unsupported_property() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $this->assertInstanceOf( WP_Translations::class, $compat_instance ); + + $this->assertNull( $compat_instance->foo ); + } + + /** + * @covers ::translate + * + * @return void + */ + public function test_translate() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $translation = $compat_instance ? $compat_instance->translate( 'baba' ) : false; + $translation_missing = $compat_instance ? $compat_instance->translate( 'does not exist' ) : false; + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertSame( 'dyado', $translation, 'Actual translation does not match expected one' ); + $this->assertSame( 'does not exist', $translation_missing, 'Actual translation fallback does not match expected one' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + } + + /** + * @covers ::translate_plural + * + * @return void + */ + public function test_translate_plural() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/plural.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $translation_1 = $compat_instance ? $compat_instance->translate_plural( 'one dragon', '%d dragons', 1 ) : false; + $translation_2 = $compat_instance ? $compat_instance->translate_plural( 'one dragon', '%d dragons', 2 ) : false; + $translation_minus_8 = $compat_instance ? $compat_instance->translate_plural( 'one dragon', '%d dragons', -8 ) : false; + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertSame( 'oney dragoney', $translation_1, 'Actual translation does not match expected one' ); + $this->assertSame( 'twoey dragoney', $translation_2, 'Actual translation does not match expected one' ); + $this->assertSame( 'twoey dragoney', $translation_minus_8, 'Actual translation does not match expected one' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + } + + /** + * @covers ::translate_plural + * + * @return void + */ + public function test_translate_plural_missing() { + global $l10n; + + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/plural.mo' ); + + $compat_instance = $l10n['wp-tests-domain'] ?? null; + + $translation_1 = $compat_instance ? $compat_instance->translate_plural( '%d house', '%d houses', 1 ) : false; + $translation_2 = $compat_instance ? $compat_instance->translate_plural( '%d car', '%d cars', 2 ) : false; + + $unload_successful = unload_textdomain( 'wp-tests-domain' ); + + $this->assertInstanceOf( WP_Translations::class, $compat_instance, 'No compat provider instance used' ); + $this->assertSame( '%d house', $translation_1, 'Actual translation fallback does not match expected one' ); + $this->assertSame( '%d cars', $translation_2, 'Actual plural translation fallback does not match expected one' ); + $this->assertTrue( $unload_successful, 'Text domain not successfully unloaded' ); + } + + /** + * @covers ::translate + * @covers ::translate_plural + * + * @ticket 41257 + * + * @return void + */ + public function test_translate_invalid_edge_cases() { + load_textdomain( 'wp-tests-domain', DIR_TESTDATA . '/pomo/simple.mo' ); + + // phpcs:disable WordPress.WP.I18n + $null_string = __( null, 'wp-tests-domain' ); + $null_singular = _n( null, 'plural', 1, 'wp-tests-domain' ); + $null_plural = _n( 'singular', null, 1, 'wp-tests-domain' ); + $null_both = _n( null, null, 1, 'wp-tests-domain' ); + $null_context = _x( 'foo', null, 'wp-tests-domain' ); + $float_number = _n( '%d house', '%d houses', 7.5, 'wp-tests-domain' ); + // phpcs:enable WordPress.WP.I18n + + unload_textdomain( 'wp-tests-domain' ); + + $this->assertNull( $null_string ); + $this->assertNull( $null_singular ); + $this->assertSame( 'singular', $null_plural ); + $this->assertNull( $null_both ); + $this->assertSame( 'foo', $null_context ); + $this->assertSame( '%d houses', $float_number ); + } +} diff --git a/tests/phpunit/tests/l10n/wpTranslationsConvert.php b/tests/phpunit/tests/l10n/wpTranslationsConvert.php new file mode 100644 index 0000000000..0de159b609 --- /dev/null +++ b/tests/phpunit/tests/l10n/wpTranslationsConvert.php @@ -0,0 +1,598 @@ +assertSame( $instance, $instance2 ); + } + + /** + * @return void + */ + public function test_no_files_loaded_returns_false() { + $instance = new WP_Translation_Controller(); + $this->assertFalse( $instance->translate( 'singular' ) ); + $this->assertFalse( $instance->translate_plural( array( 'plural0', 'plural1' ), 1 ) ); + } + + /** + * @covers ::unload + * + * @return void + */ + public function test_unload_not_loaded() { + $instance = new WP_Translation_Controller(); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest' ) ); + $this->assertFalse( $instance->unload_textdomain( 'unittest' ) ); + } + + /** + * @covers ::load + * @covers ::unload + * @covers ::is_textdomain_loaded + * @covers ::translate + * @covers ::locate_translation + * @covers ::get_files + * + * @return void + */ + public function test_unload_entire_textdomain() { + $instance = new WP_Translation_Controller(); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest' ) ); + $this->assertTrue( $instance->load_file( DIR_TESTDATA . '/l10n/example-simple.php', 'unittest' ) ); + $this->assertTrue( $instance->is_textdomain_loaded( 'unittest' ) ); + + $this->assertSame( 'translation', $instance->translate( 'original', '', 'unittest' ) ); + + $this->assertTrue( $instance->unload_textdomain( 'unittest' ) ); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest' ) ); + $this->assertFalse( $instance->translate( 'original', '', 'unittest' ) ); + } + + /** + * @covers ::unload + * @covers WP_Translation_File::get_file + * + * @return void + */ + public function test_unload_file_is_not_actually_loaded() { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'unittest' ) ); + $this->assertFalse( $controller->unload_file( DIR_TESTDATA . '/l10n/simple.mo', 'unittest' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'unittest' ) ); + $this->assertSame( 'translation', $controller->translate( 'original', '', 'unittest' ) ); + } + + /** + * @covers ::unload + * @covers ::is_textdomain_loaded + * + * @return void + */ + public function test_unload_specific_locale() { + $instance = new WP_Translation_Controller(); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest' ) ); + $this->assertTrue( $instance->load_file( DIR_TESTDATA . '/l10n/example-simple.php', 'unittest' ) ); + $this->assertTrue( $instance->is_textdomain_loaded( 'unittest' ) ); + + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest', 'es_ES' ) ); + $this->assertTrue( $instance->load_file( DIR_TESTDATA . '/l10n/example-simple.php', 'unittest', 'es_ES' ) ); + $this->assertTrue( $instance->is_textdomain_loaded( 'unittest', 'es_ES' ) ); + + $this->assertSame( 'translation', $instance->translate( 'original', '', 'unittest' ) ); + $this->assertSame( 'translation', $instance->translate( 'original', '', 'unittest', 'es_ES' ) ); + + $this->assertTrue( $instance->unload_textdomain( 'unittest', $instance->get_locale() ) ); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest' ) ); + $this->assertFalse( $instance->translate( 'original', '', 'unittest' ) ); + + $this->assertTrue( $instance->is_textdomain_loaded( 'unittest', 'es_ES' ) ); + $this->assertTrue( $instance->unload_textdomain( 'unittest', 'es_ES' ) ); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest', 'es_ES' ) ); + $this->assertFalse( $instance->translate( 'original', '', 'unittest', 'es_ES' ) ); + } + + /** + * @dataProvider data_invalid_files + * + * @param string $type + * @param string $file_contents + * @param string|bool $expected_error + * @return void + * + * @phpstan-param 'mo'|'php' $type + */ + public function test_invalid_files( string $type, string $file_contents, $expected_error = null ) { + $file = $this->temp_filename(); + + $this->assertNotFalse( $file ); + + file_put_contents( $file, $file_contents ); + + $instance = WP_Translation_File::create( $file, $type ); + + $this->assertInstanceOf( WP_Translation_File::class, $instance ); + + // Not an error condition until it attempts to parse the file. + $this->assertNull( $instance->error() ); + + // Trigger parsing. + $instance->headers(); + + $this->assertNotNull( $instance->error() ); + + if ( null !== $expected_error ) { + $this->assertSame( $expected_error, $instance->error() ); + } + } + + /** + * @return array{0: array{0: 'mo'|'php', 1: string|false, 2?: string}} + */ + public function data_invalid_files(): array { + return array( + array( 'php', '' ), + array( 'php', 'assertFalse( $instance->load_file( DIR_TESTDATA . '/l10n/file-that-doesnt-exist.mo', 'unittest' ) ); + $this->assertFalse( $instance->is_textdomain_loaded( 'unittest' ) ); + } + + /** + * @covers WP_Translation_File::create + * + * @return void + */ + public function test_create_non_existent_file() { + $this->assertFalse( WP_Translation_File::create( 'this-file-does-not-exist' ) ); + } + + /** + * @covers WP_Translation_File::create + * + * @return void + */ + public function test_create_invalid_filetype() { + $file = $this->temp_filename(); + $this->assertNotFalse( $file ); + file_put_contents( $file, '' ); + $this->assertFalse( WP_Translation_File::create( $file, 'invalid' ) ); + } + + /** + * @covers ::load + * @covers ::is_textdomain_loaded + * @covers ::translate + * @covers ::translate_plural + * @covers ::locate_translation + * @covers ::get_files + * + * @dataProvider data_simple_example_files + * + * @param string $file + * @return void + */ + public function test_simple_translation_files( string $file ) { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/' . $file, 'unittest' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'unittest' ) ); + $this->assertFalse( $controller->is_textdomain_loaded( 'textdomain not loaded' ) ); + + $this->assertFalse( $controller->translate( "string that doesn't exist", '', 'unittest' ) ); + $this->assertFalse( $controller->translate( 'original', '', 'textdomain not loaded' ) ); + + $this->assertSame( 'translation', $controller->translate( 'original', '', 'unittest' ) ); + $this->assertSame( 'translation with context', $controller->translate( 'original with context', 'context', 'unittest' ) ); + + $this->assertSame( 'translation1', $controller->translate_plural( array( 'plural0', 'plural1' ), 0, '', 'unittest' ) ); + $this->assertSame( 'translation0', $controller->translate_plural( array( 'plural0', 'plural1' ), 1, '', 'unittest' ) ); + $this->assertSame( 'translation1', $controller->translate_plural( array( 'plural0', 'plural1' ), 2, '', 'unittest' ) ); + + $this->assertSame( 'translation1 with context', $controller->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 0, 'context', 'unittest' ) ); + $this->assertSame( 'translation0 with context', $controller->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 1, 'context', 'unittest' ) ); + $this->assertSame( 'translation1 with context', $controller->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 2, 'context', 'unittest' ) ); + } + + /** + * @return array + */ + public function data_simple_example_files(): array { + return array( + array( 'example-simple.mo' ), + array( 'example-simple.php' ), + ); + } + + /** + * @covers ::load + * @covers ::unload + * @covers ::is_textdomain_loaded + * @covers ::translate + * @covers ::translate_plural + * @covers ::locate_translation + * @covers ::get_files + * @covers WP_Translation_File::get_plural_form + * @covers WP_Translation_File::make_plural_form_function + * + * @return void + */ + public function test_load_multiple_files() { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'unittest' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/simple.mo', 'unittest' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/plural.mo', 'unittest' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'unittest' ) ); + + $this->assertFalse( $controller->translate( "string that doesn't exist", '', 'unittest' ) ); + $this->assertFalse( $controller->translate( 'original', '', 'textdomain not loaded' ) ); + + // From example-simple.mo + + $this->assertSame( 'translation', $controller->translate( 'original', '', 'unittest' ) ); + $this->assertSame( 'translation with context', $controller->translate( 'original with context', 'context', 'unittest' ) ); + + $this->assertSame( 'translation1', $controller->translate_plural( array( 'plural0', 'plural1' ), 0, '', 'unittest' ) ); + $this->assertSame( 'translation0', $controller->translate_plural( array( 'plural0', 'plural1' ), 1, '', 'unittest' ) ); + $this->assertSame( 'translation1', $controller->translate_plural( array( 'plural0', 'plural1' ), 2, '', 'unittest' ) ); + + $this->assertSame( 'translation1 with context', $controller->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 0, 'context', 'unittest' ) ); + $this->assertSame( 'translation0 with context', $controller->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 1, 'context', 'unittest' ) ); + $this->assertSame( 'translation1 with context', $controller->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 2, 'context', 'unittest' ) ); + + // From simple.mo. + + $this->assertSame( 'dyado', $controller->translate( 'baba', '', 'unittest' ) ); + + // From plural.mo. + + $this->assertSame( 'oney dragoney', $controller->translate_plural( array( 'one dragon', '%d dragons' ), 1, '', 'unittest' ), 'Actual translation does not match expected one' ); + $this->assertSame( 'twoey dragoney', $controller->translate_plural( array( 'one dragon', '%d dragons' ), 2, '', 'unittest' ), 'Actual translation does not match expected one' ); + $this->assertSame( 'twoey dragoney', $controller->translate_plural( array( 'one dragon', '%d dragons' ), -8, '', 'unittest' ), 'Actual translation does not match expected one' ); + + $this->assertTrue( $controller->unload_file( DIR_TESTDATA . '/l10n/simple.mo', 'unittest' ) ); + + $this->assertFalse( $controller->translate( 'baba', '', 'unittest' ) ); + } + + /** + * @covers ::set_locale + * @covers ::get_locale + * @covers ::load + * @covers ::unload + * @covers ::is_textdomain_loaded + * @covers ::translate + * @covers ::translate_plural + * + * @return void + */ + public function test_load_multiple_locales() { + $controller = new WP_Translation_Controller(); + + $this->assertSame( 'en_US', $controller->get_locale() ); + + $controller->set_locale( 'de_DE' ); + + $this->assertSame( 'de_DE', $controller->get_locale() ); + + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'unittest' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/simple.mo', 'unittest', 'es_ES' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/plural.mo', 'unittest', 'en_US' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'unittest' ) ); + + // From example-simple.mo + + $this->assertSame( 'translation', $controller->translate( 'original', '', 'unittest' ), 'String should be translated in de_DE' ); + $this->assertFalse( $controller->translate( 'original', '', 'unittest', 'es_ES' ), 'String should not be translated in es_ES' ); + $this->assertFalse( $controller->translate( 'original', '', 'unittest', 'en_US' ), 'String should not be translated in en_US' ); + + // From simple.mo. + + $this->assertFalse( $controller->translate( 'baba', '', 'unittest' ), 'String should not be translated in de_DE' ); + $this->assertSame( 'dyado', $controller->translate( 'baba', '', 'unittest', 'es_ES' ), 'String should be translated in es_ES' ); + $this->assertFalse( $controller->translate( 'baba', '', 'unittest', 'en_US' ), 'String should not be translated in en_US' ); + + $this->assertTrue( $controller->unload_file( DIR_TESTDATA . '/l10n/plural.mo', 'unittest', 'de_DE' ) ); + + $this->assertSame( 'oney dragoney', $controller->translate_plural( array( 'one dragon', '%d dragons' ), 1, '', 'unittest', 'en_US' ), 'String should be translated in en_US' ); + + $this->assertTrue( $controller->unload_file( DIR_TESTDATA . '/l10n/plural.mo', 'unittest', 'en_US' ) ); + + $this->assertFalse( $controller->translate_plural( array( 'one dragon', '%d dragons' ), 1, '', 'unittest', 'en_US' ), 'String should not be translated in en_US' ); + } + + /** + * @covers ::unload + * + * @return void + */ + public function test_unload_with_multiple_locales() { + $ginger_mo = new WP_Translation_Controller(); + + $ginger_mo->set_locale( 'de_DE' ); + + $this->assertSame( 'de_DE', $ginger_mo->get_locale() ); + $this->assertTrue( $ginger_mo->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'unittest' ) ); + $ginger_mo->set_locale( 'es_ES' ); + $this->assertTrue( $ginger_mo->load_file( DIR_TESTDATA . '/l10n/simple.mo', 'unittest' ) ); + $ginger_mo->set_locale( 'pl_PL' ); + $this->assertTrue( $ginger_mo->load_file( DIR_TESTDATA . '/l10n/plural.mo', 'unittest' ) ); + $this->assertSame( 'pl_PL', $ginger_mo->get_locale() ); + + $this->assertTrue( $ginger_mo->is_textdomain_loaded( 'unittest' ) ); + + $ginger_mo->set_locale( 'en_US' ); + $this->assertSame( 'en_US', $ginger_mo->get_locale() ); + + $this->assertFalse( $ginger_mo->is_textdomain_loaded( 'unittest' ) ); + $this->assertTrue( $ginger_mo->is_textdomain_loaded( 'unittest', 'pl_PL' ) ); + $this->assertTrue( $ginger_mo->is_textdomain_loaded( 'unittest', 'es_ES' ) ); + $this->assertTrue( $ginger_mo->is_textdomain_loaded( 'unittest', 'de_DE' ) ); + + $this->assertTrue( $ginger_mo->unload_textdomain( 'unittest' ) ); + + $this->assertFalse( $ginger_mo->is_textdomain_loaded( 'unittest' ) ); + $this->assertFalse( $ginger_mo->is_textdomain_loaded( 'unittest', 'pl_PL' ) ); + $this->assertFalse( $ginger_mo->is_textdomain_loaded( 'unittest', 'es_ES' ) ); + $this->assertFalse( $ginger_mo->is_textdomain_loaded( 'unittest', 'de_DE' ) ); + } + + /** + * @covers ::load + * @covers ::locate_translation + * + * @return void + */ + public function test_load_with_default_textdomain() { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo' ) ); + $this->assertFalse( $controller->is_textdomain_loaded( 'unittest' ) ); + $this->assertSame( 'translation', $controller->translate( 'original' ) ); + } + + /** + * @covers ::load + * + * @return void + */ + public function test_load_same_file_twice() { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'unittest' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'unittest' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'unittest' ) ); + } + + /** + * @covers ::load + * + * @return void + */ + public function test_load_file_is_already_loaded_for_different_textdomain() { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'foo' ) ); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'bar' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'foo' ) ); + $this->assertTrue( $controller->is_textdomain_loaded( 'bar' ) ); + } + + /** + * @covers ::load + * @covers ::unload + * @covers ::is_textdomain_loaded + * @covers ::translate + * @covers ::translate_plural + * @covers ::locate_translation + * @covers ::get_files + * @covers WP_Translation_File::get_plural_form + * @covers WP_Translation_File::make_plural_form_function + * + * @return void + */ + public function test_load_no_plurals() { + $controller = new WP_Translation_Controller(); + $this->assertTrue( $controller->load_file( DIR_TESTDATA . '/l10n/fa_IR.mo', 'unittest' ) ); + + $this->assertTrue( $controller->is_textdomain_loaded( 'unittest' ) ); + + $this->assertFalse( $controller->translate( "string that doesn't exist", '', 'unittest' ) ); + + $this->assertSame( 'رونوشت‌ها فعال نشدند.', $controller->translate( 'Revisions not enabled.', '', 'unittest' ) ); + $this->assertSame( 'افزودن جدید', $controller->translate( 'Add New', 'file', 'unittest' ) ); + + $this->assertSame( '%s دیدگاه', $controller->translate_plural( array( '%s comment', '%s comments' ), 0, '', 'unittest' ) ); + $this->assertSame( '%s دیدگاه', $controller->translate_plural( array( '%s comment', '%s comments' ), 1, '', 'unittest' ) ); + $this->assertSame( '%s دیدگاه', $controller->translate_plural( array( '%s comment', '%s comments' ), 2, '', 'unittest' ) ); + } + + /** + * @covers ::get_headers + * + * @return void + */ + public function test_get_headers_no_loaded_translations() { + $controller = new WP_Translation_Controller(); + $headers = $controller->get_headers(); + $this->assertEmpty( $headers ); + } + + /** + * @covers ::get_headers + * + * @return void + */ + public function test_get_headers_with_default_textdomain() { + $controller = new WP_Translation_Controller(); + $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo' ); + $headers = $controller->get_headers(); + $this->assertSame( + array( + 'Po-Revision-Date' => '2016-01-05 18:45:32+1000', + ), + $headers + ); + } + + /** + * @covers ::get_headers + * + * @return void + */ + public function test_get_headers_no_loaded_translations_for_domain() { + $controller = new WP_Translation_Controller(); + $controller->load_file( DIR_TESTDATA . '/l10n/example-simple.mo', 'foo' ); + $headers = $controller->get_headers( 'bar' ); + $this->assertEmpty( $headers ); + } + + + /** + * @covers ::get_entries + * + * @return void + */ + public function test_get_entries_no_loaded_translations() { + $controller = new WP_Translation_Controller(); + $headers = $controller->get_entries(); + $this->assertEmpty( $headers ); + } + + /** + * @covers ::get_entries + * + * @return void + */ + public function test_get_entries_with_default_textdomain() { + $controller = new WP_Translation_Controller(); + $controller->load_file( DIR_TESTDATA . '/l10n/simple.mo' ); + $headers = $controller->get_entries(); + $this->assertSame( + array( + 'baba' => 'dyado', + "kuku\nruku" => 'yes', + ), + $headers + ); + } + + /** + * @covers ::get_entries + * + * @return void + */ + public function test_get_entries_no_loaded_translations_for_domain() { + $controller = new WP_Translation_Controller(); + $controller->load_file( DIR_TESTDATA . '/l10n/simple.mo', 'foo' ); + $headers = $controller->get_entries( 'bar' ); + $this->assertEmpty( $headers ); + } + + /** + * @dataProvider data_export_matrix + * + * @param string $source_file + * @param string $destination_format + * @return void + * + * @phpstan-param 'mo'|'php' $destination_format + */ + public function test_convert_format( string $source_file, string $destination_format ) { + $destination_file = $this->temp_filename(); + + $this->assertNotFalse( $destination_file ); + + $source = WP_Translation_File::create( $source_file ); + + $this->assertInstanceOf( WP_Translation_File::class, $source ); + + $contents = WP_Translation_File::transform( $source_file, $destination_format ); + + $this->assertNotFalse( $contents ); + + file_put_contents( $destination_file, $contents ); + + $destination = WP_Translation_File::create( $destination_file, $destination_format ); + + $this->assertInstanceOf( WP_Translation_File::class, $destination ); + $this->assertNull( $destination->error() ); + + $this->assertTrue( filesize( $destination_file ) > 0 ); + + $destination_read = WP_Translation_File::create( $destination_file, $destination_format ); + + $this->assertInstanceOf( WP_Translation_File::class, $destination_read ); + $this->assertNull( $destination_read->error() ); + + $source_headers = $source->headers(); + $destination_headers = $destination_read->headers(); + + $this->assertEquals( $source_headers, $destination_headers ); + + foreach ( $source->entries() as $original => $translation ) { + // Verify the translation is in the destination file + $new_translation = $destination_read->translate( $original ); + $this->assertSame( $translation, $new_translation ); + } + } + + /** + * @return array + */ + public function data_export_matrix(): array { + $formats = array( 'mo', 'php' ); + + $matrix = array(); + + foreach ( $formats as $input_format ) { + foreach ( $formats as $output_format ) { + $matrix[ "$input_format to $output_format" ] = array( DIR_TESTDATA . '/l10n/example-simple.' . $input_format, $output_format ); + } + } + + return $matrix; + } + + /** + * @covers WP_Translation_File::transform + * + * @return void + */ + public function test_convert_format_invalid_source() { + $this->assertFalse( WP_Translation_File::transform( 'this-file-does-not-exist', 'invalid' ) ); + $this->assertFalse( WP_Translation_File::transform( DIR_TESTDATA . '/l10n/example-simple.mo', 'invalid' ) ); + $this->assertNotFalse( WP_Translation_File::transform( DIR_TESTDATA . '/l10n/example-simple.mo', 'php' ) ); + } +}