summaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-rw-r--r--core/config/locale.php35
-rw-r--r--core/libraries/I18n.php395
-rw-r--r--core/tests/I18n_Test.php80
3 files changed, 510 insertions, 0 deletions
diff --git a/core/config/locale.php b/core/config/locale.php
new file mode 100644
index 00000000..f93df409
--- /dev/null
+++ b/core/config/locale.php
@@ -0,0 +1,35 @@
+<?php defined('SYSPATH') or die('No direct access allowed.');
+/**
+ * @package Core
+ *
+ * Default language locale name(s).
+ * First item must be a valid i18n directory name, subsequent items are alternative locales
+ * for OS's that don't support the first (e.g. Windows). The first valid locale in the array will be used.
+ * @see http://php.net/setlocale
+ */
+$config['language'] = array('en_US', 'English_United States');
+
+/**
+ * Locale timezone. Defaults to use the server timezone.
+ * @see http://php.net/timezones
+ */
+$config['timezone'] = '';
+
+// i18n settings
+
+/**
+ * The locale of the built-in localization messages (locale of strings in translate() calls).
+ * This can't be changed easily, unless all localization strings are replaced in all source files
+ * as well.
+ */
+$config['root_locale'] = 'en';
+
+/**
+ * The default locale of this installation.
+ */
+$config['default_locale'] = 'en_US';
+
+/**
+ * The path to the folder with translation files.
+ */
+$config['locale_dir'] = VARPATH . 'locale/'; \ No newline at end of file
diff --git a/core/libraries/I18n.php b/core/libraries/I18n.php
new file mode 100644
index 00000000..44aad1e8
--- /dev/null
+++ b/core/libraries/I18n.php
@@ -0,0 +1,395 @@
+<?php defined("SYSPATH") or die("No direct script access.");
+/**
+ * Gallery - a web based photo album viewer and editor
+ * Copyright (C) 2000-2008 Bharat Mediratta
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street - Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+/**
+ * @todo Add caching: e.g. keep all translation data in memory during the request.
+ * Remember the locale fallback, cache by locale
+ * @todo Might compile l10n files such that no fallbacks have to be performed.
+ * @todo Might keep all l10n data in the database instead of php files.
+ */
+class I18n_Core {
+ private $_config = array();
+ private $_data = array();
+
+ private static $_instance;
+
+ public $missing_placeholder_strategy;
+
+ private function __construct($config) {
+ $this->_config = $config;
+ }
+
+ public static function instance($config=null) {
+ if (self::$_instance == NULL || isset($config)) {
+ $config = isset($config) ? $config : Kohana::config('locale');
+ self::$_instance = new I18n_Core($config);
+ self::$_instance->missing_placeholder_strategy = new Ignore_Missing_Placeholder();
+ }
+
+ return self::$_instance;
+ }
+
+ public function translate($message, $options=array() /** @todo , $hint=null */) {
+ $locale = empty($options['locale']) ? $this->_config['default_locale'] : $options['locale'];
+ $count = empty($options['count']) ? null : $options['count'];
+ $values = $options;
+ unset($values['locale']);
+
+ $entry = $this->lookup($locale, $message);
+
+ if (empty($entry)) {
+ $entry = $this->default_entry($message);
+ }
+
+ $entry = $this->pluralize($locale, $entry, $count);
+
+ $entry = $this->interpolate($locale, $entry, $values);
+
+ return $entry;
+ }
+
+ private function get_fallbacks_for_locale($locale) {
+ $fallbacks = array();
+ $fallbacks[$locale] = true;
+ /** @todo add proper / robust locale string handling */
+ /** @todo add smart locale fallback handling, e.g. en_US -> en -> en_* -> root */
+ $locale_parts = explode('_', $locale);
+ if (count($locale_parts) == 2) {
+ $fallbacks[$locale_parts[0]] = true;
+ }
+ $fallbacks[$this->_config['default_locale']] = true;
+
+ return array_keys($fallbacks);
+ }
+
+ private function lookup($locale, $message) {
+ $entry = null;
+ $locales = $this->get_fallbacks_for_locale($locale);
+ // If message is an array (plural forms), use the first form as message id.
+ // TODO: Might rather use hash of message as msgid.
+ $key = is_array($message) ? array_shift($message) : $message;
+
+ while (!empty($locales) && $entry == null) {
+ $locale = array_shift($locales);
+
+ if ($this->has_l10n_for_locale($locale)) {
+ $entry = $this->get_entry_from_locale_data($locale, $key);
+ }
+ }
+
+ return $entry;
+ }
+
+ private function default_entry($message) {
+ return $message;
+ }
+
+ private function get_entry_from_locale_data($locale, $key) {
+ if (!isset($this->_data[$locale])) {
+ $this->load_l10n_data_for_locale($locale);
+ }
+
+ if (isset($this->_data[$locale][$key])) {
+ return $this->_data[$locale][$key];
+ }
+
+ return null;
+ }
+
+ private function load_l10n_data_for_locale($locale) {
+ $data = array();
+ include($this->_config['locale_dir'] . $locale . '.php');
+
+ $this->_data[$locale] = $data;
+ }
+
+ private function interpolate($locale, $string, $values) {
+ // TODO: Benchmark whether {{stuff}} type syntax is prohibitively slow compared to sprintf()/
+ // TODO: Benchmark whether str_replace() is much faster (no handling of escape syntax)
+ // TODO: Benchmark whether nested vs. outer function is significantly slower.
+ $callback = new I18n_Placeholder_Replacer($values, $locale, $string);
+ // TODO: Benchmark with pattern string as class constant
+ $string = preg_replace_callback("/(\\\\)?\{\{([^\}]+)\}\}/S", array($callback, 'replace'), $string);
+
+ return $string;
+ }
+
+ private function pluralize($locale, $entry, $count) {
+ if ($count == NULL || !is_array($entry)) {
+ return $entry;
+ }
+ $plural_key = self::get_plural_key($locale, $count);
+
+ if (!isset($entry[$plural_key])) {
+ // Fallback to the default plural form.
+ $plural_key = 'other';
+ }
+
+ if (isset($entry[$plural_key])) {
+ return $entry[$plural_key];
+ } else {
+ // Fallback to just any plural form.
+ list ($plural_key, $string) = each($entry);
+ return $string;
+ }
+ }
+
+ private function has_l10n_for_locale($locale) {
+ return file_exists($this->_config['locale_dir'] . $locale . '.php');
+ }
+
+ private static function get_plural_key($locale, $count) {
+ $parts = explode('_', $locale);
+ $language = $parts[0];
+
+ // Data from CLDR 1.6 (http://unicode.org/cldr/data/common/supplemental/plurals.xml).
+ // Docs: http://www.unicode.org/cldr/data/charts/supplemental/language_plural_rules.html
+ switch ($language) {
+ case 'az':
+ case 'fa':
+ case 'hu':
+ case 'ja':
+ case 'ko':
+ case 'my':
+ case 'to':
+ case 'tr':
+ case 'vi':
+ case 'yo':
+ case 'zh':
+ case 'bo':
+ case 'dz':
+ case 'id':
+ case 'jv':
+ case 'ka':
+ case 'km':
+ case 'kn':
+ case 'ms':
+ case 'th':
+ return 'other';
+
+ case 'ar':
+ if ($count == 0) {
+ return 'zero';
+ } else if ($count == 1) {
+ return 'one';
+ } else if ($count == 2) {
+ return 'two';
+ } else if (is_int($count) && ($i = $count % 100) >= 3 && $i <= 10) {
+ return 'few';
+ } else if (is_int($count) && ($i = $count % 100) >= 11 && $i <= 99) {
+ return 'many';
+ } else {
+ return 'other';
+ }
+
+ case 'pt':
+ case 'am':
+ case 'bh':
+ case 'fil':
+ case 'tl':
+ case 'guw':
+ case 'hi':
+ case 'ln':
+ case 'mg':
+ case 'nso':
+ case 'ti':
+ case 'wa':
+ if ($count == 0 || $count == 1) {
+ return 'one';
+ } else {
+ return 'other';
+ }
+
+ case 'fr':
+ if ($count >= 0 and $count < 2) {
+ return 'one';
+ } else {
+ return 'other';
+ }
+
+ case 'lv':
+ if ($count == 0) {
+ return 'zero';
+ } else if ($count % 10 == 1 && $count % 100 != 11) {
+ return 'one';
+ } else {
+ return 'other';
+ }
+
+ case 'ga':
+ case 'se':
+ case 'sma':
+ case 'smi':
+ case 'smj':
+ case 'smn':
+ case 'sms':
+ if ($count == 1) {
+ return 'one';
+ } else if ($count == 2) {
+ return 'two';
+ } else {
+ return 'other';
+ }
+
+ case 'ro':
+ case 'mo':
+ if ($count == 1) {
+ return 'one';
+ } else if (is_int($count) && $count == 0 && ($i = $count % 100) >= 1 && $i <= 19) {
+ return 'few';
+ } else {
+ return 'other';
+ }
+
+ case 'lt':
+ if (is_int($count) && $count % 10 == 1 && $count % 100 != 11) {
+ return 'one';
+ } else if (is_int($count) && ($i = $count % 10) >= 2 && $i <= 9 && ($i = $count % 100) < 11 && $i > 19) {
+ return 'few';
+ } else {
+ return 'other';
+ }
+
+ case 'hr':
+ case 'ru':
+ case 'sr':
+ case 'uk':
+ case 'be':
+ case 'bs':
+ case 'sh':
+ if (is_int($count) && $count % 10 == 1 && $count % 100 != 11) {
+ return 'one';
+ } else if (is_int($count) && ($i = $count % 10) >= 2 && $i <= 4 && ($i = $count % 100) < 12 && $i > 14) {
+ return 'few';
+ } else if (is_int($count) && ($count % 10 == 0 || (($i = $count % 10) >= 5 && $i <= 9) || (($i = $count % 100) >= 11 && $i <= 14))) {
+ return 'many';
+ } else {
+ return 'other';
+ }
+
+ case 'cs':
+ case 'sk':
+ if ($count == 1) {
+ return 'one';
+ } else if (is_int($count) && $count >= 2 && $count <= 4) {
+ return 'few';
+ } else {
+ return 'other';
+ }
+
+ case 'pl':
+ if ($count == 1) {
+ return 'one';
+ } else if (is_int($count) && ($i = $count % 10) >= 2 && $i <= 4 &&
+ ($i = $count % 100) < 12 && $i > 14 && ($i = $count % 100) < 22 && $i > 24) {
+ return 'few';
+ } else {
+ return 'other';
+ }
+
+ case 'sl':
+ if ($count % 100 == 1) {
+ return 'one';
+ } else if ($count % 100 == 2) {
+ return 'two';
+ } else if (is_int($count) && ($i = $count % 100) >= 3 && $i <= 4) {
+ return 'few';
+ } else {
+ return 'other';
+ }
+
+ case 'mt':
+ if ($count == 1) {
+ return 'one';
+ } else if ($count == 0 || is_int($count) && ($i = $count % 100) >= 2 && $i <= 10) {
+ return 'few';
+ } else if (is_int($count) && ($i = $count % 100) >= 11 && $i <= 19) {
+ return 'many';
+ } else {
+ return 'other';
+ }
+
+ case 'mk':
+ if ($count % 10 == 1) {
+ return 'one';
+ } else {
+ return 'other';
+ }
+
+ case 'cy':
+ if ($count == 1) {
+ return 'one';
+ } else if ($count == 2) {
+ return 'two';
+ } else if ($count == 8 || $count == 11) {
+ return 'many';
+ } else {
+ return 'other';
+ }
+
+ default: // en, de, etc.
+ return $count == 1 ? 'one' : 'other';
+ }
+ }
+}
+
+class I18n_Placeholder_Replacer {
+ private $_values;
+ private $_locale;
+ private $_string;
+
+ public function __construct($values, $locale, $string) {
+ $this->_values = $values;
+ $this->_locale = $locale;
+ $this->_string = $string;
+ }
+
+ function replace($matches) {
+ list ($full_match, $escaped, $placeholder) = $matches;
+
+ if ($escaped) {
+ return $full_match;
+ } else if (!isset($this->_values[$placeholder])) {
+ return I18n::instance()->missing_placeholder_strategy
+ ->replace($this->_locale, $this->_string, $this->_values, $placeholder, $full_match);
+ } else {
+ return $this->_values[$placeholder];
+ }
+ }
+}
+
+interface Missing_Placeholder_Strategy {
+ /**
+ * Handle the case where a localization requests a placeholder which is not provided in the translate() call.
+ * @param $locale The locale for this localization.
+ * @param String $string The complete message string.
+ * @param array $values All available replacement key value pairs.
+ * @param String $placeholder The placeholder for which there is no replacement value, e.g. "name"
+ * @param String $full_match The placeholder including its surrounding placeholder syntax, e.g. "{{name}}"
+ * @return String The replacement for the placeholder.
+ */
+ public function replace($locale, $string, $values, $placeholder, $full_match);
+}
+
+class Ignore_Missing_Placeholder implements Missing_Placeholder_Strategy {
+ function replace($locale, $string, $values, $placeholder, $full_match) {
+ return $full_match;
+ }
+} \ No newline at end of file
diff --git a/core/tests/I18n_Test.php b/core/tests/I18n_Test.php
new file mode 100644
index 00000000..fab3cf43
--- /dev/null
+++ b/core/tests/I18n_Test.php
@@ -0,0 +1,80 @@
+<?php defined("SYSPATH") or die("No direct script access.");
+/**
+ * Gallery - a web based photo album viewer and editor
+ * Copyright (C) 2000-2008 Bharat Mediratta
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street - Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+class I18n_Test extends Unit_Test_Case {
+ private $i18n;
+
+ public function setup() {
+ $config = array(
+ 'root_locale' => 'en',
+ 'default_locale' => 'de_DE',
+ 'locale_dir' => VARPATH . 'locale/');
+ $this->i18n = I18n::instance($config);
+
+ $locale_file_contents = <<<EOT
+<?php defined("SYSPATH") or die("No direct script access.");
+
+\$data = array(
+ 'Hello world' => 'Hallo Welt',
+ 'One item has been added' =>
+ array('one' => 'Ein Element wurde hinzugefuegt.',
+ 'other' => '{{count}} Elemente wurden hinzugefuegt.'),
+ 'Hello {{name}}, how are you today?' => 'Hallo {{name}}, wie geht es Dir heute?'
+);
+EOT;
+
+ @mkdir(VARPATH . 'locale');
+ $fp = file_put_contents(VARPATH . 'locale/de_DE.php', $locale_file_contents);
+ }
+
+ public function translate_simple_test() {
+ $result = $this->i18n->translate('Hello world');
+ $this->assert_equal('Hallo Welt', $result);
+ }
+
+ public function translate_simple_root_fallback_test() {
+ $result = $this->i18n->translate('Hello world zzz');
+ $this->assert_equal('Hello world zzz', $result);
+ }
+
+ public function translate_plural_other_test() {
+ $result = $this->i18n->translate(array('one' => 'One item has been added',
+ 'other' => '{{count}} items have been added.'),
+ array('count' => 5));
+ $this->assert_equal('5 Elemente wurden hinzugefuegt.', $result);
+ }
+
+ public function translate_plural_one_test() {
+ $result = $this->i18n->translate(array('one' => 'One item has been added',
+ 'other' => '{{count}} items have been added.'),
+ array('count' => 1));
+ $this->assert_equal('Ein Element wurde hinzugefuegt.', $result);
+ }
+
+ public function translate_interpolate_test() {
+ $result = $this->i18n->translate('Hello {{name}}, how are you today?', array('name' => 'John'));
+ $this->assert_equal('Hallo John, wie geht es Dir heute?', $result);
+ }
+
+ public function translate_interpolate_missing_value_test() {
+ $result = $this->i18n->translate('Hello {{name}}, how are you today?', array('foo' => 'bar'));
+ $this->assert_equal('Hallo {{name}}, wie geht es Dir heute?', $result);
+ }
+} \ No newline at end of file