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