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