xref: /webtrees/app/I18N.php (revision 708b06c1245be7c6dea40826c3ffd62805f375b0)
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|null 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 to worry about badly formatted strings
368     * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event()
369     *
370     * @param string $string
371     *
372     * @return string
373     */
374    public static function gedcomAge(string $string): string
375    {
376        switch ($string) {
377            case 'STILLBORN':
378                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn)
379                return self::translate('(stillborn)');
380            case 'INFANT':
381                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy)
382                return self::translate('(in infancy)');
383            case 'CHILD':
384                // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood)
385                return self::translate('(in childhood)');
386        }
387
388        $age = [];
389
390        if (preg_match('/(\d+)y/', $string, $match)) {
391            $years = (int) $match[1];
392            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
393            $age[] = self::plural('%s year', '%s years', $years, self::number($years));
394        } else {
395            $years = -1;
396        }
397
398        if (preg_match('/(\d+)m/', $string, $match)) {
399            $months = (int) $match[1];
400            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
401            $age[] = self::plural('%s month', '%s months', $months, self::number($months));
402        }
403
404        if (preg_match('/(\d+)w/', $string, $match)) {
405            $weeks = (int) $match[1];
406            // I18N: Part of an age string. e.g. 7 weeks and 3 days
407            $age[] = self::plural('%s week', '%s weeks', $weeks, self::number($weeks));
408        }
409
410        if (preg_match('/(\d+)d/', $string, $match)) {
411            $days = (int) $match[1];
412            // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days
413            $age[] = self::plural('%s day', '%s days', $days, self::number($days));
414        }
415
416        // If an age is just a number of years, only show the number
417        if (count($age) === 1 && $years >= 0) {
418            $age = [self::number($years)];
419        }
420
421        $age_string = implode(self::$list_separator, $age);
422
423        // No valid d/m/y values?  Show the original string.
424        if ($age_string === '') {
425            $age_string = $string;
426        }
427
428        if (!substr_compare($string, '<', 0, 1)) {
429            // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years)
430            return self::translate('(aged less than %s)', $age_string);
431        }
432
433        if (!substr_compare($string, '>', 0, 1)) {
434            // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years)
435            return self::translate('(aged more than %s)', $age_string);
436        }
437
438        // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years)
439        return self::translate('(aged %s)', $age_string);
440    }
441
442    /**
443     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
444     *
445     * @return string
446     */
447    public static function htmlAttributes(): string
448    {
449        return self::$locale->htmlAttributes();
450    }
451
452    /**
453     * Initialise the translation adapter with a locale setting.
454     *
455     * @param string    $code Use this locale/language code, or choose one automatically
456     * @param Tree|null $tree
457     *
458     * @return string $string
459     */
460    public static function init(string $code = '', Tree $tree = null): string
461    {
462        mb_internal_encoding('UTF-8');
463
464        if ($code !== '') {
465            // Create the specified locale
466            self::$locale = Locale::create($code);
467        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.mo')) {
468            // Select a previously used locale
469            self::$locale = Locale::create(Session::get('locale'));
470        } else {
471            if ($tree instanceof Tree) {
472                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
473            } else {
474                $default_locale = new LocaleEnUs();
475            }
476
477            // Negotiate with the browser.
478            // Search engines don't negotiate.  They get the default locale of the tree.
479            self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale);
480        }
481
482        $cache_dir  = WT_DATA_DIR . 'cache/';
483        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
484        if (file_exists($cache_file)) {
485            $filemtime = filemtime($cache_file);
486        } else {
487            $filemtime = 0;
488        }
489
490        // Load the translation file(s)
491        // Note that glob() returns false instead of an empty array when open_basedir_restriction
492        // is in force and no files are found. See PHP bug #47358.
493        if (defined('GLOB_BRACE')) {
494            $translation_files = array_merge(
495                [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'],
496                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [],
497                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: []
498            );
499        } else {
500            // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php
501            $translation_files = array_merge(
502                [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'],
503                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.csv') ?: [],
504                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.php') ?: [],
505                glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.mo') ?: [],
506                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: [],
507                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: [],
508                glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: []
509            );
510        }
511        // Rebuild files after one hour
512        $rebuild_cache = time() > $filemtime + 3600;
513        // Rebuild files if any translation file has been updated
514        foreach ($translation_files as $translation_file) {
515            if (filemtime($translation_file) > $filemtime) {
516                $rebuild_cache = true;
517                break;
518            }
519        }
520
521        if ($rebuild_cache) {
522            $translations = [];
523            foreach ($translation_files as $translation_file) {
524                $translation  = new Translation($translation_file);
525                $translations = array_merge($translations, $translation->asArray());
526            }
527            try {
528                File::mkdir($cache_dir);
529                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
530            } catch (Exception $ex) {
531                DebugBar::addThrowable($ex);
532
533                // During setup, we may not have been able to create it.
534            }
535        } else {
536            $translations = include $cache_file;
537        }
538
539        // Create a translator
540        self::$translator = new Translator($translations, self::$locale->pluralRule());
541
542        /* I18N: This punctuation is used to separate lists of items */
543        self::$list_separator = self::translate(', ');
544
545        // Create a collator
546        try {
547            if (class_exists('Collator')) {
548                // Symfony provides a very incomplete polyfill - which cannot be used.
549                self::$collator = new Collator(self::$locale->code());
550                // Ignore upper/lower case differences
551                self::$collator->setStrength(Collator::SECONDARY);
552            }
553        } catch (Exception $ex) {
554            // PHP-INTL is not installed?  We'll use a fallback later.
555            self::$collator = null;
556        }
557
558        return self::$locale->languageTag();
559    }
560
561    /**
562     * All locales for which a translation file exists.
563     *
564     * @return LocaleInterface[]
565     */
566    public static function installedLocales(): array
567    {
568        $locales = [];
569        foreach (glob(WT_ROOT . 'language/*.mo') as $file) {
570            try {
571                $locales[] = Locale::create(basename($file, '.mo'));
572            } catch (\Exception $ex) {
573                DebugBar::addThrowable($ex);
574
575                // Not a recognised locale
576            }
577        }
578        usort($locales, '\Fisharebest\Localization\Locale::compare');
579
580        return $locales;
581    }
582
583    /**
584     * Return the endonym for a given language - as per http://cldr.unicode.org/
585     *
586     * @param string $locale
587     *
588     * @return string
589     */
590    public static function languageName(string $locale): string
591    {
592        return Locale::create($locale)->endonym();
593    }
594
595    /**
596     * Return the script used by a given language
597     *
598     * @param string $locale
599     *
600     * @return string
601     */
602    public static function languageScript(string $locale): string
603    {
604        return Locale::create($locale)->script()->code();
605    }
606
607    /**
608     * Translate a number into the local representation.
609     *
610     * e.g. 12345.67 becomes
611     * en: 12,345.67
612     * fr: 12 345,67
613     * de: 12.345,67
614     *
615     * @param float $n
616     * @param int   $precision
617     *
618     * @return string
619     */
620    public static function number(float $n, int $precision = 0): string
621    {
622        return self::$locale->number(round($n, $precision));
623    }
624
625    /**
626     * Translate a fraction into a percentage.
627     *
628     * e.g. 0.123 becomes
629     * en: 12.3%
630     * fr: 12,3 %
631     * de: 12,3%
632     *
633     * @param float $n
634     * @param int   $precision
635     *
636     * @return string
637     */
638    public static function percentage(float $n, int $precision = 0): string
639    {
640        return self::$locale->percent(round($n, $precision + 2));
641    }
642
643    /**
644     * Translate a plural string
645     * echo self::plural('There is an error', 'There are errors', $num_errors);
646     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
647     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
648     *
649     * @param string $singular
650     * @param string $plural
651     * @param int    $count
652     * @param string ...$args
653     *
654     * @return string
655     */
656    public static function plural(string $singular, string $plural, int $count, ...$args): string
657    {
658        $message = self::$translator->translatePlural($singular, $plural, $count);
659
660        return sprintf($message, ...$args);
661    }
662
663    /**
664     * UTF8 version of PHP::strrev()
665     *
666     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
667     *
668     * These do not support UTF8 text direction, so we must mimic it for them.
669     *
670     * Numbers are always rendered LTR, even in RTL text.
671     * The visual direction of characters such as parentheses should be reversed.
672     *
673     * @param string $text Text to be reversed
674     *
675     * @return string
676     */
677    public static function reverseText($text): string
678    {
679        // Remove HTML markup - we can't display it and it is LTR.
680        $text = strip_tags($text);
681        // Remove HTML entities.
682        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
683
684        // LTR text doesn't need reversing
685        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
686            return $text;
687        }
688
689        // Mirrored characters
690        $text = strtr($text, self::MIRROR_CHARACTERS);
691
692        $reversed = '';
693        $digits   = '';
694        while ($text != '') {
695            $letter = mb_substr($text, 0, 1);
696            $text   = mb_substr($text, 1);
697            if (strpos(self::DIGITS, $letter) !== false) {
698                $digits .= $letter;
699            } else {
700                $reversed = $letter . $digits . $reversed;
701                $digits   = '';
702            }
703        }
704
705        return $digits . $reversed;
706    }
707
708    /**
709     * Return the direction (ltr or rtl) for a given script
710     *
711     * The PHP/intl library does not provde this information, so we need
712     * our own lookup table.
713     *
714     * @param string $script
715     *
716     * @return string
717     */
718    public static function scriptDirection($script)
719    {
720        switch ($script) {
721            case 'Arab':
722            case 'Hebr':
723            case 'Mong':
724            case 'Thaa':
725                return 'rtl';
726            default:
727                return 'ltr';
728        }
729    }
730
731    /**
732     * Perform a case-insensitive comparison of two strings.
733     *
734     * @param string $string1
735     * @param string $string2
736     *
737     * @return int
738     */
739    public static function strcasecmp($string1, $string2)
740    {
741        if (self::$collator instanceof Collator) {
742            return self::$collator->compare($string1, $string2);
743        } else {
744            return strcmp(self::strtolower($string1), self::strtolower($string2));
745        }
746    }
747
748    /**
749     * Convert a string to lower case.
750     *
751     * @param string $string
752     *
753     * @return string
754     */
755    public static function strtolower($string): string
756    {
757        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
758            $string = strtr($string, self::DOTLESS_I_TOLOWER);
759        }
760
761        return mb_strtolower($string);
762    }
763
764    /**
765     * Convert a string to upper case.
766     *
767     * @param string $string
768     *
769     * @return string
770     */
771    public static function strtoupper($string): string
772    {
773        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
774            $string = strtr($string, self::DOTLESS_I_TOUPPER);
775        }
776
777        return mb_strtoupper($string);
778    }
779
780    /**
781     * Identify the script used for a piece of text
782     *
783     * @param $string
784     *
785     * @return string
786     */
787    public static function textScript($string): string
788    {
789        $string = strip_tags($string); // otherwise HTML tags show up as latin
790        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
791        $string = str_replace([
792            '@N.N.',
793            '@P.N.',
794        ], '', $string); // otherwise unknown names show up as latin
795        $pos    = 0;
796        $strlen = strlen($string);
797        while ($pos < $strlen) {
798            // get the Unicode Code Point for the character at position $pos
799            $byte1 = ord($string[$pos]);
800            if ($byte1 < 0x80) {
801                $code_point = $byte1;
802                $chrlen     = 1;
803            } elseif ($byte1 < 0xC0) {
804                // Invalid continuation character
805                return 'Latn';
806            } elseif ($byte1 < 0xE0) {
807                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
808                $chrlen     = 2;
809            } elseif ($byte1 < 0xF0) {
810                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
811                $chrlen     = 3;
812            } elseif ($byte1 < 0xF8) {
813                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
814                $chrlen     = 3;
815            } else {
816                // Invalid UTF
817                return 'Latn';
818            }
819
820            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
821                if ($code_point >= $range[1] && $code_point <= $range[2]) {
822                    return $range[0];
823                }
824            }
825            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
826            $pos += $chrlen;
827        }
828
829        return 'Latn';
830    }
831
832    /**
833     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
834     *
835     * @param int $seconds
836     *
837     * @return string
838     */
839    public static function timeAgo($seconds)
840    {
841        $minute = 60;
842        $hour   = 60 * $minute;
843        $day    = 24 * $hour;
844        $month  = 30 * $day;
845        $year   = 365 * $day;
846
847        if ($seconds > $year) {
848            $years = intdiv($seconds, $year);
849
850            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
851        }
852
853        if ($seconds > $month) {
854            $months = intdiv($seconds, $month);
855
856            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
857        }
858
859        if ($seconds > $day) {
860            $days = intdiv($seconds, $day);
861
862            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
863        }
864
865        if ($seconds > $hour) {
866            $hours = intdiv($seconds, $hour);
867
868            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
869        }
870
871        if ($seconds > $minute) {
872            $minutes = intdiv($seconds, $minute);
873
874            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
875        }
876
877        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
878    }
879
880    /**
881     * What format is used to display dates in the current locale?
882     *
883     * @return string
884     */
885    public static function timeFormat(): string
886    {
887        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
888        return self::$translator->translate('%H:%i:%s');
889    }
890
891    /**
892     * Translate a string, and then substitute placeholders
893     *
894     * echo I18N::translate('Hello World!');
895     * echo I18N::translate('The %s sat on the mat', 'cat');
896     *
897     * @param string $message
898     * @param string ...$args
899     *
900     * @return string
901     */
902    public static function translate(string $message, ...$args): string
903    {
904        $message = self::$translator->translate($message);
905
906        return sprintf($message, ...$args);
907    }
908
909    /**
910     * Context sensitive version of translate.
911     * echo I18N::translateContext('NOMINATIVE', 'January');
912     * echo I18N::translateContext('GENITIVE', 'January');
913     *
914     * @param string $context
915     * @param string $message
916     * @param string ...$args
917     *
918     * @return string
919     */
920    public static function translateContext(string $context, string $message, ...$args): string
921    {
922        $message = self::$translator->translateContext($context, $message);
923
924        return sprintf($message, ...$args);
925    }
926}
927