diff --git a/core/ajax/translations.php b/core/ajax/translations.php index c9c6420779..e829453dbc 100644 --- a/core/ajax/translations.php +++ b/core/ajax/translations.php @@ -27,4 +27,4 @@ $app = OC_App::cleanAppId($app); $l = OC_L10N::get( $app ); -OC_JSON::success(array('data' => $l->getTranslations())); +OC_JSON::success(array('data' => $l->getTranslations(), 'plural_form' => $l->getPluralFormString())); diff --git a/core/js/js.js b/core/js/js.js index 03f660be62..1d1711383f 100644 --- a/core/js/js.js +++ b/core/js/js.js @@ -1,6 +1,6 @@ /** * Disable console output unless DEBUG mode is enabled. - * Add + * Add * define('DEBUG', true); * To the end of config/config.php to enable debug mode. * The undefined checks fix the broken ie8 console @@ -24,60 +24,121 @@ if (oc_debug !== true || typeof console === "undefined" || typeof console.log == } } -/** - * translate a string - * @param app the id of the app for which to translate the string - * @param text the string to translate - * @return string - */ -function t(app,text, vars){ - if( !( t.cache[app] )){ - $.ajax(OC.filePath('core','ajax','translations.php'),{ - async:false,//todo a proper sollution for this without sync ajax calls - data:{'app': app}, - type:'POST', - success:function(jsondata){ +function initL10N(app) { + if (!( t.cache[app] )) { + $.ajax(OC.filePath('core', 'ajax', 'translations.php'), { + async: false,//todo a proper solution for this without sync ajax calls + data: {'app': app}, + type: 'POST', + success: function (jsondata) { t.cache[app] = jsondata.data; + t.plural_form = jsondata.plural_form; } }); // Bad answer ... - if( !( t.cache[app] )){ + if (!( t.cache[app] )) { t.cache[app] = []; } } - var _build = function (text, vars) { - return text.replace(/{([^{}]*)}/g, + if (typeof t.plural_function == 'undefined') { + t.plural_function = function (n) { + var p = (n != 1) ? 1 : 0; + return { 'nplural' : 2, 'plural' : p }; + }; + + /** + * code below has been taken from jsgettext - which is LGPL licensed + * https://developer.berlios.de/projects/jsgettext/ + * http://cvs.berlios.de/cgi-bin/viewcvs.cgi/jsgettext/jsgettext/lib/Gettext.js + */ + var pf_re = new RegExp('^(\\s*nplurals\\s*=\\s*[0-9]+\\s*;\\s*plural\\s*=\\s*(?:\\s|[-\\?\\|&=!<>+*/%:;a-zA-Z0-9_\(\)])+)', 'm'); + if (pf_re.test(t.plural_form)) { + //ex english: "Plural-Forms: nplurals=2; plural=(n != 1);\n" + //pf = "nplurals=2; plural=(n != 1);"; + //ex russian: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10< =4 && (n%100<10 or n%100>=20) ? 1 : 2) + //pf = "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)"; + var pf = t.plural_form; + if (! /;\s*$/.test(pf)) pf = pf.concat(';'); + /* We used to use eval, but it seems IE has issues with it. + * We now use "new Function", though it carries a slightly + * bigger performance hit. + var code = 'function (n) { var plural; var nplurals; '+pf+' return { "nplural" : nplurals, "plural" : (plural === true ? 1 : plural ? plural : 0) }; };'; + Gettext._locale_data[domain].head.plural_func = eval("("+code+")"); + */ + var code = 'var plural; var nplurals; '+pf+' return { "nplural" : nplurals, "plural" : (plural === true ? 1 : plural ? plural : 0) };'; + t.plural_function = new Function("n", code); + } else { + console.log("Syntax error in language file. Plural-Forms header is invalid ["+plural_forms+"]"); + } + } +} +/** + * translate a string + * @param app the id of the app for which to translate the string + * @param text the string to translate + * @param vars (optional) FIXME + * @param count (optional) number to replace %n with + * @return string + */ +function t(app, text, vars, count){ + initL10N(app); + var _build = function (text, vars, count) { + return text.replace(/%n/g, count).replace(/{([^{}]*)}/g, function (a, b) { var r = vars[b]; return typeof r === 'string' || typeof r === 'number' ? r : a; } ); }; + var translation = text; if( typeof( t.cache[app][text] ) !== 'undefined' ){ - if(typeof vars === 'object') { - return _build(t.cache[app][text], vars); - } else { - return t.cache[app][text]; - } + translation = t.cache[app][text]; } - else{ - if(typeof vars === 'object') { - return _build(text, vars); - } else { - return text; - } + + if(typeof vars === 'object' || count !== undefined ) { + return _build(translation, vars, count); + } else { + return translation; } } -t.cache={}; +t.cache = {}; -/* +/** + * translate a string + * @param app the id of the app for which to translate the string + * @param text_singular the string to translate for exactly one object + * @param text_plural the string to translate for n objects + * @param count number to determine whether to use singular or plural + * @param vars (optional) FIXME + * @return string + */ +function n(app, text_singular, text_plural, count, vars) { + initL10N(app); + var identifier = '_' + text_singular + '__' + text_plural + '_'; + if( typeof( t.cache[app][identifier] ) !== 'undefined' ){ + var translation = t.cache[app][identifier]; + if ($.isArray(translation)) { + var plural = t.plural_function(count); + return t(app, translation[plural.plural], vars, count); + } + } + + if(count === 1) { + return t(app, text_singular, vars, count); + } + else{ + return t(app, text_plural, vars, count); + } +} + +/** * Sanitizes a HTML string -* @param string +* @param s string * @return Sanitized string */ function escapeHTML(s) { - return s.toString().split('&').join('&').split('<').join('<').split('"').join('"'); + return s.toString().split('&').join('&').split('<').join('<').split('"').join('"'); } /** @@ -773,7 +834,7 @@ OC.get=function(name) { var namespaces = name.split("."); var tail = namespaces.pop(); var context=window; - + for(var i = 0; i < namespaces.length; i++) { context = context[namespaces[i]]; if(!context){ @@ -792,7 +853,7 @@ OC.set=function(name, value) { var namespaces = name.split("."); var tail = namespaces.pop(); var context=window; - + for(var i = 0; i < namespaces.length; i++) { if(!context[namespaces[i]]){ context[namespaces[i]]={}; diff --git a/l10n/l10n.pl b/l10n/l10n.pl index b07d6d686b..2790ca9201 100644 --- a/l10n/l10n.pl +++ b/l10n/l10n.pl @@ -39,7 +39,7 @@ sub crawlFiles{ foreach my $i ( @files ){ next if substr( $i, 0, 1 ) eq '.'; next if $i eq 'l10n'; - + if( -d $dir.'/'.$i ){ push( @found, crawlFiles( $dir.'/'.$i )); } @@ -64,6 +64,16 @@ sub readIgnorelist{ return %ignore; } +sub getPluralInfo { + my( $info ) = @_; + + # get string + $info =~ s/.*Plural-Forms: (.+)\\n.*/$1/; + $info =~ s/^(.*)\\n.*/$1/g; + + return $info; +} + my $task = shift( @ARGV ); my $place = '..'; @@ -100,11 +110,17 @@ if( $task eq 'read' ){ foreach my $file ( @totranslate ){ next if $ignore{$file}; - my $keyword = ( $file =~ /\.js$/ ? 't:2' : 't'); + my $keywords = ''; + if( $file =~ /\.js$/ ){ + $keywords = '--keyword=t:2 --keyword=n:2,3'; + } + else{ + $keywords = '--keyword=t --keyword=n:1,2'; + } my $language = ( $file =~ /\.js$/ ? 'Python' : 'PHP'); my $joinexisting = ( -e $output ? '--join-existing' : ''); print " Reading $file\n"; - `xgettext --output="$output" $joinexisting --keyword=$keyword --language=$language "$file" --from-code=UTF-8 --package-version="5.0.0" --package-name="ownCloud Core" --msgid-bugs-address="translations\@owncloud.org"`; + `xgettext --output="$output" $joinexisting $keywords --language=$language "$file" --from-code=UTF-8 --package-version="5.0.0" --package-name="ownCloud Core" --msgid-bugs-address="translations\@owncloud.org"`; } chdir( $whereami ); } @@ -118,7 +134,7 @@ elsif( $task eq 'write' ){ print " Processing $app\n"; foreach my $language ( @languages ){ next if $language eq 'templates'; - + my $input = "${whereami}/$language/$app.po"; next unless -e $input; @@ -126,18 +142,38 @@ elsif( $task eq 'write' ){ my $array = Locale::PO->load_file_asarray( $input ); # Create array my @strings = (); + my $plurals; + foreach my $string ( @{$array} ){ - next if $string->msgid() eq '""'; - next if $string->msgstr() eq '""'; - push( @strings, $string->msgid()." => ".$string->msgstr()); + if( $string->msgid() eq '""' ){ + # Translator information + $plurals = getPluralInfo( $string->msgstr()); + } + elsif( defined( $string->msgstr_n() )){ + # plural translations + my @variants = (); + my $identifier = $string->msgid()."::".$string->msgid_plural(); + $identifier =~ s/"/_/g; + + foreach my $variant ( sort { $a <=> $b} keys( %{$string->msgstr_n()} )){ + push( @variants, $string->msgstr_n()->{$variant} ); + } + + push( @strings, "\"$identifier\" => array(".join(@variants, ",").")"); + } + else{ + # singular translations + next if $string->msgstr() eq '""'; + push( @strings, $string->msgid()." => ".$string->msgstr()); + } } next if $#strings == -1; # Skip empty files # Write PHP file open( OUT, ">$language.php" ); - print OUT "lang = $lang; } + public function load($transFile) { + $this->app = true; + include $transFile; + if(isset($TRANSLATIONS) && is_array($TRANSLATIONS)) { + $this->translations = $TRANSLATIONS; + } + if(isset($PLURAL_FORMS)) { + $this->plural_form_string = $PLURAL_FORMS; + } + } + protected function init() { if ($this->app === true) { return; @@ -138,6 +163,9 @@ class OC_L10N{ } } } + if(isset($PLURAL_FORMS)) { + $this->plural_form_string = $PLURAL_FORMS; + } } if(file_exists(OC::$SERVERROOT.'/core/l10n/l10n-'.$lang.'.php')) { @@ -153,6 +181,65 @@ class OC_L10N{ } } + /** + * @brief Creates a function that The constructor + * + * If language is not set, the constructor tries to find the right + * language. + * + * Parts of the code is copied from Habari: + * https://github.com/habari/system/blob/master/classes/locale.php + * @param $string string + * @return string + */ + protected function createPluralFormFunction($string){ + if(preg_match( '/^\s*nplurals\s*=\s*(\d+)\s*;\s*plural=(.*)$/u', $string, $matches)) { + // sanitize + $nplurals = preg_replace( '/[^0-9]/', '', $matches[1] ); + $plural = preg_replace( '#[^n0-9:\(\)\?\|\&=!<>+*/\%-]#', '', $matches[2] ); + + $body = str_replace( + array( 'plural', 'n', '$n$plurals', ), + array( '$plural', '$n', '$nplurals', ), + 'nplurals='. $nplurals . '; plural=' . $plural + ); + + // add parents + // important since PHP's ternary evaluates from left to right + $body .= ';'; + $res = ''; + $p = 0; + for($i = 0; $i < strlen($body); $i++) { + $ch = $body[$i]; + switch ( $ch ) { + case '?': + $res .= ' ? ('; + $p++; + break; + case ':': + $res .= ') : ('; + break; + case ';': + $res .= str_repeat( ')', $p ) . ';'; + $p = 0; + break; + default: + $res .= $ch; + } + } + + $body = $res . 'return ($plural>=$nplurals?$nplurals-1:$plural);'; + return create_function('$n', $body); + } + else { + // default: one plural form for all cases but n==1 (english) + return create_function( + '$n', + '$nplurals=2;$plural=($n==1?0:1);return ($plural>=$nplurals?$nplurals-1:$plural);' + ); + } + } + /** * @brief Translating * @param $text String The text we need a translation for @@ -166,6 +253,37 @@ class OC_L10N{ return new OC_L10N_String($this, $text, $parameters); } + /** + * @brief Translating + * @param $text_singular String the string to translate for exactly one object + * @param $text_plural String the string to translate for n objects + * @param $count Integer Number of objects + * @param array $parameters default:array() Parameters for sprintf + * @return \OC_L10N_String Translation or the same text + * + * Returns the translation. If no translation is found, $text will be + * returned. %n will be replaced with the number of objects. + * + * The correct plural is determined by the plural_forms-function + * provided by the po file. + * + */ + public function n($text_singular, $text_plural, $count, $parameters = array()) { + $this->init(); + $identifier = "_${text_singular}__${text_plural}_"; + if( array_key_exists($identifier, $this->translations)) { + return new OC_L10N_String( $this, $identifier, $parameters, $count ); + } + else{ + if($count === 1) { + return new OC_L10N_String($this, $text_singular, $parameters, $count); + } + else{ + return new OC_L10N_String($this, $text_plural, $parameters, $count); + } + } + } + /** * @brief Translating * @param $textArray The text array we need a translation for @@ -200,6 +318,42 @@ class OC_L10N{ return $this->translations; } + /** + * @brief getPluralFormString + * @returns string containing the gettext "Plural-Forms"-string + * + * Returns a string like "nplurals=2; plural=(n != 1);" + */ + public function getPluralFormString() { + $this->init(); + return $this->plural_form_string; + } + + /** + * @brief getPluralFormFunction + * @returns string the plural form function + * + * returned function accepts the argument $n + */ + public function getPluralFormFunction() { + $this->init(); + if(is_null($this->plural_form_function)) { + $this->plural_form_function = $this->createPluralFormFunction($this->plural_form_string); + } + return $this->plural_form_function; + } + + /** + * @brief get localizations + * @returns Fetch all localizations + * + * Returns an associative array with all localizations + */ + public function getLocalizations() { + $this->init(); + return $this->localizations; + } + /** * @brief Localization * @param $type Type of localization @@ -230,8 +384,12 @@ class OC_L10N{ case 'date': case 'datetime': case 'time': - if($data instanceof DateTime) return $data->format($this->localizations[$type]); - elseif(is_string($data)) $data = strtotime($data); + if($data instanceof DateTime) { + return $data->format($this->localizations[$type]); + } + elseif(is_string($data)) { + $data = strtotime($data); + } $locales = array(self::findLanguage()); if (strlen($locales[0]) == 2) { $locales[] = $locales[0].'_'.strtoupper($locales[0]); diff --git a/lib/l10n/string.php b/lib/l10n/string.php index 8eef10071e..88c85b32e7 100644 --- a/lib/l10n/string.php +++ b/lib/l10n/string.php @@ -7,19 +7,50 @@ */ class OC_L10N_String{ + /** + * @var OC_L10N + */ protected $l10n; - public function __construct($l10n, $text, $parameters) { + + /** + * @var string + */ + protected $text; + + /** + * @var array + */ + protected $parameters; + + /** + * @var integer + */ + protected $count; + + public function __construct($l10n, $text, $parameters, $count = 1) { $this->l10n = $l10n; $this->text = $text; $this->parameters = $parameters; - + $this->count = $count; } public function __toString() { $translations = $this->l10n->getTranslations(); + + $text = $this->text; if(array_key_exists($this->text, $translations)) { - return vsprintf($translations[$this->text], $this->parameters); + if(is_array($translations[$this->text])) { + $fn = $this->l10n->getPluralFormFunction(); + $id = $fn($this->count); + $text = $translations[$this->text][$id]; + } + else{ + $text = $translations[$this->text]; + } } - return vsprintf($this->text, $this->parameters); + + // Replace %n first (won't interfere with vsprintf) + $text = str_replace('%n', $this->count, $text); + return vsprintf($text, $this->parameters); } } diff --git a/tests/data/l10n/cs.php b/tests/data/l10n/cs.php new file mode 100644 index 0000000000..1c5907bc14 --- /dev/null +++ b/tests/data/l10n/cs.php @@ -0,0 +1,5 @@ + array("%n okno", "%n okna", "%n oken") +); +$PLURAL_FORMS = "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;"; diff --git a/tests/data/l10n/de.php b/tests/data/l10n/de.php new file mode 100644 index 0000000000..858ec8af49 --- /dev/null +++ b/tests/data/l10n/de.php @@ -0,0 +1,5 @@ + array("%n Datei", "%n Dateien") +); +$PLURAL_FORMS = "nplurals=2; plural=(n != 1);"; diff --git a/tests/data/l10n/ru.php b/tests/data/l10n/ru.php new file mode 100644 index 0000000000..dd46293db6 --- /dev/null +++ b/tests/data/l10n/ru.php @@ -0,0 +1,5 @@ + array("%n файл", "%n файла", "%n файлов") +); +$PLURAL_FORMS = "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"; diff --git a/tests/lib/l10n.php b/tests/lib/l10n.php new file mode 100644 index 0000000000..dfc5790c2e --- /dev/null +++ b/tests/lib/l10n.php @@ -0,0 +1,55 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +class Test_L10n extends PHPUnit_Framework_TestCase { + + public function testGermanPluralTranslations() { + $l = new OC_L10N('test'); + $transFile = OC::$SERVERROOT.'/tests/data/l10n/de.php'; + + $l->load($transFile); + $this->assertEquals('1 Datei', (string)$l->n('%n file', '%n files', 1)); + $this->assertEquals('2 Dateien', (string)$l->n('%n file', '%n files', 2)); + } + + public function testRussianPluralTranslations() { + $l = new OC_L10N('test'); + $transFile = OC::$SERVERROOT.'/tests/data/l10n/ru.php'; + + $l->load($transFile); + $this->assertEquals('1 файл', (string)$l->n('%n file', '%n files', 1)); + $this->assertEquals('2 файла', (string)$l->n('%n file', '%n files', 2)); + $this->assertEquals('6 файлов', (string)$l->n('%n file', '%n files', 6)); + $this->assertEquals('21 файл', (string)$l->n('%n file', '%n files', 21)); + $this->assertEquals('22 файла', (string)$l->n('%n file', '%n files', 22)); + $this->assertEquals('26 файлов', (string)$l->n('%n file', '%n files', 26)); + + /* + 1 file 1 файл 1 папка + 2-4 files 2-4 файла 2-4 папки + 5-20 files 5-20 файлов 5-20 папок + 21 files 21 файл 21 папка + 22-24 files 22-24 файла 22-24 папки + 25-30 files 25-30 файлов 25-30 папок + etc + 100 files 100 файлов, 100 папок + 1000 files 1000 файлов 1000 папок + */ + } + + public function testCzechPluralTranslations() { + $l = new OC_L10N('test'); + $transFile = OC::$SERVERROOT.'/tests/data/l10n/cs.php'; + + $l->load($transFile); + $this->assertEquals('1 okno', (string)$l->n('%n window', '%n windows', 1)); + $this->assertEquals('2 okna', (string)$l->n('%n window', '%n windows', 2)); + $this->assertEquals('5 oken', (string)$l->n('%n window', '%n windows', 5)); + } + +}