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