Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.86% covered (success)
95.86%
139 / 145
85.71% covered (success)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
I18n
95.86% covered (success)
95.86%
139 / 145
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%
12 / 12
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%
7 / 7
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        if ($match == 'en') {
187            self::$_translations = array();
188        } else {
189            $data                = file_get_contents(self::_getPath($match . '.json'));
190            self::$_translations = Json::decode($data);
191        }
192    }
193
194    /**
195     * get list of available translations based on files found
196     *
197     * @access public
198     * @static
199     * @return array
200     */
201    public static function getAvailableLanguages()
202    {
203        if (count(self::$_availableLanguages) == 0) {
204            self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
205            $languageIterator            = new AppendIterator();
206            $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
207            $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
208            foreach ($languageIterator as $file) {
209                $language = $file->getBasename('.json');
210                if ($language != 'en') {
211                    self::$_availableLanguages[] = $language;
212                }
213            }
214        }
215        return self::$_availableLanguages;
216    }
217
218    /**
219     * detect the clients supported languages and return them ordered by preference
220     *
221     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
222     *
223     * @access public
224     * @static
225     * @return array
226     */
227    public static function getBrowserLanguages()
228    {
229        $languages = array();
230        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
231            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
232            foreach ($languageRanges as $languageRange) {
233                if (preg_match(
234                    '/(\*|[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})))?/',
235                    trim($languageRange), $match
236                )) {
237                    if (!isset($match[2])) {
238                        $match[2] = '1.0';
239                    } else {
240                        $match[2] = (string) floatval($match[2]);
241                    }
242                    if (!isset($languages[$match[2]])) {
243                        $languages[$match[2]] = array();
244                    }
245                    $languages[$match[2]][] = strtolower($match[1]);
246                }
247            }
248            krsort($languages);
249        }
250        return $languages;
251    }
252
253    /**
254     * get currently loaded language
255     *
256     * @access public
257     * @static
258     * @return string
259     */
260    public static function getLanguage()
261    {
262        return self::$_language;
263    }
264
265    /**
266     * get list of language labels
267     *
268     * Only for given language codes, otherwise all labels.
269     *
270     * @access public
271     * @static
272     * @param  array $languages
273     * @return array
274     */
275    public static function getLanguageLabels($languages = array())
276    {
277        $file = self::_getPath('languages.json');
278        if (count(self::$_languageLabels) == 0 && is_readable($file)) {
279            $data                  = file_get_contents($file);
280            self::$_languageLabels = Json::decode($data);
281        }
282        if (count($languages) == 0) {
283            return self::$_languageLabels;
284        }
285        return array_intersect_key(self::$_languageLabels, array_flip($languages));
286    }
287
288    /**
289     * determines if the current language is written right-to-left (RTL)
290     *
291     * @access public
292     * @static
293     * @return bool
294     */
295    public static function isRtl()
296    {
297        return in_array(self::$_language, array('ar', 'he'));
298    }
299
300    /**
301     * set the default language
302     *
303     * @access public
304     * @static
305     * @param  string $lang
306     */
307    public static function setLanguageFallback($lang)
308    {
309        if (in_array($lang, self::getAvailableLanguages())) {
310            self::$_languageFallback = $lang;
311        }
312    }
313
314    /**
315     * get language file path
316     *
317     * @access protected
318     * @static
319     * @param  string $file
320     * @return string
321     */
322    protected static function _getPath($file = '')
323    {
324        if (empty(self::$_path)) {
325            self::$_path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
326        }
327        return self::$_path . (empty($file) ? '' : DIRECTORY_SEPARATOR . $file);
328    }
329
330    /**
331     * determines the plural form to use based on current language and given number
332     *
333     * From: https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
334     *
335     * @access protected
336     * @static
337     * @param  int $n
338     * @return int
339     */
340    protected static function _getPluralForm($n)
341    {
342        switch (self::$_language) {
343            case 'ar':
344                return $n === 0 ? 0 : ($n === 1 ? 1 : ($n === 2 ? 2 : ($n % 100 >= 3 && $n % 100 <= 10 ? 3 : ($n % 100 >= 11 ? 4 : 5))));
345            case 'cs':
346            case 'sk':
347                return $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2);
348            case 'co':
349            case 'fr':
350            case 'oc':
351            case 'tr':
352            case 'zh':
353                return $n > 1 ? 1 : 0;
354            case 'he':
355                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
356            case 'id':
357            case 'ja':
358            case 'jbo':
359            case 'th':
360                return 0;
361            case 'lt':
362                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
363            case 'pl':
364                return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
365            case 'ro':
366                return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
367            case 'ru':
368            case 'uk':
369                return $n % 10 === 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
370            case 'sl':
371                return $n % 100 === 1 ? 1 : ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0));
372            default:
373                // bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt
374                return $n !== 1 ? 1 : 0;
375        }
376    }
377
378    /**
379     * compares two language preference arrays and returns the preferred match
380     *
381     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
382     *
383     * @access protected
384     * @static
385     * @param  array $acceptedLanguages
386     * @param  array $availableLanguages
387     * @return string
388     */
389    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
390    {
391        $matches = array();
392        $any     = false;
393        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
394            $acceptedQuality = floatval($acceptedQuality);
395            if ($acceptedQuality === 0.0) {
396                continue;
397            }
398            foreach ($availableLanguages as $availableValue) {
399                $availableQuality = 1.0;
400                foreach ($acceptedValues as $acceptedValue) {
401                    if ($acceptedValue === '*') {
402                        $any = true;
403                    }
404                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
405                    if ($matchingGrade > 0) {
406                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
407                        if (!isset($matches[$q])) {
408                            $matches[$q] = array();
409                        }
410                        if (!in_array($availableValue, $matches[$q])) {
411                            $matches[$q][] = $availableValue;
412                        }
413                    }
414                }
415            }
416        }
417        if (count($matches) === 0 && $any) {
418            if (count($availableLanguages) > 0) {
419                $matches['1.0'] = $availableLanguages;
420            }
421        }
422        if (count($matches) === 0) {
423            return self::$_languageFallback;
424        }
425        krsort($matches);
426        $topmatches = current($matches);
427        return current($topmatches);
428    }
429
430    /**
431     * compare two language IDs and return the degree they match
432     *
433     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
434     *
435     * @access protected
436     * @static
437     * @param  string $a
438     * @param  string $b
439     * @return float
440     */
441    protected static function _matchLanguage($a, $b)
442    {
443        $a = explode('-', $a);
444        $b = explode('-', $b);
445        for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
446            if ($a[$i] !== $b[$i]) {
447                break;
448            }
449        }
450        return $i === 0 ? 0 : (float) $i / count($a);
451    }
452}