├── .travis.yml ├── example-php-translation.php ├── ginger-mo.php ├── lib ├── class-ginger-mo-translation-compat.php ├── class-ginger-mo-translation-file-json.php ├── class-ginger-mo-translation-file-mo.php ├── class-ginger-mo-translation-file-php.php ├── class-ginger-mo-translation-file.php └── class-ginger-mo.php ├── phpunit.xml.dist ├── tests ├── Ginger_MO_Convert_Tests.php ├── Ginger_MO_TestCase.php ├── Ginger_MO_Tests.php ├── Testable_Ginger_MO_Translation_File.php ├── bootstrap.php └── data │ ├── example-simple-jed.json │ ├── example-simple-po2json.json │ ├── example-simple.json │ ├── example-simple.mo │ ├── example-simple.php │ └── example-simple.po └── tools └── convert-formats.php /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: php 3 | matrix: 4 | include: 5 | - php: hhvm 6 | env: PHP_MBSTRING_OVERLOAD=0 7 | - php: 7.0 8 | env: PHP_MBSTRING_OVERLOAD=0 9 | - php: 5.6 10 | env: PHP_MBSTRING_OVERLOAD=0 11 | - php: 5.6 12 | env: PHP_MBSTRING_OVERLOAD=7 13 | - php: 5.5 14 | env: PHP_MBSTRING_OVERLOAD=0 15 | - php: 5.4 16 | env: PHP_MBSTRING_OVERLOAD=0 17 | - php: 5.3 18 | env: PHP_MBSTRING_OVERLOAD=0 19 | - php: 5.2 20 | env: PHP_MBSTRING_OVERLOAD=0 21 | 22 | # php is configured directly, as phpunit doesn't allow mbstring.func_overload to pass through using -d 23 | before_script: 24 | - | 25 | if [[ "$PHP_MBSTRING_OVERLOAD" -ne "0" ]]; then 26 | echo "mbstring.func_overload=$PHP_MBSTRING_OVERLOAD" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 27 | fi 28 | 29 | script: 30 | - phpunit --coverage-clover=coverage.xml 31 | 32 | after_success: 33 | - bash <(curl -s https://codecov.io/bash) 34 | 35 | notifications: 36 | slack: 37 | secure: r6GZSQn1PhTQvkMNWHdl6cfnYyToxYd+0BWfASZLqhb4Sqhn+hiDACci8oSMSUsOw9vemP26ksHfnP8qtFg/zJIv3AeHEVWY7VJwdVJvGrQLRJin7I5EUE0cZu9RslL6876TcAudk2kLGlCUp9WTw93Nm25Iw6oY3aKCKUqRv8W9R8/EkwI4qPmMgwD5vYMHihX4E4QruT9+ASFFtSZUoXWoviwAzoU8QZt/fTu9mMYW1bYCnQHVOhnhMjcFZXkls7VGJ5GZinStEFbCsfMB+UYvRLBO1tbpr5yNjCeQRplN3TRbdD5+My5CzgRx8H+pv75RU1FmPepsNvew8E+9vkXN1R8cK26hBK70JJEHKuUkXgRagpYOXTUJlEBXYKijCetFVJiNPCLeuDJDTZNe6DNh/stUtbMJrxj2Q4iJ6DGTkGosXGjx4q7Hq+gVSqG343+/+QxwQxk0/v1qXrM2YcL5GxLnL2vUt21IdRlm3oIXe3Z3eyOc7rh9HX3oglSFkGubYL/njfnJJqsGpOTLJOw0wiTDs/1JCkeGlDzBbCub15d7//I4jPILJlHZAIiedQqb5okViTuD/SzH5FkuSoI8k6Wg5AdI7tgaIFChkn0aPv8TCNiVfd29PUzU//wYpPtxsH4JsUqlcpvkC/lsysFK5XfiitFGY3AZz+/EPZc= 38 | -------------------------------------------------------------------------------- /example-php-translation.php: -------------------------------------------------------------------------------- 1 | 'example_translation_plural_forms_24w3487639867k95836', 28 | 29 | /* 30 | * Standard PO headers can be added, although not needed or used. 31 | */ 32 | 'headers' => array( 33 | 'PO-Revision-Date' => '2016-01-05 18:45:32+1000', 34 | 'MIME-Version' => '1.0', 35 | 'Content-Type' => 'text/plain; charset=UTF-8', 36 | 'Content-Transfer-Encoding' => '8bit', 37 | 'X-Generator' => 'GlotPress/1.0-alpha-1100', 38 | 'Project-Id-Version' => 'Example Project', 39 | ), 40 | 41 | /* 42 | * Strings are stored as they are in a standard gettext .mo file. 43 | * Originals are the Array key, Translation the value. 44 | * - Singular strings are as-is 45 | * - Plural forms are separated by \0, or alternatively as a PHP array 46 | * - Context is before the original, with \4 following it 47 | */ 48 | 'entries' => array( 49 | "singular" => 'singular translation', 50 | "context\4singular" => 'singular translation with context', 51 | "plural0\0plural1" => "plural0 translation\0plural1 translation\0plural2 translation", 52 | "array0\0array1" => array( "array0 translation", "array1 translation", "array2 translation" ), 53 | "context\4plural0\0plural1" => "plural0 translation with context\0plural1 translation with context\0plural2 translation with context", 54 | ), 55 | ); 56 | -------------------------------------------------------------------------------- /ginger-mo.php: -------------------------------------------------------------------------------- 1 | load( __DIR__ . '/example-json-translation.json', 'testtextdomain' ); 19 | 20 | var_dump( Ginger_MO::instance()->translate( "singular", "context", 'testtextdomain') ); 21 | var_dump( Ginger_MO::instance()->translate_plural( array( "plural0", "plural1" ), 1, false, 'testtextdomain' ) ); 22 | 23 | die(); 24 | //*/ 25 | 26 | /* 27 | // PHP testing 28 | 29 | Ginger_MO::instance()->load( __DIR__ . '/example-php-translation.php', 'testtextdomain' ); 30 | Ginger_MO::instance()->load( __DIR__ . '/example-php-translation.php', 'testtextdomain' ); 31 | Ginger_MO::instance()->load( __DIR__ . '/example-php-translation.php', 'otherdomain' ); 32 | 33 | var_dump( Ginger_MO::instance()->translate( "singular", "context", 'testtextdomain') ); 34 | var_dump( Ginger_MO::instance()->translate_plural( array( "plural0", "plural1" ), 1, false, 'testtextdomain' ) ); 35 | var_dump( Ginger_MO::instance()->translate( "singular", "context", 'otherdomain') ); 36 | 37 | die(); 38 | //*/ 39 | 40 | /* 41 | 42 | add_action( 'init', function() { 43 | 44 | var_dump( Ginger_MO::instance() ); 45 | 46 | var_dump( __( '%1$s - Comments on %2$s', 'whoop' ) ); 47 | 48 | var_dump( Ginger_MO::instance() ); 49 | 50 | die(); 51 | 52 | } ); 53 | */ 54 | 55 | /* 56 | $ginger = new Ginger_MO(); $ginger->load( WP_LANG_DIR . '/continents-cities-fr_FR.mo' ); 57 | 58 | var_dump( $ginger->translate( $ginger->example_string ), $ginger ); 59 | 60 | 61 | // Want some crazy Plural forms? Try Russian. 62 | // "Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n%10==1 && n%100!=11) ? 3 : ((n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20)) ? 1 : 2);\n" 63 | 64 | 65 | $time = microtime(1); 66 | $ginger = new Ginger_MO( WP_LANG_DIR . '/admin-fr_FR.mo' ); 67 | var_dump( "Loading took " . (microtime(1)-$time) ); 68 | 69 | 70 | // Plurals! 71 | $plural = array(); 72 | foreach ( range( 0, 5 ) as $i ) { 73 | $plural[$i] = $ginger->translate_n( '%s aPost', '%s aPosts', $i ); 74 | } 75 | var_dump( $plural ); 76 | 77 | 78 | $time = microtime(1); 79 | $translations = array(); 80 | foreach ( [ "%s Post\0%s Posts" ] as $string ) { 81 | $translations[ $string ] = $ginger->translate( $string ); 82 | } 83 | var_dump( "Loading took " . (microtime(1)-$time) . ' loading ' . count ($ginger->entries) . ' translations' ); 84 | 85 | var_dump( $translations ); 86 | 87 | 88 | */ 89 | -------------------------------------------------------------------------------- /lib/class-ginger-mo-translation-compat.php: -------------------------------------------------------------------------------- 1 | is_loaded( $domain ); 7 | } 8 | 9 | public function offsetGet( $domain ) { 10 | return new Ginger_MO_Translation_Compat_Provider( $domain ); 11 | } 12 | 13 | public function offsetSet( $domain, $value ) { 14 | // Not supported 15 | return false; 16 | } 17 | 18 | public function offsetUnset( $domain ) { 19 | return Ginger_MO::instance()->unload( $domain ); 20 | } 21 | 22 | public function load_textdomain( $return, $domain, $mofile ) { 23 | do_action( 'load_textdomain', $domain, $mofile ); 24 | $mofile = apply_filters( 'load_textdomain_mofile', $mofile, $domain ); 25 | 26 | return Ginger_MO::instance()->load( $mofile, $domain ); 27 | } 28 | 29 | public function unload_textdomain( $return, $domain ) { 30 | do_action( 'unload_textdomain', $domain ); 31 | 32 | return Ginger_MO::instance()->unload( $domain ); 33 | } 34 | 35 | public static function overwrite_wordpress() { 36 | global $l10n; 37 | 38 | $l10n = new Ginger_MO_Translation_Compat(); 39 | 40 | add_filter( 'override_unload_textdomain', array( $l10n, 'unload_textdomain' ), 10, 2 ); 41 | add_filter( 'override_load_textdomain', array( $l10n, 'load_textdomain' ), 10, 3 ); 42 | } 43 | } 44 | 45 | class Ginger_MO_Translation_Compat_Provider { 46 | protected $textdomain = 'default'; 47 | 48 | public function __construct( $textdomain = 'default' ) { 49 | $this->textdomain = $textdomain; 50 | } 51 | 52 | public function translate_plural( $single, $plural, $number = 1, $context = '' ) { 53 | $translation = Ginger_MO::instance()->translate_plural( array( $single, $plural ), $number, $context, $this->textdomain ); 54 | if ( $translation ) { 55 | return $translation; 56 | } 57 | 58 | // Fall back to the original with English grammar rules. 59 | return ( $number == 1 ? $single : $plural ); 60 | } 61 | 62 | public function translate( $text, $context = '' ) { 63 | $translation = Ginger_MO::instance()->translate( $text, $context, $this->textdomain ); 64 | if ( $translation ) { 65 | return $translation; 66 | } 67 | 68 | // Fall back to the original. 69 | return $text; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/class-ginger-mo-translation-file-json.php: -------------------------------------------------------------------------------- 1 | file ); 6 | $data = json_decode( $data, true ); 7 | 8 | if ( ! $data || ! is_array( $data ) ) { 9 | $this->error = true; 10 | if ( function_exists( 'json_last_error_msg' ) ) { 11 | $this->error = 'JSON Error: ' . json_last_error_msg(); 12 | } elseif ( function_exists( 'json_last_error' ) ) { 13 | $this->error = 'JSON Error code: ' . (int) json_last_error(); 14 | } 15 | return; 16 | } 17 | 18 | // Support JED JSON files which wrap po2json 19 | if ( isset( $data['domain'] ) && isset( $data['locale_data'][ $data['domain'] ] ) ) { 20 | $data = $data['locale_data'][ $data['domain'] ]; 21 | } 22 | 23 | if ( isset( $data[''] ) ) { 24 | $this->headers = array_change_key_case( $data[''], CASE_LOWER ); 25 | unset( $data[''] ); 26 | } 27 | 28 | foreach ( $data as $key => $item ) { 29 | if ( ! is_array( $item ) ) { 30 | // Straight Key => Value translations 31 | $this->entries[ $key ] = $item; 32 | } else { 33 | if ( null === $item[0] ) { 34 | // Singular - po2json format 35 | $this->entries[ $key ] = $item[1]; 36 | } elseif ( false !== strpos( $key, "\0" ) ) { 37 | // Singular - Straight Key (plural\0plural) => [ plural, plural ] format 38 | $this->entries[ $key ] = $item; 39 | } else { 40 | // Plurals - po2json format ( plural0 => [ plural1, translation0, translation1 ] ) 41 | $key .= "\0" . $item[0]; 42 | $this->entries[ $key ] = array_slice( $item, 1 ); 43 | } 44 | } 45 | } 46 | 47 | $this->parsed = true; 48 | } 49 | 50 | protected function create_file( $headers, $entries ) { 51 | // json headers are lowercase 52 | $headers = array_change_key_case( $headers ); 53 | // Prefix as the first key. 54 | $entries = array_merge( array( '' => $headers ), $entries ); 55 | 56 | if ( defined( 'JSON_PRETTY_PRINT' ) ) { 57 | $json = json_encode( (array) $entries, JSON_PRETTY_PRINT ); 58 | } else { 59 | $json = json_encode( (array) $entries ); 60 | } 61 | 62 | return (bool) file_put_contents( $this->file, $json ); 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /lib/class-ginger-mo-translation-file-mo.php: -------------------------------------------------------------------------------- 1 | use_mb_functions = function_exists('mb_substr') && ( (ini_get( 'mbstring.func_overload' ) & 2) != 0 ); 12 | } 13 | 14 | protected function detect_endian_and_validate_file( $header ) { 15 | $big = unpack( 'N', $header ); 16 | $big = reset( $big ); 17 | $little = unpack( 'V', $header ); 18 | $little = reset( $little ); 19 | 20 | if ( $big === self::MAGIC_MARKER ) { 21 | return 'N'; 22 | } elseif ( $little === self::MAGIC_MARKER ) { 23 | return 'V'; 24 | } else { 25 | $this->error = "Magic Marker doesn't exist"; 26 | return false; 27 | } 28 | } 29 | 30 | protected function parse_file() { 31 | $this->parsed = true; 32 | 33 | $file_contents = file_get_contents( $this->file ); 34 | $file_length = $this->strlen( $file_contents ); 35 | 36 | if ( $file_length < 24 ) { 37 | $this->error = 'Invalid Data.'; 38 | return false; 39 | } 40 | 41 | $this->uint32 = $this->detect_endian_and_validate_file( $this->substr( $file_contents, 0, 4 ) ); 42 | if ( ! $this->uint32 ) { 43 | return false; 44 | } 45 | 46 | $offsets = $this->substr( $file_contents, 4, 24 ); 47 | if ( ! $offsets ) { 48 | return false; 49 | } 50 | 51 | $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 ); 52 | 53 | $offsets['originals_length'] = $offsets['translations_addr'] - $offsets['originals_addr']; 54 | $offsets['translations_length'] = $offsets['hash_addr'] - $offsets['translations_addr']; 55 | 56 | if ( $offsets['rev'] > 0 ) { 57 | $this->error = 'Unsupported Revision.'; 58 | return false; 59 | } 60 | 61 | if ( $offsets['translations_addr'] > $file_length || $offsets['originals_addr'] > $file_length ) { 62 | $this->error = 'Invalid Data.'; 63 | return false; 64 | } 65 | 66 | // Load the Originals 67 | $original_data = str_split( $this->substr( $file_contents, $offsets['originals_addr'], $offsets['originals_length'] ), 8 ); 68 | $translations_data = str_split( $this->substr( $file_contents, $offsets['translations_addr'], $offsets['translations_length'] ), 8 ); 69 | 70 | foreach ( array_keys( $original_data ) as $i ) { 71 | $o = unpack( "{$this->uint32}length/{$this->uint32}pos", $original_data[ $i ] ); 72 | $t = unpack( "{$this->uint32}length/{$this->uint32}pos", $translations_data[ $i ] ); 73 | 74 | $original = $this->substr( $file_contents, $o['pos'], $o['length'] ); 75 | $translation = $this->substr( $file_contents, $t['pos'], $t['length'] ); 76 | $translation = rtrim( $translation, "\0" ); // GlotPress bug 77 | 78 | // Metadata about the MO file is stored in the first translation entry. 79 | if ( '' === $original ) { 80 | foreach ( explode( "\n", $translation ) as $meta_line ) { 81 | if ( ! $meta_line ) continue; 82 | list( $name, $value ) = array_map( 'trim', explode( ':', $meta_line, 2 ) ); 83 | $this->headers[ strtolower( $name ) ] = $value; 84 | } 85 | } else { 86 | $this->entries[ $original ] = $translation; 87 | } 88 | } 89 | 90 | return true; 91 | } 92 | 93 | protected function create_file( $headers, $entries ) { 94 | // Prefix the headers as the first key. 95 | $headers_string = ''; 96 | foreach ( $headers as $header => $value ) { 97 | $headers_string .= "{$header}: $value\n"; 98 | } 99 | $entries = array_merge( array( '' => $headers_string ), $entries ); 100 | $entry_count = count( $entries ); 101 | 102 | // Flatten any plurals into a combined string 103 | foreach ( $entries as $i => $entry ) { 104 | if ( is_array( $entry ) ) { 105 | $entries[ $i ] = implode( "\0", $entry ); 106 | } 107 | } 108 | 109 | if ( ! $this->uint32 ) { 110 | $this->uint32 = 'V'; 111 | } 112 | 113 | $bytes_for_entries = $entry_count * 4 * 2; // Pair of 32bit ints per entry. 114 | $originals_addr = 28 /* header */; 115 | $translations_addr = $originals_addr + $bytes_for_entries; 116 | $hash_addr = $translations_addr + $bytes_for_entries; 117 | $entry_offsets = $hash_addr; 118 | 119 | $file_header = pack( $this->uint32 . '*', self::MAGIC_MARKER, 0 /* rev */, $entry_count, $originals_addr, $translations_addr, 0 /* hash_length */, $hash_addr ); 120 | 121 | $o_entries = $t_entries = $o_addr = $t_addr = ''; 122 | foreach ( $entries as $original => $translations ) { 123 | $o_addr .= pack( $this->uint32 . '*', $this->strlen( $original ), $entry_offsets ); 124 | $entry_offsets += $this->strlen( $original ) + 1; 125 | $o_entries .= $original . pack('x'); 126 | } 127 | 128 | foreach ( $entries as $original => $translations ) { 129 | $t_addr .= pack( $this->uint32 . '*', $this->strlen( $translations ), $entry_offsets ); 130 | $entry_offsets += $this->strlen( $translations ) + 1; 131 | $t_entries .= $translations . pack('x'); 132 | } 133 | 134 | return (bool) file_put_contents( $this->file, $file_header . $o_addr . $t_addr . $o_entries . $t_entries ); 135 | } 136 | 137 | /** 138 | * Helper method for when `mbstring.func_overload` is in force. 139 | * 140 | * @ignore 141 | */ 142 | protected function substr( $string, $from, $bytes ) { 143 | if ( $this->use_mb_functions ) { 144 | return mb_substr( $string, $from, $bytes, '8bit' ); 145 | } else { 146 | return substr( $string, $from, $bytes ); 147 | } 148 | } 149 | 150 | /** 151 | * Helper method for when `mbstring.func_overload` is in force. 152 | * 153 | * @ignore 154 | */ 155 | protected function strlen( $string ) { 156 | if ( $this->use_mb_functions ) { 157 | return mb_strlen( $string, '8bit' ); 158 | } else { 159 | return strlen( $string ); 160 | } 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /lib/class-ginger-mo-translation-file-php.php: -------------------------------------------------------------------------------- 1 | file ); 6 | if ( ! $result || ! is_array( $result ) ) { 7 | $this->error = true; 8 | return; 9 | } 10 | 11 | foreach ( array( 'headers', 'entries', 'plural_form_function' ) as $field ) { 12 | if ( isset( $result[ $field ] ) ) { 13 | $this->$field = $result[ $field ]; 14 | } 15 | } 16 | 17 | $this->headers = array_change_key_case( $this->headers, CASE_LOWER ); 18 | $this->parsed = true; 19 | } 20 | 21 | protected function create_file( $headers, $entries ) { 22 | $file_contents = '<' . "?php\n"; 23 | if ( ! empty( $headers['x-converter'] ) ) { 24 | $file_contents .= "// {$headers['x-converter']}.\n"; 25 | } 26 | 27 | $plural_func = false; 28 | if ( isset( $headers['plural-forms'] ) ) { 29 | $plural_func_contents = $this->generate_plural_forms_function_content( $headers['plural-forms'] ); 30 | if ( $plural_func_contents ) { 31 | $plural_form_function = 'plural_forms_' . preg_replace( '![^0-9a-z_]!i', '_', basename( $this->file ) ) . '_' . sha1( uniqid( rand(), true ) ); 32 | $plural_func = "if ( ! function_exists( '{$plural_form_function}' ) ) { function {$plural_form_function}( \$n ) { $plural_func_contents } }"; 33 | } 34 | } 35 | 36 | if ( $plural_func ) { 37 | $file_contents .= $plural_func . "\n"; 38 | } 39 | $file_contents .= 'return ' . var_export( compact( 'plural_form_function', 'headers', 'entries' ), true ) . ';'; 40 | 41 | return (bool) file_put_contents( $this->file, $file_contents ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/class-ginger-mo-translation-file.php: -------------------------------------------------------------------------------- 1 | "Translation" ] 9 | 10 | protected $plural_form_function = ''; 11 | 12 | protected function __construct( $file, $context = 'read' ) { 13 | $this->file = $file; 14 | 15 | if ( 'write' == $context ) { 16 | if ( file_exists( $file ) ) { 17 | $this->error = is_writable( $file ) ? false : "File is not writable"; 18 | } elseif ( ! is_writable( dirname( $file ) ) ) { 19 | $this->error = "Directory not writable"; 20 | } 21 | } elseif ( ! is_readable( $file ) ) { 22 | $this->error = "File not readable"; 23 | } 24 | } 25 | 26 | public static function create( $file, $context = 'read', $filetype = null ) { 27 | if ( ! $filetype ) { 28 | $filetype = substr( $file, strrpos( $file, '.' )+1 ); 29 | } 30 | 31 | switch ( $filetype ) { 32 | case 'mo': 33 | if ( ! class_exists( 'Ginger_MO_Translation_File_MO' ) ) { 34 | include dirname(__FILE__) . '/class-ginger-mo-translation-file-mo.php'; 35 | } 36 | $moe = new Ginger_MO_Translation_File_MO( $file, $context ); 37 | break; 38 | case 'php': 39 | if ( ! class_exists( 'Ginger_MO_Translation_File_PHP' ) ) { 40 | include dirname(__FILE__) . '/class-ginger-mo-translation-file-php.php'; 41 | } 42 | $moe = new Ginger_MO_Translation_File_PHP( $file, $context ); 43 | break; 44 | case 'json': 45 | if ( ! class_exists( 'Ginger_MO_Translation_File_JSON' ) ) { 46 | include dirname(__FILE__) . '/class-ginger-mo-translation-file-json.php'; 47 | } 48 | $moe = new Ginger_MO_Translation_File_JSON( $file, $context ); 49 | break; 50 | default: 51 | $moe = false; 52 | } 53 | 54 | return $moe; 55 | } 56 | 57 | public function headers() { 58 | if ( ! $this->parsed ) { 59 | $this->parse_file(); 60 | } 61 | return $this->headers; 62 | } 63 | 64 | public function entries() { 65 | if ( ! $this->parsed ) { 66 | $this->parse_file(); 67 | } 68 | return $this->entries; 69 | } 70 | 71 | public function error() { 72 | return $this->error; 73 | } 74 | 75 | public function get_file() { 76 | return $this->file; 77 | } 78 | 79 | public function translate( $string ) { 80 | if ( ! $this->parsed ) { 81 | $this->parse_file(); 82 | } 83 | 84 | return isset( $this->entries[ $string ] ) ? $this->entries[ $string ] : false; 85 | } 86 | 87 | public function get_plural_form( $number ) { 88 | if ( ! $this->parsed ) { 89 | $this->parse_file(); 90 | } 91 | 92 | // Incase a plural form is specified as a header, but no function included, build one. 93 | if ( ! $this->plural_form_function && isset( $this->headers['plural-forms'] ) ) { 94 | $this->plural_form_function = $this->generate_plural_forms_function( $this->headers['plural-forms'] ); 95 | } 96 | 97 | if ( $this->plural_form_function && is_callable( $this->plural_form_function ) ) { 98 | return call_user_func( $this->plural_form_function, $number ); 99 | } 100 | 101 | // Default plural form matches English, only "One" is considered singular. 102 | return ( $number == 1 ? 0 : 1 ); 103 | } 104 | 105 | public function export( Ginger_MO_Translation_File $destination ) { 106 | if ( $destination->error() ) { 107 | return false; 108 | } 109 | 110 | if ( ! $this->parsed ) { 111 | $this->parse_file(); 112 | } 113 | 114 | $headers = $this->headers; 115 | $headers['x-converter'] = 'Generated by Ginger-MO from ' . basename( $this->file ) . ' on ' . date('r'); 116 | 117 | $destination->create_file( $headers, $this->entries ); 118 | $this->error = $destination->error(); 119 | 120 | return ! $this->error; 121 | } 122 | 123 | protected function generate_plural_forms_function( $plural_form ) { 124 | $plural_func_contents = $this->generate_plural_forms_function_content( $plural_form ); 125 | if ( ! $plural_func_contents ) { 126 | return false; 127 | } 128 | 129 | return create_function( '$n', $plural_func_contents ); 130 | } 131 | 132 | protected function generate_plural_forms_function_content( $plural_form ) { 133 | $plural_func_contents = false; 134 | // Validate that the plural form function is legit 135 | // This should/could use a more strict plural matching (such as validating it's a valid expression) 136 | if ( $plural_form && preg_match( '#^nplurals=(\d+);\s*plural=([n>add_parenthese_to_plural_exression( $match[2] ) ); 138 | $plural_func_contents = "return (int)($nexpression);"; 139 | } 140 | return $plural_func_contents; 141 | } 142 | 143 | /** 144 | * Adds parentheses to the inner parts of ternary operators in 145 | * plural expressions, because PHP evaluates ternary oerators from left to right 146 | * 147 | * Borrowed from WordPress POMO 148 | * 149 | * @param string $expression the expression without parentheses 150 | * @return string the expression with parentheses added 151 | */ 152 | protected function add_parenthese_to_plural_exression( $expression ) { 153 | $expression .= ';'; 154 | $res = ''; 155 | $depth = 0; 156 | for ( $i = 0; $i < strlen( $expression ); $i++ ) { 157 | $char = substr( $expression, $i, 1 ); 158 | switch ( $char ) { 159 | case '?': 160 | $res .= ' ? ('; 161 | $depth++; 162 | break; 163 | case ':': 164 | $res .= ') : ('; 165 | break; 166 | case ';': 167 | $res .= ' ' . str_repeat(')', $depth) . ';'; 168 | $depth = 0; 169 | break; 170 | default: 171 | $res .= $char; 172 | } 173 | } 174 | 175 | return rtrim( $res, ';' ); 176 | } 177 | 178 | protected function parse_file() {} 179 | protected function create_file( $headers, $entries ) { 180 | $this->error = "Format not supported."; 181 | return false; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /lib/class-ginger-mo.php: -------------------------------------------------------------------------------- 1 | [ $object1, $object2 ] ] 6 | protected $loaded_files = array(); // [ /path/to/file.mo => $object ] 7 | 8 | public static function instance() { 9 | static $instance = false; 10 | return $instance ? $instance : $instance = new Ginger_MO(); 11 | } 12 | 13 | public function load( $translation_file, $textdomain = null ) { 14 | if ( ! class_exists( 'Ginger_MO_Translation_File' ) ) { 15 | include dirname( __FILE__ ) . '/class-ginger-mo-translation-file.php'; 16 | } 17 | 18 | if ( ! $textdomain ) { 19 | $textdomain = $this->default_textdomain; 20 | } 21 | 22 | $translation_file = realpath( $translation_file ); 23 | if ( !empty( $this->loaded_files[ $translation_file ][ $textdomain ] ) ) { 24 | if ( $this->loaded_files[ $translation_file ][ $textdomain ] && ! $this->loaded_files[ $translation_file ][ $textdomain ]->error() ) { 25 | return true; 26 | } 27 | return false; 28 | } 29 | 30 | if ( !empty( $this->loaded_files[ $translation_file ] ) ) { 31 | $moe = reset( $this->loaded_files[ $translation_file ] ); 32 | } else { 33 | $moe = Ginger_MO_Translation_File::create( $translation_file ); 34 | if ( ! $moe || $moe->error() ) { 35 | $moe = false; 36 | } 37 | } 38 | $this->loaded_files[ $translation_file ][ $textdomain ] = $moe; 39 | 40 | if ( ! $moe ) { 41 | return false; 42 | } 43 | 44 | if ( ! isset( $this->loaded_translations[ $textdomain ] ) ) { 45 | $this->loaded_translations[ $textdomain ] = array(); 46 | } 47 | 48 | // Prefix translations to ensure that last-loaded takes preference 49 | array_unshift( $this->loaded_translations[ $textdomain ], $moe ); 50 | 51 | return true; 52 | } 53 | 54 | public function unload( $textdomain, $mo = null ) { 55 | if ( ! $this->is_loaded( $textdomain ) ) { 56 | return false; 57 | } 58 | 59 | if ( $mo ) { 60 | foreach ( $this->loaded_translations[ $textdomain ] as $i => $moe ) { 61 | if ( $mo === $moe ) { 62 | unset( $this->loaded_translations[ $textdomain ][ $i ] ); 63 | unset( $this->loaded_files[ $moe->get_file() ][ $textdomain ] ); 64 | return true; 65 | } 66 | } 67 | return true; 68 | } 69 | 70 | foreach ( $this->loaded_translations[ $textdomain ] as $moe ) { 71 | unset( $this->loaded_files[ $moe->get_file() ][ $textdomain ] ); 72 | } 73 | 74 | unset( $this->loaded_translations[ $textdomain ] ); 75 | 76 | return true; 77 | } 78 | 79 | public function is_loaded( $textdomain ) { 80 | return !empty( $this->loaded_translations[ $textdomain ] ); 81 | } 82 | 83 | public function translate( $text, $context = null, $textdomain = null ) { 84 | if ( $context ) { 85 | $context .= "\4"; 86 | } 87 | 88 | $translation = $this->locate_translation( "{$context}{$text}", $textdomain ); 89 | 90 | if ( ! $translation ) { 91 | return false; 92 | } 93 | 94 | return $translation['entries']; 95 | } 96 | 97 | public function translate_plural( $plurals, $number, $context = null, $textdomain = null ) { 98 | if ( $context ) { 99 | $context .= "\4"; 100 | } 101 | $text = implode( "\0", $plurals ); 102 | $translation = $this->locate_translation( "{$context}{$text}", $textdomain ); 103 | 104 | if ( ! $translation ) { 105 | return false; 106 | } 107 | 108 | $t = is_array( $translation['entries'] ) ? $translation['entries'] : explode( "\0", $translation['entries'] ); 109 | $num = $translation['source']->get_plural_form( $number ); 110 | return $t[ $num ]; 111 | } 112 | 113 | protected function locate_translation( $string, $textdomain = null ) { 114 | if ( ! $this->loaded_translations ) { 115 | return false; 116 | } 117 | if ( ! $textdomain ) { 118 | $textdomain = $this->default_textdomain; 119 | } 120 | 121 | // Find the translation in all loaded files for this text domain 122 | foreach ( $this->get_mo_files( $textdomain ) as $moe ) { 123 | if ( false !== ( $translation = $moe->translate( $string ) ) ) { 124 | return array( 125 | 'entries' => $translation, 126 | 'source' => $moe 127 | ); 128 | } 129 | if ( $moe->error() ) { 130 | // Unload this file, something is wrong. 131 | $this->unload( $textdomain, $moe ); 132 | } 133 | } 134 | 135 | // Nothing could be found 136 | return false; 137 | } 138 | 139 | protected function get_mo_files( $textdomain = null ) { 140 | $moes = array(); 141 | if ( isset( $this->loaded_translations[ $textdomain ] ) ) { 142 | $moes = $this->loaded_translations[ $textdomain ]; 143 | } 144 | return $moes; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/Ginger_MO_Convert_Tests.php: -------------------------------------------------------------------------------- 1 | temp_file(); 10 | $source = Ginger_MO_Translation_File::create( $source_file, 'read' ); 11 | $destination = Ginger_MO_Translation_File::create( $destination_file, 'write', $destination_format ); 12 | 13 | $this->assertFalse( $source->error() ); 14 | $this->assertFalse( $destination->error() ); 15 | 16 | $this->assertTrue( $source->export( $destination ) ); 17 | 18 | $this->assertFalse( $source->error() ); 19 | $this->assertFalse( $destination->error() ); 20 | 21 | $this->assertTrue( filesize( $destination_file ) > 0 ); 22 | 23 | $destination_read = Ginger_MO_Translation_File::create( $destination_file, 'read', $destination_format ); 24 | 25 | $this->assertFalse( $destination_read->error() ); 26 | 27 | $source_headers = $source->headers(); 28 | $destination_headers = $destination_read->headers(); 29 | unset( $destination_headers['x-converter'] ); // We add this. 30 | 31 | $this->assertEquals( $source_headers, $destination_headers ); 32 | 33 | foreach ( $source->entries() as $original => $translation ) { 34 | // Verify the translation is in the destination file 35 | if ( false !== strpos( $original, "\0" ) ) { 36 | // Plurals: 37 | $translation = is_array( $translation ) ? implode( "\0", $translation ) : $translation; 38 | $new_translation = $destination_read->translate( $original ); 39 | $new_translation = is_array( $new_translation ) ? implode( "\0", $new_translation ) : $new_translation; 40 | 41 | $this->assertSame( $translation, $new_translation ); 42 | 43 | } else { 44 | // Single 45 | $new_translation = $destination_read->translate( $original ); 46 | 47 | $this->assertSame( $translation, $new_translation ); 48 | } 49 | } 50 | 51 | } 52 | 53 | function dataprovider_export_matrix() { 54 | $sources = array( 55 | 'example-simple.json', 56 | 'example-simple-jed.json', 57 | 'example-simple-po2json.json', 58 | 'example-simple.mo', 59 | 'example-simple.php' 60 | ); 61 | $outputs = array( 'mo', 'json', 'php' ); 62 | 63 | $matrix = array(); 64 | foreach ( $sources as $s ) { 65 | foreach ( $outputs as $output_format ) { 66 | $matrix[] = array( GINGER_MO_TEST_DATA . $s, $output_format ); 67 | } 68 | } 69 | 70 | return $matrix; 71 | } 72 | } -------------------------------------------------------------------------------- /tests/Ginger_MO_TestCase.php: -------------------------------------------------------------------------------- 1 | temp_files[] = $file; 16 | return $file; 17 | } 18 | 19 | function __destruct() { 20 | foreach ( $this->temp_files as $file ) { 21 | unlink( $file ); 22 | } 23 | $this->temp_files = array(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Ginger_MO_Tests.php: -------------------------------------------------------------------------------- 1 | assertFalse( $instance->translate( "singular" ) ); 7 | $this->assertFalse( $instance->translate_plural( array( "plural0", "plural1" ), 1 ) ); 8 | } 9 | 10 | function test_unload_entire_textdomain() { 11 | $instance = new Ginger_MO; 12 | $this->assertFalse( $instance->is_loaded( 'unittest' ) ); 13 | $this->assertTrue( $instance->load( GINGER_MO_TEST_DATA . 'example-simple.php', 'unittest' ) ); 14 | $this->assertTrue( $instance->is_loaded( 'unittest' ) ); 15 | 16 | $this->assertSame( 'translation', $instance->translate( 'original', null, 'unittest' ) ); 17 | 18 | $this->assertTrue( $instance->unload( 'unittest' ) ); 19 | $this->assertFalse( $instance->is_loaded( 'unittest' ) ); 20 | $this->assertFalse( $instance->translate( 'original', null, 'unittest' ) ); 21 | } 22 | 23 | /** 24 | * @dataProvider dataprovider_invalid_files 25 | */ 26 | function test_invalid_files( $type, $file_contents, $expected_error = null ) { 27 | $file = $this->temp_file( $file_contents ); 28 | 29 | $instance = Ginger_MO_Translation_File::create( $file, 'read', $type ); 30 | 31 | // Not an error condition until it attempts to parse the file. 32 | $this->assertFalse( $instance->error() ); 33 | 34 | // Trigger parsing. 35 | $instance->headers(); 36 | 37 | $this->assertNotFalse( $instance->error() ); 38 | 39 | if ( $expected_error ) { 40 | $this->assertSame( $expected_error, $instance->error() ); 41 | } 42 | } 43 | 44 | function dataprovider_invalid_files() { 45 | return array( 46 | // filetype, file ( contents ) [, expected error string ] 47 | array( 'php', '' ), 48 | array( 'php', 'assertFalse( $instance->load( GINGER_MO_TEST_DATA . 'file-that-doesnt-exist.mo', 'unittest' ) ); 63 | $this->assertFalse( $instance->is_loaded( 'unittest' ) ); 64 | } 65 | 66 | /** 67 | * @dataProvider dataprovider_simple_example_files 68 | */ 69 | function test_simple_translation_files( $file ) { 70 | $ginger_mo = new Ginger_MO; 71 | $this->assertTrue( $ginger_mo->load( GINGER_MO_TEST_DATA . $file, 'unittest' ) ); 72 | 73 | $this->assertTrue( $ginger_mo->is_loaded( 'unittest' ) ); 74 | $this->assertFalse( $ginger_mo->is_loaded( 'textdomain not loaded' ) ); 75 | 76 | $this->assertFalse( $ginger_mo->translate( "string that doesn't exist", null, 'unittest' ) ); 77 | $this->assertFalse( $ginger_mo->translate( 'original', null, 'textdomain not loaded' ) ); 78 | 79 | $this->assertSame( 'translation', $ginger_mo->translate( 'original', null, 'unittest' ) ); 80 | $this->assertSame( 'translation with context', $ginger_mo->translate( 'original with context', 'context', 'unittest' ) ); 81 | 82 | $this->assertSame( 'translation1', $ginger_mo->translate_plural( array( 'plural0', 'plural1' ), 0, null, 'unittest' ) ); 83 | $this->assertSame( 'translation0', $ginger_mo->translate_plural( array( 'plural0', 'plural1' ), 1, null, 'unittest' ) ); 84 | $this->assertSame( 'translation1', $ginger_mo->translate_plural( array( 'plural0', 'plural1' ), 2, null, 'unittest' ) ); 85 | 86 | $this->assertSame( 'translation1 with context', $ginger_mo->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 0, 'context', 'unittest' ) ); 87 | $this->assertSame( 'translation0 with context', $ginger_mo->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 1, 'context', 'unittest' ) ); 88 | $this->assertSame( 'translation1 with context', $ginger_mo->translate_plural( array( 'plural0 with context', 'plural1 with context' ), 2, 'context', 'unittest' ) ); 89 | } 90 | 91 | function dataprovider_simple_example_files() { 92 | return array( 93 | array( 'example-simple.json' ), 94 | array( 'example-simple-jed.json' ), 95 | array( 'example-simple-po2json.json' ), 96 | array( 'example-simple.mo' ), 97 | array( 'example-simple.php' ), 98 | ); 99 | } 100 | 101 | /** 102 | * @dataProvider plural_form_function_pairs 103 | */ 104 | function test_plural_form_functions( $plural_form, $values ) { 105 | $instance = Testable_Ginger_MO_Translation_File::get_testable_instance(); 106 | $plural_func = $instance->generate_plural_forms_function( $plural_form ); 107 | $this->assertTrue( is_callable( $plural_func ) ); 108 | 109 | foreach ( $values as $number => $expected ) { 110 | $form = $plural_func( $number ); 111 | $this->assertSame( $expected, $form, print_r( compact( 'number', 'expected', 'form' ), true ) ); 112 | } 113 | 114 | } 115 | 116 | function plural_form_function_pairs() { 117 | return array( 118 | // Bulgarian, etc. 119 | array( 'nplurals=2; plural=n != 1', array( 120 | 0 => 1, 121 | 1 => 0, 122 | 2 => 1, 123 | 3 => 1, 124 | 10 => 1, 125 | 11 => 1 126 | ) ), 127 | // Japanese 128 | array( 'nplurals=2; plural=0', array( 129 | 0 => 0, 130 | 1 => 0, 131 | 2 => 0, 132 | 3 => 0, 133 | 10 => 0, 134 | 11 => 0 135 | ) ), 136 | // French 137 | array( 'nplurals=2; plural=n > 1', array( 138 | 0 => 0, 139 | 1 => 0, 140 | 2 => 1, 141 | 3 => 1, 142 | 10 => 1, 143 | 11 => 1 144 | ) ), 145 | /* 146 | * Arabic: http://www.arabeyes.org/Plural_Forms 147 | * 0: First form: for 0 148 | * 1: Second form: for 1 149 | * 2: Third form: for 2 150 | * 3: Fourth form: for numbers that end with a number between 3 and 10 (like: 103, 1405, 23409). 151 | * 4: Fifth form: for numbers that end with a number between 11 and 99 (like: 1099, 278). 152 | * 5: Sixth form: for numbers above 100 ending with 0, 1 or 2 (like: 100, 232, 3001) 153 | */ 154 | array( 'nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);', array( 155 | 0 => 0, 156 | 1 => 1, 157 | 2 => 2, 158 | 3 => 3, 159 | 103 => 3, 160 | 1405 => 3, 161 | 23409 => 3, 162 | 11 => 4, 163 | 12 => 4, 164 | 98 => 4, 165 | 99 => 4, 166 | 111 => 4, 167 | 132 => 4, 168 | 100 => 5, 169 | 101 => 5, 170 | 102 => 5, 171 | // 232 => 5, // This seems broken, according to the plural form function, this should be form 4. 172 | 3001 => 5, 173 | ) ), 174 | // Slovenian 175 | array( 'nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);', array( 176 | 0 => 3, 177 | 1 => 0, 178 | 2 => 1, 179 | 3 => 2, 180 | 4 => 2, 181 | 5 => 3, 182 | 99 => 3, 183 | 100 => 3, 184 | 101 => 0, 185 | 102 => 1, 186 | 103 => 2, 187 | 104 => 2, 188 | 1405 => 3, 189 | 23409 => 3, 190 | ) ), 191 | // Icelandic 192 | array( 'nplurals=2; plural=(n % 100 != 1 && n % 100 != 21 && n % 100 != 31 && n % 100 != 41 && n % 100 != 51 && n % 100 != 61 && n % 100 != 71 && n % 100 != 81 && n % 100 != 91);', array( 193 | 0 => 1, 194 | 1 => 0, 195 | 2 => 1, 196 | 99 => 1, 197 | 100 => 1, 198 | 101 => 0, 199 | 102 => 1, 200 | 121 => 0, 201 | 190 => 1, 202 | 191 => 0, 203 | 192 => 1, 204 | ) ), 205 | /* 206 | * Scottish Gaelic 207 | * 0: Form 1 is for 1, 11 208 | * 1: Form 2 is for 2, 12 209 | * 2: Form 3 is for 3-10, 13-19 210 | * 3: Form 4 is everything else: 20+ 211 | */ 212 | array( 'nplurals=4; plural=(n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3;', array( 213 | 0 => 3, 214 | 1 => 0, 215 | 2 => 1, 216 | 3 => 2, 217 | 5 => 2, 218 | 10 => 2, 219 | 11 => 0, 220 | 12 => 1, 221 | 21 => 3, 222 | 22 => 3, 223 | 31 => 3, 224 | 32 => 3, 225 | ) ), 226 | ); 227 | /* 228 | * Plural forms from GlotPress which aren't included here yet. 229 | * (n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2) 230 | * (n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2) 231 | * (n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) 232 | * (n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2) 233 | * (n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) 234 | * n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2 235 | * (n==1 ? 0 : n%10>=2 && n%10<=4 && n%100==20 ? 1 : 2) 236 | * n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4 237 | * (n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3 238 | * (n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2 239 | * n==1 || n%10==1 ? 0 : 1 240 | * (n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3 241 | */ 242 | } 243 | 244 | } -------------------------------------------------------------------------------- /tests/Testable_Ginger_MO_Translation_File.php: -------------------------------------------------------------------------------- 1 | 4 | array ( 5 | 'original' => 'translation', 6 | 'contextoriginal with context' => 'translation with context', 7 | 'plural0' . "\0" . 'plural1' => array( 'translation0', 'translation1' ), 8 | 'contextplural0 with context' . "\0" . 'plural1 with context' => 'translation0 with context' . "\0" . 'translation1 with context', 9 | ), 10 | ); 11 | -------------------------------------------------------------------------------- /tests/data/example-simple.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2016-01-05 18:45:32+1000\n" 4 | 5 | msgid "original" 6 | msgstr "translation" 7 | 8 | msgctxt "context" 9 | msgid "original with context" 10 | msgstr "translation with context" 11 | 12 | msgid "plural0" 13 | msgid_plural "plural1" 14 | msgstr[0] "translation0" 15 | msgstr[1] "translation1" 16 | 17 | msgctxt "context" 18 | msgid "plural0 with context" 19 | msgid_plural "plural1 with context" 20 | msgstr[0] "translation0 with context" 21 | msgstr[1] "translation1 with context" 22 | 23 | 24 | -------------------------------------------------------------------------------- /tools/convert-formats.php: -------------------------------------------------------------------------------- 1 | error() ) { 27 | echo "Error: Source is unreadable\n"; 28 | exit(1); 29 | } elseif ( ! $destination || $destination->error() ) { 30 | echo "Error: Destination is unwritable\n"; 31 | exit(1); 32 | } 33 | 34 | if ( ! $source->export( $destination ) ) { 35 | echo "Error Converting file: " . $source->error() . "\n"; 36 | exit(1); 37 | } 38 | echo "DONE.\n"; 39 | --------------------------------------------------------------------------------