xref: /webtrees/app/I18N.php (revision a45f98897789fc9ff88705eb09ae5f037bf49c10)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2018 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16namespace Fisharebest\Webtrees;
17
18use Collator;
19use Exception;
20use Fisharebest\ExtCalendar\ArabicCalendar;
21use Fisharebest\ExtCalendar\CalendarInterface;
22use Fisharebest\ExtCalendar\GregorianCalendar;
23use Fisharebest\ExtCalendar\JewishCalendar;
24use Fisharebest\ExtCalendar\PersianCalendar;
25use Fisharebest\Localization\Locale;
26use Fisharebest\Localization\Locale\LocaleEnUs;
27use Fisharebest\Localization\Locale\LocaleInterface;
28use Fisharebest\Localization\Translation;
29use Fisharebest\Localization\Translator;
30use Fisharebest\Webtrees\Functions\FunctionsEdit;
31
32/**
33 * Internationalization (i18n) and localization (l10n).
34 */
35class I18N
36{
37    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
38    private static $locale;
39
40    /** @var Translator An object that performs translation */
41    private static $translator;
42
43    /** @var  Collator From the php-intl library */
44    private static $collator;
45
46    // Digits are always rendered LTR, even in RTL text.
47    const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
48
49    // These locales need special handling for the dotless letter I.
50    const DOTLESS_I_LOCALES = [
51        'az',
52        'tr',
53    ];
54    const DOTLESS_I_TOLOWER = [
55        'I' => 'ı',
56        'İ' => 'i',
57    ];
58    const DOTLESS_I_TOUPPER = [
59        'ı' => 'I',
60        'i' => 'İ',
61    ];
62
63    // The ranges of characters used by each script.
64    const SCRIPT_CHARACTER_RANGES = [
65        [
66            'Latn',
67            0x0041,
68            0x005A,
69        ],
70        [
71            'Latn',
72            0x0061,
73            0x007A,
74        ],
75        [
76            'Latn',
77            0x0100,
78            0x02AF,
79        ],
80        [
81            'Grek',
82            0x0370,
83            0x03FF,
84        ],
85        [
86            'Cyrl',
87            0x0400,
88            0x052F,
89        ],
90        [
91            'Hebr',
92            0x0590,
93            0x05FF,
94        ],
95        [
96            'Arab',
97            0x0600,
98            0x06FF,
99        ],
100        [
101            'Arab',
102            0x0750,
103            0x077F,
104        ],
105        [
106            'Arab',
107            0x08A0,
108            0x08FF,
109        ],
110        [
111            'Deva',
112            0x0900,
113            0x097F,
114        ],
115        [
116            'Taml',
117            0x0B80,
118            0x0BFF,
119        ],
120        [
121            'Sinh',
122            0x0D80,
123            0x0DFF,
124        ],
125        [
126            'Thai',
127            0x0E00,
128            0x0E7F,
129        ],
130        [
131            'Geor',
132            0x10A0,
133            0x10FF,
134        ],
135        [
136            'Grek',
137            0x1F00,
138            0x1FFF,
139        ],
140        [
141            'Deva',
142            0xA8E0,
143            0xA8FF,
144        ],
145        [
146            'Hans',
147            0x3000,
148            0x303F,
149        ],
150        // Mixed CJK, not just Hans
151        [
152            'Hans',
153            0x3400,
154            0xFAFF,
155        ],
156        // Mixed CJK, not just Hans
157        [
158            'Hans',
159            0x20000,
160            0x2FA1F,
161        ],
162        // Mixed CJK, not just Hans
163    ];
164
165    // Characters that are displayed in mirror form in RTL text.
166    const MIRROR_CHARACTERS = [
167        '('  => ')',
168        ')'  => '(',
169        '['  => ']',
170        ']'  => '[',
171        '{'  => '}',
172        '}'  => '{',
173        '<'  => '>',
174        '>'  => '<',
175        '‹ ' => '›',
176        '› ' => '‹',
177        '«'  => '»',
178        '»'  => '«',
179        '﴾ ' => '﴿',
180        '﴿ ' => '﴾',
181        '“ ' => '”',
182        '” ' => '“',
183        '‘ ' => '’',
184        '’ ' => '‘',
185    ];
186
187    // Default list of locales to show in the menu.
188    const DEFAULT_LOCALES = [
189        'ar',
190        'bg',
191        'bs',
192        'ca',
193        'cs',
194        'da',
195        'de',
196        'el',
197        'en-GB',
198        'en-US',
199        'es',
200        'et',
201        'fi',
202        'fr',
203        'he',
204        'hr',
205        'hu',
206        'is',
207        'it',
208        'ka',
209        'kk',
210        'lt',
211        'mr',
212        'nb',
213        'nl',
214        'nn',
215        'pl',
216        'pt',
217        'ru',
218        'sk',
219        'sv',
220        'tr',
221        'uk',
222        'vi',
223        'zh-Hans',
224    ];
225
226    /** @var string Punctuation used to separate list items, typically a comma */
227    public static $list_separator;
228
229    /**
230     * The prefered locales for this site, or a default list if no preference.
231     *
232     * @return LocaleInterface[]
233     */
234    public static function activeLocales()
235    {
236        $code_list = Site::getPreference('LANGUAGES');
237
238        if ($code_list === '') {
239            $codes = self::DEFAULT_LOCALES;
240        } else {
241            $codes = explode(',', $code_list);
242        }
243
244        $locales = [];
245        foreach ($codes as $code) {
246            if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) {
247                try {
248                    $locales[] = Locale::create($code);
249                } catch (\Exception $ex) {
250                    DebugBar::addThrowable($ex);
251
252                    // No such locale exists?
253                }
254            }
255        }
256        usort($locales, '\Fisharebest\Localization\Locale::compare');
257
258        return $locales;
259    }
260
261    /**
262     * Which MySQL collation should be used for this locale?
263     *
264     * @return string
265     */
266    public static function collation()
267    {
268        $collation = self::$locale->collation();
269        switch ($collation) {
270            case 'croatian_ci':
271            case 'german2_ci':
272            case 'vietnamese_ci':
273                // Only available in MySQL 5.6
274                return 'utf8_unicode_ci';
275            default:
276                return 'utf8_' . $collation;
277        }
278    }
279
280    /**
281     * What format is used to display dates in the current locale?
282     *
283     * @return string
284     */
285    public static function dateFormat()
286    {
287        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
288        return self::$translator->translate('%j %F %Y');
289    }
290
291    /**
292     * Generate consistent I18N for datatables.js
293     *
294     * @param array|null $lengths An optional array of page lengths
295     *
296     * @return string
297     */
298    public static function datatablesI18N(array $lengths = [
299        10,
300        20,
301        30,
302        50,
303        100,
304        -1,
305    ])
306    {
307        $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), 10);
308
309        return
310            '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' .
311            '"language": {' .
312            ' "paginate": {' .
313            '  "first":    "' . self::translate('first') . '",' .
314            '  "last":     "' . self::translate('last') . '",' .
315            '  "next":     "' . self::translate('next') . '",' .
316            '  "previous": "' . self::translate('previous') . '"' .
317            ' },' .
318            ' "emptyTable":     "' . self::translate('No records to display') . '",' .
319            ' "info":           "' . /* I18N: %s are placeholders for numbers */
320            self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' .
321            ' "infoEmpty":      "' . self::translate('Showing %1$s to %2$s of %3$s', self::$locale->digits('0'), self::$locale->digits('0'), self::$locale->digits('0')) . '",' .
322            ' "infoFiltered":   "' . /* I18N: %s is a placeholder for a number */
323            self::translate('(filtered from %s total entries)', '_MAX_') . '",' .
324            ' "lengthMenu":     "' . /* I18N: %s is a number of records per page */
325            self::translate('Display %s', addslashes($length_options)) . '",' .
326            ' "loadingRecords": "' . self::translate('Loading…') . '",' .
327            ' "processing":     "' . self::translate('Loading…') . '",' .
328            ' "search":         "' . self::translate('Filter') . '",' .
329            ' "zeroRecords":    "' . self::translate('No records to display') . '"' .
330            '}';
331    }
332
333    /**
334     * Convert the digits 0-9 into the local script
335     *
336     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
337     *
338     * @param int $n
339     *
340     * @return string
341     */
342    public static function digits($n)
343    {
344        return self::$locale->digits($n);
345    }
346
347    /**
348     * What is the direction of the current locale
349     *
350     * @return string "ltr" or "rtl"
351     */
352    public static function direction()
353    {
354        return self::$locale->direction();
355    }
356
357    /**
358     * What is the first day of the week.
359     *
360     * @return int Sunday=0, Monday=1, etc.
361     */
362    public static function firstDay()
363    {
364        return self::$locale->territory()->firstDay();
365    }
366
367    /**
368     * Convert a GEDCOM age string into translated_text
369     *
370     * NB: The import function will have normalised this, so we don't need
371     * to worry about badly formatted strings
372     * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event()
373     *
374     * @param $string
375     *
376     * @return string
377     */
378    public static function gedcomAge($string)
379    {
380        switch ($string) {
381            case 'STILLBORN':
382                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn)
383                return self::translate('(stillborn)');
384            case 'INFANT':
385                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy)
386                return self::translate('(in infancy)');
387            case 'CHILD':
388                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood)
389                return self::translate('(in childhood)');
390        }
391        $age = [];
392        if (preg_match('/(\d+)y/', $string, $match)) {
393            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
394            $years = $match[1];
395            $age[] = self::plural('%s year', '%s years', $years, self::number($years));
396        } else {
397            $years = -1;
398        }
399        if (preg_match('/(\d+)m/', $string, $match)) {
400            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
401            $age[] = self::plural('%s month', '%s months', $match[1], self::number($match[1]));
402        }
403        if (preg_match('/(\d+)w/', $string, $match)) {
404            // I18N: Part of an age string. e.g. 7 weeks and 3 days
405            $age[] = self::plural('%s week', '%s weeks', $match[1], self::number($match[1]));
406        }
407        if (preg_match('/(\d+)d/', $string, $match)) {
408            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
409            $age[] = self::plural('%s day', '%s days', $match[1], self::number($match[1]));
410        }
411        // If an age is just a number of years, only show the number
412        if (count($age) === 1 && $years >= 0) {
413            $age = $years;
414        }
415        if ($age) {
416            if (!substr_compare($string, '<', 0, 1)) {
417                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years)
418                return self::translate('(aged less than %s)', $age);
419            } elseif (!substr_compare($string, '>', 0, 1)) {
420                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years)
421                return self::translate('(aged more than %s)', $age);
422            } else {
423                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years)
424                return self::translate('(aged %s)', $age);
425            }
426        } else {
427            // Not a valid string?
428            return self::translate('(aged %s)', $string);
429        }
430    }
431
432    /**
433     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
434     *
435     * @return string
436     */
437    public static function htmlAttributes()
438    {
439        return self::$locale->htmlAttributes();
440    }
441
442    /**
443     * Initialise the translation adapter with a locale setting.
444     *
445     * @param string $code Use this locale/language code, or choose one automatically
446     *
447     * @return string $string
448     */
449    public static function init($code = '')
450    {
451        mb_internal_encoding('UTF-8');
452
453        if ($code !== '') {
454            // Create the specified locale
455            self::$locale = Locale::create($code);
456        } else {
457            // Negotiate a locale, but if we can't then use a failsafe
458            self::$locale = new LocaleEnUs();
459            if (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.mo')) {
460                // Previously used
461                self::$locale = Locale::create(Session::get('locale'));
462            } else {
463                // Browser negotiation
464                $default_locale = new LocaleEnUs();
465                try {
466                    // @TODO, when no language is requested by the user (e.g. search engines), we should use
467                    // the tree's default language.  However, we currently initialise languages before trees,
468                    //  so there is no tree available for us to use.
469                } catch (\Exception $ex) {
470                    DebugBar::addThrowable($ex);
471                }
472                self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale);
473            }
474        }
475
476        $cache_dir  = WT_DATA_DIR . 'cache/';
477        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
478        if (file_exists($cache_file)) {
479            $filemtime = filemtime($cache_file);
480        } else {
481            $filemtime = 0;
482        }
483
484        // Load the translation file(s)
485        // Note that glob() returns false instead of an empty array when open_basedir_restriction
486        // is in force and no files are found. See PHP bug #47358.
487        if (defined('GLOB_BRACE')) {
488            $translation_files = array_merge(
489                [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'],
490                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [],
491                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: []
492            );
493        } else {
494            // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php
495            $translation_files = array_merge(
496                [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'],
497                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.csv') ?: [],
498                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.php') ?: [],
499                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.mo') ?: [],
500                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: [],
501                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: [],
502                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: []
503            );
504        }
505        // Rebuild files after one hour
506        $rebuild_cache = time() > $filemtime + 3600;
507        // Rebuild files if any translation file has been updated
508        foreach ($translation_files as $translation_file) {
509            if (filemtime($translation_file) > $filemtime) {
510                $rebuild_cache = true;
511                break;
512            }
513        }
514
515        if ($rebuild_cache) {
516            $translations = [];
517            foreach ($translation_files as $translation_file) {
518                $translation  = new Translation($translation_file);
519                $translations = array_merge($translations, $translation->asArray());
520            }
521            try {
522                File::mkdir($cache_dir);
523                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
524            } catch (Exception $ex) {
525                DebugBar::addThrowable($ex);
526
527                // During setup, we may not have been able to create it.
528            }
529        } else {
530            $translations = include $cache_file;
531        }
532
533        // Create a translator
534        self::$translator = new Translator($translations, self::$locale->pluralRule());
535
536        /* I18N: This punctuation is used to separate lists of items */
537        self::$list_separator = self::translate(', ');
538
539        // Create a collator
540        try {
541            // PHP 5.6 cannot catch errors, so test first
542            if (class_exists('Collator')) {
543                self::$collator = new Collator(self::$locale->code());
544                // Ignore upper/lower case differences
545                self::$collator->setStrength(Collator::SECONDARY);
546            }
547        } catch (Exception $ex) {
548            DebugBar::addThrowable($ex);
549
550            // PHP-INTL is not installed?  We'll use a fallback later.
551        }
552
553        return self::$locale->languageTag();
554    }
555
556    /**
557     * All locales for which a translation file exists.
558     *
559     * @return LocaleInterface[]
560     */
561    public static function installedLocales()
562    {
563        $locales = [];
564        foreach (glob(WT_ROOT . 'language/*.mo') as $file) {
565            try {
566                $locales[] = Locale::create(basename($file, '.mo'));
567            } catch (\Exception $ex) {
568                DebugBar::addThrowable($ex);
569
570                // Not a recognised locale
571            }
572        }
573        usort($locales, '\Fisharebest\Localization\Locale::compare');
574
575        return $locales;
576    }
577
578    /**
579     * Return the endonym for a given language - as per http://cldr.unicode.org/
580     *
581     * @param string $locale
582     *
583     * @return string
584     */
585    public static function languageName($locale)
586    {
587        return Locale::create($locale)->endonym();
588    }
589
590    /**
591     * Return the script used by a given language
592     *
593     * @param string $locale
594     *
595     * @return string
596     */
597    public static function languageScript($locale)
598    {
599        return Locale::create($locale)->script()->code();
600    }
601
602    /**
603     * Translate a number into the local representation.
604     *
605     * e.g. 12345.67 becomes
606     * en: 12,345.67
607     * fr: 12 345,67
608     * de: 12.345,67
609     *
610     * @param float $n
611     * @param int   $precision
612     *
613     * @return string
614     */
615    public static function number($n, $precision = 0)
616    {
617        return self::$locale->number(round($n, $precision));
618    }
619
620    /**
621     * Translate a fraction into a percentage.
622     *
623     * e.g. 0.123 becomes
624     * en: 12.3%
625     * fr: 12,3 %
626     * de: 12,3%
627     *
628     * @param float $n
629     * @param int   $precision
630     *
631     * @return string
632     */
633    public static function percentage($n, $precision = 0)
634    {
635        return self::$locale->percent(round($n, $precision + 2));
636    }
637
638    /**
639     * Translate a plural string
640     *
641     * echo self::plural('There is an error', 'There are errors', $num_errors);
642     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
643     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
644     *
645     * @return string
646     */
647    public static function plural(...$args)
648    {
649        $args[0] = self::$translator->translatePlural($args[0], $args[1], (int) $args[2]);
650        unset($args[1], $args[2]);
651
652        return sprintf(...$args);
653    }
654
655    /**
656     * UTF8 version of PHP::strrev()
657     *
658     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
659     *
660     * These do not support UTF8 text direction, so we must mimic it for them.
661     *
662     * Numbers are always rendered LTR, even in RTL text.
663     * The visual direction of characters such as parentheses should be reversed.
664     *
665     * @param string $text Text to be reversed
666     *
667     * @return string
668     */
669    public static function reverseText($text)
670    {
671        // Remove HTML markup - we can't display it and it is LTR.
672        $text = strip_tags($text);
673        // Remove HTML entities.
674        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
675
676        // LTR text doesn't need reversing
677        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
678            return $text;
679        }
680
681        // Mirrored characters
682        $text = strtr($text, self::MIRROR_CHARACTERS);
683
684        $reversed = '';
685        $digits   = '';
686        while ($text != '') {
687            $letter = mb_substr($text, 0, 1);
688            $text   = mb_substr($text, 1);
689            if (strpos(self::DIGITS, $letter) !== false) {
690                $digits .= $letter;
691            } else {
692                $reversed = $letter . $digits . $reversed;
693                $digits   = '';
694            }
695        }
696
697        return $digits . $reversed;
698    }
699
700    /**
701     * Return the direction (ltr or rtl) for a given script
702     *
703     * The PHP/intl library does not provde this information, so we need
704     * our own lookup table.
705     *
706     * @param string $script
707     *
708     * @return string
709     */
710    public static function scriptDirection($script)
711    {
712        switch ($script) {
713            case 'Arab':
714            case 'Hebr':
715            case 'Mong':
716            case 'Thaa':
717                return 'rtl';
718            default:
719                return 'ltr';
720        }
721    }
722
723    /**
724     * Perform a case-insensitive comparison of two strings.
725     *
726     * @param string $string1
727     * @param string $string2
728     *
729     * @return int
730     */
731    public static function strcasecmp($string1, $string2)
732    {
733        if (self::$collator instanceof Collator) {
734            return self::$collator->compare($string1, $string2);
735        } else {
736            return strcmp(self::strtolower($string1), self::strtolower($string2));
737        }
738    }
739
740    /**
741     * Convert a string to lower case.
742     *
743     * @param string $string
744     *
745     * @return string
746     */
747    public static function strtolower($string)
748    {
749        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
750            $string = strtr($string, self::DOTLESS_I_TOLOWER);
751        }
752
753        return mb_strtolower($string);
754    }
755
756    /**
757     * Convert a string to upper case.
758     *
759     * @param string $string
760     *
761     * @return string
762     */
763    public static function strtoupper($string)
764    {
765        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
766            $string = strtr($string, self::DOTLESS_I_TOUPPER);
767        }
768
769        return mb_strtoupper($string);
770    }
771
772    /**
773     * Identify the script used for a piece of text
774     *
775     * @param $string
776     *
777     * @return string
778     */
779    public static function textScript($string)
780    {
781        $string = strip_tags($string); // otherwise HTML tags show up as latin
782        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
783        $string = str_replace([
784            '@N.N.',
785            '@P.N.',
786        ], '', $string); // otherwise unknown names show up as latin
787        $pos    = 0;
788        $strlen = strlen($string);
789        while ($pos < $strlen) {
790            // get the Unicode Code Point for the character at position $pos
791            $byte1 = ord($string[$pos]);
792            if ($byte1 < 0x80) {
793                $code_point = $byte1;
794                $chrlen     = 1;
795            } elseif ($byte1 < 0xC0) {
796                // Invalid continuation character
797                return 'Latn';
798            } elseif ($byte1 < 0xE0) {
799                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
800                $chrlen     = 2;
801            } elseif ($byte1 < 0xF0) {
802                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
803                $chrlen     = 3;
804            } elseif ($byte1 < 0xF8) {
805                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
806                $chrlen     = 3;
807            } else {
808                // Invalid UTF
809                return 'Latn';
810            }
811
812            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
813                if ($code_point >= $range[1] && $code_point <= $range[2]) {
814                    return $range[0];
815                }
816            }
817            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
818            $pos += $chrlen;
819        }
820
821        return 'Latn';
822    }
823
824    /**
825     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
826     *
827     * @param int $seconds
828     *
829     * @return string
830     */
831    public static function timeAgo($seconds)
832    {
833        $minute = 60;
834        $hour   = 60 * $minute;
835        $day    = 24 * $hour;
836        $month  = 30 * $day;
837        $year   = 365 * $day;
838
839        if ($seconds > $year) {
840            $years = (int)($seconds / $year);
841
842            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
843        } elseif ($seconds > $month) {
844            $months = (int)($seconds / $month);
845
846            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
847        } elseif ($seconds > $day) {
848            $days = (int)($seconds / $day);
849
850            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
851        } elseif ($seconds > $hour) {
852            $hours = (int)($seconds / $hour);
853
854            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
855        } elseif ($seconds > $minute) {
856            $minutes = (int)($seconds / $minute);
857
858            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
859        } else {
860            return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
861        }
862    }
863
864    /**
865     * What format is used to display dates in the current locale?
866     *
867     * @return string
868     */
869    public static function timeFormat()
870    {
871        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
872        return self::$translator->translate('%H:%i:%s');
873    }
874
875    /**
876     * Translate a string, and then substitute placeholders
877     *
878     * echo I18N::translate('Hello World!');
879     * echo I18N::translate('The %s sat on the mat', 'cat');
880     *
881     * @return string
882     */
883    public static function translate(...$args)
884    {
885        $args[0] = self::$translator->translate($args[0]);
886
887        return sprintf(...$args);
888    }
889
890    /**
891     * Context sensitive version of translate.
892     *
893     * echo I18N::translateContext('NOMINATIVE', 'January');
894     * echo I18N::translateContext('GENITIVE', 'January');
895     *
896     * @return string
897     */
898    public static function translateContext(...$args)
899    {
900        $args[1] = self::$translator->translateContext($args[0], $args[1]);
901        unset($args[0]);
902
903        return sprintf(...$args);
904    }
905}
906