diff --git a/src/wp-admin/includes/class-language-pack-upgrader.php b/src/wp-admin/includes/class-language-pack-upgrader.php index 3c3d42a56a9c8..34fa706b5e3b5 100644 --- a/src/wp-admin/includes/class-language-pack-upgrader.php +++ b/src/wp-admin/includes/class-language-pack-upgrader.php @@ -288,6 +288,45 @@ public function bulk_upgrade( $language_updates = array(), $args = array() ) { ) ); + foreach ( $language_updates_results as $translation ) { + switch ( $translation['type'] ) { + case 'plugin': + $file = WP_LANG_DIR . '/plugins/' . $translation['slug'] . '-' . $translation['language'] . '.mo'; + break; + case 'theme': + $file = WP_LANG_DIR . '/themes/' . $translation['slug'] . '-' . $translation['language'] . '.mo'; + break; + default: + $file = WP_LANG_DIR . '/' . $translation['language'] . '.mo'; + break; + } + + if ( file_exists( $file ) ) { + /** This filter is documented in wp-includes/l10n.php */ + $preferred_format = apply_filters( 'translation_file_format', 'php' ); + if ( ! in_array( $preferred_format, array( 'php', 'mo', 'json' ), true ) ) { + $preferred_format = 'php'; + } + + $mofile_preferred = str_replace( '.mo', ".mo.$preferred_format", $file ); + + /** This filter is documented in wp-includes/l10n.php */ + $convert = apply_filters( 'convert_translation_files', true ); + + if ( 'mo' !== $preferred_format && $convert ) { + $contents = Ginger_MO_Translation_File::transform( $file, $preferred_format ); + + if ( false !== $contents ) { + if ( true === $this->fs_connect( array( dirname( $file ) ) ) ) { + $wp_filesystem->put_contents( $mofile_preferred, $contents, FS_CHMOD_FILE ); + } else { + file_put_contents( $mofile_preferred, $contents, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents + } + } + } + } + } + // Re-add upgrade hooks. add_action( 'upgrader_process_complete', array( 'Language_Pack_Upgrader', 'async_upgrade' ), 20 ); add_action( 'upgrader_process_complete', 'wp_version_check', 10, 0 ); diff --git a/src/wp-includes/class-wp-locale-switcher.php b/src/wp-includes/class-wp-locale-switcher.php index d07490f107d1d..8b52fcc4fbec8 100644 --- a/src/wp-includes/class-wp-locale-switcher.php +++ b/src/wp-includes/class-wp-locale-switcher.php @@ -283,6 +283,8 @@ private function change_locale( $locale ) { $wp_locale = new WP_Locale(); + Ginger_MO::instance()->set_locale( $locale ); + /** * Fires when the locale is switched to or restored. * diff --git a/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-json.php b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-json.php new file mode 100644 index 0000000000000..e1821adafea7f --- /dev/null +++ b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-json.php @@ -0,0 +1,117 @@ +parsed = true; + + $data = file_get_contents( $this->file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + + if ( false === $data ) { + $this->error = true; + return; + } + + $data = json_decode( $data, true ); + + if ( false === $data || ! is_array( $data ) ) { + $this->error = json_last_error_msg(); + return; + } + + if ( ! isset( $data['domain'] ) || ! isset( $data['locale_data'][ $data['domain'] ] ) ) { + $this->error = true; + return; + } + + if ( isset( $data['translation-revision-date'] ) ) { + $this->headers['po-revision-date'] = $data['translation-revision-date']; + } + + $entries = $data['locale_data'][ $data['domain'] ]; + + foreach ( $entries as $key => $item ) { + if ( '' === $key ) { + $headers = array_change_key_case( $item ); + if ( isset( $headers['lang'] ) ) { + $this->headers['language'] = $headers['lang']; + unset( $headers['lang'] ); + } + + $this->headers = array_merge( + $this->headers, + $headers + ); + continue; + } + + if ( is_string( $item ) ) { + $this->entries[ (string) $key ] = $item; + } elseif ( is_array( $item ) ) { + $this->entries[ (string) $key ] = implode( "\0", $item ); + } + } + + unset( $this->headers['domain'] ); + } + + /** + * Exports translation contents as a string. + * + * @return string Translation file contents. + */ + public function export(): string { + $headers = array_change_key_case( $this->headers ); + + $domain = $headers['domain'] ?? 'messages'; + + $data = array( + 'domain' => $domain, + 'locale_data' => array( + $domain => $this->entries, + ), + ); + + if ( isset( $headers['po-revision-date'] ) ) { + $data['translation-revision-date'] = $headers['po-revision-date']; + } + + if ( isset( $headers['x-generator'] ) ) { + $data['generator'] = $headers['x-generator']; + } + + $data['locale_data'][ $domain ][''] = array( + 'domain' => $domain, + ); + + if ( isset( $headers['plural-forms'] ) ) { + $data['locale_data'][ $domain ]['']['plural-forms'] = $headers['plural-forms']; + } + + if ( isset( $headers['language'] ) ) { + $data['locale_data'][ $domain ]['']['lang'] = $headers['language']; + } + + $json = json_encode( $data, JSON_PRETTY_PRINT ); + + if ( false === $json ) { + return ''; + } + + return $json; + } +} diff --git a/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-mo.php b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-mo.php new file mode 100644 index 0000000000000..f54ec917bb922 --- /dev/null +++ b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-mo.php @@ -0,0 +1,209 @@ +error = "Magic Marker doesn't exist"; + return false; + } + + /** + * Parses the file. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @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. + * + * @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 . pack( 'x' ); + } + + foreach ( $entries as $translations ) { + $t_addr .= pack( $this->uint32 . '*', strlen( $translations ), $entry_offsets ); + $entry_offsets += strlen( $translations ) + 1; + $t_entries .= $translations . pack( 'x' ); + } + + return $file_header . $o_addr . $t_addr . $o_entries . $t_entries; + } +} diff --git a/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-php.php b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-php.php new file mode 100644 index 0000000000000..ae1d451af445f --- /dev/null +++ b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file-php.php @@ -0,0 +1,111 @@ +parsed = true; + + $result = include $this->file; + if ( ! $result || ! is_array( $result ) ) { + $this->error = true; + 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. + * + * @return string Translation file contents. + */ + public function export(): string { + $data = array_merge( $this->headers, array( 'messages' => $this->entries ) ); + + return 'var_export( $data ) . ';' . PHP_EOL; + } + + /** + * 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. + * + * Polyfill for array_is_list() in PHP 8.1. + * + * @see https://github.com/symfony/polyfill-php81/tree/main + * + * @SuppressWarnings(PHPMD.UnusedLocalVariable) + * + * @codeCoverageIgnore + * + * @param array $arr The array being evaluated. + * @return bool True if array is a list, false otherwise. + */ + private function array_is_list( array $arr ): bool { + if ( function_exists( 'array_is_list' ) ) { + return 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; + } + + /** + * Outputs or returns a parsable string representation of a variable. + * + * Like {@see var_export()} but "minified", using short array syntax + * and no newlines. + * + * @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 = $this->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/ginger-mo/class-ginger-mo-translation-file.php b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file.php new file mode 100644 index 0000000000000..7f15b0223e792 --- /dev/null +++ b/src/wp-includes/ginger-mo/class-ginger-mo-translation-file.php @@ -0,0 +1,289 @@ + + */ + protected $headers = array(); + + /** + * Whether file has been parsed. + * + * @var bool + */ + protected $parsed = false; + + /** + * Error information. + * + * @var bool|string + */ + protected $error = false; + + /** + * File name. + * + * @var string + */ + protected $file = ''; + + /** + * Translation entries. + * + * @var array + */ + protected $entries = array(); + + /** + * Plural forms function. + * + * @var callable|null Plural forms. + */ + protected $plural_forms = null; + + /** + * Constructor. + * + * @param string $file File to load. + */ + protected function __construct( string $file ) { + $this->file = $file; + } + + /** + * Creates a new Ginger_MO_Translation_File instance for a given file. + * + * @param string $file File name. + * @param string|null $filetype Optional. File type. Default inferred from file name. + * @return false|Ginger_MO_Translation_File + * + * @phpstan-param 'mo'|'json'|'php'|null $filetype + */ + 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 Ginger_MO_Translation_File_MO( $file ); + case 'php': + return new Ginger_MO_Translation_File_PHP( $file ); + case 'json': + if ( function_exists( 'json_decode' ) ) { + return new Ginger_MO_Translation_File_JSON( $file ); + } + break; + default: + return false; + } + + return false; + } + + /** + * Returns all headers. + * + * @return array Headers. + */ + public function headers() { + if ( ! $this->parsed ) { + $this->parse_file(); + } + return $this->headers; + } + + /** + * Returns all entries. + * + * @return array Entries. + * @phstan-return array> Entries. + */ + public function entries() { + if ( ! $this->parsed ) { + $this->parse_file(); + } + + return $this->entries; + } + + /** + * Returns the current error information. + * + * @phpstan-impure + * + * @return bool|string Error + */ + public function error() { + return $this->error; + } + + /** + * Returns the file name. + * + * @return string File name. + */ + public function get_file(): string { + return $this->file; + } + + /** + * Translates a given string. + * + * @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. + * + * @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. + * + * @phpstan-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 + * + * @param string $expression Plural form expression. + * @return callable(int $num): int Plural forms function. + */ + public function make_plural_form_function( string $expression ) { + 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' ); + } + } + + /** + * Creates a new Ginger_MO_Translation_File instance for a given file. + * + * @param string $file Source file name. + * @param string $filetype Desired target file type. + * @return string|false Transformed translation file contents on success, false otherwise. + * + * @phpstan-param 'mo'|'json'|'php' $filetype + */ + public static function transform( string $file, string $filetype ) { + $destination = null; + + $source = self::create( $file ); + + if ( false === $source ) { + return false; + } + + switch ( $filetype ) { + case 'mo': + $destination = new Ginger_MO_Translation_File_MO( '' ); + break; + case 'php': + $destination = new Ginger_MO_Translation_File_PHP( '' ); + break; + case 'json': + if ( function_exists( 'json_decode' ) ) { + $destination = new Ginger_MO_Translation_File_JSON( '' ); + } + break; + default: + return false; + } + + if ( null === $destination ) { + return false; + } + + $success = $destination->import( $source ); + + if ( ! $success ) { + return false; + } + + return $destination->export(); + } + + /** + * Parses the file. + * + * @return void + */ + protected function parse_file() { + // Needs to be implemented in child classes. + // Not abstract because it's used in this class. + } + + /** + * Imports translations from another file. + * + * @param Ginger_MO_Translation_File $source Source file. + * @return bool True on success, false otherwise. + */ + protected function import( Ginger_MO_Translation_File $source ): bool { + if ( false !== $source->error() ) { + return false; + } + + $this->headers = $source->headers(); + $this->entries = $source->entries(); + $this->error = $source->error(); + + return false === $this->error; + } + + /** + * Exports translation contents as a string. + * + * @return string Translation file contents. + */ + abstract public function export(): string; +} diff --git a/src/wp-includes/ginger-mo/class-ginger-mo-translations.php b/src/wp-includes/ginger-mo/class-ginger-mo-translations.php new file mode 100644 index 0000000000000..9aade2f29e8f0 --- /dev/null +++ b/src/wp-includes/ginger-mo/class-ginger-mo-translations.php @@ -0,0 +1,141 @@ + $headers + * @property-read array $entries + */ +class Ginger_MO_Translations { + /** + * Text domain. + * + * @var string + */ + protected $textdomain = 'default'; + + /** + * Constructor. + * + * @param string $textdomain Text domain. + */ + public function __construct( string $textdomain = 'default' ) { + $this->textdomain = $textdomain; + } + + /** + * Magic getter for backward compatibility. + * + * @param string $name Property name. + * @return mixed + */ + public function __get( string $name ) { + if ( 'entries' === $name ) { + $entries = Ginger_MO::instance()->get_entries( $this->textdomain ); + + $result = array(); + + foreach ( $entries as $original => $translations ) { + $result[] = $this->make_entry( $original, $translations ); + } + + return $result; + } + + if ( 'headers' === $name ) { + return Ginger_MO::instance()->get_headers( $this->textdomain ); + } + + return null; + } + + /** + * Magic setter. + * + * @param string $name Property name. + * @param mixed $value Property value. + * @return void + */ + public function __set( string $name, $value ) {} + + /** + * Build a Translation_Entry from original string and translation strings. + * + * @see MO::make_entry() + * + * @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. + * + * @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 = Ginger_MO::instance()->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. + * + * @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 $singular; + } + + $translation = Ginger_MO::instance()->translate( $singular, (string) $context, $this->textdomain ); + if ( false !== $translation ) { + return $translation; + } + + // Fall back to the original. + return $singular; + } +} diff --git a/src/wp-includes/ginger-mo/class-ginger-mo.php b/src/wp-includes/ginger-mo/class-ginger-mo.php new file mode 100644 index 0000000000000..4b7c296647928 --- /dev/null +++ b/src/wp-includes/ginger-mo/class-ginger-mo.php @@ -0,0 +1,376 @@ + [ Textdomain => [ .., .. ] ] ] + * + * @var array> + */ + protected $loaded_translations = array(); + + /** + * List of loaded translation files. + * + * [ Filename => [ Locale => [ Textdomain => Ginger_MO_Translation_File ] ] ] + * + * @var array>> + */ + protected $loaded_files = array(); + + /** + * Returns the Ginger_MO singleton. + * + * @return Ginger_MO + */ + public static function instance(): Ginger_MO { + static $instance; + + if ( ! $instance ) { + $instance = new Ginger_MO(); + } + + return $instance; + } + + /** + * Returns the current locale. + * + * @return string Locale. + */ + public function get_locale(): string { + return $this->current_locale; + } + + /** + * Sets the current locale. + * + * @param string $locale Locale. + * @return void + */ + public function set_locale( string $locale ) { + $this->current_locale = $locale; + } + + /** + * Loads a translation file. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @param string $translation_file Translation file. + * @param string $textdomain Text domain. + * @param string $locale Optional. Locale. Default current locale. + * @return bool True on success, false otherwise. + */ + public function load( 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 false === $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 = Ginger_MO_Translation_File::create( $translation_file ); + if ( false === $moe || false !== $moe->error() ) { + $moe = false; + } + } + + $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] = $moe; + + if ( ! $moe instanceof Ginger_MO_Translation_File ) { + return false; + } + + if ( ! isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) { + $this->loaded_translations[ $locale ][ $textdomain ] = array(); + } + + // Ensure that last-loaded translation takes precedence. + array_unshift( $this->loaded_translations[ $locale ][ $textdomain ], $moe ); + + return true; + } + + /** + * Unload all translation files or a specific one for a given text domain. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @param string $textdomain Text domain. + * @param Ginger_MO_Translation_File|string $mo Translation file instance or file name. + * @param string $locale Optional. Locale. Default all locales. + * @return bool True on success, false otherwise. + */ + public function unload( string $textdomain = 'default', $mo = null, string $locale = null ): bool { + if ( ! $this->is_loaded( $textdomain, $locale ) ) { + return false; + } + + if ( null !== $mo ) { + if ( null !== $locale ) { + foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $i => $moe ) { + if ( $mo === $moe || $mo === $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 ( $mo === $moe || $mo === $moe->get_file() ) { + unset( $this->loaded_translations[ $l ][ $textdomain ][ $i ] ); + unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] ); + return true; + } + } + } + + return true; + } + + 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; + } + + foreach ( $this->loaded_translations as $l => $domains ) { + if ( ! isset( $domains[ $textdomain ] ) ) { + continue; + } + + foreach ( $domains[ $textdomain ] as $moe ) { + unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] ); + } + + unset( $this->loaded_translations[ $l ][ $textdomain ] ); + } + + return true; + } + + /** + * Determines whether translations are loaded for a given text domain. + * + * @param string $textdomain Text domain. + * @param string $locale Optional. Locale. Default current locale. + * @return bool True if there are any loaded translations, false otherwise. + */ + public function is_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. + * + * @param string $text Text to translate. + * @param string $context Optional. Context for the string. + * @param string $textdomain Text domain. + * @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. + * + * @todo Revisit this. + * + * @param array{0: string, 1: string} $plurals Pair of singular and plural translation. + * @param int $number Number of items. + * @param string $context Optional. Context for the string. + * @param string $textdomain Text domain. + * @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 Ginger_MO_Translation_File $source */ + $source = $translation['source']; + $num = $source->get_plural_form( $number ); + + // TODO: Use nplurals from Plural-Forms header? + // See \Translations::translate_plural() in core. + + return $translation['entries'][ $num ] ?? $translation['entries'][0]; + } + + /** + * Returns all existing headers for a given text domain. + * + * @param string $textdomain Text domain. + * @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. + * + * @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. + * + * @param string $textdomain Text domain. + * @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. + * + * @param string $singular Singular translation. + * @param string $textdomain Text domain. + * @param string $locale Optional. Locale. Default current locale. + * @return array{source: Ginger_MO_Translation_File, entries: string[]}|false Translations on success, false otherwise. + */ + 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 ( false !== $moe->error() ) { + // Unload this file, something is wrong. + $this->unload( $textdomain, $moe, $locale ); + } + } + + // Nothing could be found. + return false; + } + + /** + * Returns all translation files for a given text domain. + * + * @param string $textdomain Text domain. + * @param string $locale Optional. Locale. Default current locale. + * @return Ginger_MO_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.php b/src/wp-includes/l10n.php index a085d4483f827..03eac8f41d634 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -707,6 +707,7 @@ function translate_nooped_plural( $nooped_plural, $count, $domain = 'default' ) * @global MO[] $l10n An array of all currently loaded text domains. * @global MO[] $l10n_unloaded An array of all text domains that have been unloaded again. * @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry. + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. * * @param string $domain Text domain. Unique identifier for retrieving translated strings. * @param string $mofile Path to the .mo file. @@ -715,7 +716,7 @@ function translate_nooped_plural( $nooped_plural, $count, $domain = 'default' ) */ function load_textdomain( $domain, $mofile, $locale = null ) { /** @var WP_Textdomain_Registry $wp_textdomain_registry */ - global $l10n, $l10n_unloaded, $wp_textdomain_registry; + global $l10n, $l10n_unloaded, $wp_textdomain_registry, $wp_filesystem; $l10n_unloaded = (array) $l10n_unloaded; @@ -788,22 +789,109 @@ 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 ); + // Ensures the correct locale is set as the current one, in case it was filtered. + Ginger_MO::instance()->set_locale( $locale ); - return false; + /** + * Filters the preferred file format for translation files. + * + * @since 6.5.0 + * + * @param string $convert Preferred file format. Possible values: 'php', 'mo', 'json'. Default: 'php'. + */ + $preferred_format = apply_filters( 'translation_file_format', 'php' ); + if ( ! in_array( $preferred_format, array( 'php', 'mo', 'json' ), true ) ) { + $preferred_format = 'php'; } - if ( isset( $l10n[ $domain ] ) ) { - $mo->merge_with( $l10n[ $domain ] ); + $mofile_preferred = str_replace( '.mo', ".mo.$preferred_format", $mofile ); + + if ( 'mo' !== $preferred_format ) { + + /** This filter is documented in wp-includes/l10n.php */ + $mofile_preferred = apply_filters( 'load_textdomain_mofile', $mofile_preferred, $domain ); + + /** + * Filters the file path for loading translations for the given text domain. + * + * The file could be an MO, JSON, or PHP file. + * + * @since 6.5.0 + * + * @param string $file Path to the translation file to load. + * @param string $domain The text domain. + */ + $mofile_preferred = apply_filters( 'load_translation_file', $mofile_preferred, $domain ); + + $success = Ginger_MO::instance()->load( $mofile_preferred, $domain, $locale ); + + if ( $success ) { + if ( isset( $l10n[ $domain ] ) && $l10n[ $domain ] instanceof MO ) { + Ginger_MO::instance()->load( $l10n[ $domain ]->get_filename(), $domain, $locale ); + } + + // Unset Noop_Translations reference in get_translations_for_domain. + unset( $l10n[ $domain ] ); + $l10n[ $domain ] = new Ginger_MO_Translations( $domain ); + + $wp_textdomain_registry->set( $domain, $locale, dirname( $mofile ) ); + + return true; + } } - unset( $l10n_unloaded[ $domain ] ); + /** This action is documented in wp-includes/l10n.php */ + do_action( 'load_textdomain', $domain, $mofile ); + + /** This filter is documented in wp-includes/l10n.php */ + $mofile = apply_filters( 'load_textdomain_mofile', $mofile, $domain ); + + /** This filter is documented in wp-includes/l10n.php */ + $mofile = apply_filters( 'load_translation_file', $mofile, $domain ); + + $success = Ginger_MO::instance()->load( $mofile, $domain, $locale ); - $l10n[ $domain ] = &$mo; + if ( $success ) { + if ( isset( $l10n[ $domain ] ) && $l10n[ $domain ] instanceof MO ) { + Ginger_MO::instance()->load( $l10n[ $domain ]->get_filename(), $domain, $locale ); + } - $wp_textdomain_registry->set( $domain, $locale, dirname( $mofile ) ); + // Unset Noop_Translations reference in get_translations_for_domain. + unset( $l10n[ $domain ] ); + + $l10n[ $domain ] = new Ginger_MO_Translations( $domain ); + + $wp_textdomain_registry->set( $domain, $locale, dirname( $mofile ) ); + + /** + * Filters whether existing MO files should be automatically converted to the preferred format. + * + * Only runs when no corresponding PHP or JSON translation file exists yet. + * + * The preferred format is determined by the {@see 'translation_file_format'} filter + * + * Useful for testing/debugging. + * + * @param bool $convert Whether to convert MO files to PHP or JSON files. Default true. + */ + $convert = apply_filters( 'convert_translation_files', true ); + + if ( 'mo' !== $preferred_format && $convert ) { + $contents = Ginger_MO_Translation_File::transform( $mofile, $preferred_format ); + + if ( false !== $contents ) { + if ( ! function_exists( 'WP_Filesystem' ) ) { + require_once ABSPATH . '/wp-admin/includes/file.php'; + } + + if ( true === WP_Filesystem() ) { + $wp_filesystem->put_contents( $mofile_preferred, $contents, FS_CHMOD_FILE ); + } else { + file_put_contents( $mofile_preferred, $contents, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents + } + } + } + } return true; } @@ -867,7 +955,8 @@ function unload_textdomain( $domain, $reloadable = false ) { unset( $l10n[ $domain ] ); if ( ! $reloadable ) { - $l10n_unloaded[ $domain ] = true; + // Since we support multiple locales, we don't actually need to unload reloadable text domains. + return Ginger_MO::instance()->unload( $domain ); } return true; diff --git a/src/wp-settings.php b/src/wp-settings.php index 528f335cb7c2a..845effcfe889a 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -114,6 +114,12 @@ require ABSPATH . WPINC . '/class-wp.php'; require ABSPATH . WPINC . '/class-wp-error.php'; require ABSPATH . WPINC . '/pomo/mo.php'; +require ABSPATH . WPINC . '/ginger-mo/class-ginger-mo.php'; +require ABSPATH . WPINC . '/ginger-mo/class-ginger-mo-translations.php'; +require ABSPATH . WPINC . '/ginger-mo/class-ginger-mo-translation-file.php'; +require ABSPATH . WPINC . '/ginger-mo/class-ginger-mo-translation-file-mo.php'; +require ABSPATH . WPINC . '/ginger-mo/class-ginger-mo-translation-file-json.php'; +require ABSPATH . WPINC . '/ginger-mo/class-ginger-mo-translation-file-php.php'; /** * @global wpdb $wpdb WordPress database abstraction object. @@ -604,6 +610,8 @@ $GLOBALS['wp_locale_switcher'] = new WP_Locale_Switcher(); $GLOBALS['wp_locale_switcher']->init(); +Ginger_MO::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/languages/admin-es_ES.mo.php b/tests/phpunit/data/languages/admin-es_ES.mo.php new file mode 100644 index 0000000000000..b9b1f126ddf4d --- /dev/null +++ b/tests/phpunit/data/languages/admin-es_ES.mo.php @@ -0,0 +1,2 @@ +'2016-10-25 18:29+0200','mime-version'=>'1.0','content-type'=>'text/plain; charset=UTF-8','content-transfer-encoding'=>'8bit','plural-forms'=>'nplurals=2; plural=n != 1;','x-generator'=>'Poedit 1.8.10','project-id-version'=>'Administration','language'=>'es_ES','messages'=>['Comment queries now have cache handling to improve performance. New arguments in %s make crafting robust comment queries simpler.'=>'Las consultas de comentarios ahora tiene una caché que mejora el rendimiento. Nuevos argumentos en %s hacen que sea más fácil crear consultas robustas.','Comment query improvements'=>'Mejoras en las consultas de comentarios','New %1$s, %2$s, and %3$s objects make interacting with terms, comments, and networks more predictable and intuitive in code.'=>'Ahora los objetos %1$s, %2$s y %3$s hacen que interactuar con términos, comentarios y redes sea más predecible y que el código sea más intuitivo.','Term, comment, and network objects'=>'Objetos de término, comentario y red','Thank you for updating! WordPress %s makes your site more connected and responsive.'=>'¡Gracias por actualizar! WordPress %s hace que tu sitio esté más conectado y sea más adaptable.']]; diff --git a/tests/phpunit/data/languages/de_DE.mo.php b/tests/phpunit/data/languages/de_DE.mo.php new file mode 100644 index 0000000000000..7905f5905aec6 --- /dev/null +++ b/tests/phpunit/data/languages/de_DE.mo.php @@ -0,0 +1,2 @@ +'2019-03-28 19:42+0300','mime-version'=>'1.0','content-type'=>'text/plain; charset=UTF-8','content-transfer-encoding'=>'8bit','plural-forms'=>'nplurals=2; plural=n != 1;','x-generator'=>'Poedit 2.2.1','project-id-version'=>'Development (5.2.x)','language'=>'de_DE','pot-creation-date'=>'','last-translator'=>'','language-team'=>'','messages'=>['Update %s now'=>'Jetzt %s aktualisieren','[%1$s] Confirm Action: %2$s'=>'[%1$s] Aktion bestätigen: %2$s','[%s] Erasure Request Fulfilled'=>'[%s] Löschauftrag ausgeführt','[%s] Personal Data Export'=>'[%s] Export personenbezogener Daten','html_lang_attribute'=>'de-DE','number_format_decimal_point'=>',','number_format_thousands_sep'=>'.','text directionltr'=>'ltr']]; diff --git a/tests/phpunit/data/languages/es_ES.mo.php b/tests/phpunit/data/languages/es_ES.mo.php new file mode 100644 index 0000000000000..76bbb0878bb2a --- /dev/null +++ b/tests/phpunit/data/languages/es_ES.mo.php @@ -0,0 +1,2 @@ +'2020-07-23 21:12+0300','mime-version'=>'1.0','content-type'=>'text/plain; charset=UTF-8','content-transfer-encoding'=>'8bit','plural-forms'=>'nplurals=2; plural=n != 1;','x-generator'=>'Poedit 2.3','project-id-version'=>'Development (5.2.x)','language'=>'es_ES','last-translator'=>'','language-team'=>'','messages'=>['ERROR: Sorry, that username is not allowed.'=>'ERROR: Lo siento, ese nombre de usuario no está permitido.','Invalid parameter.'=>'Parámetro no válido. ','[%1$s] Confirm Action: %2$s'=>'[%1$s] Confirmar la acción: %2$s','[%s] Erasure Request Fulfilled'=>'[%s] Solicitud de borrado completada','[%s] Personal Data Export'=>'[%s] Exportación de datos personales','menu(Currently set to: %s)'=>'(Actualmente fijado en: %s)','menu location(Current: %s)'=>'(Actual: %s)','text directionltr'=>'ltr']];