From 461b05071828178c423b0654d2978744c0147cae Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 25 Jun 2014 10:42:09 +0200 Subject: [PATCH 1/2] Add locale negotiation to Icinga\Util\Translator Translator::getPreferredLocaleCode($header) can now be used with the HTTP "Accept-Language" header to return the best matching locale using the user's preferations reported by the browser and our available locale stack. Additionally Translator::getLocale and Translator::getLanguage were replaced by Translator::splitLocaleCode to provide a more flexible implemenation in order to identify specific parts of a particular locale or the current one. refs #6074 --- application/layouts/scripts/layout.phtml | 2 +- library/Icinga/Util/Translator.php | 96 +++++++++++-- .../library/Icinga/Util/TranslatorTest.php | 132 ++++++++++++++++++ 3 files changed, 220 insertions(+), 10 deletions(-) diff --git a/application/layouts/scripts/layout.phtml b/application/layouts/scripts/layout.phtml index 4cc47d69a..b4fd504ed 100644 --- a/application/layouts/scripts/layout.phtml +++ b/application/layouts/scripts/layout.phtml @@ -12,7 +12,7 @@ if (array_key_exists('_dev', $_GET)) { } $ie8jsfile = 'js/icinga.ie8.js'; -$lang = Translator::getLanguage(); +$lang = Translator::splitLocaleCode()->language; $isIframe = $this->layout()->isIframe; $iframeClass = $isIframe ? ' iframe' : ''; diff --git a/library/Icinga/Util/Translator.php b/library/Icinga/Util/Translator.php index 89f74f76a..e112fb3cd 100644 --- a/library/Icinga/Util/Translator.php +++ b/library/Icinga/Util/Translator.php @@ -46,8 +46,6 @@ class Translator */ const DEFAULT_LOCALE = 'en_US'; - protected static $locale = 'C'; - /** * Known gettext domains and directories * @@ -107,20 +105,32 @@ class Translator } } else { $locale = setlocale(LC_ALL, 0); - self::$locale = $locale; putenv('LC_ALL=' . $locale); // Failsafe, Win and Unix putenv('LANG=' . $locale); // Windows fix, untested } } - public static function getLocale() + /** + * Split and return the language code and country code of the given locale or the current locale + * + * @param string $locale The locale code to split, or null to split the current locale + * + * @return stdClass An object with a 'language' and 'country' attribute + */ + public static function splitLocaleCode($locale = null) { - return self::$locale; - } + $matches = array(); + $locale = $locale !== null ? $locale : setlocale(LC_ALL, 0); + if (preg_match('@([a-z]{2})[_-]([A-Z]{2})@', $locale, $matches)) { + list($languageCode, $countryCode) = array_slice($matches, 1); + } elseif ($locale === 'C') { + list($languageCode, $countryCode) = preg_split('@[_-]@', static::DEFAULT_LOCALE, 2); + } else { + $languageCode = $locale; + $countryCode = null; + } - public static function getLanguage() - { - return self::$locale === 'C' ? 'en' : substr(self::$locale, 0, 2); + return (object) array('language' => $languageCode, 'country' => $countryCode); } /** @@ -144,4 +154,72 @@ class Translator } return $codes; } + + /** + * Return the preferred locale based on the given HTTP header and the available translations + * + * @param string $header The HTTP "Accept-Language" header + * + * @return string The browser's preferred locale code + */ + public static function getPreferredLocaleCode($header) + { + $headerValues = explode(',', $header); + for ($i = 0; $i < count($headerValues); $i++) { + // In order to accomplish a stable sort we need to take the original + // index into account as well during element comparison + $headerValues[$i] = array($headerValues[$i], $i); + } + usort( // Sort DESC but keep equal elements ASC + $headerValues, + function ($a, $b) { + $qValA = (float) (strpos($a[0], ';') > 0 ? substr(array_pop((explode(';', $a[0], 2))), 2) : 1); + $qValB = (float) (strpos($b[0], ';') > 0 ? substr(array_pop((explode(';', $b[0], 2))), 2) : 1); + return $qValA < $qValB ? 1 : ($qValA > $qValB ? -1 : ($a[1] > $b[1] ? 1 : ($a[1] < $b[1] ? -1 : 0))); + } + ); + for ($i = 0; $i < count($headerValues); $i++) { + // We need to reset the array to its original structure once it's sorted + $headerValues[$i] = $headerValues[$i][0]; + } + $requestedLocales = array(); + foreach ($headerValues as $headerValue) { + if (strpos($headerValue, ';') > 0) { + $parts = explode(';', $headerValue, 2); + $headerValue = $parts[0]; + } + $requestedLocales[] = str_replace('-', '_', $headerValue); + } + + $similarMatch = null; + $availableLocales = static::getAvailableLocaleCodes(); + $perfectMatch = array_shift((array_intersect($requestedLocales, $availableLocales))); + foreach ($requestedLocales as $requestedLocale) { + if ($perfectMatch === $requestedLocale) { + // The perfect match must be preferred when reached before a similar match is found + return $perfectMatch; + } + $similarMatches = array(); + $localeObj = static::splitLocaleCode($requestedLocale); + foreach ($availableLocales as $availableLocale) { + if (static::splitLocaleCode($availableLocale)->language === $localeObj->language) { + $similarMatches[] = $availableLocale; + } + } + if (!empty($similarMatches)) { + $similarMatch = array_shift($similarMatches); // There is no "best" similar match, just use the first + break; + } + } + + if (!$perfectMatch && $similarMatch) { + return $similarMatch; + } elseif ($similarMatch && static::splitLocaleCode($similarMatch)->language === static::splitLocaleCode($perfectMatch)->language) { + return $perfectMatch; + } elseif ($similarMatch) { + return $similarMatch; + } + + return static::DEFAULT_LOCALE; + } } diff --git a/test/php/library/Icinga/Util/TranslatorTest.php b/test/php/library/Icinga/Util/TranslatorTest.php index 0610ba87c..1c63e6c68 100644 --- a/test/php/library/Icinga/Util/TranslatorTest.php +++ b/test/php/library/Icinga/Util/TranslatorTest.php @@ -8,6 +8,14 @@ use Exception; use Icinga\Test\BaseTestCase; use Icinga\Util\Translator; +class TranslatorWithHardcodedLocaleCodes extends Translator +{ + public static function getAvailableLocaleCodes() + { + return array('en_US', 'de_DE', 'de_AT'); + } +} + class TranslatorTest extends BaseTestCase { public function setUp() @@ -75,4 +83,128 @@ class TranslatorTest extends BaseTestCase 'Translator::translate does not translate the given message correctly to French' ); } + + public function testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly() + { + $localeObj = Translator::splitLocaleCode('de_DE'); + $this->assertEquals( + 'de', + $localeObj->language, + 'Translator::splitLocaleCode does not split the language code correctly' + ); + $this->assertEquals( + 'DE', + $localeObj->country, + 'Translator::splitLocaleCode does not split the country code correctly' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly + */ + public function testWhetherSplitLocaleCodeCanHandleEncodingSuffixes() + { + $this->assertEquals( + 'US', + Translator::splitLocaleCode('en_US.UTF-8')->country, + 'Translator::splitLocaleCode does not handle encoding suffixes correctly' + ); + } + + public function testWhetherSplitLocaleCodeInterpretsInvalidLocaleCodesAsLanguageCodes() + { + $this->assertEquals( + 'de', + Translator::splitLocaleCode('de')->language, + 'Translator::splitLocaleCode does not interpret invalid locale codes as language codes' + ); + $this->assertEquals( + 'en~US', + Translator::splitLocaleCode('en~US')->language, + 'Translator::splitLocaleCode does not interpret invalid locale codes as language codes' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly + */ + public function testWhetherSplitLocaleCodeReturnsTheDefaultLocaleWhenGivenCAsLocale() + { + $cLocaleObj = Translator::splitLocaleCode('C'); + $defaultLocaleObj = Translator::splitLocaleCode(Translator::DEFAULT_LOCALE); + $this->assertEquals( + $defaultLocaleObj->language, + $cLocaleObj->language, + 'Translator::splitLocaleCode does not return the default language code when given C as locale' + ); + $this->assertEquals( + $defaultLocaleObj->country, + $cLocaleObj->country, + 'Translator::splitLocaleCode does not return the default country code when given C as locale' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly + * @depends testWhetherSplitLocaleCodeInterpretsInvalidLocaleCodesAsLanguageCodes + */ + public function testWhetherGetPreferredLocaleCodeFavorsPerfectMatches() + { + $this->assertEquals( + 'de_DE', + TranslatorWithHardcodedLocaleCodes::getPreferredLocaleCode('jp,de_DE;q=0.8,de;q=0.6'), + 'Translator::getPreferredLocaleCode does not favor perfect matches' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly + * @depends testWhetherSplitLocaleCodeInterpretsInvalidLocaleCodesAsLanguageCodes + */ + public function testWhetherGetPreferredLocaleCodeReturnsThePreferredSimilarMatchEvenThoughAPerfectMatchWasFound() + { + $this->assertEquals( + 'de_DE', + TranslatorWithHardcodedLocaleCodes::getPreferredLocaleCode('de_CH,en_US;q=0.8'), + 'Translator::getPreferredLocaleCode does not return the preferred similar match' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly + * @depends testWhetherSplitLocaleCodeInterpretsInvalidLocaleCodesAsLanguageCodes + */ + public function testWhetherGetPreferredLocaleCodeReturnsAPerfectMatchEvenThoughASimilarMatchWasFound() + { + $this->assertEquals( + 'de_AT', + TranslatorWithHardcodedLocaleCodes::getPreferredLocaleCode('de,de_AT;q=0.5'), + 'Translator::getPreferredLocaleCode does not return a perfect ' + . 'match if a similar match with higher priority was found' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeInterpretsInvalidLocaleCodesAsLanguageCodes + */ + public function testWhetherGetPreferredLocaleCodeReturnsASimilarMatchIfNoPerfectMatchCouldBeFound() + { + $this->assertEquals( + 'de_DE', + TranslatorWithHardcodedLocaleCodes::getPreferredLocaleCode('de,en'), + 'Translator::getPreferredLocaleCode does not return the most preferred similar match' + ); + } + + /** + * @depends testWhetherSplitLocaleCodeSplitsValidLocalesCorrectly + */ + public function testWhetherGetPreferredLocaleCodeReturnsTheDefaultLocaleIfNoMatchCouldBeFound() + { + $this->assertEquals( + Translator::DEFAULT_LOCALE, + TranslatorWithHardcodedLocaleCodes::getPreferredLocaleCode('fr_FR,jp_JP'), + 'Translator::getPreferredLocaleCode does not return the default locale if no match could be found' + ); + } } From 2fc793096a9f91a5ff8b9313ad68fc4bbc7873da Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 25 Jun 2014 11:49:47 +0200 Subject: [PATCH 2/2] Use the preferred language sent by the browser, not the configured one refs #6074 --- .../Icinga/Application/ApplicationBootstrap.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/library/Icinga/Application/ApplicationBootstrap.php b/library/Icinga/Application/ApplicationBootstrap.php index bb530ce29..d1328be15 100644 --- a/library/Icinga/Application/ApplicationBootstrap.php +++ b/library/Icinga/Application/ApplicationBootstrap.php @@ -471,26 +471,27 @@ abstract class ApplicationBootstrap /** * Setup internationalization using gettext * - * Uses the language defined in the global config or the default one + * Uses the preferred language sent by the browser or the default one * * @return self */ protected function setupInternationalization() { + $localeDir = $this->getApplicationDir('locale'); + if (file_exists($localeDir) && is_dir($localeDir)) { + Translator::registerDomain(Translator::DEFAULT_DOMAIN, $localeDir); + } + try { Translator::setupLocale( - $this->config->global !== null ? $this->config->global->get('language', Translator::DEFAULT_LOCALE) + isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) + ? Translator::getPreferredLocaleCode($_SERVER['HTTP_ACCEPT_LANGUAGE']) : Translator::DEFAULT_LOCALE ); } catch (Exception $error) { Logger::error($error); } - $localeDir = $this->getApplicationDir('locale'); - if (file_exists($localeDir) && is_dir($localeDir)) { - Translator::registerDomain(Translator::DEFAULT_DOMAIN, $localeDir); - } - return $this; } }