1<?php 2/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ 3 4namespace Icinga\Util; 5 6use Icinga\Exception\IcingaException; 7 8/** 9 * Helper class to ease internationalization when using gettext 10 */ 11class Translator 12{ 13 /** 14 * The default gettext domain used as fallback 15 */ 16 const DEFAULT_DOMAIN = 'icinga'; 17 18 /** 19 * The locale code that is used in the project 20 */ 21 const DEFAULT_LOCALE = 'en_US'; 22 23 /** 24 * Known gettext domains and directories 25 * 26 * @var array 27 */ 28 private static $knownDomains = array(); 29 30 /** 31 * Translate a string 32 * 33 * Falls back to the default domain in case the string cannot be translated using the given domain 34 * 35 * @param string $text The string to translate 36 * @param string $domain The primary domain to use 37 * @param string|null $context Optional parameter for context based translation 38 * 39 * @return string The translated string 40 */ 41 public static function translate($text, $domain, $context = null) 42 { 43 if ($context !== null) { 44 $res = self::pgettext($text, $domain, $context); 45 if ($res === $text && $domain !== self::DEFAULT_DOMAIN) { 46 $res = self::pgettext($text, self::DEFAULT_DOMAIN, $context); 47 } 48 return $res; 49 } 50 51 $res = dgettext($domain, $text); 52 if ($res === $text && $domain !== self::DEFAULT_DOMAIN) { 53 return dgettext(self::DEFAULT_DOMAIN, $text); 54 } 55 return $res; 56 } 57 58 /** 59 * Translate a plural string 60 * 61 * Falls back to the default domain in case the string cannot be translated using the given domain 62 * 63 * @param string $textSingular The string in singular form to translate 64 * @param string $textPlural The string in plural form to translate 65 * @param integer $number The amount to determine from whether to return singular or plural 66 * @param string $domain The primary domain to use 67 * @param string|null $context Optional parameter for context based translation 68 * 69 * @return string The translated string 70 */ 71 public static function translatePlural($textSingular, $textPlural, $number, $domain, $context = null) 72 { 73 if ($context !== null) { 74 $res = self::pngettext($textSingular, $textPlural, $number, $domain, $context); 75 if (($res === $textSingular || $res === $textPlural) && $domain !== self::DEFAULT_DOMAIN) { 76 $res = self::pngettext($textSingular, $textPlural, $number, self::DEFAULT_DOMAIN, $context); 77 } 78 return $res; 79 } 80 81 $res = dngettext($domain, $textSingular, $textPlural, $number); 82 if (($res === $textSingular || $res === $textPlural) && $domain !== self::DEFAULT_DOMAIN) { 83 $res = dngettext(self::DEFAULT_DOMAIN, $textSingular, $textPlural, $number); 84 } 85 return $res; 86 } 87 88 /** 89 * Emulated pgettext() 90 * 91 * @link http://php.net/manual/de/book.gettext.php#89975 92 * 93 * @param $text 94 * @param $domain 95 * @param $context 96 * 97 * @return string 98 */ 99 public static function pgettext($text, $domain, $context) 100 { 101 $contextString = "{$context}\004{$text}"; 102 103 $translation = dcgettext( 104 $domain, 105 $contextString, 106 defined('LC_MESSAGES') ? LC_MESSAGES : LC_ALL 107 ); 108 109 if ($translation == $contextString) { 110 return $text; 111 } else { 112 return $translation; 113 } 114 } 115 116 /** 117 * Emulated pngettext() 118 * 119 * @link http://php.net/manual/de/book.gettext.php#89975 120 * 121 * @param $textSingular 122 * @param $textPlural 123 * @param $number 124 * @param $domain 125 * @param $context 126 * 127 * @return string 128 */ 129 public static function pngettext($textSingular, $textPlural, $number, $domain, $context) 130 { 131 $contextString = "{$context}\004{$textSingular}"; 132 133 $translation = dcngettext( 134 $domain, 135 $contextString, 136 $textPlural, 137 $number, 138 defined('LC_MESSAGES') ? LC_MESSAGES : LC_ALL 139 ); 140 141 if ($translation == $contextString || $translation == $textPlural) { 142 return ($number == 1 ? $textSingular : $textPlural); 143 } else { 144 return $translation; 145 } 146 } 147 148 /** 149 * Register a new gettext domain 150 * 151 * @param string $name The name of the domain to register 152 * @param string $directory The directory where message catalogs can be found 153 * 154 * @throws IcingaException In case the domain was not successfully registered 155 */ 156 public static function registerDomain($name, $directory) 157 { 158 if (bindtextdomain($name, $directory) === false) { 159 throw new IcingaException( 160 'Cannot register domain \'%s\' with path \'%s\'', 161 $name, 162 $directory 163 ); 164 } 165 bind_textdomain_codeset($name, 'UTF-8'); 166 self::$knownDomains[$name] = $directory; 167 } 168 169 /** 170 * Set the locale to use 171 * 172 * @param string $localeName The name of the locale to use 173 * 174 * @throws IcingaException In case the locale's name is invalid 175 */ 176 public static function setupLocale($localeName) 177 { 178 if (setlocale(LC_ALL, $localeName . '.UTF-8') === false && setlocale(LC_ALL, $localeName) === false) { 179 setlocale(LC_ALL, 'C'); // C == "use whatever is hardcoded" 180 if ($localeName !== self::DEFAULT_LOCALE) { 181 throw new IcingaException( 182 'Cannot set locale \'%s\' for category \'LC_ALL\'', 183 $localeName 184 ); 185 } 186 } else { 187 $locale = setlocale(LC_ALL, 0); 188 putenv('LC_ALL=' . $locale); // Failsafe, Win and Unix 189 putenv('LANG=' . $locale); // Windows fix, untested 190 191 // https://www.gnu.org/software/gettext/manual/html_node/The-LANGUAGE-variable.html 192 putenv('LANGUAGE=' . $localeName . ':' . getenv('LANGUAGE')); 193 } 194 } 195 196 /** 197 * Split and return the language code and country code of the given locale or the current locale 198 * 199 * @param string $locale The locale code to split, or null to split the current locale 200 * 201 * @return object An object with a 'language' and 'country' attribute 202 */ 203 public static function splitLocaleCode($locale = null) 204 { 205 $matches = array(); 206 $locale = $locale !== null ? $locale : setlocale(LC_ALL, 0); 207 if (preg_match('@([a-z]{2})[_-]([a-z]{2})@i', $locale, $matches)) { 208 list($languageCode, $countryCode) = array_slice($matches, 1); 209 } elseif ($locale === 'C') { 210 list($languageCode, $countryCode) = preg_split('@[_-]@', static::DEFAULT_LOCALE, 2); 211 } else { 212 $languageCode = $locale; 213 $countryCode = null; 214 } 215 216 return (object) array('language' => $languageCode, 'country' => $countryCode); 217 } 218 219 /** 220 * Return a list of all locale codes currently available in the known domains 221 * 222 * @return array 223 */ 224 public static function getAvailableLocaleCodes() 225 { 226 $codes = array(static::DEFAULT_LOCALE); 227 foreach (array_values(self::$knownDomains) as $directory) { 228 $dh = opendir($directory); 229 while (false !== ($name = readdir($dh))) { 230 if (substr($name, 0, 1) !== '.' 231 && false === in_array($name, $codes) 232 && is_dir($directory . DIRECTORY_SEPARATOR . $name) 233 ) { 234 $codes[] = $name; 235 } 236 } 237 } 238 sort($codes); 239 240 return $codes; 241 } 242 243 /** 244 * Return the preferred locale based on the given HTTP header and the available translations 245 * 246 * @param string $header The HTTP "Accept-Language" header 247 * 248 * @return string The browser's preferred locale code 249 */ 250 public static function getPreferredLocaleCode($header) 251 { 252 $headerValues = explode(',', $header); 253 for ($i = 0; $i < count($headerValues); $i++) { 254 // In order to accomplish a stable sort we need to take the original 255 // index into account as well during element comparison 256 $headerValues[$i] = array($headerValues[$i], $i); 257 } 258 usort( // Sort DESC but keep equal elements ASC 259 $headerValues, 260 function ($a, $b) { 261 $tagA = explode(';', $a[0], 2); 262 $tagB = explode(';', $b[0], 2); 263 $qValA = (float) (strpos($a[0], ';') > 0 ? substr(array_pop($tagA), 2) : 1); 264 $qValB = (float) (strpos($b[0], ';') > 0 ? substr(array_pop($tagB), 2) : 1); 265 return $qValA < $qValB ? 1 : ($qValA > $qValB ? -1 : ($a[1] > $b[1] ? 1 : ($a[1] < $b[1] ? -1 : 0))); 266 } 267 ); 268 for ($i = 0; $i < count($headerValues); $i++) { 269 // We need to reset the array to its original structure once it's sorted 270 $headerValues[$i] = $headerValues[$i][0]; 271 } 272 $requestedLocales = array(); 273 foreach ($headerValues as $headerValue) { 274 if (strpos($headerValue, ';') > 0) { 275 $parts = explode(';', $headerValue, 2); 276 $headerValue = $parts[0]; 277 } 278 $requestedLocales[] = str_replace('-', '_', $headerValue); 279 } 280 $requestedLocales = array_combine( 281 array_map('strtolower', array_values($requestedLocales)), 282 array_values($requestedLocales) 283 ); 284 285 $availableLocales = static::getAvailableLocaleCodes(); 286 $availableLocales = array_combine( 287 array_map('strtolower', array_values($availableLocales)), 288 array_values($availableLocales) 289 ); 290 291 $similarMatch = null; 292 293 foreach ($requestedLocales as $requestedLocaleLowered => $requestedLocale) { 294 $localeObj = static::splitLocaleCode($requestedLocaleLowered); 295 296 if (isset($availableLocales[$requestedLocaleLowered]) 297 && (! $similarMatch || static::splitLocaleCode($similarMatch)->language === $localeObj->language) 298 ) { 299 // Prefer perfect match only if no similar match has been found yet or the perfect match is more precise 300 // than the similar match 301 return $availableLocales[$requestedLocaleLowered]; 302 } 303 304 if (! $similarMatch) { 305 foreach ($availableLocales as $availableLocaleLowered => $availableLocale) { 306 if (static::splitLocaleCode($availableLocaleLowered)->language === $localeObj->language) { 307 $similarMatch = $availableLocaleLowered; 308 break; 309 } 310 } 311 } 312 } 313 314 return $similarMatch ? $availableLocales[$similarMatch] : static::DEFAULT_LOCALE; 315 } 316} 317