xref: /webtrees/app/I18N.php (revision f8e2489662d6c80c8fdae561ceca2ffff31df312)
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;
29a25f0a04SGreg Roach
30a25f0a04SGreg Roach/**
3176692c8bSGreg Roach * Internationalization (i18n) and localization (l10n).
32a25f0a04SGreg Roach */
33c1010edaSGreg Roachclass I18N
34c1010edaSGreg Roach{
3515834aaeSGreg Roach    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
36c999a340SGreg Roach    private static $locale;
37c999a340SGreg Roach
3876692c8bSGreg Roach    /** @var Translator An object that performs translation */
393bdc890bSGreg Roach    private static $translator;
403bdc890bSGreg Roach
41c9ec599fSGreg Roach    /** @var  Collator|null From the php-intl library */
42991b93ddSGreg Roach    private static $collator;
43991b93ddSGreg Roach
44a25f0a04SGreg Roach    // Digits are always rendered LTR, even in RTL text.
4516d6367aSGreg Roach    private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
46a25f0a04SGreg Roach
47991b93ddSGreg Roach    // These locales need special handling for the dotless letter I.
4816d6367aSGreg Roach    private const DOTLESS_I_LOCALES = [
49c1010edaSGreg Roach        'az',
50c1010edaSGreg Roach        'tr',
51c1010edaSGreg Roach    ];
5216d6367aSGreg Roach    private const DOTLESS_I_TOLOWER = [
53c1010edaSGreg Roach        'I' => 'ı',
54c1010edaSGreg Roach        'İ' => 'i',
55c1010edaSGreg Roach    ];
5616d6367aSGreg Roach    private const DOTLESS_I_TOUPPER = [
57c1010edaSGreg Roach        'ı' => 'I',
58c1010edaSGreg Roach        'i' => 'İ',
59c1010edaSGreg Roach    ];
60a25f0a04SGreg Roach
61991b93ddSGreg Roach    // The ranges of characters used by each script.
6216d6367aSGreg Roach    private const SCRIPT_CHARACTER_RANGES = [
63c1010edaSGreg Roach        [
64c1010edaSGreg Roach            'Latn',
65c1010edaSGreg Roach            0x0041,
66c1010edaSGreg Roach            0x005A,
67c1010edaSGreg Roach        ],
68c1010edaSGreg Roach        [
69c1010edaSGreg Roach            'Latn',
70c1010edaSGreg Roach            0x0061,
71c1010edaSGreg Roach            0x007A,
72c1010edaSGreg Roach        ],
73c1010edaSGreg Roach        [
74c1010edaSGreg Roach            'Latn',
75c1010edaSGreg Roach            0x0100,
76c1010edaSGreg Roach            0x02AF,
77c1010edaSGreg Roach        ],
78c1010edaSGreg Roach        [
79c1010edaSGreg Roach            'Grek',
80c1010edaSGreg Roach            0x0370,
81c1010edaSGreg Roach            0x03FF,
82c1010edaSGreg Roach        ],
83c1010edaSGreg Roach        [
84c1010edaSGreg Roach            'Cyrl',
85c1010edaSGreg Roach            0x0400,
86c1010edaSGreg Roach            0x052F,
87c1010edaSGreg Roach        ],
88c1010edaSGreg Roach        [
89c1010edaSGreg Roach            'Hebr',
90c1010edaSGreg Roach            0x0590,
91c1010edaSGreg Roach            0x05FF,
92c1010edaSGreg Roach        ],
93c1010edaSGreg Roach        [
94c1010edaSGreg Roach            'Arab',
95c1010edaSGreg Roach            0x0600,
96c1010edaSGreg Roach            0x06FF,
97c1010edaSGreg Roach        ],
98c1010edaSGreg Roach        [
99c1010edaSGreg Roach            'Arab',
100c1010edaSGreg Roach            0x0750,
101c1010edaSGreg Roach            0x077F,
102c1010edaSGreg Roach        ],
103c1010edaSGreg Roach        [
104c1010edaSGreg Roach            'Arab',
105c1010edaSGreg Roach            0x08A0,
106c1010edaSGreg Roach            0x08FF,
107c1010edaSGreg Roach        ],
108c1010edaSGreg Roach        [
109c1010edaSGreg Roach            'Deva',
110c1010edaSGreg Roach            0x0900,
111c1010edaSGreg Roach            0x097F,
112c1010edaSGreg Roach        ],
113c1010edaSGreg Roach        [
114c1010edaSGreg Roach            'Taml',
115c1010edaSGreg Roach            0x0B80,
116c1010edaSGreg Roach            0x0BFF,
117c1010edaSGreg Roach        ],
118c1010edaSGreg Roach        [
119c1010edaSGreg Roach            'Sinh',
120c1010edaSGreg Roach            0x0D80,
121c1010edaSGreg Roach            0x0DFF,
122c1010edaSGreg Roach        ],
123c1010edaSGreg Roach        [
124c1010edaSGreg Roach            'Thai',
125c1010edaSGreg Roach            0x0E00,
126c1010edaSGreg Roach            0x0E7F,
127c1010edaSGreg Roach        ],
128c1010edaSGreg Roach        [
129c1010edaSGreg Roach            'Geor',
130c1010edaSGreg Roach            0x10A0,
131c1010edaSGreg Roach            0x10FF,
132c1010edaSGreg Roach        ],
133c1010edaSGreg Roach        [
134c1010edaSGreg Roach            'Grek',
135c1010edaSGreg Roach            0x1F00,
136c1010edaSGreg Roach            0x1FFF,
137c1010edaSGreg Roach        ],
138c1010edaSGreg Roach        [
139c1010edaSGreg Roach            'Deva',
140c1010edaSGreg Roach            0xA8E0,
141c1010edaSGreg Roach            0xA8FF,
142c1010edaSGreg Roach        ],
143c1010edaSGreg Roach        [
144c1010edaSGreg Roach            'Hans',
145c1010edaSGreg Roach            0x3000,
146c1010edaSGreg Roach            0x303F,
147c1010edaSGreg Roach        ],
148c1010edaSGreg Roach        // Mixed CJK, not just Hans
149c1010edaSGreg Roach        [
150c1010edaSGreg Roach            'Hans',
151c1010edaSGreg Roach            0x3400,
152c1010edaSGreg Roach            0xFAFF,
153c1010edaSGreg Roach        ],
154c1010edaSGreg Roach        // Mixed CJK, not just Hans
155c1010edaSGreg Roach        [
156c1010edaSGreg Roach            'Hans',
157c1010edaSGreg Roach            0x20000,
158c1010edaSGreg Roach            0x2FA1F,
159c1010edaSGreg Roach        ],
160c1010edaSGreg Roach        // Mixed CJK, not just Hans
16113abd6f3SGreg Roach    ];
162a25f0a04SGreg Roach
163991b93ddSGreg Roach    // Characters that are displayed in mirror form in RTL text.
16416d6367aSGreg Roach    private const MIRROR_CHARACTERS = [
165a25f0a04SGreg Roach        '('  => ')',
166a25f0a04SGreg Roach        ')'  => '(',
167a25f0a04SGreg Roach        '['  => ']',
168a25f0a04SGreg Roach        ']'  => '[',
169a25f0a04SGreg Roach        '{'  => '}',
170a25f0a04SGreg Roach        '}'  => '{',
171a25f0a04SGreg Roach        '<'  => '>',
172a25f0a04SGreg Roach        '>'  => '<',
173a25f0a04SGreg Roach        '‹ ' => '›',
174a25f0a04SGreg Roach        '› ' => '‹',
175a25f0a04SGreg Roach        '«'  => '»',
176a25f0a04SGreg Roach        '»'  => '«',
177a25f0a04SGreg Roach        '﴾ ' => '﴿',
178a25f0a04SGreg Roach        '﴿ ' => '﴾',
179a25f0a04SGreg Roach        '“ ' => '”',
180a25f0a04SGreg Roach        '” ' => '“',
181a25f0a04SGreg Roach        '‘ ' => '’',
182a25f0a04SGreg Roach        '’ ' => '‘',
18313abd6f3SGreg Roach    ];
184a25f0a04SGreg Roach
185991b93ddSGreg Roach    // Default list of locales to show in the menu.
18616d6367aSGreg Roach    private const DEFAULT_LOCALES = [
187c1010edaSGreg Roach        'ar',
188c1010edaSGreg Roach        'bg',
189c1010edaSGreg Roach        'bs',
190c1010edaSGreg Roach        'ca',
191c1010edaSGreg Roach        'cs',
192c1010edaSGreg Roach        'da',
193c1010edaSGreg Roach        'de',
194c1010edaSGreg Roach        'el',
195c1010edaSGreg Roach        'en-GB',
196c1010edaSGreg Roach        'en-US',
197c1010edaSGreg Roach        'es',
198c1010edaSGreg Roach        'et',
199c1010edaSGreg Roach        'fi',
200c1010edaSGreg Roach        'fr',
201c1010edaSGreg Roach        'he',
202c1010edaSGreg Roach        'hr',
203c1010edaSGreg Roach        'hu',
204c1010edaSGreg Roach        'is',
205c1010edaSGreg Roach        'it',
206c1010edaSGreg Roach        'ka',
207c1010edaSGreg Roach        'kk',
208c1010edaSGreg Roach        'lt',
209c1010edaSGreg Roach        'mr',
210c1010edaSGreg Roach        'nb',
211c1010edaSGreg Roach        'nl',
212c1010edaSGreg Roach        'nn',
213c1010edaSGreg Roach        'pl',
214c1010edaSGreg Roach        'pt',
215c1010edaSGreg Roach        'ru',
216c1010edaSGreg Roach        'sk',
217c1010edaSGreg Roach        'sv',
218c1010edaSGreg Roach        'tr',
219c1010edaSGreg Roach        'uk',
220c1010edaSGreg Roach        'vi',
221c1010edaSGreg Roach        'zh-Hans',
222991b93ddSGreg Roach    ];
223991b93ddSGreg Roach
224a25f0a04SGreg Roach    /** @var string Punctuation used to separate list items, typically a comma */
225a25f0a04SGreg Roach    public static $list_separator;
226a25f0a04SGreg Roach
227a25f0a04SGreg Roach    /**
228dfeee0a8SGreg Roach     * The prefered locales for this site, or a default list if no preference.
229dfeee0a8SGreg Roach     *
230dfeee0a8SGreg Roach     * @return LocaleInterface[]
231dfeee0a8SGreg Roach     */
2328f53f488SRico Sonntag    public static function activeLocales(): array
233c1010edaSGreg Roach    {
234dfeee0a8SGreg Roach        $code_list = Site::getPreference('LANGUAGES');
235dfeee0a8SGreg Roach
23615d603e7SGreg Roach        if ($code_list === '') {
237991b93ddSGreg Roach            $codes = self::DEFAULT_LOCALES;
238dfeee0a8SGreg Roach        } else {
239991b93ddSGreg Roach            $codes = explode(',', $code_list);
240dfeee0a8SGreg Roach        }
241dfeee0a8SGreg Roach
24213abd6f3SGreg Roach        $locales = [];
243dfeee0a8SGreg Roach        foreach ($codes as $code) {
244362b8464SGreg Roach            if (file_exists(WT_ROOT . 'resources/lang/' . $code . '/messages.mo')) {
245dfeee0a8SGreg Roach                try {
246dfeee0a8SGreg Roach                    $locales[] = Locale::create($code);
24791495569SGreg Roach                } catch (Exception $ex) {
248dfeee0a8SGreg Roach                    // No such locale exists?
249dfeee0a8SGreg Roach                }
250dfeee0a8SGreg Roach            }
251dfeee0a8SGreg Roach        }
252362b8464SGreg Roach
253dfeee0a8SGreg Roach        usort($locales, '\Fisharebest\Localization\Locale::compare');
254dfeee0a8SGreg Roach
255dfeee0a8SGreg Roach        return $locales;
256dfeee0a8SGreg Roach    }
257dfeee0a8SGreg Roach
258dfeee0a8SGreg Roach    /**
259dfeee0a8SGreg Roach     * Which MySQL collation should be used for this locale?
260dfeee0a8SGreg Roach     *
261dfeee0a8SGreg Roach     * @return string
262dfeee0a8SGreg Roach     */
263e364afe4SGreg Roach    public static function collation(): string
264c1010edaSGreg Roach    {
265dfeee0a8SGreg Roach        $collation = self::$locale->collation();
266dfeee0a8SGreg Roach        switch ($collation) {
267dfeee0a8SGreg Roach            case 'croatian_ci':
268dfeee0a8SGreg Roach            case 'german2_ci':
269dfeee0a8SGreg Roach            case 'vietnamese_ci':
270dfeee0a8SGreg Roach                // Only available in MySQL 5.6
271dfeee0a8SGreg Roach                return 'utf8_unicode_ci';
272dfeee0a8SGreg Roach            default:
273dfeee0a8SGreg Roach                return 'utf8_' . $collation;
274dfeee0a8SGreg Roach        }
275dfeee0a8SGreg Roach    }
276dfeee0a8SGreg Roach
277dfeee0a8SGreg Roach    /**
278dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
279dfeee0a8SGreg Roach     *
280dfeee0a8SGreg Roach     * @return string
281dfeee0a8SGreg Roach     */
2828f53f488SRico Sonntag    public static function dateFormat(): string
283c1010edaSGreg Roach    {
284bbb76c12SGreg Roach        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
285bbb76c12SGreg Roach        return self::$translator->translate('%j %F %Y');
286dfeee0a8SGreg Roach    }
287dfeee0a8SGreg Roach
288dfeee0a8SGreg Roach    /**
289dfeee0a8SGreg Roach     * Generate consistent I18N for datatables.js
290dfeee0a8SGreg Roach     *
29155664801SGreg Roach     * @param int[] $lengths An optional array of page lengths
292dfeee0a8SGreg Roach     *
293dfeee0a8SGreg Roach     * @return string
294dfeee0a8SGreg Roach     */
295c1010edaSGreg Roach    public static function datatablesI18N(array $lengths = [
296c1010edaSGreg Roach        10,
297c1010edaSGreg Roach        20,
298c1010edaSGreg Roach        30,
299c1010edaSGreg Roach        50,
300c1010edaSGreg Roach        100,
301c1010edaSGreg Roach        -1,
30255664801SGreg Roach    ]): string
30355664801SGreg Roach    {
30455664801SGreg Roach        $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), '10');
305dfeee0a8SGreg Roach
306dfeee0a8SGreg Roach        return
3072d9b2ebaSGreg Roach            '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' .
308dfeee0a8SGreg Roach            '"language": {' .
309dfeee0a8SGreg Roach            ' "paginate": {' .
310bbb76c12SGreg Roach            '  "first":    "' . self::translate('first') . '",' .
311bbb76c12SGreg Roach            '  "last":     "' . self::translate('last') . '",' .
312bbb76c12SGreg Roach            '  "next":     "' . self::translate('next') . '",' .
313bbb76c12SGreg Roach            '  "previous": "' . self::translate('previous') . '"' .
314dfeee0a8SGreg Roach            ' },' .
315dfeee0a8SGreg Roach            ' "emptyTable":     "' . self::translate('No records to display') . '",' .
316c1010edaSGreg Roach            ' "info":           "' . /* I18N: %s are placeholders for numbers */
317c1010edaSGreg Roach            self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' .
3180b6446e1SGreg 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')) . '",' .
319c1010edaSGreg Roach            ' "infoFiltered":   "' . /* I18N: %s is a placeholder for a number */
320c1010edaSGreg Roach            self::translate('(filtered from %s total entries)', '_MAX_') . '",' .
321c1010edaSGreg Roach            ' "lengthMenu":     "' . /* I18N: %s is a number of records per page */
322c1010edaSGreg Roach            self::translate('Display %s', addslashes($length_options)) . '",' .
323dfeee0a8SGreg Roach            ' "loadingRecords": "' . self::translate('Loading…') . '",' .
324dfeee0a8SGreg Roach            ' "processing":     "' . self::translate('Loading…') . '",' .
325dfeee0a8SGreg Roach            ' "search":         "' . self::translate('Filter') . '",' .
326dfeee0a8SGreg Roach            ' "zeroRecords":    "' . self::translate('No records to display') . '"' .
3272d9b2ebaSGreg Roach            '}';
328dfeee0a8SGreg Roach    }
329dfeee0a8SGreg Roach
330dfeee0a8SGreg Roach    /**
331dfeee0a8SGreg Roach     * Convert the digits 0-9 into the local script
332dfeee0a8SGreg Roach     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
333dfeee0a8SGreg Roach     *
33455664801SGreg Roach     * @param string|int $n
335dfeee0a8SGreg Roach     *
336dfeee0a8SGreg Roach     * @return string
337dfeee0a8SGreg Roach     */
3388f53f488SRico Sonntag    public static function digits($n): string
339c1010edaSGreg Roach    {
34055664801SGreg Roach        return self::$locale->digits((string) $n);
341dfeee0a8SGreg Roach    }
342dfeee0a8SGreg Roach
343dfeee0a8SGreg Roach    /**
344dfeee0a8SGreg Roach     * What is the direction of the current locale
345dfeee0a8SGreg Roach     *
346dfeee0a8SGreg Roach     * @return string "ltr" or "rtl"
347dfeee0a8SGreg Roach     */
3488f53f488SRico Sonntag    public static function direction(): string
349c1010edaSGreg Roach    {
350dfeee0a8SGreg Roach        return self::$locale->direction();
351dfeee0a8SGreg Roach    }
352dfeee0a8SGreg Roach
353dfeee0a8SGreg Roach    /**
3547231a557SGreg Roach     * What is the first day of the week.
3557231a557SGreg Roach     *
356cbc1590aSGreg Roach     * @return int Sunday=0, Monday=1, etc.
3577231a557SGreg Roach     */
3588f53f488SRico Sonntag    public static function firstDay(): int
359c1010edaSGreg Roach    {
3607231a557SGreg Roach        return self::$locale->territory()->firstDay();
3617231a557SGreg Roach    }
3627231a557SGreg Roach
3637231a557SGreg Roach    /**
364dfeee0a8SGreg Roach     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
365dfeee0a8SGreg Roach     *
366dfeee0a8SGreg Roach     * @return string
367dfeee0a8SGreg Roach     */
3688f53f488SRico Sonntag    public static function htmlAttributes(): string
369c1010edaSGreg Roach    {
370dfeee0a8SGreg Roach        return self::$locale->htmlAttributes();
371dfeee0a8SGreg Roach    }
372dfeee0a8SGreg Roach
373dfeee0a8SGreg Roach    /**
374a25f0a04SGreg Roach     * Initialise the translation adapter with a locale setting.
375a25f0a04SGreg Roach     *
37615d603e7SGreg Roach     * @param string    $code Use this locale/language code, or choose one automatically
377e58a20ffSGreg Roach     * @param Tree|null $tree
378a25f0a04SGreg Roach     *
379a25f0a04SGreg Roach     * @return string $string
380a25f0a04SGreg Roach     */
381e58a20ffSGreg Roach    public static function init(string $code = '', Tree $tree = null): string
382c1010edaSGreg Roach    {
38315d603e7SGreg Roach        if ($code !== '') {
3843bdc890bSGreg Roach            // Create the specified locale
3853bdc890bSGreg Roach            self::$locale = Locale::create($code);
3864ee95e68SRico Sonntag        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) {
387e58a20ffSGreg Roach            // Select a previously used locale
38831bc7874SGreg Roach            self::$locale = Locale::create(Session::get('locale'));
3893bdc890bSGreg Roach        } else {
390e58a20ffSGreg Roach            if ($tree instanceof Tree) {
391e58a20ffSGreg Roach                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
392e58a20ffSGreg Roach            } else {
39359f2f229SGreg Roach                $default_locale = new LocaleEnUs();
3943bdc890bSGreg Roach            }
395e58a20ffSGreg Roach
396e58a20ffSGreg Roach            // Negotiate with the browser.
397e58a20ffSGreg Roach            // Search engines don't negotiate.  They get the default locale of the tree.
398149573a1SGreg Roach            self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale);
3993bdc890bSGreg Roach        }
4003bdc890bSGreg Roach
401f1af7e1cSGreg Roach        $cache_dir  = WT_DATA_DIR . 'cache/';
402f1af7e1cSGreg Roach        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
4033bdc890bSGreg Roach        if (file_exists($cache_file)) {
4043bdc890bSGreg Roach            $filemtime = filemtime($cache_file);
4053bdc890bSGreg Roach        } else {
4063bdc890bSGreg Roach            $filemtime = 0;
4073bdc890bSGreg Roach        }
4083bdc890bSGreg Roach
4093bdc890bSGreg Roach        // Load the translation file(s)
410362b8464SGreg Roach        $translation_files = [
411362b8464SGreg Roach            WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo',
412362b8464SGreg Roach        ];
413362b8464SGreg Roach
4147a7f87d7SGreg Roach        // Rebuild files after one hour
4157a7f87d7SGreg Roach        $rebuild_cache = time() > $filemtime + 3600;
4161e71bdc0SGreg Roach        // Rebuild files if any translation file has been updated
4173bdc890bSGreg Roach        foreach ($translation_files as $translation_file) {
4183bdc890bSGreg Roach            if (filemtime($translation_file) > $filemtime) {
4193bdc890bSGreg Roach                $rebuild_cache = true;
420a25f0a04SGreg Roach                break;
421a25f0a04SGreg Roach            }
422a25f0a04SGreg Roach        }
4233bdc890bSGreg Roach
4243bdc890bSGreg Roach        if ($rebuild_cache) {
42513abd6f3SGreg Roach            $translations = [];
4263bdc890bSGreg Roach            foreach ($translation_files as $translation_file) {
4273bdc890bSGreg Roach                $translation  = new Translation($translation_file);
4283bdc890bSGreg Roach                $translations = array_merge($translations, $translation->asArray());
429a25f0a04SGreg Roach            }
430f1af7e1cSGreg Roach            try {
431f1af7e1cSGreg Roach                File::mkdir($cache_dir);
432f1af7e1cSGreg Roach                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
433f1af7e1cSGreg Roach            } catch (Exception $ex) {
4347c2999b4SGreg Roach                // During setup, we may not have been able to create it.
435c85fb0c4SGreg Roach            }
4363bdc890bSGreg Roach        } else {
4373bdc890bSGreg Roach            $translations = include $cache_file;
438a25f0a04SGreg Roach        }
439a25f0a04SGreg Roach
4403bdc890bSGreg Roach        // Create a translator
4413bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
442a25f0a04SGreg Roach
443bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
444bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
445a25f0a04SGreg Roach
446991b93ddSGreg Roach        // Create a collator
447991b93ddSGreg Roach        try {
448444a65ecSGreg Roach            if (class_exists('Collator')) {
449c9ec599fSGreg Roach                // Symfony provides a very incomplete polyfill - which cannot be used.
450991b93ddSGreg Roach                self::$collator = new Collator(self::$locale->code());
451991b93ddSGreg Roach                // Ignore upper/lower case differences
452991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
453444a65ecSGreg Roach            }
454991b93ddSGreg Roach        } catch (Exception $ex) {
455991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
456c9ec599fSGreg Roach            self::$collator = null;
457991b93ddSGreg Roach        }
458991b93ddSGreg Roach
4595331c5eaSGreg Roach        return self::$locale->languageTag();
460a25f0a04SGreg Roach    }
461a25f0a04SGreg Roach
462a25f0a04SGreg Roach    /**
463c999a340SGreg Roach     * All locales for which a translation file exists.
464c999a340SGreg Roach     *
46515834aaeSGreg Roach     * @return LocaleInterface[]
466c999a340SGreg Roach     */
4678f53f488SRico Sonntag    public static function installedLocales(): array
468c1010edaSGreg Roach    {
46913abd6f3SGreg Roach        $locales = [];
470362b8464SGreg Roach
471362b8464SGreg Roach        foreach (glob(WT_ROOT . 'resources/lang/*/messages.mo') as $file) {
472c999a340SGreg Roach            try {
473*f8e24896SGreg Roach                $locales[] = Locale::create(basename(dirname($file)));
474362b8464SGreg Roach            } catch (DomainException $ex) {
4753bdc890bSGreg Roach                // Not a recognised locale
476a25f0a04SGreg Roach            }
477a25f0a04SGreg Roach        }
478c999a340SGreg Roach        usort($locales, '\Fisharebest\Localization\Locale::compare');
479c999a340SGreg Roach
480c999a340SGreg Roach        return $locales;
481a25f0a04SGreg Roach    }
482a25f0a04SGreg Roach
483a25f0a04SGreg Roach    /**
484a25f0a04SGreg Roach     * Return the endonym for a given language - as per http://cldr.unicode.org/
485a25f0a04SGreg Roach     *
486a25f0a04SGreg Roach     * @param string $locale
487a25f0a04SGreg Roach     *
488a25f0a04SGreg Roach     * @return string
489a25f0a04SGreg Roach     */
49055664801SGreg Roach    public static function languageName(string $locale): string
491c1010edaSGreg Roach    {
492c999a340SGreg Roach        return Locale::create($locale)->endonym();
493a25f0a04SGreg Roach    }
494a25f0a04SGreg Roach
495a25f0a04SGreg Roach    /**
496a25f0a04SGreg Roach     * Return the script used by a given language
497a25f0a04SGreg Roach     *
498a25f0a04SGreg Roach     * @param string $locale
499a25f0a04SGreg Roach     *
500a25f0a04SGreg Roach     * @return string
501a25f0a04SGreg Roach     */
50255664801SGreg Roach    public static function languageScript(string $locale): string
503c1010edaSGreg Roach    {
504c999a340SGreg Roach        return Locale::create($locale)->script()->code();
505a25f0a04SGreg Roach    }
506a25f0a04SGreg Roach
507a25f0a04SGreg Roach    /**
508dfeee0a8SGreg Roach     * Translate a number into the local representation.
509dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
510dfeee0a8SGreg Roach     * en: 12,345.67
511dfeee0a8SGreg Roach     * fr: 12 345,67
512dfeee0a8SGreg Roach     * de: 12.345,67
513dfeee0a8SGreg Roach     *
514dfeee0a8SGreg Roach     * @param float $n
515cbc1590aSGreg Roach     * @param int   $precision
516a25f0a04SGreg Roach     *
517a25f0a04SGreg Roach     * @return string
518a25f0a04SGreg Roach     */
51955664801SGreg Roach    public static function number(float $n, int $precision = 0): string
520c1010edaSGreg Roach    {
521dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
522dfeee0a8SGreg Roach    }
523dfeee0a8SGreg Roach
524dfeee0a8SGreg Roach    /**
525dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
526dfeee0a8SGreg Roach     * e.g. 0.123 becomes
527dfeee0a8SGreg Roach     * en: 12.3%
528dfeee0a8SGreg Roach     * fr: 12,3 %
529dfeee0a8SGreg Roach     * de: 12,3%
530dfeee0a8SGreg Roach     *
531dfeee0a8SGreg Roach     * @param float $n
532cbc1590aSGreg Roach     * @param int   $precision
533dfeee0a8SGreg Roach     *
534dfeee0a8SGreg Roach     * @return string
535dfeee0a8SGreg Roach     */
53655664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
537c1010edaSGreg Roach    {
538dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
539dfeee0a8SGreg Roach    }
540dfeee0a8SGreg Roach
541dfeee0a8SGreg Roach    /**
542dfeee0a8SGreg Roach     * Translate a plural string
543dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
544dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
545dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
546dfeee0a8SGreg Roach     *
547924d091bSGreg Roach     * @param string $singular
548924d091bSGreg Roach     * @param string $plural
549924d091bSGreg Roach     * @param int    $count
550a515be7cSGreg Roach     * @param string ...$args
551e93111adSRico Sonntag     *
552dfeee0a8SGreg Roach     * @return string
553dfeee0a8SGreg Roach     */
554924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
555c1010edaSGreg Roach    {
556924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
557dfeee0a8SGreg Roach
558924d091bSGreg Roach        return sprintf($message, ...$args);
559dfeee0a8SGreg Roach    }
560dfeee0a8SGreg Roach
561dfeee0a8SGreg Roach    /**
562dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
563dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
564dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
565dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
566dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
567dfeee0a8SGreg Roach     *
568dfeee0a8SGreg Roach     * @param string $text Text to be reversed
569dfeee0a8SGreg Roach     *
570dfeee0a8SGreg Roach     * @return string
571dfeee0a8SGreg Roach     */
5728f53f488SRico Sonntag    public static function reverseText($text): string
573c1010edaSGreg Roach    {
574dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
5759524b7b5SGreg Roach        $text = strip_tags($text);
5769524b7b5SGreg Roach        // Remove HTML entities.
5779524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
578dfeee0a8SGreg Roach
579dfeee0a8SGreg Roach        // LTR text doesn't need reversing
580dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
581dfeee0a8SGreg Roach            return $text;
582dfeee0a8SGreg Roach        }
583dfeee0a8SGreg Roach
584dfeee0a8SGreg Roach        // Mirrored characters
585991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
586dfeee0a8SGreg Roach
587dfeee0a8SGreg Roach        $reversed = '';
588dfeee0a8SGreg Roach        $digits   = '';
589e364afe4SGreg Roach        while ($text !== '') {
590dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
591dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
592dfeee0a8SGreg Roach            if (strpos(self::DIGITS, $letter) !== false) {
593dfeee0a8SGreg Roach                $digits .= $letter;
594a25f0a04SGreg Roach            } else {
595dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
596dfeee0a8SGreg Roach                $digits   = '';
597dfeee0a8SGreg Roach            }
598a25f0a04SGreg Roach        }
599a25f0a04SGreg Roach
600dfeee0a8SGreg Roach        return $digits . $reversed;
601a25f0a04SGreg Roach    }
602a25f0a04SGreg Roach
603a25f0a04SGreg Roach    /**
604a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
605a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
606a25f0a04SGreg Roach     * our own lookup table.
607a25f0a04SGreg Roach     *
608a25f0a04SGreg Roach     * @param string $script
609a25f0a04SGreg Roach     *
610a25f0a04SGreg Roach     * @return string
611a25f0a04SGreg Roach     */
612e364afe4SGreg Roach    public static function scriptDirection($script): string
613c1010edaSGreg Roach    {
614a25f0a04SGreg Roach        switch ($script) {
615a25f0a04SGreg Roach            case 'Arab':
616a25f0a04SGreg Roach            case 'Hebr':
617a25f0a04SGreg Roach            case 'Mong':
618a25f0a04SGreg Roach            case 'Thaa':
619a25f0a04SGreg Roach                return 'rtl';
620a25f0a04SGreg Roach            default:
621a25f0a04SGreg Roach                return 'ltr';
622a25f0a04SGreg Roach        }
623a25f0a04SGreg Roach    }
624a25f0a04SGreg Roach
625a25f0a04SGreg Roach    /**
626991b93ddSGreg Roach     * Perform a case-insensitive comparison of two strings.
627a25f0a04SGreg Roach     *
628a25f0a04SGreg Roach     * @param string $string1
629a25f0a04SGreg Roach     * @param string $string2
630a25f0a04SGreg Roach     *
631cbc1590aSGreg Roach     * @return int
632a25f0a04SGreg Roach     */
633e364afe4SGreg Roach    public static function strcasecmp($string1, $string2): int
634c1010edaSGreg Roach    {
635991b93ddSGreg Roach        if (self::$collator instanceof Collator) {
636991b93ddSGreg Roach            return self::$collator->compare($string1, $string2);
637a25f0a04SGreg Roach        }
638e364afe4SGreg Roach
639e364afe4SGreg Roach        return strcmp(self::strtolower($string1), self::strtolower($string2));
640c9ec599fSGreg Roach    }
641a25f0a04SGreg Roach
642a25f0a04SGreg Roach    /**
643991b93ddSGreg Roach     * Convert a string to lower case.
644a25f0a04SGreg Roach     *
645dfeee0a8SGreg Roach     * @param string $string
646a25f0a04SGreg Roach     *
647a25f0a04SGreg Roach     * @return string
648a25f0a04SGreg Roach     */
6498f53f488SRico Sonntag    public static function strtolower($string): string
650c1010edaSGreg Roach    {
651991b93ddSGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
652991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
653a25f0a04SGreg Roach        }
6545ddad20bSGreg Roach
6555ddad20bSGreg Roach        return mb_strtolower($string);
656a25f0a04SGreg Roach    }
657a25f0a04SGreg Roach
658a25f0a04SGreg Roach    /**
659991b93ddSGreg Roach     * Convert a string to upper case.
660dfeee0a8SGreg Roach     *
661dfeee0a8SGreg Roach     * @param string $string
662a25f0a04SGreg Roach     *
663a25f0a04SGreg Roach     * @return string
664a25f0a04SGreg Roach     */
6658f53f488SRico Sonntag    public static function strtoupper($string): string
666c1010edaSGreg Roach    {
667991b93ddSGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
668991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
669a25f0a04SGreg Roach        }
6705ddad20bSGreg Roach
6715ddad20bSGreg Roach        return mb_strtoupper($string);
672a25f0a04SGreg Roach    }
673a25f0a04SGreg Roach
674dfeee0a8SGreg Roach    /**
675dfeee0a8SGreg Roach     * Identify the script used for a piece of text
676dfeee0a8SGreg Roach     *
677d0bfc631SGreg Roach     * @param string $string
678dfeee0a8SGreg Roach     *
679dfeee0a8SGreg Roach     * @return string
680dfeee0a8SGreg Roach     */
6818f53f488SRico Sonntag    public static function textScript($string): string
682c1010edaSGreg Roach    {
683dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
684dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
685c1010edaSGreg Roach        $string = str_replace([
686c1010edaSGreg Roach            '@N.N.',
687c1010edaSGreg Roach            '@P.N.',
688c1010edaSGreg Roach        ], '', $string); // otherwise unknown names show up as latin
689dfeee0a8SGreg Roach        $pos    = 0;
690dfeee0a8SGreg Roach        $strlen = strlen($string);
691dfeee0a8SGreg Roach        while ($pos < $strlen) {
692dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
693dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
694dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
695dfeee0a8SGreg Roach                $code_point = $byte1;
696dfeee0a8SGreg Roach                $chrlen     = 1;
697dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
698dfeee0a8SGreg Roach                // Invalid continuation character
699dfeee0a8SGreg Roach                return 'Latn';
700dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
701dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
702dfeee0a8SGreg Roach                $chrlen     = 2;
703dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
704dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
705dfeee0a8SGreg Roach                $chrlen     = 3;
706dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
707dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
708dfeee0a8SGreg Roach                $chrlen     = 3;
709dfeee0a8SGreg Roach            } else {
710dfeee0a8SGreg Roach                // Invalid UTF
711dfeee0a8SGreg Roach                return 'Latn';
712dfeee0a8SGreg Roach            }
713dfeee0a8SGreg Roach
714991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
715dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
716dfeee0a8SGreg Roach                    return $range[0];
717dfeee0a8SGreg Roach                }
718dfeee0a8SGreg Roach            }
719dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
720dfeee0a8SGreg Roach            $pos += $chrlen;
721dfeee0a8SGreg Roach        }
722dfeee0a8SGreg Roach
723dfeee0a8SGreg Roach        return 'Latn';
724dfeee0a8SGreg Roach    }
725dfeee0a8SGreg Roach
726dfeee0a8SGreg Roach    /**
727dfeee0a8SGreg Roach     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
728dfeee0a8SGreg Roach     *
729cbc1590aSGreg Roach     * @param int $seconds
730dfeee0a8SGreg Roach     *
731dfeee0a8SGreg Roach     * @return string
732dfeee0a8SGreg Roach     */
733e364afe4SGreg Roach    public static function timeAgo($seconds): string
734c1010edaSGreg Roach    {
735dfeee0a8SGreg Roach        $minute = 60;
736dfeee0a8SGreg Roach        $hour   = 60 * $minute;
737dfeee0a8SGreg Roach        $day    = 24 * $hour;
738dfeee0a8SGreg Roach        $month  = 30 * $day;
739dfeee0a8SGreg Roach        $year   = 365 * $day;
740dfeee0a8SGreg Roach
741dfeee0a8SGreg Roach        if ($seconds > $year) {
742cdaafeeeSGreg Roach            $years = intdiv($seconds, $year);
743cbc1590aSGreg Roach
744dfeee0a8SGreg Roach            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
745b2ce94c6SRico Sonntag        }
746b2ce94c6SRico Sonntag
747b2ce94c6SRico Sonntag        if ($seconds > $month) {
748cdaafeeeSGreg Roach            $months = intdiv($seconds, $month);
749cbc1590aSGreg Roach
750dfeee0a8SGreg Roach            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
751b2ce94c6SRico Sonntag        }
752b2ce94c6SRico Sonntag
753b2ce94c6SRico Sonntag        if ($seconds > $day) {
754cdaafeeeSGreg Roach            $days = intdiv($seconds, $day);
755cbc1590aSGreg Roach
756dfeee0a8SGreg Roach            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
757b2ce94c6SRico Sonntag        }
758b2ce94c6SRico Sonntag
759b2ce94c6SRico Sonntag        if ($seconds > $hour) {
760cdaafeeeSGreg Roach            $hours = intdiv($seconds, $hour);
761cbc1590aSGreg Roach
762dfeee0a8SGreg Roach            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
763b2ce94c6SRico Sonntag        }
764b2ce94c6SRico Sonntag
765b2ce94c6SRico Sonntag        if ($seconds > $minute) {
766cdaafeeeSGreg Roach            $minutes = intdiv($seconds, $minute);
767cbc1590aSGreg Roach
768dfeee0a8SGreg Roach            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
769dfeee0a8SGreg Roach        }
770b2ce94c6SRico Sonntag
771b2ce94c6SRico Sonntag        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
772dfeee0a8SGreg Roach    }
773dfeee0a8SGreg Roach
774dfeee0a8SGreg Roach    /**
775dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
776dfeee0a8SGreg Roach     *
777dfeee0a8SGreg Roach     * @return string
778dfeee0a8SGreg Roach     */
7798f53f488SRico Sonntag    public static function timeFormat(): string
780c1010edaSGreg Roach    {
781bbb76c12SGreg Roach        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
782bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
783dfeee0a8SGreg Roach    }
784dfeee0a8SGreg Roach
785dfeee0a8SGreg Roach    /**
786dfeee0a8SGreg Roach     * Translate a string, and then substitute placeholders
787dfeee0a8SGreg Roach     * echo I18N::translate('Hello World!');
788dfeee0a8SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
789dfeee0a8SGreg Roach     *
790924d091bSGreg Roach     * @param string $message
791a515be7cSGreg Roach     * @param string ...$args
792c3283ed7SGreg Roach     *
793dfeee0a8SGreg Roach     * @return string
794dfeee0a8SGreg Roach     */
795924d091bSGreg Roach    public static function translate(string $message, ...$args): string
796c1010edaSGreg Roach    {
797924d091bSGreg Roach        $message = self::$translator->translate($message);
798dfeee0a8SGreg Roach
799924d091bSGreg Roach        return sprintf($message, ...$args);
800dfeee0a8SGreg Roach    }
801dfeee0a8SGreg Roach
802dfeee0a8SGreg Roach    /**
803dfeee0a8SGreg Roach     * Context sensitive version of translate.
804a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
805a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
806dfeee0a8SGreg Roach     *
807924d091bSGreg Roach     * @param string $context
808924d091bSGreg Roach     * @param string $message
809a515be7cSGreg Roach     * @param string ...$args
810c3283ed7SGreg Roach     *
811dfeee0a8SGreg Roach     * @return string
812dfeee0a8SGreg Roach     */
813924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
814c1010edaSGreg Roach    {
815924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
816dfeee0a8SGreg Roach
817924d091bSGreg Roach        return sprintf($message, ...$args);
818a25f0a04SGreg Roach    }
819a25f0a04SGreg Roach}
820