xref: /webtrees/app/I18N.php (revision fc747c0fe6ab8c4d4d65bbb60eb220a1c792d7f6)
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\Localization\Locale;
21use Fisharebest\Localization\Locale\LocaleEnUs;
22use Fisharebest\Localization\Locale\LocaleInterface;
23use Fisharebest\Localization\Translation;
24use Fisharebest\Localization\Translator;
25use Fisharebest\Webtrees\Functions\FunctionsEdit;
26
27/**
28 * Internationalization (i18n) and localization (l10n).
29 */
30class I18N
31{
32    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
33    private static $locale;
34
35    /** @var Translator An object that performs translation */
36    private static $translator;
37
38    /** @var  Collator From the php-intl library */
39    private static $collator;
40
41    // Digits are always rendered LTR, even in RTL text.
42    const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
43
44    // These locales need special handling for the dotless letter I.
45    const DOTLESS_I_LOCALES = [
46        'az',
47        'tr',
48    ];
49    const DOTLESS_I_TOLOWER = [
50        'I' => 'ı',
51        'İ' => 'i',
52    ];
53    const DOTLESS_I_TOUPPER = [
54        'ı' => 'I',
55        'i' => 'İ',
56    ];
57
58    // The ranges of characters used by each script.
59    const SCRIPT_CHARACTER_RANGES = [
60        [
61            'Latn',
62            0x0041,
63            0x005A,
64        ],
65        [
66            'Latn',
67            0x0061,
68            0x007A,
69        ],
70        [
71            'Latn',
72            0x0100,
73            0x02AF,
74        ],
75        [
76            'Grek',
77            0x0370,
78            0x03FF,
79        ],
80        [
81            'Cyrl',
82            0x0400,
83            0x052F,
84        ],
85        [
86            'Hebr',
87            0x0590,
88            0x05FF,
89        ],
90        [
91            'Arab',
92            0x0600,
93            0x06FF,
94        ],
95        [
96            'Arab',
97            0x0750,
98            0x077F,
99        ],
100        [
101            'Arab',
102            0x08A0,
103            0x08FF,
104        ],
105        [
106            'Deva',
107            0x0900,
108            0x097F,
109        ],
110        [
111            'Taml',
112            0x0B80,
113            0x0BFF,
114        ],
115        [
116            'Sinh',
117            0x0D80,
118            0x0DFF,
119        ],
120        [
121            'Thai',
122            0x0E00,
123            0x0E7F,
124        ],
125        [
126            'Geor',
127            0x10A0,
128            0x10FF,
129        ],
130        [
131            'Grek',
132            0x1F00,
133            0x1FFF,
134        ],
135        [
136            'Deva',
137            0xA8E0,
138            0xA8FF,
139        ],
140        [
141            'Hans',
142            0x3000,
143            0x303F,
144        ],
145        // Mixed CJK, not just Hans
146        [
147            'Hans',
148            0x3400,
149            0xFAFF,
150        ],
151        // Mixed CJK, not just Hans
152        [
153            'Hans',
154            0x20000,
155            0x2FA1F,
156        ],
157        // Mixed CJK, not just Hans
158    ];
159
160    // Characters that are displayed in mirror form in RTL text.
161    const MIRROR_CHARACTERS = [
162        '('  => ')',
163        ')'  => '(',
164        '['  => ']',
165        ']'  => '[',
166        '{'  => '}',
167        '}'  => '{',
168        '<'  => '>',
169        '>'  => '<',
170        '‹ ' => '›',
171        '› ' => '‹',
172        '«'  => '»',
173        '»'  => '«',
174        '﴾ ' => '﴿',
175        '﴿ ' => '﴾',
176        '“ ' => '”',
177        '” ' => '“',
178        '‘ ' => '’',
179        '’ ' => '‘',
180    ];
181
182    // Default list of locales to show in the menu.
183    const DEFAULT_LOCALES = [
184        'ar',
185        'bg',
186        'bs',
187        'ca',
188        'cs',
189        'da',
190        'de',
191        'el',
192        'en-GB',
193        'en-US',
194        'es',
195        'et',
196        'fi',
197        'fr',
198        'he',
199        'hr',
200        'hu',
201        'is',
202        'it',
203        'ka',
204        'kk',
205        'lt',
206        'mr',
207        'nb',
208        'nl',
209        'nn',
210        'pl',
211        'pt',
212        'ru',
213        'sk',
214        'sv',
215        'tr',
216        'uk',
217        'vi',
218        'zh-Hans',
219    ];
220
221    /** @var string Punctuation used to separate list items, typically a comma */
222    public static $list_separator;
223
224    /**
225     * The prefered locales for this site, or a default list if no preference.
226     *
227     * @return LocaleInterface[]
228     */
229    public static function activeLocales(): array
230    {
231        $code_list = Site::getPreference('LANGUAGES');
232
233        if ($code_list === '') {
234            $codes = self::DEFAULT_LOCALES;
235        } else {
236            $codes = explode(',', $code_list);
237        }
238
239        $locales = [];
240        foreach ($codes as $code) {
241            if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) {
242                try {
243                    $locales[] = Locale::create($code);
244                } catch (\Exception $ex) {
245                    DebugBar::addThrowable($ex);
246
247                    // No such locale exists?
248                }
249            }
250        }
251        usort($locales, '\Fisharebest\Localization\Locale::compare');
252
253        return $locales;
254    }
255
256    /**
257     * Which MySQL collation should be used for this locale?
258     *
259     * @return string
260     */
261    public static function collation()
262    {
263        $collation = self::$locale->collation();
264        switch ($collation) {
265            case 'croatian_ci':
266            case 'german2_ci':
267            case 'vietnamese_ci':
268                // Only available in MySQL 5.6
269                return 'utf8_unicode_ci';
270            default:
271                return 'utf8_' . $collation;
272        }
273    }
274
275    /**
276     * What format is used to display dates in the current locale?
277     *
278     * @return string
279     */
280    public static function dateFormat(): string
281    {
282        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
283        return self::$translator->translate('%j %F %Y');
284    }
285
286    /**
287     * Generate consistent I18N for datatables.js
288     *
289     * @param int[] $lengths An optional array of page lengths
290     *
291     * @return string
292     */
293    public static function datatablesI18N(array $lengths = [
294        10,
295        20,
296        30,
297        50,
298        100,
299        -1,
300    ]): string
301    {
302        $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), '10');
303
304        return
305            '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' .
306            '"language": {' .
307            ' "paginate": {' .
308            '  "first":    "' . self::translate('first') . '",' .
309            '  "last":     "' . self::translate('last') . '",' .
310            '  "next":     "' . self::translate('next') . '",' .
311            '  "previous": "' . self::translate('previous') . '"' .
312            ' },' .
313            ' "emptyTable":     "' . self::translate('No records to display') . '",' .
314            ' "info":           "' . /* I18N: %s are placeholders for numbers */
315            self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' .
316            ' "infoEmpty":      "' . self::translate('Showing %1$s to %2$s of %3$s', self::$locale->digits('0'), self::$locale->digits('0'), self::$locale->digits('0')) . '",' .
317            ' "infoFiltered":   "' . /* I18N: %s is a placeholder for a number */
318            self::translate('(filtered from %s total entries)', '_MAX_') . '",' .
319            ' "lengthMenu":     "' . /* I18N: %s is a number of records per page */
320            self::translate('Display %s', addslashes($length_options)) . '",' .
321            ' "loadingRecords": "' . self::translate('Loading…') . '",' .
322            ' "processing":     "' . self::translate('Loading…') . '",' .
323            ' "search":         "' . self::translate('Filter') . '",' .
324            ' "zeroRecords":    "' . self::translate('No records to display') . '"' .
325            '}';
326    }
327
328    /**
329     * Convert the digits 0-9 into the local script
330     *
331     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
332     *
333     * @param string|int $n
334     *
335     * @return string
336     */
337    public static function digits($n): string
338    {
339        return self::$locale->digits((string) $n);
340    }
341
342    /**
343     * What is the direction of the current locale
344     *
345     * @return string "ltr" or "rtl"
346     */
347    public static function direction(): string
348    {
349        return self::$locale->direction();
350    }
351
352    /**
353     * What is the first day of the week.
354     *
355     * @return int Sunday=0, Monday=1, etc.
356     */
357    public static function firstDay(): int
358    {
359        return self::$locale->territory()->firstDay();
360    }
361
362    /**
363     * Convert a GEDCOM age string into translated_text
364     *
365     * NB: The import function will have normalised this, so we don't need
366     * to worry about badly formatted strings
367     * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event()
368     *
369     * @param $string
370     *
371     * @return string
372     */
373    public static function gedcomAge(string $string): string
374    {
375        switch ($string) {
376            case 'STILLBORN':
377                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn)
378                return self::translate('(stillborn)');
379            case 'INFANT':
380                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy)
381                return self::translate('(in infancy)');
382            case 'CHILD':
383                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood)
384                return self::translate('(in childhood)');
385        }
386        $age = [];
387        if (preg_match('/(\d+)y/', $string, $match)) {
388            $years = (int) $match[1];
389            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
390            $age[] = self::plural('%s year', '%s years', $years, self::number($years));
391        } else {
392            $years = -1;
393        }
394        if (preg_match('/(\d+)m/', $string, $match)) {
395            $months = (int) $match[1];
396            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
397            $age[] = self::plural('%s month', '%s months', $months, self::number($months));
398        }
399        if (preg_match('/(\d+)w/', $string, $match)) {
400            $weeks = (int) $match[1];
401            // I18N: Part of an age string. e.g. 7 weeks and 3 days
402            $age[] = self::plural('%s week', '%s weeks', $weeks, self::number($weeks));
403        }
404        if (preg_match('/(\d+)d/', $string, $match)) {
405            $days = (int) $match[1];
406            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
407            $age[] = self::plural('%s day', '%s days', $days, self::number($days));
408        }
409        // If an age is just a number of years, only show the number
410        if (count($age) === 1 && $years >= 0) {
411            $age = $years;
412        }
413        if ($age) {
414            if (!substr_compare($string, '<', 0, 1)) {
415                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years)
416                return self::translate('(aged less than %s)', $age);
417            }
418
419            if (!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            }
423
424            // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years)
425            return self::translate('(aged %s)', $age);
426        }
427
428        // Not a valid string?
429        return self::translate('(aged %s)', $string);
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(): string
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(string $code = ''): string
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(): array
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(string $locale): string
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(string $locale): string
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(float $n, int $precision = 0): string
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(float $n, int $precision = 0): string
634    {
635        return self::$locale->percent(round($n, $precision + 2));
636    }
637
638    /**
639     * Translate a plural string
640     * echo self::plural('There is an error', 'There are errors', $num_errors);
641     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
642     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
643     *
644     * @param string $singular
645     * @param string $plural
646     * @param int    $count
647     * @param string ...$args
648     *
649     * @return string
650     */
651    public static function plural(string $singular, string $plural, int $count, ...$args): string
652    {
653        $message = self::$translator->translatePlural($singular, $plural, $count);
654
655        return sprintf($message, ...$args);
656    }
657
658    /**
659     * UTF8 version of PHP::strrev()
660     *
661     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
662     *
663     * These do not support UTF8 text direction, so we must mimic it for them.
664     *
665     * Numbers are always rendered LTR, even in RTL text.
666     * The visual direction of characters such as parentheses should be reversed.
667     *
668     * @param string $text Text to be reversed
669     *
670     * @return string
671     */
672    public static function reverseText($text): string
673    {
674        // Remove HTML markup - we can't display it and it is LTR.
675        $text = strip_tags($text);
676        // Remove HTML entities.
677        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
678
679        // LTR text doesn't need reversing
680        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
681            return $text;
682        }
683
684        // Mirrored characters
685        $text = strtr($text, self::MIRROR_CHARACTERS);
686
687        $reversed = '';
688        $digits   = '';
689        while ($text != '') {
690            $letter = mb_substr($text, 0, 1);
691            $text   = mb_substr($text, 1);
692            if (strpos(self::DIGITS, $letter) !== false) {
693                $digits .= $letter;
694            } else {
695                $reversed = $letter . $digits . $reversed;
696                $digits   = '';
697            }
698        }
699
700        return $digits . $reversed;
701    }
702
703    /**
704     * Return the direction (ltr or rtl) for a given script
705     *
706     * The PHP/intl library does not provde this information, so we need
707     * our own lookup table.
708     *
709     * @param string $script
710     *
711     * @return string
712     */
713    public static function scriptDirection($script)
714    {
715        switch ($script) {
716            case 'Arab':
717            case 'Hebr':
718            case 'Mong':
719            case 'Thaa':
720                return 'rtl';
721            default:
722                return 'ltr';
723        }
724    }
725
726    /**
727     * Perform a case-insensitive comparison of two strings.
728     *
729     * @param string $string1
730     * @param string $string2
731     *
732     * @return int
733     */
734    public static function strcasecmp($string1, $string2)
735    {
736        if (self::$collator instanceof Collator) {
737            return self::$collator->compare($string1, $string2);
738        }
739
740        return strcmp(self::strtolower($string1), self::strtolower($string2));
741    }
742
743    /**
744     * Convert a string to lower case.
745     *
746     * @param string $string
747     *
748     * @return string
749     */
750    public static function strtolower($string): string
751    {
752        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
753            $string = strtr($string, self::DOTLESS_I_TOLOWER);
754        }
755
756        return mb_strtolower($string);
757    }
758
759    /**
760     * Convert a string to upper case.
761     *
762     * @param string $string
763     *
764     * @return string
765     */
766    public static function strtoupper($string): string
767    {
768        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
769            $string = strtr($string, self::DOTLESS_I_TOUPPER);
770        }
771
772        return mb_strtoupper($string);
773    }
774
775    /**
776     * Identify the script used for a piece of text
777     *
778     * @param $string
779     *
780     * @return string
781     */
782    public static function textScript($string): string
783    {
784        $string = strip_tags($string); // otherwise HTML tags show up as latin
785        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
786        $string = str_replace([
787            '@N.N.',
788            '@P.N.',
789        ], '', $string); // otherwise unknown names show up as latin
790        $pos    = 0;
791        $strlen = strlen($string);
792        while ($pos < $strlen) {
793            // get the Unicode Code Point for the character at position $pos
794            $byte1 = ord($string[$pos]);
795            if ($byte1 < 0x80) {
796                $code_point = $byte1;
797                $chrlen     = 1;
798            } elseif ($byte1 < 0xC0) {
799                // Invalid continuation character
800                return 'Latn';
801            } elseif ($byte1 < 0xE0) {
802                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
803                $chrlen     = 2;
804            } elseif ($byte1 < 0xF0) {
805                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
806                $chrlen     = 3;
807            } elseif ($byte1 < 0xF8) {
808                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
809                $chrlen     = 3;
810            } else {
811                // Invalid UTF
812                return 'Latn';
813            }
814
815            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
816                if ($code_point >= $range[1] && $code_point <= $range[2]) {
817                    return $range[0];
818                }
819            }
820            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
821            $pos += $chrlen;
822        }
823
824        return 'Latn';
825    }
826
827    /**
828     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
829     *
830     * @param int $seconds
831     *
832     * @return string
833     */
834    public static function timeAgo($seconds)
835    {
836        $minute = 60;
837        $hour   = 60 * $minute;
838        $day    = 24 * $hour;
839        $month  = 30 * $day;
840        $year   = 365 * $day;
841
842        if ($seconds > $year) {
843            $years = intdiv($seconds, $year);
844
845            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
846        }
847
848        if ($seconds > $month) {
849            $months = intdiv($seconds, $month);
850
851            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
852        }
853
854        if ($seconds > $day) {
855            $days = intdiv($seconds, $day);
856
857            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
858        }
859
860        if ($seconds > $hour) {
861            $hours = intdiv($seconds, $hour);
862
863            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
864        }
865
866        if ($seconds > $minute) {
867            $minutes = intdiv($seconds, $minute);
868
869            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
870        }
871
872        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
873    }
874
875    /**
876     * What format is used to display dates in the current locale?
877     *
878     * @return string
879     */
880    public static function timeFormat(): string
881    {
882        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
883        return self::$translator->translate('%H:%i:%s');
884    }
885
886    /**
887     * Translate a string, and then substitute placeholders
888     *
889     * echo I18N::translate('Hello World!');
890     * echo I18N::translate('The %s sat on the mat', 'cat');
891     *
892     * @param string $message
893     * @param string ...$args
894     *
895     * @return string
896     */
897    public static function translate(string $message, ...$args): string
898    {
899        $message = self::$translator->translate($message);
900
901        return sprintf($message, ...$args);
902    }
903
904    /**
905     * Context sensitive version of translate.
906     * echo I18N::translateContext('NOMINATIVE', 'January');
907     * echo I18N::translateContext('GENITIVE', 'January');
908     *
909     * @param string $context
910     * @param string $message
911     * @param string ...$args
912     *
913     * @return string
914     */
915    public static function translateContext(string $context, string $message, ...$args): string
916    {
917        $message = self::$translator->translateContext($context, $message);
918
919        return sprintf($message, ...$args);
920    }
921}
922