From b060d575244ab16a089a458b2144c9185f2201cf Mon Sep 17 00:00:00 2001 From: El RIDO Date: Sun, 6 Sep 2015 19:21:17 +0200 Subject: [PATCH] - implemented php side of plural translation - using it to generate labels dynamically for the expire options (deprecating the [expire_labels] configuration). - added translation of the human readable data sizes to support the french octet - fixed IEC label for kibibytes --- cfg/conf.ini | 12 ----------- i18n/fr.json | 11 +++++++++- js/zerobin.js | 2 +- lib/filter.php | 35 +++++++++++++++++++++++++++--- lib/i18n.php | 57 +++++++++++++++++++++++++++++++++++++++++++++---- lib/zerobin.php | 8 ++----- tst/filter.php | 23 +++++++++++++++++--- tst/i18n.php | 30 +++++++++++++++++++++++++- tst/zerobin.php | 26 ++++++---------------- 9 files changed, 154 insertions(+), 50 deletions(-) diff --git a/cfg/conf.ini b/cfg/conf.ini index e6ccc476..2bb240a1 100644 --- a/cfg/conf.ini +++ b/cfg/conf.ini @@ -57,18 +57,6 @@ default = "1week" 1year = 31536000 never = 0 -[expire_labels] -; descriptive labels for the expiration times -; must match those in [expire_options] -5min = "5 minutes" -10min = "10 minutes" -1hour = "1 hour" -1day = "1 day" -1week = "1 week" -1month = "1 month" -1year = "1 year" -never = "Never" - [traffic] ; time limit between calls from the same IP address in seconds ; Set this to 0 to disable rate limiting. diff --git a/i18n/fr.json b/i18n/fr.json index 08ee78ca..60e6fcab 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -123,5 +123,14 @@ "Could not create paste: %s": "Could not create paste: %s", "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": - "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)" + "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)", + "B": "o", + "KiB": "Kio", + "MiB": "Mio", + "GiB": "Gio", + "TiB": "Tio", + "PiB": "Pio", + "EiB": "Eio", + "ZiB": "Zio", + "YiB": "Yio" } diff --git a/js/zerobin.js b/js/zerobin.js index 61ea94fd..82a6dce3 100644 --- a/js/zerobin.js +++ b/js/zerobin.js @@ -647,7 +647,7 @@ $(function() { { event.preventDefault(); var source = $(event.target), - commentid = event.data.commentid + commentid = event.data.commentid, hint = i18n._('Optional nickname...'); // Remove any other reply area. diff --git a/lib/filter.php b/lib/filter.php index 18205f95..c4bd95d4 100644 --- a/lib/filter.php +++ b/lib/filter.php @@ -33,7 +33,36 @@ class filter } /** - * format a given number of bytes + * format a given time string into a human readable label (localized) + * + * accepts times in the format "[integer][time unit]" + * + * @access public + * @static + * @param string $time + * @throws Exception + * @return string + */ + public static function time_humanreadable($time) + { + if (preg_match('/^(\d+) *(\w+)$/', $time, $matches) !== 1) { + throw new Exception("Error parsing time format '$time'", 30); + } + switch ($matches[2]) { + case 'sec': + $unit = 'second'; + break; + case 'min': + $unit = 'minute'; + break; + default: + $unit = rtrim($matches[2], 's'); + } + return i18n::_(array('%d ' . $unit, '%d ' . $unit . 's'), (int) $matches[1]); + } + + /** + * format a given number of bytes in IEC 80000-13:2008 notation (localized) * * @access public * @static @@ -42,13 +71,13 @@ class filter */ public static function size_humanreadable($size) { - $iec = array('B', 'kiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'); + $iec = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'); $i = 0; while ( ( $size / 1024 ) >= 1 ) { $size = $size / 1024; $i++; } - return number_format($size, ($i ? 2 : 0), '.', ' ') . ' ' . $iec[$i]; + return number_format($size, ($i ? 2 : 0), '.', ' ') . ' ' . i18n::_($iec[$i]); } /** diff --git a/lib/i18n.php b/lib/i18n.php index f225b883..632f2158 100644 --- a/lib/i18n.php +++ b/lib/i18n.php @@ -17,14 +17,23 @@ */ class i18n { + /** + * language + * + * @access protected + * @static + * @var string + */ + protected static $_language = 'en'; + /** * translation cache * - * @access private + * @access protected * @static * @var array */ - private static $_translations = array(); + protected static $_translations = array(); /** * translate a string, alias for translate() @@ -53,12 +62,30 @@ class i18n { if (empty($messageId)) return $messageId; if (count(self::$_translations) === 0) self::loadTranslations(); + $messages = $messageId; + if (is_array($messageId)) + { + $messageId = count($messageId) > 1 ? $messageId[1] : $messageId[0]; + } if (!array_key_exists($messageId, self::$_translations)) { - self::$_translations[$messageId] = $messageId; + self::$_translations[$messageId] = $messages; } $args = func_get_args(); - $args[0] = self::$_translations[$messageId]; + if (is_array(self::$_translations[$messageId])) + { + $number = (int) $args[1]; + $key = self::_getPluralForm($number); + $max = count(self::$_translations[$messageId]) - 1; + if ($key > $max) $key = $max; + + $args[0] = self::$_translations[$messageId][$key]; + $args[1] = $number; + } + else + { + $args[0] = self::$_translations[$messageId]; + } return call_user_func_array('sprintf', $args); } @@ -91,6 +118,7 @@ class i18n // load translations if ($match != 'en') { + self::$_language = $match; self::$_translations = json_decode( file_get_contents($path . DIRECTORY_SEPARATOR . $match . '.json'), true @@ -137,6 +165,27 @@ class i18n return $languages; } + /** + * determines the plural form to use based on current language and given number + * + * From: http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html + * + * @param int $n + * @return int + */ + protected static function _getPluralForm($n) + { + switch (self::$_language) { + case 'fr': + return ($n > 1 ? 1 : 0); + case 'pl': + return ($n == 1 ? 0 : $n%10 >= 2 && $n %10 <=4 && ($n%100 < 10 || $n%100 >= 20) ? 1 : 2); + // en, de + default: + return ($n != 1 ? 1 : 0); + } + } + /** * compares two language preference arrays and returns the preferred match * diff --git a/lib/zerobin.php b/lib/zerobin.php index 6f9b12ca..17f3aa92 100644 --- a/lib/zerobin.php +++ b/lib/zerobin.php @@ -579,12 +579,8 @@ class zerobin // label all the expiration options $expire = array(); - foreach ($this->_conf['expire_options'] as $key => $value) { - $expire[$key] = i18n::_( - array_key_exists($key, $this->_conf['expire_labels']) ? - $this->_conf['expire_labels'][$key] : - $key - ); + foreach ($this->_conf['expire_options'] as $time => $seconds) { + $expire[$time] = ($seconds == 0) ? i18n::_(ucfirst($time)): filter::time_humanreadable($time); } $page = new RainTPL; diff --git a/tst/filter.php b/tst/filter.php index c058a66a..a1888e4c 100644 --- a/tst/filter.php +++ b/tst/filter.php @@ -9,14 +9,31 @@ class filterTest extends PHPUnit_Framework_TestCase ); } + public function testFilterMakesTimesHumanlyReadable() + { + $this->assertEquals('5 minutes', filter::time_humanreadable('5min')); + $this->assertEquals('90 seconds', filter::time_humanreadable('90sec')); + $this->assertEquals('1 week', filter::time_humanreadable('1week')); + $this->assertEquals('6 months', filter::time_humanreadable('6months')); + } + + /** + * @expectedException Exception + * @expectedExceptionCode 30 + */ + public function testFilterFailTimesHumanlyReadable() + { + filter::time_humanreadable('five_minutes'); + } + public function testFilterMakesSizesHumanlyReadable() { $this->assertEquals('1 B', filter::size_humanreadable(1)); $this->assertEquals('1 000 B', filter::size_humanreadable(1000)); - $this->assertEquals('1.00 kiB', filter::size_humanreadable(1024)); - $this->assertEquals('1.21 kiB', filter::size_humanreadable(1234)); + $this->assertEquals('1.00 KiB', filter::size_humanreadable(1024)); + $this->assertEquals('1.21 KiB', filter::size_humanreadable(1234)); $exponent = 1024; - $this->assertEquals('1 000.00 kiB', filter::size_humanreadable(1000 * $exponent)); + $this->assertEquals('1 000.00 KiB', filter::size_humanreadable(1000 * $exponent)); $this->assertEquals('1.00 MiB', filter::size_humanreadable(1024 * $exponent)); $this->assertEquals('1.21 MiB', filter::size_humanreadable(1234 * $exponent)); $exponent *= 1024; diff --git a/tst/i18n.php b/tst/i18n.php index 2efa11e2..78ed3f0f 100644 --- a/tst/i18n.php +++ b/tst/i18n.php @@ -25,11 +25,39 @@ class i18nTest extends PHPUnit_Framework_TestCase $this->assertEquals($messageId, i18n::_($messageId), 'fallback to en'); } - public function testBrowserLanguageDetection() + public function testBrowserLanguageDeDetection() { $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'de-CH,de;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2'; i18n::loadTranslations(); $this->assertEquals($this->_translations['en'], i18n::_('en'), 'browser language de'); + $this->assertEquals('0 Stunden', i18n::_('%d hours', 0), '0 hours in german'); + $this->assertEquals('1 Stunde', i18n::_('%d hours', 1), '1 hour in german'); + $this->assertEquals('2 Stunden', i18n::_('%d hours', 2), '2 hours in french'); + } + + public function testBrowserLanguageFrDetection() + { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr-CH,fr;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2'; + i18n::loadTranslations(); + $this->assertEquals('fr', i18n::_('en'), 'browser language fr'); + $this->assertEquals('0 heure', i18n::_('%d hours', 0), '0 hours in french'); + $this->assertEquals('1 heure', i18n::_('%d hours', 1), '1 hour in french'); + $this->assertEquals('2 heures', i18n::_('%d hours', 2), '2 hours in french'); + } + + public function testBrowserLanguagePlDetection() + { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'pl;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2'; + i18n::loadTranslations(); + $this->assertEquals('pl', i18n::_('en'), 'browser language pl'); + $this->assertEquals('2 godzina', i18n::_('%d hours', 2), 'hours in polish'); + } + + public function testBrowserLanguageAnyDetection() + { + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = '*'; + i18n::loadTranslations(); + $this->assertTrue(strlen(i18n::_('en')) == 2, 'browser language any'); } public function testVariableInjection() diff --git a/tst/zerobin.php b/tst/zerobin.php index a639130d..ed7c191b 100644 --- a/tst/zerobin.php +++ b/tst/zerobin.php @@ -108,23 +108,6 @@ class zerobinTest extends PHPUnit_Framework_TestCase $content = ob_get_contents(); } - /** - * @runInSeparateProcess - */ - public function testConfMissingExpireLabel() - { - $this->reset(); - $options = parse_ini_file($this->_conf, true); - $options['expire_options']['foobar123'] = 10; - if (!is_file($this->_conf . '.bak') && is_file($this->_conf)) - rename($this->_conf, $this->_conf . '.bak'); - helper::createIniFile($this->_conf, $options); - ini_set('magic_quotes_gpc', 1); - ob_start(); - new zerobin; - $content = ob_get_contents(); - } - /** * @runInSeparateProcess */ @@ -461,7 +444,9 @@ class zerobinTest extends PHPUnit_Framework_TestCase if (!is_file($this->_conf . '.bak') && is_file($this->_conf)) rename($this->_conf, $this->_conf . '.bak'); helper::createIniFile($this->_conf, $options); + $this->_model->create(self::$pasteid, self::$paste); $this->_model->createComment(self::$pasteid, self::$pasteid, self::$commentid, self::$comment); + $this->assertTrue($this->_model->existsComment(self::$pasteid, self::$pasteid, self::$commentid), 'comment exists before posting data'); $_POST = self::$comment; $_POST['pasteid'] = self::$pasteid; $_POST['parentid'] = self::$pasteid; @@ -747,9 +732,12 @@ class zerobinTest extends PHPUnit_Framework_TestCase { $this->reset(); $expiredPaste = self::$paste; - $expiredPaste['meta']['expire_date'] = $expiredPaste['meta']['postdate']; + $expiredPaste['meta']['expire_date'] = 1000; + $this->assertFalse($this->_model->exists(self::$pasteid), 'paste does not exist before being created'); $this->_model->create(self::$pasteid, $expiredPaste); - $_SERVER['QUERY_STRING'] = self::$pasteid; + $this->assertTrue($this->_model->exists(self::$pasteid), 'paste exists before deleting data'); + $_GET['pasteid'] = self::$pasteid; + $_GET['deletetoken'] = 'does not matter in this context, but has to be set'; ob_start(); new zerobin; $content = ob_get_contents();