xref: /webtrees/app/I18N.php (revision 55a938b85858aa77ef715a924d890ed992143551)
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 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);
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);
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);
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            // PHP 5.6 cannot catch errors, so test first
548            if (class_exists('Collator')) {
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            DebugBar::addThrowable($ex);
555
556            // PHP-INTL is not installed?  We'll use a fallback later.
557        }
558
559        return self::$locale->languageTag();
560    }
561
562    /**
563     * All locales for which a translation file exists.
564     *
565     * @return LocaleInterface[]
566     */
567    public static function installedLocales(): array
568    {
569        $locales = [];
570        foreach (glob(WT_ROOT . 'language/*.mo') as $file) {
571            try {
572                $locales[] = Locale::create(basename($file, '.mo'));
573            } catch (\Exception $ex) {
574                DebugBar::addThrowable($ex);
575
576                // Not a recognised locale
577            }
578        }
579        usort($locales, '\Fisharebest\Localization\Locale::compare');
580
581        return $locales;
582    }
583
584    /**
585     * Return the endonym for a given language - as per http://cldr.unicode.org/
586     *
587     * @param string $locale
588     *
589     * @return string
590     */
591    public static function languageName(string $locale): string
592    {
593        return Locale::create($locale)->endonym();
594    }
595
596    /**
597     * Return the script used by a given language
598     *
599     * @param string $locale
600     *
601     * @return string
602     */
603    public static function languageScript(string $locale): string
604    {
605        return Locale::create($locale)->script()->code();
606    }
607
608    /**
609     * Translate a number into the local representation.
610     *
611     * e.g. 12345.67 becomes
612     * en: 12,345.67
613     * fr: 12 345,67
614     * de: 12.345,67
615     *
616     * @param float $n
617     * @param int   $precision
618     *
619     * @return string
620     */
621    public static function number(float $n, int $precision = 0): string
622    {
623        return self::$locale->number(round($n, $precision));
624    }
625
626    /**
627     * Translate a fraction into a percentage.
628     *
629     * e.g. 0.123 becomes
630     * en: 12.3%
631     * fr: 12,3 %
632     * de: 12,3%
633     *
634     * @param float $n
635     * @param int   $precision
636     *
637     * @return string
638     */
639    public static function percentage(float $n, int $precision = 0): string
640    {
641        return self::$locale->percent(round($n, $precision + 2));
642    }
643
644    /**
645     * Translate a plural string
646     * echo self::plural('There is an error', 'There are errors', $num_errors);
647     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
648     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
649     *
650     * @param string $singular
651     * @param string $plural
652     * @param int    $count
653     * @param string ...$args
654     *
655     * @return string
656     */
657    public static function plural(string $singular, string $plural, int $count, ...$args): string
658    {
659        $message = self::$translator->translatePlural($singular, $plural, $count);
660
661        return sprintf($message, ...$args);
662    }
663
664    /**
665     * UTF8 version of PHP::strrev()
666     *
667     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
668     *
669     * These do not support UTF8 text direction, so we must mimic it for them.
670     *
671     * Numbers are always rendered LTR, even in RTL text.
672     * The visual direction of characters such as parentheses should be reversed.
673     *
674     * @param string $text Text to be reversed
675     *
676     * @return string
677     */
678    public static function reverseText($text): string
679    {
680        // Remove HTML markup - we can't display it and it is LTR.
681        $text = strip_tags($text);
682        // Remove HTML entities.
683        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
684
685        // LTR text doesn't need reversing
686        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
687            return $text;
688        }
689
690        // Mirrored characters
691        $text = strtr($text, self::MIRROR_CHARACTERS);
692
693        $reversed = '';
694        $digits   = '';
695        while ($text != '') {
696            $letter = mb_substr($text, 0, 1);
697            $text   = mb_substr($text, 1);
698            if (strpos(self::DIGITS, $letter) !== false) {
699                $digits .= $letter;
700            } else {
701                $reversed = $letter . $digits . $reversed;
702                $digits   = '';
703            }
704        }
705
706        return $digits . $reversed;
707    }
708
709    /**
710     * Return the direction (ltr or rtl) for a given script
711     *
712     * The PHP/intl library does not provde this information, so we need
713     * our own lookup table.
714     *
715     * @param string $script
716     *
717     * @return string
718     */
719    public static function scriptDirection($script)
720    {
721        switch ($script) {
722            case 'Arab':
723            case 'Hebr':
724            case 'Mong':
725            case 'Thaa':
726                return 'rtl';
727            default:
728                return 'ltr';
729        }
730    }
731
732    /**
733     * Perform a case-insensitive comparison of two strings.
734     *
735     * @param string $string1
736     * @param string $string2
737     *
738     * @return int
739     */
740    public static function strcasecmp($string1, $string2)
741    {
742        if (self::$collator instanceof Collator) {
743            return self::$collator->compare($string1, $string2);
744        }
745
746        return strcmp(self::strtolower($string1), self::strtolower($string2));
747    }
748
749    /**
750     * Convert a string to lower case.
751     *
752     * @param string $string
753     *
754     * @return string
755     */
756    public static function strtolower($string): string
757    {
758        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
759            $string = strtr($string, self::DOTLESS_I_TOLOWER);
760        }
761
762        return mb_strtolower($string);
763    }
764
765    /**
766     * Convert a string to upper case.
767     *
768     * @param string $string
769     *
770     * @return string
771     */
772    public static function strtoupper($string): string
773    {
774        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
775            $string = strtr($string, self::DOTLESS_I_TOUPPER);
776        }
777
778        return mb_strtoupper($string);
779    }
780
781    /**
782     * Identify the script used for a piece of text
783     *
784     * @param $string
785     *
786     * @return string
787     */
788    public static function textScript($string): string
789    {
790        $string = strip_tags($string); // otherwise HTML tags show up as latin
791        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
792        $string = str_replace([
793            '@N.N.',
794            '@P.N.',
795        ], '', $string); // otherwise unknown names show up as latin
796        $pos    = 0;
797        $strlen = strlen($string);
798        while ($pos < $strlen) {
799            // get the Unicode Code Point for the character at position $pos
800            $byte1 = ord($string[$pos]);
801            if ($byte1 < 0x80) {
802                $code_point = $byte1;
803                $chrlen     = 1;
804            } elseif ($byte1 < 0xC0) {
805                // Invalid continuation character
806                return 'Latn';
807            } elseif ($byte1 < 0xE0) {
808                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
809                $chrlen     = 2;
810            } elseif ($byte1 < 0xF0) {
811                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
812                $chrlen     = 3;
813            } elseif ($byte1 < 0xF8) {
814                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
815                $chrlen     = 3;
816            } else {
817                // Invalid UTF
818                return 'Latn';
819            }
820
821            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
822                if ($code_point >= $range[1] && $code_point <= $range[2]) {
823                    return $range[0];
824                }
825            }
826            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
827            $pos += $chrlen;
828        }
829
830        return 'Latn';
831    }
832
833    /**
834     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
835     *
836     * @param int $seconds
837     *
838     * @return string
839     */
840    public static function timeAgo($seconds)
841    {
842        $minute = 60;
843        $hour   = 60 * $minute;
844        $day    = 24 * $hour;
845        $month  = 30 * $day;
846        $year   = 365 * $day;
847
848        if ($seconds > $year) {
849            $years = intdiv($seconds, $year);
850
851            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
852        }
853
854        if ($seconds > $month) {
855            $months = intdiv($seconds, $month);
856
857            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
858        }
859
860        if ($seconds > $day) {
861            $days = intdiv($seconds, $day);
862
863            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
864        }
865
866        if ($seconds > $hour) {
867            $hours = intdiv($seconds, $hour);
868
869            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
870        }
871
872        if ($seconds > $minute) {
873            $minutes = intdiv($seconds, $minute);
874
875            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
876        }
877
878        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
879    }
880
881    /**
882     * What format is used to display dates in the current locale?
883     *
884     * @return string
885     */
886    public static function timeFormat(): string
887    {
888        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
889        return self::$translator->translate('%H:%i:%s');
890    }
891
892    /**
893     * Translate a string, and then substitute placeholders
894     *
895     * echo I18N::translate('Hello World!');
896     * echo I18N::translate('The %s sat on the mat', 'cat');
897     *
898     * @param string $message
899     * @param string ...$args
900     *
901     * @return string
902     */
903    public static function translate(string $message, ...$args): string
904    {
905        $message = self::$translator->translate($message);
906
907        return sprintf($message, ...$args);
908    }
909
910    /**
911     * Context sensitive version of translate.
912     * echo I18N::translateContext('NOMINATIVE', 'January');
913     * echo I18N::translateContext('GENITIVE', 'January');
914     *
915     * @param string $context
916     * @param string $message
917     * @param string ...$args
918     *
919     * @return string
920     */
921    public static function translateContext(string $context, string $message, ...$args): string
922    {
923        $message = self::$translator->translateContext($context, $message);
924
925        return sprintf($message, ...$args);
926    }
927}
928