Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.80% covered (success)
95.80%
137 / 143
85.71% covered (success)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
I18n
95.80% covered (success)
95.80%
137 / 143
85.71% covered (success)
85.71%
12 / 14
118
0.00% covered (danger)
0.00%
0 / 1
 _
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translate
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
13
 encode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadTranslations
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getAvailableLanguages
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getBrowserLanguages
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguageLabels
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isRtl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguageFallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 _getPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 _getPluralForm
83.87% covered (success)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
76.61
 _getMatchingLanguage
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
13
 _matchLanguage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php declare(strict_types=1);
2/**
3 * PrivateBin
4 *
5 * a zero-knowledge paste bin
6 *
7 * @link      https://github.com/PrivateBin/PrivateBin
8 * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
9 * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
10 */
11
12namespace PrivateBin;
13
14use AppendIterator;
15use GlobIterator;
16
17/**
18 * I18n
19 *
20 * provides internationalization tools like translation, browser language detection, etc.
21 */
22class I18n
23{
24    /**
25     * language
26     *
27     * @access protected
28     * @static
29     * @var    string
30     */
31    protected static $_language = 'en';
32
33    /**
34     * language fallback
35     *
36     * @access protected
37     * @static
38     * @var    string
39     */
40    protected static $_languageFallback = 'en';
41
42    /**
43     * language labels
44     *
45     * @access protected
46     * @static
47     * @var    array
48     */
49    protected static $_languageLabels = array();
50
51    /**
52     * available languages
53     *
54     * @access protected
55     * @static
56     * @var    array
57     */
58    protected static $_availableLanguages = array();
59
60    /**
61     * path to language files
62     *
63     * @access protected
64     * @static
65     * @var    string
66     */
67    protected static $_path = '';
68
69    /**
70     * translation cache
71     *
72     * @access protected
73     * @static
74     * @var    array
75     */
76    protected static $_translations = array();
77
78    /**
79     * translate a string, alias for translate()
80     *
81     * @access public
82     * @static
83     * @param  string|array $messageId
84     * @param  mixed $args one or multiple parameters injected into placeholders
85     * @return string
86     */
87    public static function _($messageId, ...$args)
88    {
89        return forward_static_call_array('PrivateBin\I18n::translate', func_get_args());
90    }
91
92    /**
93     * translate a string
94     *
95     * @access public
96     * @static
97     * @param  string|array $messageId
98     * @param  mixed $args one or multiple parameters injected into placeholders
99     * @return string
100     */
101    public static function translate($messageId, ...$args)
102    {
103        if (empty($messageId)) {
104            return $messageId;
105        }
106        if (empty(self::$_translations)) {
107            self::loadTranslations();
108        }
109        $messages = $messageId;
110        if (is_array($messageId)) {
111            $messageId = count($messageId) > 1 ? $messageId[1] : $messageId[0];
112        }
113        if (!array_key_exists($messageId, self::$_translations)) {
114            self::$_translations[$messageId] = $messages;
115        }
116        array_unshift($args, $messageId);
117        if (is_array(self::$_translations[$messageId])) {
118            $number = (int) $args[1];
119            $key    = self::_getPluralForm($number);
120            $max    = count(self::$_translations[$messageId]) - 1;
121            if ($key > $max) {
122                $key = $max;
123            }
124
125            $args[0] = self::$_translations[$messageId][$key];
126            $args[1] = $number;
127        } else {
128            $args[0] = self::$_translations[$messageId];
129        }
130        // encode any non-integer arguments and the message ID, if it doesn't contain a link or keyboard input
131        $argsCount = count($args);
132        for ($i = 0; $i < $argsCount; ++$i) {
133            if ($i === 0) {
134                if (str_contains($args[0], '<a') || str_contains($args[0], '<kbd>')) {
135                    continue;
136                }
137            } elseif (is_int($args[$i])) {
138                continue;
139            }
140            $args[$i] = self::encode($args[$i]);
141        }
142        return call_user_func_array('sprintf', $args);
143    }
144
145    /**
146     * encode HTML entities for output into an HTML5 document
147     *
148     * @access public
149     * @static
150     * @param  string $string
151     * @return string
152     */
153    public static function encode($string)
154    {
155        return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_DISALLOWED, 'UTF-8', false);
156    }
157
158    /**
159     * loads translations
160     *
161     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
162     *
163     * @access public
164     * @static
165     */
166    public static function loadTranslations()
167    {
168        $availableLanguages = self::getAvailableLanguages();
169
170        // check if the lang cookie was set and that language exists
171        if (
172            array_key_exists('lang', $_COOKIE) &&
173            ($key = array_search($_COOKIE['lang'], $availableLanguages)) !== false
174        ) {
175            $match = $availableLanguages[$key];
176        }
177        // find a translation file matching the browsers language preferences
178        else {
179            $match = self::_getMatchingLanguage(
180                self::getBrowserLanguages(), $availableLanguages
181            );
182        }
183
184        // load translations
185        self::$_language     = $match;
186        self::$_translations = ($match == 'en') ? array() : Json::decode(
187            file_get_contents(self::_getPath($match . '.json'))
188        );
189    }
190
191    /**
192     * get list of available translations based on files found
193     *
194     * @access public
195     * @static
196     * @return array
197     */
198    public static function getAvailableLanguages()
199    {
200        if (count(self::$_availableLanguages) == 0) {
201            self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
202            $languageIterator            = new AppendIterator();
203            $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
204            $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
205            foreach ($languageIterator as $file) {
206                $language = $file->getBasename('.json');
207                if ($language != 'en') {
208                    self::$_availableLanguages[] = $language;
209                }
210            }
211        }
212        return self::$_availableLanguages;
213    }
214
215    /**
216     * detect the clients supported languages and return them ordered by preference
217     *
218     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
219     *
220     * @access public
221     * @static
222     * @return array
223     */
224    public static function getBrowserLanguages()
225    {
226        $languages = array();
227        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
228            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
229            foreach ($languageRanges as $languageRange) {
230                if (preg_match(
231                    '/(\*|[a-zA-Z0-9]{1,8}(?:-[a-zA-Z0-9]{1,8})*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?/',
232                    trim($languageRange), $match
233                )) {
234                    if (!isset($match[2])) {
235                        $match[2] = '1.0';
236                    } else {
237                        $match[2] = (string) floatval($match[2]);
238                    }
239                    if (!isset($languages[$match[2]])) {
240                        $languages[$match[2]] = array();
241                    }
242                    $languages[$match[2]][] = strtolower($match[1]);
243                }
244            }
245            krsort($languages);
246        }
247        return $languages;
248    }
249
250    /**
251     * get currently loaded language
252     *
253     * @access public
254     * @static
255     * @return string
256     */
257    public static function getLanguage()
258    {
259        return self::$_language;
260    }
261
262    /**
263     * get list of language labels
264     *
265     * Only for given language codes, otherwise all labels.
266     *
267     * @access public
268     * @static
269     * @param  array $languages
270     * @return array
271     */
272    public static function getLanguageLabels($languages = array())
273    {
274        $file = self::_getPath('languages.json');
275        if (count(self::$_languageLabels) == 0 && is_readable($file)) {
276            self::$_languageLabels = Json::decode(file_get_contents($file));
277        }
278        if (count($languages) == 0) {
279            return self::$_languageLabels;
280        }
281        return array_intersect_key(self::$_languageLabels, array_flip($languages));
282    }
283
284    /**
285     * determines if the current language is written right-to-left (RTL)
286     *
287     * @access public
288     * @static
289     * @return bool
290     */
291    public static function isRtl()
292    {
293        return in_array(self::$_language, array('ar', 'he'));
294    }
295
296    /**
297     * set the default language
298     *
299     * @access public
300     * @static
301     * @param  string $lang
302     */
303    public static function setLanguageFallback($lang)
304    {
305        if (in_array($lang, self::getAvailableLanguages())) {
306            self::$_languageFallback = $lang;
307        }
308    }
309
310    /**
311     * get language file path
312     *
313     * @access protected
314     * @static
315     * @param  string $file
316     * @return string
317     */
318    protected static function _getPath($file = '')
319    {
320        if (empty(self::$_path)) {
321            self::$_path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
322        }
323        return self::$_path . (empty($file) ? '' : DIRECTORY_SEPARATOR . $file);
324    }
325
326    /**
327     * determines the plural form to use based on current language and given number
328     *
329     * From: https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
330     *
331     * @access protected
332     * @static
333     * @param  int $n
334     * @return int
335     */
336    protected static function _getPluralForm($n)
337    {
338        switch (self::$_language) {
339            case 'ar':
340                return $n === 0 ? 0 : ($n === 1 ? 1 : ($n === 2 ? 2 : ($n % 100 >= 3 && $n % 100 <= 10 ? 3 : ($n % 100 >= 11 ? 4 : 5))));
341            case 'cs':
342            case 'sk':
343                return $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2);
344            case 'co':
345            case 'fr':
346            case 'oc':
347            case 'tr':
348            case 'zh':
349                return $n > 1 ? 1 : 0;
350            case 'he':
351                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
352            case 'id':
353            case 'ja':
354            case 'jbo':
355            case 'th':
356                return 0;
357            case 'lt':
358                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
359            case 'pl':
360                return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
361            case 'ro':
362                return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
363            case 'ru':
364            case 'uk':
365                return $n % 10 === 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
366            case 'sl':
367                return $n % 100 === 1 ? 1 : ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0));
368            default:
369                // bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt
370                return $n !== 1 ? 1 : 0;
371        }
372    }
373
374    /**
375     * compares two language preference arrays and returns the preferred match
376     *
377     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
378     *
379     * @access protected
380     * @static
381     * @param  array $acceptedLanguages
382     * @param  array $availableLanguages
383     * @return string
384     */
385    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
386    {
387        $matches = array();
388        $any     = false;
389        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
390            $acceptedQuality = floatval($acceptedQuality);
391            if ($acceptedQuality === 0.0) {
392                continue;
393            }
394            foreach ($availableLanguages as $availableValue) {
395                $availableQuality = 1.0;
396                foreach ($acceptedValues as $acceptedValue) {
397                    if ($acceptedValue === '*') {
398                        $any = true;
399                    }
400                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
401                    if ($matchingGrade > 0) {
402                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
403                        if (!isset($matches[$q])) {
404                            $matches[$q] = array();
405                        }
406                        if (!in_array($availableValue, $matches[$q])) {
407                            $matches[$q][] = $availableValue;
408                        }
409                    }
410                }
411            }
412        }
413        if (count($matches) === 0 && $any) {
414            if (count($availableLanguages) > 0) {
415                $matches['1.0'] = $availableLanguages;
416            }
417        }
418        if (count($matches) === 0) {
419            return self::$_languageFallback;
420        }
421        krsort($matches);
422        $topmatches = current($matches);
423        return current($topmatches);
424    }
425
426    /**
427     * compare two language IDs and return the degree they match
428     *
429     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
430     *
431     * @access protected
432     * @static
433     * @param  string $a
434     * @param  string $b
435     * @return float
436     */
437    protected static function _matchLanguage($a, $b)
438    {
439        $a = explode('-', $a);
440        $b = explode('-', $b);
441        for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
442            if ($a[$i] !== $b[$i]) {
443                break;
444            }
445        }
446        return $i === 0 ? 0 : (float) $i / count($a);
447    }
448}