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' + ); + } }