I18n::setLanguageFallback()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 2
eloc 2
nc 2
nop 1
crap 2
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
12
namespace PrivateBin;
13
14
use AppendIterator;
15
use GlobIterator;
16
17
/**
18
 * I18n
19
 *
20
 * provides internationalization tools like translation, browser language detection, etc.
21
 */
22
class 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 110
    public static function _($messageId, ...$args)
88
    {
89 110
        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 110
    public static function translate($messageId, ...$args)
102
    {
103 110
        if (empty($messageId)) {
104 36
            return $messageId;
105
        }
106 110
        if (empty(self::$_translations)) {
107 86
            self::loadTranslations();
108
        }
109 110
        $messages = $messageId;
110 110
        if (is_array($messageId)) {
111 38
            $messageId = count($messageId) > 1 ? $messageId[1] : $messageId[0];
112
        }
113 110
        if (!array_key_exists($messageId, self::$_translations)) {
114 93
            self::$_translations[$messageId] = $messages;
115
        }
116 110
        array_unshift($args, $messageId);
117 110
        if (is_array(self::$_translations[$messageId])) {
118 48
            $number = (int) $args[1];
119 48
            $key    = self::_getPluralForm($number);
120 48
            $max    = count(self::$_translations[$messageId]) - 1;
121 48
            if ($key > $max) {
122
                $key = $max;
123
            }
124
125 48
            $args[0] = self::$_translations[$messageId][$key];
126 48
            $args[1] = $number;
127
        } else {
128 99
            $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 110
        $argsCount = count($args);
132 110
        for ($i = 0; $i < $argsCount; ++$i) {
133 110
            if ($i === 0) {
134 110
                if (str_contains($args[0], '<a') || str_contains($args[0], '<kbd>')) {
135 110
                    continue;
136
                }
137 67
            } elseif (is_int($args[$i])) {
138 55
                continue;
139
            }
140 110
            $args[$i] = self::encode($args[$i]);
141
        }
142 110
        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 110
    public static function encode($string)
154
    {
155 110
        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 97
    public static function loadTranslations()
167
    {
168 97
        $availableLanguages = self::getAvailableLanguages();
169
170
        // check if the lang cookie was set and that language exists
171
        if (
172 97
            array_key_exists('lang', $_COOKIE) &&
173 97
            ($key = array_search($_COOKIE['lang'], $availableLanguages)) !== false
174
        ) {
175 10
            $match = $availableLanguages[$key];
176
        }
177
        // find a translation file matching the browsers language preferences
178
        else {
179 87
            $match = self::_getMatchingLanguage(
180 87
                self::getBrowserLanguages(), $availableLanguages
181 87
            );
182
        }
183
184
        // load translations
185 97
        self::$_language     = $match;
186 97
        if ($match == 'en') {
187 78
            self::$_translations = array();
188
        } else {
189 19
            $data                = file_get_contents(self::_getPath($match . '.json'));
190 19
            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 146
    public static function getAvailableLanguages()
202
    {
203 146
        if (count(self::$_availableLanguages) == 0) {
204 129
            self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
205 129
            $languageIterator            = new AppendIterator();
206 129
            $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
207 129
            $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
208 129
            foreach ($languageIterator as $file) {
209 129
                $language = $file->getBasename('.json');
210 129
                if ($language != 'en') {
211 129
                    self::$_availableLanguages[] = $language;
212
                }
213
            }
214
        }
215 146
        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 87
    public static function getBrowserLanguages()
228
    {
229 87
        $languages = array();
230 87
        if (array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
231 13
            $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
232 13
            foreach ($languageRanges as $languageRange) {
233 13
                if (preg_match(
234 13
                    '/(\*|[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 13
                    trim($languageRange), $match
236 13
                )) {
237 13
                    if (!isset($match[2])) {
238 6
                        $match[2] = '1.0';
239
                    } else {
240 9
                        $match[2] = (string) floatval($match[2]);
241
                    }
242 13
                    if (!isset($languages[$match[2]])) {
243 13
                        $languages[$match[2]] = array();
244
                    }
245 13
                    $languages[$match[2]][] = strtolower($match[1]);
246
                }
247
            }
248 13
            krsort($languages);
249
        }
250 87
        return $languages;
251
    }
252
253
    /**
254
     * get currently loaded language
255
     *
256
     * @access public
257
     * @static
258
     * @return string
259
     */
260 52
    public static function getLanguage()
261
    {
262 52
        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 38
    public static function getLanguageLabels($languages = array())
276
    {
277 38
        $file = self::_getPath('languages.json');
278 38
        if (count(self::$_languageLabels) == 0 && is_readable($file)) {
279 34
            $data                  = file_get_contents($file);
280 34
            self::$_languageLabels = Json::decode($data);
281
        }
282 38
        if (count($languages) == 0) {
283 1
            return self::$_languageLabels;
284
        }
285 37
        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 41
    public static function isRtl()
296
    {
297 41
        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 126
    public static function setLanguageFallback($lang)
308
    {
309 126
        if (in_array($lang, self::getAvailableLanguages())) {
310 3
            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 143
    protected static function _getPath($file = '')
323
    {
324 143
        if (empty(self::$_path)) {
325 128
            self::$_path = PUBLIC_PATH . DIRECTORY_SEPARATOR . 'i18n';
326
        }
327 143
        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 48
    protected static function _getPluralForm($n)
341
    {
342 48
        switch (self::$_language) {
343 48
            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 48
            case 'cs':
346 47
            case 'sk':
347 1
                return $n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2);
348 47
            case 'co':
349 47
            case 'fr':
350 43
            case 'oc':
351 42
            case 'tr':
352 42
            case 'zh':
353 6
                return $n > 1 ? 1 : 0;
354 41
            case 'he':
355
                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
356 41
            case 'id':
357 41
            case 'ja':
358 41
            case 'jbo':
359 41
            case 'th':
360
                return 0;
361 41
            case 'lt':
362
                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
363 41
            case 'pl':
364 1
                return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
365 40
            case 'ro':
366
                return $n === 1 ? 0 : (($n === 0 || ($n % 100 > 0 && $n % 100 < 20)) ? 1 : 2);
367 40
            case 'ru':
368 39
            case 'uk':
369 1
                return $n % 10 === 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
370 39
            case 'sl':
371 1
                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 38
                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 87
    protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
390
    {
391 87
        $matches = array();
392 87
        $any     = false;
393 87
        foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
394 13
            $acceptedQuality = floatval($acceptedQuality);
395 13
            if ($acceptedQuality === 0.0) {
396 2
                continue;
397
            }
398 13
            foreach ($availableLanguages as $availableValue) {
399 13
                $availableQuality = 1.0;
400 13
                foreach ($acceptedValues as $acceptedValue) {
401 13
                    if ($acceptedValue === '*') {
402 1
                        $any = true;
403
                    }
404 13
                    $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
405 13
                    if ($matchingGrade > 0) {
406 9
                        $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);
407 9
                        if (!isset($matches[$q])) {
408 9
                            $matches[$q] = array();
409
                        }
410 9
                        if (!in_array($availableValue, $matches[$q])) {
411 9
                            $matches[$q][] = $availableValue;
412
                        }
413
                    }
414
                }
415
            }
416
        }
417 87
        if (count($matches) === 0 && $any) {
418 1
            if (count($availableLanguages) > 0) {
419 1
                $matches['1.0'] = $availableLanguages;
420
            }
421
        }
422 87
        if (count($matches) === 0) {
423 77
            return self::$_languageFallback;
424
        }
425 10
        krsort($matches);
426 10
        $topmatches = current($matches);
427 10
        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 13
    protected static function _matchLanguage($a, $b)
442
    {
443 13
        $a = explode('-', $a);
444 13
        $b = explode('-', $b);
445 13
        for ($i = 0, $n = min(count($a), count($b)); $i < $n; ++$i) {
446 13
            if ($a[$i] !== $b[$i]) {
447 13
                break;
448
            }
449
        }
450 13
        return $i === 0 ? 0 : (float) $i / count($a);
451
    }
452
}
453