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