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
This commit is contained in:
parent
53de3686a8
commit
461b050718
|
@ -12,7 +12,7 @@ if (array_key_exists('_dev', $_GET)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$ie8jsfile = 'js/icinga.ie8.js';
|
$ie8jsfile = 'js/icinga.ie8.js';
|
||||||
$lang = Translator::getLanguage();
|
$lang = Translator::splitLocaleCode()->language;
|
||||||
$isIframe = $this->layout()->isIframe;
|
$isIframe = $this->layout()->isIframe;
|
||||||
$iframeClass = $isIframe ? ' iframe' : '';
|
$iframeClass = $isIframe ? ' iframe' : '';
|
||||||
|
|
||||||
|
|
|
@ -46,8 +46,6 @@ class Translator
|
||||||
*/
|
*/
|
||||||
const DEFAULT_LOCALE = 'en_US';
|
const DEFAULT_LOCALE = 'en_US';
|
||||||
|
|
||||||
protected static $locale = 'C';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Known gettext domains and directories
|
* Known gettext domains and directories
|
||||||
*
|
*
|
||||||
|
@ -107,20 +105,32 @@ class Translator
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$locale = setlocale(LC_ALL, 0);
|
$locale = setlocale(LC_ALL, 0);
|
||||||
self::$locale = $locale;
|
|
||||||
putenv('LC_ALL=' . $locale); // Failsafe, Win and Unix
|
putenv('LC_ALL=' . $locale); // Failsafe, Win and Unix
|
||||||
putenv('LANG=' . $locale); // Windows fix, untested
|
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 (object) array('language' => $languageCode, 'country' => $countryCode);
|
||||||
{
|
|
||||||
return self::$locale === 'C' ? 'en' : substr(self::$locale, 0, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -144,4 +154,72 @@ class Translator
|
||||||
}
|
}
|
||||||
return $codes;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,14 @@ use Exception;
|
||||||
use Icinga\Test\BaseTestCase;
|
use Icinga\Test\BaseTestCase;
|
||||||
use Icinga\Util\Translator;
|
use Icinga\Util\Translator;
|
||||||
|
|
||||||
|
class TranslatorWithHardcodedLocaleCodes extends Translator
|
||||||
|
{
|
||||||
|
public static function getAvailableLocaleCodes()
|
||||||
|
{
|
||||||
|
return array('en_US', 'de_DE', 'de_AT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TranslatorTest extends BaseTestCase
|
class TranslatorTest extends BaseTestCase
|
||||||
{
|
{
|
||||||
public function setUp()
|
public function setUp()
|
||||||
|
@ -75,4 +83,128 @@ class TranslatorTest extends BaseTestCase
|
||||||
'Translator::translate does not translate the given message correctly to French'
|
'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'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue