diff options
Diffstat (limited to '')
-rw-r--r-- | wp-includes/l10n.php | 102 | ||||
-rw-r--r-- | wp-includes/l10n/class-wp-translation-controller.php | 437 | ||||
-rw-r--r-- | wp-includes/l10n/class-wp-translation-file-mo.php | 239 | ||||
-rw-r--r-- | wp-includes/l10n/class-wp-translation-file-php.php | 79 | ||||
-rw-r--r-- | wp-includes/l10n/class-wp-translation-file.php | 312 | ||||
-rw-r--r-- | wp-includes/l10n/class-wp-translations.php | 152 |
6 files changed, 1301 insertions, 20 deletions
diff --git a/wp-includes/l10n.php b/wp-includes/l10n.php index a085d44..38c30b8 100644 --- a/wp-includes/l10n.php +++ b/wp-includes/l10n.php @@ -150,6 +150,15 @@ function determine_locale() { ( isset( $_GET['_locale'] ) && 'user' === $_GET['_locale'] && wp_is_json_request() ) ) { $determined_locale = get_user_locale(); + } elseif ( + ( ! empty( $_REQUEST['language'] ) || isset( $GLOBALS['wp_local_package'] ) ) + && wp_installing() + ) { + if ( ! empty( $_REQUEST['language'] ) ) { + $determined_locale = sanitize_locale_name( $_REQUEST['language'] ); + } else { + $determined_locale = $GLOBALS['wp_local_package']; + } } if ( ! $determined_locale ) { @@ -161,7 +170,7 @@ function determine_locale() { * * @since 5.0.0 * - * @param string $locale The locale. + * @param string $determined_locale The locale. */ return apply_filters( 'determine_locale', $determined_locale ); } @@ -780,32 +789,71 @@ function load_textdomain( $domain, $mofile, $locale = null ) { */ $mofile = apply_filters( 'load_textdomain_mofile', $mofile, $domain ); - if ( ! is_readable( $mofile ) ) { - return false; - } - if ( ! $locale ) { $locale = determine_locale(); } - $mo = new MO(); - if ( ! $mo->import_from_file( $mofile ) ) { - $wp_textdomain_registry->set( $domain, $locale, false ); + $i18n_controller = WP_Translation_Controller::get_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(); + + if ( 'mo' !== $preferred_format ) { + $translation_files[] = substr_replace( $mofile, ".l10n.$preferred_format", - strlen( '.mo' ) ); } - unset( $l10n_unloaded[ $domain ] ); + $translation_files[] = $mofile; + + 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; + } + } + + return false; } /** @@ -857,6 +905,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::get_instance()->unload_textdomain( $domain ); + } + if ( isset( $l10n[ $domain ] ) ) { if ( $l10n[ $domain ] instanceof NOOP_Translations ) { unset( $l10n[ $domain ] ); @@ -895,7 +948,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 ); @@ -1382,25 +1435,34 @@ function translate_user_role( $name, $domain = 'default' ) { } /** - * Gets all available languages based on the presence of *.mo files in a given directory. + * Gets all available languages based on the presence of *.mo and *.l10n.php files in a given directory. * * The default directory is WP_LANG_DIR. * * @since 3.0.0 * @since 4.7.0 The results are now filterable with the {@see 'get_available_languages'} filter. + * @since 6.5.0 The initial file list is now cached and also takes into account *.l10n.php files. + * + * @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry. * * @param string $dir A directory to search for language files. * Default WP_LANG_DIR. * @return string[] An array of language codes or an empty array if no languages are present. - * Language codes are formed by stripping the .mo extension from the language file names. + * Language codes are formed by stripping the file extension from the language file names. */ function get_available_languages( $dir = null ) { + global $wp_textdomain_registry; + $languages = array(); - $lang_files = glob( ( is_null( $dir ) ? WP_LANG_DIR : $dir ) . '/*.mo' ); + $path = is_null( $dir ) ? WP_LANG_DIR : $dir; + $lang_files = $wp_textdomain_registry->get_language_files_from_path( $path ); + if ( $lang_files ) { foreach ( $lang_files as $lang_file ) { $lang_file = basename( $lang_file, '.mo' ); + $lang_file = basename( $lang_file, '.l10n.php' ); + if ( ! str_starts_with( $lang_file, 'continents-cities' ) && ! str_starts_with( $lang_file, 'ms-' ) && ! str_starts_with( $lang_file, 'admin-' ) ) { $languages[] = $lang_file; @@ -1416,7 +1478,7 @@ function get_available_languages( $dir = null ) { * @param string[] $languages An array of available language codes. * @param string $dir The directory where the language files were found. */ - return apply_filters( 'get_available_languages', $languages, $dir ); + return apply_filters( 'get_available_languages', array_unique( $languages ), $dir ); } /** diff --git a/wp-includes/l10n/class-wp-translation-controller.php b/wp-includes/l10n/class-wp-translation-controller.php new file mode 100644 index 0000000..b44384c --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-controller.php @@ -0,0 +1,437 @@ +<?php +/** + * I18N: WP_Translation_Controller class. + * + * @package WordPress + * @subpackage I18N + * @since 6.5.0 + */ + +/** + * Class WP_Translation_Controller. + * + * @since 6.5.0 + */ +final class WP_Translation_Controller { + /** + * Current locale. + * + * @since 6.5.0 + * @var string + */ + protected $current_locale = 'en_US'; + + /** + * Map of loaded translations per locale and text domain. + * + * [ Locale => [ Textdomain => [ ..., ... ] ] ] + * + * @since 6.5.0 + * @var array<string, array<string, WP_Translation_File[]>> + */ + protected $loaded_translations = array(); + + /** + * List of loaded translation files. + * + * [ Filename => [ Locale => [ Textdomain => WP_Translation_File ] ] ] + * + * @since 6.5.0 + * @var array<string, array<string, array<string, WP_Translation_File|false>>> + */ + protected $loaded_files = array(); + + /** + * Container for the main instance of the class. + * + * @since 6.5.0 + * @var WP_Translation_Controller|null + */ + private static $instance = null; + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.5.0 + * + * @return WP_Translation_Controller + */ + public static function get_instance(): WP_Translation_Controller { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$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 ) { + if ( isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) { + 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 ) { + if ( ! isset( $domains[ $textdomain ] ) ) { + continue; + } + + 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 { + $unloaded = false; + + if ( null !== $locale ) { + if ( isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) { + $unloaded = true; + foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $moe ) { + unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] ); + } + } + + unset( $this->loaded_translations[ $locale ][ $textdomain ] ); + + return $unloaded; + } + + 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<string, string> 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<string, string> 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/wp-includes/l10n/class-wp-translation-file-mo.php b/wp-includes/l10n/class-wp-translation-file-mo.php new file mode 100644 index 0000000..3f5e725 --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-file-mo.php @@ -0,0 +1,239 @@ +<?php +/** + * I18N: WP_Translation_File_MO class. + * + * @package WordPress + * @subpackage I18N + * @since 6.5.0 + */ + +/** + * Class WP_Translation_File_MO. + * + * @since 6.5.0 + */ +class WP_Translation_File_MO extends WP_Translation_File { + /** + * Endian value. + * + * V for little endian, N for big endian, or false. + * + * Used for unpack(). + * + * @since 6.5.0 + * @var false|'V'|'N' + */ + protected $uint32 = false; + + /** + * The magic number of the GNU message catalog format. + * + * @since 6.5.0 + * @var int + */ + const MAGIC_MARKER = 0x950412de; + + /** + * Detects endian and validates file. + * + * @since 6.5.0 + * + * @param string $header File contents. + * @return false|'V'|'N' V for little endian, N for big endian, or false on failure. + */ + protected function detect_endian_and_validate_file( string $header ) { + $big = unpack( 'N', $header ); + + if ( false === $big ) { + return false; + } + + $big = reset( $big ); + + if ( false === $big ) { + return false; + } + + $little = unpack( 'V', $header ); + + if ( false === $little ) { + return false; + } + + $little = reset( $little ); + + if ( false === $little ) { + return false; + } + + // Force cast to an integer as it can be a float on x86 systems. See https://core.trac.wordpress.org/ticket/60678. + if ( (int) self::MAGIC_MARKER === $big ) { + return 'N'; + } + + // Force cast to an integer as it can be a float on x86 systems. See https://core.trac.wordpress.org/ticket/60678. + if ( (int) self::MAGIC_MARKER === $little ) { + return 'V'; + } + + $this->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 { + /* + * In MO files, the key normally contains both singular and plural versions. + * However, this just adds the singular string for lookup, + * which caters for cases where both __( 'Product' ) and _n( 'Product', 'Products' ) + * are used and the translation is expected to be the same for both. + */ + $parts = explode( "\0", (string) $original ); + + $this->entries[ $parts[0] ] = $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 . '*', + // Force cast to an integer as it can be a float on x86 systems. See https://core.trac.wordpress.org/ticket/60678. + (int) 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/wp-includes/l10n/class-wp-translation-file-php.php b/wp-includes/l10n/class-wp-translation-file-php.php new file mode 100644 index 0000000..f93dd01 --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-file-php.php @@ -0,0 +1,79 @@ +<?php +/** + * I18N: WP_Translation_File_PHP class. + * + * @package WordPress + * @subpackage I18N + * @since 6.5.0 + */ + +/** + * Class WP_Translation_File_PHP. + * + * @since 6.5.0 + */ +class WP_Translation_File_PHP extends WP_Translation_File { + /** + * Parses the file. + * + * @since 6.5.0 + */ + protected function parse_file() { + $this->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 $original => $translation ) { + $this->entries[ (string) $original ] = $translation; + } + 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 '<?php' . PHP_EOL . 'return ' . $this->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/wp-includes/l10n/class-wp-translation-file.php b/wp-includes/l10n/class-wp-translation-file.php new file mode 100644 index 0000000..e550f9c --- /dev/null +++ b/wp-includes/l10n/class-wp-translation-file.php @@ -0,0 +1,312 @@ +<?php +/** + * I18N: WP_Translation_File class. + * + * @package WordPress + * @subpackage I18N + * @since 6.5.0 + */ + +/** + * Class WP_Translation_File. + * + * @since 6.5.0 + */ +abstract class WP_Translation_File { + /** + * List of headers. + * + * @since 6.5.0 + * @var array<string, string> + */ + 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<string, string> + */ + 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<string, string> Headers. + */ + public function headers(): array { + if ( ! $this->parsed ) { + $this->parse_file(); + } + return $this->headers; + } + + /** + * Returns all entries. + * + * @since 6.5.0 + * + * @return array<string, string[]> 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 given number. + * + * @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(); + } + + if ( null === $this->plural_forms && isset( $this->headers['plural-forms'] ) ) { + $expression = $this->get_plural_expression_from_header( $this->headers['plural-forms'] ); + $this->plural_forms = $this->make_plural_form_function( $expression ); + } + + 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 ); + } + + /** + * Returns the plural forms expression as a tuple. + * + * @since 6.5.0 + * + * @param string $header Plural-Forms header string. + * @return string Plural forms expression. + */ + protected function get_plural_expression_from_header( string $header ): string { + if ( preg_match( '/^\s*nplurals\s*=\s*(\d+)\s*;\s+plural\s*=\s*(.+)$/', $header, $matches ) ) { + return trim( $matches[2] ); + } + + return 'n != 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. + */ + protected 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/wp-includes/l10n/class-wp-translations.php b/wp-includes/l10n/class-wp-translations.php new file mode 100644 index 0000000..e177e1d --- /dev/null +++ b/wp-includes/l10n/class-wp-translations.php @@ -0,0 +1,152 @@ +<?php +/** + * I18N: WP_Translations class. + * + * @package WordPress + * @subpackage I18N + * @since 6.5.0 + */ + +/** + * Class WP_Translations. + * + * @since 6.5.0 + * + * @property-read array<string, string> $headers + * @property-read array<string, string[]> $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]; + } + + $entry->singular = $original; + $entry->translations = explode( "\0", $translations ); + $entry->is_plural = count( $entry->translations ) > 1; + + 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; + } +} |