Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.77% covered (success)
95.77%
136 / 142
85.71% covered (success)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
I18n
95.77% covered (success)
95.77%
136 / 142
85.71% covered (success)
85.71%
12 / 14
116
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.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
10
 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%
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
84.38% covered (success)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
76.66
 _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, but not the message itself
131        // The message ID comes from trusted sources (code or translation JSON files),
132        // while parameters may come from untrusted sources and need HTML entity encoding
133        // to prevent XSS attacks when the message is inserted into HTML context
134        $argsCount = count($args);
135        for ($i = 1; $i < $argsCount; ++$i) {
136            if (is_int($args[$i])) {
137                continue;
138            }
139            $args[$i] = self::encode($args[$i]);
140        }
141        return call_user_func_array('sprintf', $args);
142    }
143
144    /**
145     * encode HTML entities for output into an HTML5 document
146     *
147     * @access public
148     * @static
149     * @param  string $string
150     * @return string
151     */
152    public static function encode($string)
153    {
154        return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_DISALLOWED, 'UTF-8', false);
155    }
156
157    /**
158     * loads translations
159     *
160     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
161     *
162     * @access public
163     * @static
164     * @throws JsonException
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            self::$_language = $availableLanguages[$key];
176        }
177        // find a translation file matching the browsers language preferences
178        else {
179            self::$_language = self::_getMatchingLanguage(
180                self::getBrowserLanguages(), $availableLanguages
181            );
182        }
183
184        // load translations
185        if (self::$_language === 'en') {
186            self::$_translations = array();
187        } else {
188            $data                = file_get_contents(self::_getPath(self::$_language . '.json'));
189            self::$_translations = Json::decode($data);
190        }
191    }
192
193    /**
194     * get list of available translations based on files found
195     *
196     * @access public
197     * @static
198     * @return array
199     */
200    public static function getAvailableLanguages()
201    {
202        if (count(self::$_availableLanguages) === 0) {
203            self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
204            $languageIterator            = new AppendIterator();
205            $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
206            $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
207            foreach ($languageIterator as $file) {
208                $language = $file->getBasename('.json');
209                if ($language !== 'en') {
210                    self::$_availableLanguages[] = $language;
211                }
212            }
213        }
214        return self::$_availableLanguages;
215    }
216
217    /**
218     * detect the clients supported languages and return them ordered by preference
219     *
220     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
221     *
222     * @access public
223     * @static
224     * @return array
225     */
226    public static function getBrowserLanguages()
227    {
228        $languages = array();
229        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
230            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
231            foreach ($languageRanges as $languageRange) {
232                if (preg_match(
233                    '/(\*|[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})))?/',
234                    trim($languageRange), $match
235                )) {
236                    if (!isset($match[2])) {
237                        $match[2] = '1.0';
238                    } else {
239                        $match[2] = (string) floatval($match[2]);
240                    }
241                    if (!isset($languages[$match[2]])) {
242                        $languages[$match[2]] = array();
243                    }
244                    $languages[$match[2]][] = strtolower($match[1]);
245                }
246            }
247            krsort($languages);
248        }
249        return $languages;
250    }
251
252    /**
253     * get currently loaded language
254     *
255     * @access public
256     * @static
257     * @return string
258     */
259    public static function getLanguage()
260    {
261        return self::$_language;
262    }
263
264    /**
265     * get list of language labels
266     *
267     * Only for given language codes, otherwise all labels.
268     *
269     * @access public
270     * @static
271     * @param  array $languages
272     * @throws JsonException
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 'fa':
350            case 'fr':
351            case 'oc':
352            case 'tr':
353            case 'zh':
354                return $n > 1 ? 1 : 0;
355            case 'he':
356                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
357            case 'id':
358            case 'ja':
359            case 'jbo':
360            case 'th':
361                return 0;
362            case 'lt':
363                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
364            case 'pl':
365                return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
366            case 'ro':
367                return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
368            case 'ru':
369            case 'uk':
370                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
371            case 'sl':
372                return $n % 100 === 1 ? 1 : ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0));
373            default:
374                // bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt, sv
375                return $n !== 1 ? 1 : 0;
376        }
377    }
378
379    /**
380     * compares two language preference arrays and returns the preferred match
381     *
382     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
383     *
384     * @access protected
385     * @static
386     * @param  array $acceptedLanguages
387     * @param  array $availableLanguages
388     * @return string
389     */
390    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
391    {
392        $matches = array();
393        $any     = false;
394        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
395            $acceptedQuality = floatval($acceptedQuality);
396            if ($acceptedQuality === 0.0) {
397                continue;
398            }
399            foreach ($availableLanguages as $availableValue) {
400                $availableQuality = 1.0;
401                foreach ($acceptedValues as $acceptedValue) {
402                    if ($acceptedValue === '*') {
403                        $any = true;
404                    }
405                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
406                    if ($matchingGrade > 0) {
407                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
408                        if (!isset($matches[$q])) {
409                            $matches[$q] = array();
410                        }
411                        if (!in_array($availableValue, $matches[$q])) {
412                            $matches[$q][] = $availableValue;
413                        }
414                    }
415                }
416            }
417        }
418        if (count($matches) === 0 && $any) {
419            if (count($availableLanguages) > 0) {
420                $matches['1.0'] = $availableLanguages;
421            }
422        }
423        if (count($matches) === 0) {
424            return self::$_languageFallback;
425        }
426        krsort($matches);
427        $topmatches = current($matches);
428        return current($topmatches);
429    }
430
431    /**
432     * compare two language IDs and return the degree they match
433     *
434     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
435     *
436     * @access protected
437     * @static
438     * @param  string $a
439     * @param  string $b
440     * @return float
441     */
442    protected static function _matchLanguage($a, $b)
443    {
444        $a = explode('-', $a);
445        $b = explode('-', $b);
446        for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
447            if ($a[$i] !== $b[$i]) {
448                break;
449            }
450        }
451        return $i === 0 ? 0 : (float) $i / count($a);
452    }
453}