xref: /webtrees/app/I18N.php (revision 6cd97bf60294b6bc9d4c582ab3b7f80e0fb7d71e)
1a25f0a04SGreg Roach<?php
2a25f0a04SGreg Roach/**
3a25f0a04SGreg Roach * webtrees: online genealogy
48fcd0d32SGreg Roach * Copyright (C) 2019 webtrees development team
5a25f0a04SGreg Roach * This program is free software: you can redistribute it and/or modify
6a25f0a04SGreg Roach * it under the terms of the GNU General Public License as published by
7a25f0a04SGreg Roach * the Free Software Foundation, either version 3 of the License, or
8a25f0a04SGreg Roach * (at your option) any later version.
9a25f0a04SGreg Roach * This program is distributed in the hope that it will be useful,
10a25f0a04SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
11a25f0a04SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12a25f0a04SGreg Roach * GNU General Public License for more details.
13a25f0a04SGreg Roach * You should have received a copy of the GNU General Public License
14a25f0a04SGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>.
15a25f0a04SGreg Roach */
16e7f56f2aSGreg Roachdeclare(strict_types=1);
17e7f56f2aSGreg Roach
1876692c8bSGreg Roachnamespace Fisharebest\Webtrees;
19a25f0a04SGreg Roach
20991b93ddSGreg Roachuse Collator;
21362b8464SGreg Roachuse DomainException;
22f1af7e1cSGreg Roachuse Exception;
23c999a340SGreg Roachuse Fisharebest\Localization\Locale;
241e71bdc0SGreg Roachuse Fisharebest\Localization\Locale\LocaleEnUs;
2515834aaeSGreg Roachuse Fisharebest\Localization\Locale\LocaleInterface;
263bdc890bSGreg Roachuse Fisharebest\Localization\Translation;
273bdc890bSGreg Roachuse Fisharebest\Localization\Translator;
2815d603e7SGreg Roachuse Fisharebest\Webtrees\Functions\FunctionsEdit;
2902086832SGreg Roachuse Fisharebest\Webtrees\Module\LanguageEnglishUnitedStates;
30d37db671SGreg Roachuse Fisharebest\Webtrees\Module\ModuleCustomInterface;
3102086832SGreg Roachuse Fisharebest\Webtrees\Module\ModuleLanguageInterface;
32d37db671SGreg Roachuse Fisharebest\Webtrees\Services\ModuleService;
339e24ac1cSGreg Roachuse const GLOB_NOSORT;
34*6cd97bf6SGreg Roachuse Illuminate\Support\Collection;
35a25f0a04SGreg Roach
36a25f0a04SGreg Roach/**
3776692c8bSGreg Roach * Internationalization (i18n) and localization (l10n).
38a25f0a04SGreg Roach */
39c1010edaSGreg Roachclass I18N
40c1010edaSGreg Roach{
41d37db671SGreg Roach    // MO files use special characters for plurals and context.
42d37db671SGreg Roach    public const PLURAL  = '\x00';
43d37db671SGreg Roach    public const CONTEXT = '\x04';
44d37db671SGreg Roach
4515834aaeSGreg Roach    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
46c999a340SGreg Roach    private static $locale;
47c999a340SGreg Roach
4876692c8bSGreg Roach    /** @var Translator An object that performs translation */
493bdc890bSGreg Roach    private static $translator;
503bdc890bSGreg Roach
51c9ec599fSGreg Roach    /** @var  Collator|null From the php-intl library */
52991b93ddSGreg Roach    private static $collator;
53991b93ddSGreg Roach
54a25f0a04SGreg Roach    // Digits are always rendered LTR, even in RTL text.
5516d6367aSGreg Roach    private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
56a25f0a04SGreg Roach
57991b93ddSGreg Roach    // These locales need special handling for the dotless letter I.
5816d6367aSGreg Roach    private const DOTLESS_I_LOCALES = [
59c1010edaSGreg Roach        'az',
60c1010edaSGreg Roach        'tr',
61c1010edaSGreg Roach    ];
6216d6367aSGreg Roach    private const DOTLESS_I_TOLOWER = [
63c1010edaSGreg Roach        'I' => 'ı',
64c1010edaSGreg Roach        'İ' => 'i',
65c1010edaSGreg Roach    ];
6616d6367aSGreg Roach    private const DOTLESS_I_TOUPPER = [
67c1010edaSGreg Roach        'ı' => 'I',
68c1010edaSGreg Roach        'i' => 'İ',
69c1010edaSGreg Roach    ];
70a25f0a04SGreg Roach
71991b93ddSGreg Roach    // The ranges of characters used by each script.
7216d6367aSGreg Roach    private const SCRIPT_CHARACTER_RANGES = [
73c1010edaSGreg Roach        [
74c1010edaSGreg Roach            'Latn',
75c1010edaSGreg Roach            0x0041,
76c1010edaSGreg Roach            0x005A,
77c1010edaSGreg Roach        ],
78c1010edaSGreg Roach        [
79c1010edaSGreg Roach            'Latn',
80c1010edaSGreg Roach            0x0061,
81c1010edaSGreg Roach            0x007A,
82c1010edaSGreg Roach        ],
83c1010edaSGreg Roach        [
84c1010edaSGreg Roach            'Latn',
85c1010edaSGreg Roach            0x0100,
86c1010edaSGreg Roach            0x02AF,
87c1010edaSGreg Roach        ],
88c1010edaSGreg Roach        [
89c1010edaSGreg Roach            'Grek',
90c1010edaSGreg Roach            0x0370,
91c1010edaSGreg Roach            0x03FF,
92c1010edaSGreg Roach        ],
93c1010edaSGreg Roach        [
94c1010edaSGreg Roach            'Cyrl',
95c1010edaSGreg Roach            0x0400,
96c1010edaSGreg Roach            0x052F,
97c1010edaSGreg Roach        ],
98c1010edaSGreg Roach        [
99c1010edaSGreg Roach            'Hebr',
100c1010edaSGreg Roach            0x0590,
101c1010edaSGreg Roach            0x05FF,
102c1010edaSGreg Roach        ],
103c1010edaSGreg Roach        [
104c1010edaSGreg Roach            'Arab',
105c1010edaSGreg Roach            0x0600,
106c1010edaSGreg Roach            0x06FF,
107c1010edaSGreg Roach        ],
108c1010edaSGreg Roach        [
109c1010edaSGreg Roach            'Arab',
110c1010edaSGreg Roach            0x0750,
111c1010edaSGreg Roach            0x077F,
112c1010edaSGreg Roach        ],
113c1010edaSGreg Roach        [
114c1010edaSGreg Roach            'Arab',
115c1010edaSGreg Roach            0x08A0,
116c1010edaSGreg Roach            0x08FF,
117c1010edaSGreg Roach        ],
118c1010edaSGreg Roach        [
119c1010edaSGreg Roach            'Deva',
120c1010edaSGreg Roach            0x0900,
121c1010edaSGreg Roach            0x097F,
122c1010edaSGreg Roach        ],
123c1010edaSGreg Roach        [
124c1010edaSGreg Roach            'Taml',
125c1010edaSGreg Roach            0x0B80,
126c1010edaSGreg Roach            0x0BFF,
127c1010edaSGreg Roach        ],
128c1010edaSGreg Roach        [
129c1010edaSGreg Roach            'Sinh',
130c1010edaSGreg Roach            0x0D80,
131c1010edaSGreg Roach            0x0DFF,
132c1010edaSGreg Roach        ],
133c1010edaSGreg Roach        [
134c1010edaSGreg Roach            'Thai',
135c1010edaSGreg Roach            0x0E00,
136c1010edaSGreg Roach            0x0E7F,
137c1010edaSGreg Roach        ],
138c1010edaSGreg Roach        [
139c1010edaSGreg Roach            'Geor',
140c1010edaSGreg Roach            0x10A0,
141c1010edaSGreg Roach            0x10FF,
142c1010edaSGreg Roach        ],
143c1010edaSGreg Roach        [
144c1010edaSGreg Roach            'Grek',
145c1010edaSGreg Roach            0x1F00,
146c1010edaSGreg Roach            0x1FFF,
147c1010edaSGreg Roach        ],
148c1010edaSGreg Roach        [
149c1010edaSGreg Roach            'Deva',
150c1010edaSGreg Roach            0xA8E0,
151c1010edaSGreg Roach            0xA8FF,
152c1010edaSGreg Roach        ],
153c1010edaSGreg Roach        [
154c1010edaSGreg Roach            'Hans',
155c1010edaSGreg Roach            0x3000,
156c1010edaSGreg Roach            0x303F,
157c1010edaSGreg Roach        ],
158c1010edaSGreg Roach        // Mixed CJK, not just Hans
159c1010edaSGreg Roach        [
160c1010edaSGreg Roach            'Hans',
161c1010edaSGreg Roach            0x3400,
162c1010edaSGreg Roach            0xFAFF,
163c1010edaSGreg Roach        ],
164c1010edaSGreg Roach        // Mixed CJK, not just Hans
165c1010edaSGreg Roach        [
166c1010edaSGreg Roach            'Hans',
167c1010edaSGreg Roach            0x20000,
168c1010edaSGreg Roach            0x2FA1F,
169c1010edaSGreg Roach        ],
170c1010edaSGreg Roach        // Mixed CJK, not just Hans
17113abd6f3SGreg Roach    ];
172a25f0a04SGreg Roach
173991b93ddSGreg Roach    // Characters that are displayed in mirror form in RTL text.
17416d6367aSGreg Roach    private const MIRROR_CHARACTERS = [
175a25f0a04SGreg Roach        '('  => ')',
176a25f0a04SGreg Roach        ')'  => '(',
177a25f0a04SGreg Roach        '['  => ']',
178a25f0a04SGreg Roach        ']'  => '[',
179a25f0a04SGreg Roach        '{'  => '}',
180a25f0a04SGreg Roach        '}'  => '{',
181a25f0a04SGreg Roach        '<'  => '>',
182a25f0a04SGreg Roach        '>'  => '<',
183a25f0a04SGreg Roach        '‹ ' => '›',
184a25f0a04SGreg Roach        '› ' => '‹',
185a25f0a04SGreg Roach        '«'  => '»',
186a25f0a04SGreg Roach        '»'  => '«',
187a25f0a04SGreg Roach        '﴾ ' => '﴿',
188a25f0a04SGreg Roach        '﴿ ' => '﴾',
189a25f0a04SGreg Roach        '“ ' => '”',
190a25f0a04SGreg Roach        '” ' => '“',
191a25f0a04SGreg Roach        '‘ ' => '’',
192a25f0a04SGreg Roach        '’ ' => '‘',
19313abd6f3SGreg Roach    ];
194a25f0a04SGreg Roach
195a25f0a04SGreg Roach    /** @var string Punctuation used to separate list items, typically a comma */
196a25f0a04SGreg Roach    public static $list_separator;
197a25f0a04SGreg Roach
198a25f0a04SGreg Roach    /**
19902086832SGreg Roach     * The preferred locales for this site, or a default list if no preference.
200dfeee0a8SGreg Roach     *
201dfeee0a8SGreg Roach     * @return LocaleInterface[]
202dfeee0a8SGreg Roach     */
2038f53f488SRico Sonntag    public static function activeLocales(): array
204c1010edaSGreg Roach    {
20502086832SGreg Roach        $locales = app(ModuleService::class)
20602086832SGreg Roach            ->findByInterface(ModuleLanguageInterface::class)
20702086832SGreg Roach            ->map(function (ModuleLanguageInterface $module): LocaleInterface {
20802086832SGreg Roach                return $module->locale();
20902086832SGreg Roach            });
210dfeee0a8SGreg Roach
21102086832SGreg Roach        if ($locales->isEmpty()) {
21202086832SGreg Roach            return [new LocaleEnUs()];
213dfeee0a8SGreg Roach        }
214dfeee0a8SGreg Roach
21502086832SGreg Roach        return $locales->all();
216dfeee0a8SGreg Roach    }
217dfeee0a8SGreg Roach
218dfeee0a8SGreg Roach    /**
219dfeee0a8SGreg Roach     * Which MySQL collation should be used for this locale?
220dfeee0a8SGreg Roach     *
221dfeee0a8SGreg Roach     * @return string
222dfeee0a8SGreg Roach     */
223e364afe4SGreg Roach    public static function collation(): string
224c1010edaSGreg Roach    {
225dfeee0a8SGreg Roach        $collation = self::$locale->collation();
226dfeee0a8SGreg Roach        switch ($collation) {
227dfeee0a8SGreg Roach            case 'croatian_ci':
228dfeee0a8SGreg Roach            case 'german2_ci':
229dfeee0a8SGreg Roach            case 'vietnamese_ci':
230dfeee0a8SGreg Roach                // Only available in MySQL 5.6
231dfeee0a8SGreg Roach                return 'utf8_unicode_ci';
232dfeee0a8SGreg Roach            default:
233dfeee0a8SGreg Roach                return 'utf8_' . $collation;
234dfeee0a8SGreg Roach        }
235dfeee0a8SGreg Roach    }
236dfeee0a8SGreg Roach
237dfeee0a8SGreg Roach    /**
238dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
239dfeee0a8SGreg Roach     *
240dfeee0a8SGreg Roach     * @return string
241dfeee0a8SGreg Roach     */
2428f53f488SRico Sonntag    public static function dateFormat(): string
243c1010edaSGreg Roach    {
244bbb76c12SGreg Roach        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
245bbb76c12SGreg Roach        return self::$translator->translate('%j %F %Y');
246dfeee0a8SGreg Roach    }
247dfeee0a8SGreg Roach
248dfeee0a8SGreg Roach    /**
249dfeee0a8SGreg Roach     * Generate consistent I18N for datatables.js
250dfeee0a8SGreg Roach     *
25155664801SGreg Roach     * @param int[] $lengths An optional array of page lengths
252dfeee0a8SGreg Roach     *
253dfeee0a8SGreg Roach     * @return string
254dfeee0a8SGreg Roach     */
255c1010edaSGreg Roach    public static function datatablesI18N(array $lengths = [
256c1010edaSGreg Roach        10,
257c1010edaSGreg Roach        20,
258c1010edaSGreg Roach        30,
259c1010edaSGreg Roach        50,
260c1010edaSGreg Roach        100,
261c1010edaSGreg Roach        -1,
26255664801SGreg Roach    ]): string
26355664801SGreg Roach    {
26455664801SGreg Roach        $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), '10');
265dfeee0a8SGreg Roach
266dfeee0a8SGreg Roach        return
2672d9b2ebaSGreg Roach            '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' .
268dfeee0a8SGreg Roach            '"language": {' .
269dfeee0a8SGreg Roach            ' "paginate": {' .
270bbb76c12SGreg Roach            '  "first":    "' . self::translate('first') . '",' .
271bbb76c12SGreg Roach            '  "last":     "' . self::translate('last') . '",' .
272bbb76c12SGreg Roach            '  "next":     "' . self::translate('next') . '",' .
273bbb76c12SGreg Roach            '  "previous": "' . self::translate('previous') . '"' .
274dfeee0a8SGreg Roach            ' },' .
275dfeee0a8SGreg Roach            ' "emptyTable":     "' . self::translate('No records to display') . '",' .
276c1010edaSGreg Roach            ' "info":           "' . /* I18N: %s are placeholders for numbers */
277c1010edaSGreg Roach            self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' .
2780b6446e1SGreg Roach            ' "infoEmpty":      "' . self::translate('Showing %1$s to %2$s of %3$s', self::$locale->digits('0'), self::$locale->digits('0'), self::$locale->digits('0')) . '",' .
279c1010edaSGreg Roach            ' "infoFiltered":   "' . /* I18N: %s is a placeholder for a number */
280c1010edaSGreg Roach            self::translate('(filtered from %s total entries)', '_MAX_') . '",' .
281c1010edaSGreg Roach            ' "lengthMenu":     "' . /* I18N: %s is a number of records per page */
282c1010edaSGreg Roach            self::translate('Display %s', addslashes($length_options)) . '",' .
283dfeee0a8SGreg Roach            ' "loadingRecords": "' . self::translate('Loading…') . '",' .
284dfeee0a8SGreg Roach            ' "processing":     "' . self::translate('Loading…') . '",' .
285dfeee0a8SGreg Roach            ' "search":         "' . self::translate('Filter') . '",' .
286dfeee0a8SGreg Roach            ' "zeroRecords":    "' . self::translate('No records to display') . '"' .
2872d9b2ebaSGreg Roach            '}';
288dfeee0a8SGreg Roach    }
289dfeee0a8SGreg Roach
290dfeee0a8SGreg Roach    /**
291dfeee0a8SGreg Roach     * Convert the digits 0-9 into the local script
292dfeee0a8SGreg Roach     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
293dfeee0a8SGreg Roach     *
29455664801SGreg Roach     * @param string|int $n
295dfeee0a8SGreg Roach     *
296dfeee0a8SGreg Roach     * @return string
297dfeee0a8SGreg Roach     */
2988f53f488SRico Sonntag    public static function digits($n): string
299c1010edaSGreg Roach    {
30055664801SGreg Roach        return self::$locale->digits((string) $n);
301dfeee0a8SGreg Roach    }
302dfeee0a8SGreg Roach
303dfeee0a8SGreg Roach    /**
304dfeee0a8SGreg Roach     * What is the direction of the current locale
305dfeee0a8SGreg Roach     *
306dfeee0a8SGreg Roach     * @return string "ltr" or "rtl"
307dfeee0a8SGreg Roach     */
3088f53f488SRico Sonntag    public static function direction(): string
309c1010edaSGreg Roach    {
310dfeee0a8SGreg Roach        return self::$locale->direction();
311dfeee0a8SGreg Roach    }
312dfeee0a8SGreg Roach
313dfeee0a8SGreg Roach    /**
3147231a557SGreg Roach     * What is the first day of the week.
3157231a557SGreg Roach     *
316cbc1590aSGreg Roach     * @return int Sunday=0, Monday=1, etc.
3177231a557SGreg Roach     */
3188f53f488SRico Sonntag    public static function firstDay(): int
319c1010edaSGreg Roach    {
3207231a557SGreg Roach        return self::$locale->territory()->firstDay();
3217231a557SGreg Roach    }
3227231a557SGreg Roach
3237231a557SGreg Roach    /**
324dfeee0a8SGreg Roach     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
325dfeee0a8SGreg Roach     *
326dfeee0a8SGreg Roach     * @return string
327dfeee0a8SGreg Roach     */
3288f53f488SRico Sonntag    public static function htmlAttributes(): string
329c1010edaSGreg Roach    {
330dfeee0a8SGreg Roach        return self::$locale->htmlAttributes();
331dfeee0a8SGreg Roach    }
332dfeee0a8SGreg Roach
333dfeee0a8SGreg Roach    /**
334a25f0a04SGreg Roach     * Initialise the translation adapter with a locale setting.
335a25f0a04SGreg Roach     *
33615d603e7SGreg Roach     * @param string    $code  Use this locale/language code, or choose one automatically
337e58a20ffSGreg Roach     * @param Tree|null $tree
338c116a5ccSGreg Roach     * @param bool      $setup During setup, we cannot access the database.
339a25f0a04SGreg Roach     *
340a25f0a04SGreg Roach     * @return string $string
341a25f0a04SGreg Roach     */
342c116a5ccSGreg Roach    public static function init(string $code = '', Tree $tree = null, $setup = true): string
343c1010edaSGreg Roach    {
34415d603e7SGreg Roach        if ($code !== '') {
3453bdc890bSGreg Roach            // Create the specified locale
3463bdc890bSGreg Roach            self::$locale = Locale::create($code);
3474ee95e68SRico Sonntag        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) {
348e58a20ffSGreg Roach            // Select a previously used locale
34931bc7874SGreg Roach            self::$locale = Locale::create(Session::get('locale'));
3503bdc890bSGreg Roach        } else {
351e58a20ffSGreg Roach            if ($tree instanceof Tree) {
352e58a20ffSGreg Roach                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
353e58a20ffSGreg Roach            } else {
35459f2f229SGreg Roach                $default_locale = new LocaleEnUs();
3553bdc890bSGreg Roach            }
356e58a20ffSGreg Roach
357e58a20ffSGreg Roach            // Negotiate with the browser.
358e58a20ffSGreg Roach            // Search engines don't negotiate.  They get the default locale of the tree.
359c116a5ccSGreg Roach            if ($setup) {
360c116a5ccSGreg Roach                $installed_locales = app(ModuleService::class)->setupLanguages()
361c116a5ccSGreg Roach                    ->map(function (ModuleLanguageInterface $module): LocaleInterface {
362c116a5ccSGreg Roach                        return $module->locale();
363c116a5ccSGreg Roach                    });
364c116a5ccSGreg Roach            } else {
365c116a5ccSGreg Roach                $installed_locales = self::installedLocales();
366c116a5ccSGreg Roach            }
367c116a5ccSGreg Roach
368c116a5ccSGreg Roach            self::$locale = Locale::httpAcceptLanguage($_SERVER, $installed_locales->all(), $default_locale);
3693bdc890bSGreg Roach        }
3703bdc890bSGreg Roach
371f1af7e1cSGreg Roach        $cache_dir  = WT_DATA_DIR . 'cache/';
372f1af7e1cSGreg Roach        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
3733bdc890bSGreg Roach        if (file_exists($cache_file)) {
3743bdc890bSGreg Roach            $filemtime = filemtime($cache_file);
3753bdc890bSGreg Roach        } else {
3763bdc890bSGreg Roach            $filemtime = 0;
3773bdc890bSGreg Roach        }
3783bdc890bSGreg Roach
3793bdc890bSGreg Roach        // Load the translation file(s)
380362b8464SGreg Roach        $translation_files = [
381362b8464SGreg Roach            WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo',
382362b8464SGreg Roach        ];
383362b8464SGreg Roach
3847a7f87d7SGreg Roach        // Rebuild files after one hour
3857a7f87d7SGreg Roach        $rebuild_cache = time() > $filemtime + 3600;
3861e71bdc0SGreg Roach        // Rebuild files if any translation file has been updated
3873bdc890bSGreg Roach        foreach ($translation_files as $translation_file) {
3883bdc890bSGreg Roach            if (filemtime($translation_file) > $filemtime) {
3893bdc890bSGreg Roach                $rebuild_cache = true;
390a25f0a04SGreg Roach                break;
391a25f0a04SGreg Roach            }
392a25f0a04SGreg Roach        }
3933bdc890bSGreg Roach
3943bdc890bSGreg Roach        if ($rebuild_cache) {
39513abd6f3SGreg Roach            $translations = [];
3963bdc890bSGreg Roach            foreach ($translation_files as $translation_file) {
3973bdc890bSGreg Roach                $translation  = new Translation($translation_file);
3983bdc890bSGreg Roach                $translations = array_merge($translations, $translation->asArray());
399a25f0a04SGreg Roach            }
400f1af7e1cSGreg Roach            try {
401f1af7e1cSGreg Roach                File::mkdir($cache_dir);
402f1af7e1cSGreg Roach                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
403f1af7e1cSGreg Roach            } catch (Exception $ex) {
4047c2999b4SGreg Roach                // During setup, we may not have been able to create it.
405c85fb0c4SGreg Roach            }
4063bdc890bSGreg Roach        } else {
4073bdc890bSGreg Roach            $translations = include $cache_file;
408a25f0a04SGreg Roach        }
409a25f0a04SGreg Roach
410d37db671SGreg Roach        // Add translations from custom modules (but not during setup)
411c116a5ccSGreg Roach        if (!$setup) {
41202086832SGreg Roach            $custom_modules = app(ModuleService::class)
41302086832SGreg Roach                ->findByInterface(ModuleCustomInterface::class);
414d37db671SGreg Roach
415d37db671SGreg Roach            foreach ($custom_modules as $custom_module) {
416d37db671SGreg Roach                $custom_translations = $custom_module->customTranslations(self::$locale->languageTag());
417d37db671SGreg Roach                $translations        = array_merge($translations, $custom_translations);
418d37db671SGreg Roach            }
419d37db671SGreg Roach        }
420d37db671SGreg Roach
4213bdc890bSGreg Roach        // Create a translator
4223bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
423a25f0a04SGreg Roach
424bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
425bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
426a25f0a04SGreg Roach
427991b93ddSGreg Roach        // Create a collator
428991b93ddSGreg Roach        try {
429444a65ecSGreg Roach            if (class_exists('Collator')) {
430c9ec599fSGreg Roach                // Symfony provides a very incomplete polyfill - which cannot be used.
431991b93ddSGreg Roach                self::$collator = new Collator(self::$locale->code());
432991b93ddSGreg Roach                // Ignore upper/lower case differences
433991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
434444a65ecSGreg Roach            }
435991b93ddSGreg Roach        } catch (Exception $ex) {
436991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
437c9ec599fSGreg Roach            self::$collator = null;
438991b93ddSGreg Roach        }
439991b93ddSGreg Roach
4405331c5eaSGreg Roach        return self::$locale->languageTag();
441a25f0a04SGreg Roach    }
442a25f0a04SGreg Roach
443a25f0a04SGreg Roach    /**
444c999a340SGreg Roach     * All locales for which a translation file exists.
445c999a340SGreg Roach     *
446c116a5ccSGreg Roach     * @return Collection
44715834aaeSGreg Roach     * @return LocaleInterface[]
448c999a340SGreg Roach     */
449c116a5ccSGreg Roach    public static function installedLocales(): Collection
450c1010edaSGreg Roach    {
45102086832SGreg Roach        return app(ModuleService::class)
45202086832SGreg Roach            ->findByInterface(ModuleLanguageInterface::class, true)
45302086832SGreg Roach            ->map(function (ModuleLanguageInterface $module): LocaleInterface {
45402086832SGreg Roach                return $module->locale();
455c116a5ccSGreg Roach            });
456a25f0a04SGreg Roach    }
457a25f0a04SGreg Roach
458a25f0a04SGreg Roach    /**
459a25f0a04SGreg Roach     * Return the endonym for a given language - as per http://cldr.unicode.org/
460a25f0a04SGreg Roach     *
461a25f0a04SGreg Roach     * @param string $locale
462a25f0a04SGreg Roach     *
463a25f0a04SGreg Roach     * @return string
464a25f0a04SGreg Roach     */
46555664801SGreg Roach    public static function languageName(string $locale): string
466c1010edaSGreg Roach    {
467c999a340SGreg Roach        return Locale::create($locale)->endonym();
468a25f0a04SGreg Roach    }
469a25f0a04SGreg Roach
470a25f0a04SGreg Roach    /**
471a25f0a04SGreg Roach     * Return the script used by a given language
472a25f0a04SGreg Roach     *
473a25f0a04SGreg Roach     * @param string $locale
474a25f0a04SGreg Roach     *
475a25f0a04SGreg Roach     * @return string
476a25f0a04SGreg Roach     */
47755664801SGreg Roach    public static function languageScript(string $locale): string
478c1010edaSGreg Roach    {
479c999a340SGreg Roach        return Locale::create($locale)->script()->code();
480a25f0a04SGreg Roach    }
481a25f0a04SGreg Roach
482a25f0a04SGreg Roach    /**
483dfeee0a8SGreg Roach     * Translate a number into the local representation.
484dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
485dfeee0a8SGreg Roach     * en: 12,345.67
486dfeee0a8SGreg Roach     * fr: 12 345,67
487dfeee0a8SGreg Roach     * de: 12.345,67
488dfeee0a8SGreg Roach     *
489dfeee0a8SGreg Roach     * @param float $n
490cbc1590aSGreg Roach     * @param int   $precision
491a25f0a04SGreg Roach     *
492a25f0a04SGreg Roach     * @return string
493a25f0a04SGreg Roach     */
49455664801SGreg Roach    public static function number(float $n, int $precision = 0): string
495c1010edaSGreg Roach    {
496dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
497dfeee0a8SGreg Roach    }
498dfeee0a8SGreg Roach
499dfeee0a8SGreg Roach    /**
500dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
501dfeee0a8SGreg Roach     * e.g. 0.123 becomes
502dfeee0a8SGreg Roach     * en: 12.3%
503dfeee0a8SGreg Roach     * fr: 12,3 %
504dfeee0a8SGreg Roach     * de: 12,3%
505dfeee0a8SGreg Roach     *
506dfeee0a8SGreg Roach     * @param float $n
507cbc1590aSGreg Roach     * @param int   $precision
508dfeee0a8SGreg Roach     *
509dfeee0a8SGreg Roach     * @return string
510dfeee0a8SGreg Roach     */
51155664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
512c1010edaSGreg Roach    {
513dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
514dfeee0a8SGreg Roach    }
515dfeee0a8SGreg Roach
516dfeee0a8SGreg Roach    /**
517dfeee0a8SGreg Roach     * Translate a plural string
518dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
519dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
520dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
521dfeee0a8SGreg Roach     *
522924d091bSGreg Roach     * @param string $singular
523924d091bSGreg Roach     * @param string $plural
524924d091bSGreg Roach     * @param int    $count
525a515be7cSGreg Roach     * @param string ...$args
526e93111adSRico Sonntag     *
527dfeee0a8SGreg Roach     * @return string
528dfeee0a8SGreg Roach     */
529924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
530c1010edaSGreg Roach    {
531924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
532dfeee0a8SGreg Roach
533924d091bSGreg Roach        return sprintf($message, ...$args);
534dfeee0a8SGreg Roach    }
535dfeee0a8SGreg Roach
536dfeee0a8SGreg Roach    /**
537dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
538dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
539dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
540dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
541dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
542dfeee0a8SGreg Roach     *
543dfeee0a8SGreg Roach     * @param string $text Text to be reversed
544dfeee0a8SGreg Roach     *
545dfeee0a8SGreg Roach     * @return string
546dfeee0a8SGreg Roach     */
5478f53f488SRico Sonntag    public static function reverseText($text): string
548c1010edaSGreg Roach    {
549dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
5509524b7b5SGreg Roach        $text = strip_tags($text);
5519524b7b5SGreg Roach        // Remove HTML entities.
5529524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
553dfeee0a8SGreg Roach
554dfeee0a8SGreg Roach        // LTR text doesn't need reversing
555dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
556dfeee0a8SGreg Roach            return $text;
557dfeee0a8SGreg Roach        }
558dfeee0a8SGreg Roach
559dfeee0a8SGreg Roach        // Mirrored characters
560991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
561dfeee0a8SGreg Roach
562dfeee0a8SGreg Roach        $reversed = '';
563dfeee0a8SGreg Roach        $digits   = '';
564e364afe4SGreg Roach        while ($text !== '') {
565dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
566dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
567dfeee0a8SGreg Roach            if (strpos(self::DIGITS, $letter) !== false) {
568dfeee0a8SGreg Roach                $digits .= $letter;
569a25f0a04SGreg Roach            } else {
570dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
571dfeee0a8SGreg Roach                $digits   = '';
572dfeee0a8SGreg Roach            }
573a25f0a04SGreg Roach        }
574a25f0a04SGreg Roach
575dfeee0a8SGreg Roach        return $digits . $reversed;
576a25f0a04SGreg Roach    }
577a25f0a04SGreg Roach
578a25f0a04SGreg Roach    /**
579a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
580a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
581a25f0a04SGreg Roach     * our own lookup table.
582a25f0a04SGreg Roach     *
583a25f0a04SGreg Roach     * @param string $script
584a25f0a04SGreg Roach     *
585a25f0a04SGreg Roach     * @return string
586a25f0a04SGreg Roach     */
587e364afe4SGreg Roach    public static function scriptDirection($script): string
588c1010edaSGreg Roach    {
589a25f0a04SGreg Roach        switch ($script) {
590a25f0a04SGreg Roach            case 'Arab':
591a25f0a04SGreg Roach            case 'Hebr':
592a25f0a04SGreg Roach            case 'Mong':
593a25f0a04SGreg Roach            case 'Thaa':
594a25f0a04SGreg Roach                return 'rtl';
595a25f0a04SGreg Roach            default:
596a25f0a04SGreg Roach                return 'ltr';
597a25f0a04SGreg Roach        }
598a25f0a04SGreg Roach    }
599a25f0a04SGreg Roach
600a25f0a04SGreg Roach    /**
601991b93ddSGreg Roach     * Perform a case-insensitive comparison of two strings.
602a25f0a04SGreg Roach     *
603a25f0a04SGreg Roach     * @param string $string1
604a25f0a04SGreg Roach     * @param string $string2
605a25f0a04SGreg Roach     *
606cbc1590aSGreg Roach     * @return int
607a25f0a04SGreg Roach     */
608e364afe4SGreg Roach    public static function strcasecmp($string1, $string2): int
609c1010edaSGreg Roach    {
610991b93ddSGreg Roach        if (self::$collator instanceof Collator) {
611991b93ddSGreg Roach            return self::$collator->compare($string1, $string2);
612a25f0a04SGreg Roach        }
613e364afe4SGreg Roach
614e364afe4SGreg Roach        return strcmp(self::strtolower($string1), self::strtolower($string2));
615c9ec599fSGreg Roach    }
616a25f0a04SGreg Roach
617a25f0a04SGreg Roach    /**
618991b93ddSGreg Roach     * Convert a string to lower case.
619a25f0a04SGreg Roach     *
620dfeee0a8SGreg Roach     * @param string $string
621a25f0a04SGreg Roach     *
622a25f0a04SGreg Roach     * @return string
623a25f0a04SGreg Roach     */
6248f53f488SRico Sonntag    public static function strtolower($string): string
625c1010edaSGreg Roach    {
62602086832SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
627991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
628a25f0a04SGreg Roach        }
6295ddad20bSGreg Roach
6305ddad20bSGreg Roach        return mb_strtolower($string);
631a25f0a04SGreg Roach    }
632a25f0a04SGreg Roach
633a25f0a04SGreg Roach    /**
634991b93ddSGreg Roach     * Convert a string to upper case.
635dfeee0a8SGreg Roach     *
636dfeee0a8SGreg Roach     * @param string $string
637a25f0a04SGreg Roach     *
638a25f0a04SGreg Roach     * @return string
639a25f0a04SGreg Roach     */
6408f53f488SRico Sonntag    public static function strtoupper($string): string
641c1010edaSGreg Roach    {
64202086832SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
643991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
644a25f0a04SGreg Roach        }
6455ddad20bSGreg Roach
6465ddad20bSGreg Roach        return mb_strtoupper($string);
647a25f0a04SGreg Roach    }
648a25f0a04SGreg Roach
649dfeee0a8SGreg Roach    /**
650dfeee0a8SGreg Roach     * Identify the script used for a piece of text
651dfeee0a8SGreg Roach     *
652d0bfc631SGreg Roach     * @param string $string
653dfeee0a8SGreg Roach     *
654dfeee0a8SGreg Roach     * @return string
655dfeee0a8SGreg Roach     */
6568f53f488SRico Sonntag    public static function textScript($string): string
657c1010edaSGreg Roach    {
658dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
659dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
660c1010edaSGreg Roach        $string = str_replace([
661c1010edaSGreg Roach            '@N.N.',
662c1010edaSGreg Roach            '@P.N.',
663c1010edaSGreg Roach        ], '', $string); // otherwise unknown names show up as latin
664dfeee0a8SGreg Roach        $pos    = 0;
665dfeee0a8SGreg Roach        $strlen = strlen($string);
666dfeee0a8SGreg Roach        while ($pos < $strlen) {
667dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
668dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
669dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
670dfeee0a8SGreg Roach                $code_point = $byte1;
671dfeee0a8SGreg Roach                $chrlen     = 1;
672dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
673dfeee0a8SGreg Roach                // Invalid continuation character
674dfeee0a8SGreg Roach                return 'Latn';
675dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
676dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
677dfeee0a8SGreg Roach                $chrlen     = 2;
678dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
679dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
680dfeee0a8SGreg Roach                $chrlen     = 3;
681dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
682dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
683dfeee0a8SGreg Roach                $chrlen     = 3;
684dfeee0a8SGreg Roach            } else {
685dfeee0a8SGreg Roach                // Invalid UTF
686dfeee0a8SGreg Roach                return 'Latn';
687dfeee0a8SGreg Roach            }
688dfeee0a8SGreg Roach
689991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
690dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
691dfeee0a8SGreg Roach                    return $range[0];
692dfeee0a8SGreg Roach                }
693dfeee0a8SGreg Roach            }
694dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
695dfeee0a8SGreg Roach            $pos += $chrlen;
696dfeee0a8SGreg Roach        }
697dfeee0a8SGreg Roach
698dfeee0a8SGreg Roach        return 'Latn';
699dfeee0a8SGreg Roach    }
700dfeee0a8SGreg Roach
701dfeee0a8SGreg Roach    /**
702dfeee0a8SGreg Roach     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
703dfeee0a8SGreg Roach     *
704cbc1590aSGreg Roach     * @param int $seconds
705dfeee0a8SGreg Roach     *
706dfeee0a8SGreg Roach     * @return string
707dfeee0a8SGreg Roach     */
708e364afe4SGreg Roach    public static function timeAgo($seconds): string
709c1010edaSGreg Roach    {
710dfeee0a8SGreg Roach        $minute = 60;
711dfeee0a8SGreg Roach        $hour   = 60 * $minute;
712dfeee0a8SGreg Roach        $day    = 24 * $hour;
713dfeee0a8SGreg Roach        $month  = 30 * $day;
714dfeee0a8SGreg Roach        $year   = 365 * $day;
715dfeee0a8SGreg Roach
716dfeee0a8SGreg Roach        if ($seconds > $year) {
717cdaafeeeSGreg Roach            $years = intdiv($seconds, $year);
718cbc1590aSGreg Roach
719dfeee0a8SGreg Roach            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
720b2ce94c6SRico Sonntag        }
721b2ce94c6SRico Sonntag
722b2ce94c6SRico Sonntag        if ($seconds > $month) {
723cdaafeeeSGreg Roach            $months = intdiv($seconds, $month);
724cbc1590aSGreg Roach
725dfeee0a8SGreg Roach            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
726b2ce94c6SRico Sonntag        }
727b2ce94c6SRico Sonntag
728b2ce94c6SRico Sonntag        if ($seconds > $day) {
729cdaafeeeSGreg Roach            $days = intdiv($seconds, $day);
730cbc1590aSGreg Roach
731dfeee0a8SGreg Roach            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
732b2ce94c6SRico Sonntag        }
733b2ce94c6SRico Sonntag
734b2ce94c6SRico Sonntag        if ($seconds > $hour) {
735cdaafeeeSGreg Roach            $hours = intdiv($seconds, $hour);
736cbc1590aSGreg Roach
737dfeee0a8SGreg Roach            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
738b2ce94c6SRico Sonntag        }
739b2ce94c6SRico Sonntag
740b2ce94c6SRico Sonntag        if ($seconds > $minute) {
741cdaafeeeSGreg Roach            $minutes = intdiv($seconds, $minute);
742cbc1590aSGreg Roach
743dfeee0a8SGreg Roach            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
744dfeee0a8SGreg Roach        }
745b2ce94c6SRico Sonntag
746b2ce94c6SRico Sonntag        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
747dfeee0a8SGreg Roach    }
748dfeee0a8SGreg Roach
749dfeee0a8SGreg Roach    /**
750dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
751dfeee0a8SGreg Roach     *
752dfeee0a8SGreg Roach     * @return string
753dfeee0a8SGreg Roach     */
7548f53f488SRico Sonntag    public static function timeFormat(): string
755c1010edaSGreg Roach    {
756bbb76c12SGreg Roach        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
757bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
758dfeee0a8SGreg Roach    }
759dfeee0a8SGreg Roach
760dfeee0a8SGreg Roach    /**
761dfeee0a8SGreg Roach     * Translate a string, and then substitute placeholders
762dfeee0a8SGreg Roach     * echo I18N::translate('Hello World!');
763dfeee0a8SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
764dfeee0a8SGreg Roach     *
765924d091bSGreg Roach     * @param string $message
766a515be7cSGreg Roach     * @param string ...$args
767c3283ed7SGreg Roach     *
768dfeee0a8SGreg Roach     * @return string
769dfeee0a8SGreg Roach     */
770924d091bSGreg Roach    public static function translate(string $message, ...$args): string
771c1010edaSGreg Roach    {
772924d091bSGreg Roach        $message = self::$translator->translate($message);
773dfeee0a8SGreg Roach
774924d091bSGreg Roach        return sprintf($message, ...$args);
775dfeee0a8SGreg Roach    }
776dfeee0a8SGreg Roach
777dfeee0a8SGreg Roach    /**
778dfeee0a8SGreg Roach     * Context sensitive version of translate.
779a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
780a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
781dfeee0a8SGreg Roach     *
782924d091bSGreg Roach     * @param string $context
783924d091bSGreg Roach     * @param string $message
784a515be7cSGreg Roach     * @param string ...$args
785c3283ed7SGreg Roach     *
786dfeee0a8SGreg Roach     * @return string
787dfeee0a8SGreg Roach     */
788924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
789c1010edaSGreg Roach    {
790924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
791dfeee0a8SGreg Roach
792924d091bSGreg Roach        return sprintf($message, ...$args);
793a25f0a04SGreg Roach    }
794a25f0a04SGreg Roach}
795