Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.36% covered (success)
96.36%
159 / 165
87.50% covered (success)
87.50%
14 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
I18n
96.36% covered (success)
96.36%
159 / 165
87.50% covered (success)
87.50%
14 / 16
138
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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getAvailableLanguages
100.00% covered (success)
100.00%
11 / 11
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
 _normalizeBrowserLanguages
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
18
 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
 getCopyHotkey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 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.85% covered (success)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
76.81
 _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 = [];
50
51    /**
52     * available languages
53     *
54     * @access protected
55     * @static
56     * @var    array
57     */
58    protected static $_availableLanguages = [];
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 = [];
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::_normalizeBrowserLanguages(
181                    self::getBrowserLanguages(), $availableLanguages
182                ),
183                $availableLanguages
184            );
185        }
186
187        // load translations
188        if (self::$_language === 'en') {
189            self::$_translations = [];
190        } else {
191            $data                = file_get_contents(self::_getPath(self::$_language . '.json'));
192            self::$_translations = Json::decode($data);
193        }
194    }
195
196    /**
197     * get list of available translations based on files found
198     *
199     * @access public
200     * @static
201     * @return array
202     */
203    public static function getAvailableLanguages()
204    {
205        if (count(self::$_availableLanguages) === 0) {
206            self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
207            $languageIterator            = new AppendIterator();
208            $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
209            $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
210            $languageIterator->append(new GlobIterator(self::_getPath('??-??.json'))); // for regional variants like zh-tw
211            foreach ($languageIterator as $file) {
212                $language = $file->getBasename('.json');
213                if ($language !== 'en') {
214                    self::$_availableLanguages[] = $language;
215                }
216            }
217        }
218        return self::$_availableLanguages;
219    }
220
221    /**
222     * detect the clients supported languages and return them ordered by preference
223     *
224     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
225     *
226     * @access public
227     * @static
228     * @return array
229     */
230    public static function getBrowserLanguages()
231    {
232        $languages = [];
233        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
234            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
235            foreach ($languageRanges as $languageRange) {
236                if (preg_match(
237                    '/(\*|[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})))?/',
238                    trim($languageRange), $match
239                )) {
240                    if (!isset($match[2])) {
241                        $match[2] = '1.0';
242                    } else {
243                        $match[2] = (string) floatval($match[2]);
244                    }
245                    if (!isset($languages[$match[2]])) {
246                        $languages[$match[2]] = [];
247                    }
248                    $languages[$match[2]][] = strtolower($match[1]);
249                }
250            }
251            krsort($languages);
252        }
253        return $languages;
254    }
255
256    /**
257     * normalize browser language aliases to available locale IDs
258     *
259     * @access protected
260     * @static
261     * @param  array $languages
262     * @param  array $availableLanguages
263     * @return array
264     */
265    protected static function _normalizeBrowserLanguages($languages, $availableLanguages)
266    {
267        // Base zh stays before zh-tw because regional locales are appended after base locales.
268        $hasSimplifiedChinese  = in_array('zh', $availableLanguages, true);
269        $hasTraditionalChinese = in_array('zh-tw', $availableLanguages, true);
270        foreach ($languages as $quality => $languageRanges) {
271            foreach ($languageRanges as $index => $languageRange) {
272                $isSimplifiedChinese = $languageRange === 'zh-hans' || str_starts_with($languageRange, 'zh-hans-') ||
273                    $languageRange === 'zh-cn' || str_starts_with($languageRange, 'zh-cn-') ||
274                    $languageRange === 'zh-sg' || str_starts_with($languageRange, 'zh-sg-');
275                $isTraditionalChinese = str_starts_with($languageRange, 'zh-tw-') ||
276                    $languageRange === 'zh-hant' || str_starts_with($languageRange, 'zh-hant-') ||
277                    $languageRange === 'zh-hk' || str_starts_with($languageRange, 'zh-hk-') ||
278                    $languageRange === 'zh-mo' || str_starts_with($languageRange, 'zh-mo-');
279                if ($hasSimplifiedChinese && $isSimplifiedChinese) {
280                    $languages[$quality][$index] = 'zh';
281                } elseif ($hasTraditionalChinese && $isTraditionalChinese) {
282                    $languages[$quality][$index] = 'zh-tw';
283                }
284            }
285        }
286        return $languages;
287    }
288
289    /**
290     * get currently loaded language
291     *
292     * @access public
293     * @static
294     * @return string
295     */
296    public static function getLanguage()
297    {
298        return self::$_language;
299    }
300
301    /**
302     * get list of language labels
303     *
304     * Only for given language codes, otherwise all labels.
305     *
306     * @access public
307     * @static
308     * @param  array $languages
309     * @throws JsonException
310     * @return array
311     */
312    public static function getLanguageLabels($languages = [])
313    {
314        $file = self::_getPath('languages.json');
315        if (count(self::$_languageLabels) === 0 && is_readable($file)) {
316            $data                  = file_get_contents($file);
317            self::$_languageLabels = Json::decode($data);
318        }
319        if (count($languages) === 0) {
320            return self::$_languageLabels;
321        }
322        return array_intersect_key(self::$_languageLabels, array_flip($languages));
323    }
324
325    /**
326     * determines if the current language is written right-to-left (RTL)
327     *
328     * @access public
329     * @static
330     * @return bool
331     */
332    public static function isRtl()
333    {
334        return in_array(self::$_language, ['ar', 'fa', 'he']);
335    }
336
337    /**
338     * get OS-specific copy hotkey modifier key name based on user agent
339     *
340     * @access public
341     * @static
342     * @return string 'Cmd' on macOS, 'Ctrl' otherwise
343     */
344    public static function getCopyHotkey()
345    {
346        return array_key_exists('HTTP_USER_AGENT', $_SERVER) &&
347            str_contains($_SERVER['HTTP_USER_AGENT'], 'Mac') ? self::_('Cmd') : self::_('Ctrl');
348    }
349
350    /**
351     * set the default language
352     *
353     * @access public
354     * @static
355     * @param  string $lang
356     */
357    public static function setLanguageFallback($lang)
358    {
359        if (in_array($lang, self::getAvailableLanguages())) {
360            self::$_languageFallback = $lang;
361        }
362    }
363
364    /**
365     * get language file path
366     *
367     * @access protected
368     * @static
369     * @param  string $file
370     * @return string
371     */
372    protected static function _getPath($file = '')
373    {
374        if (empty(self::$_path)) {
375            self::$_path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
376        }
377        return self::$_path . (empty($file) ? '' : DIRECTORY_SEPARATOR . $file);
378    }
379
380    /**
381     * determines the plural form to use based on current language and given number
382     *
383     * From: https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
384     *
385     * @access protected
386     * @static
387     * @param  int $n
388     * @return int
389     */
390    protected static function _getPluralForm($n)
391    {
392        switch (self::$_language) {
393            case 'ar':
394                return $n === 0 ? 0 : ($n === 1 ? 1 : ($n === 2 ? 2 : ($n % 100 >= 3 && $n % 100 <= 10 ? 3 : ($n % 100 >= 11 ? 4 : 5))));
395            case 'cs':
396            case 'sk':
397                return $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2);
398            case 'co':
399            case 'fa':
400            case 'fr':
401            case 'oc':
402            case 'tr':
403            case 'zh':
404            case 'zh-tw':
405                return $n > 1 ? 1 : 0;
406            case 'he':
407                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
408            case 'id':
409            case 'ja':
410            case 'jbo':
411            case 'th':
412                return 0;
413            case 'lt':
414                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
415            case 'pl':
416                return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
417            case 'ro':
418                return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
419            case 'ru':
420            case 'uk':
421                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
422            case 'sl':
423                return $n % 100 === 1 ? 1 : ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0));
424            default:
425                // bg, ca, de, el, en, es, et, fi, hu, it, nl, no, pt, sv
426                return $n !== 1 ? 1 : 0;
427        }
428    }
429
430    /**
431     * compares two language preference arrays and returns the preferred match
432     *
433     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
434     *
435     * @access protected
436     * @static
437     * @param  array $acceptedLanguages
438     * @param  array $availableLanguages
439     * @return string
440     */
441    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
442    {
443        $matches = [];
444        $any     = false;
445        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
446            $acceptedQuality = floatval($acceptedQuality);
447            if ($acceptedQuality === 0.0) {
448                continue;
449            }
450            foreach ($availableLanguages as $availableValue) {
451                $availableQuality = 1.0;
452                foreach ($acceptedValues as $acceptedValue) {
453                    if ($acceptedValue === '*') {
454                        $any = true;
455                    }
456                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
457                    if ($matchingGrade > 0) {
458                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
459                        if (!isset($matches[$q])) {
460                            $matches[$q] = [];
461                        }
462                        if (!in_array($availableValue, $matches[$q])) {
463                            $matches[$q][] = $availableValue;
464                        }
465                    }
466                }
467            }
468        }
469        if (count($matches) === 0 && $any) {
470            if (count($availableLanguages) > 0) {
471                $matches['1.0'] = $availableLanguages;
472            }
473        }
474        if (count($matches) === 0) {
475            return self::$_languageFallback;
476        }
477        krsort($matches);
478        $topmatches = current($matches);
479        return current($topmatches);
480    }
481
482    /**
483     * compare two language IDs and return the degree they match
484     *
485     * From: https://stackoverflow.com/questions/3770513/detect-browser-language-in-php#3771447
486     *
487     * @access protected
488     * @static
489     * @param  string $a
490     * @param  string $b
491     * @return float
492     */
493    protected static function _matchLanguage($a, $b)
494    {
495        $a = explode('-', $a);
496        $b = explode('-', $b);
497        for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
498            if ($a[$i] !== $b[$i]) {
499                break;
500            }
501        }
502        return $i === 0 ? 0 : (float) $i / count($a);
503    }
504}