xref: /webtrees/app/I18N.php (revision 4ee95e686e17b520c100a4fe77ec146625f511cc)
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     */
263c1010edaSGreg Roach    public static function collation()
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    {
383c314ecc9SGreg Roach        mb_internal_encoding('UTF-8');
384c314ecc9SGreg Roach
38515d603e7SGreg Roach        if ($code !== '') {
3863bdc890bSGreg Roach            // Create the specified locale
3873bdc890bSGreg Roach            self::$locale = Locale::create($code);
388*4ee95e68SRico Sonntag        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) {
389e58a20ffSGreg Roach            // Select a previously used locale
39031bc7874SGreg Roach            self::$locale = Locale::create(Session::get('locale'));
3913bdc890bSGreg Roach        } else {
392e58a20ffSGreg Roach            if ($tree instanceof Tree) {
393e58a20ffSGreg Roach                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
394e58a20ffSGreg Roach            } else {
39559f2f229SGreg Roach                $default_locale = new LocaleEnUs();
3963bdc890bSGreg Roach            }
397e58a20ffSGreg Roach
398e58a20ffSGreg Roach            // Negotiate with the browser.
399e58a20ffSGreg Roach            // Search engines don't negotiate.  They get the default locale of the tree.
400149573a1SGreg Roach            self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale);
4013bdc890bSGreg Roach        }
4023bdc890bSGreg Roach
403f1af7e1cSGreg Roach        $cache_dir  = WT_DATA_DIR . 'cache/';
404f1af7e1cSGreg Roach        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
4053bdc890bSGreg Roach        if (file_exists($cache_file)) {
4063bdc890bSGreg Roach            $filemtime = filemtime($cache_file);
4073bdc890bSGreg Roach        } else {
4083bdc890bSGreg Roach            $filemtime = 0;
4093bdc890bSGreg Roach        }
4103bdc890bSGreg Roach
4113bdc890bSGreg Roach        // Load the translation file(s)
412362b8464SGreg Roach        $translation_files = [
413362b8464SGreg Roach            WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo',
414362b8464SGreg Roach        ];
415362b8464SGreg Roach
4167a7f87d7SGreg Roach        // Rebuild files after one hour
4177a7f87d7SGreg Roach        $rebuild_cache = time() > $filemtime + 3600;
4181e71bdc0SGreg Roach        // Rebuild files if any translation file has been updated
4193bdc890bSGreg Roach        foreach ($translation_files as $translation_file) {
4203bdc890bSGreg Roach            if (filemtime($translation_file) > $filemtime) {
4213bdc890bSGreg Roach                $rebuild_cache = true;
422a25f0a04SGreg Roach                break;
423a25f0a04SGreg Roach            }
424a25f0a04SGreg Roach        }
4253bdc890bSGreg Roach
4263bdc890bSGreg Roach        if ($rebuild_cache) {
42713abd6f3SGreg Roach            $translations = [];
4283bdc890bSGreg Roach            foreach ($translation_files as $translation_file) {
4293bdc890bSGreg Roach                $translation  = new Translation($translation_file);
4303bdc890bSGreg Roach                $translations = array_merge($translations, $translation->asArray());
431a25f0a04SGreg Roach            }
432f1af7e1cSGreg Roach            try {
433f1af7e1cSGreg Roach                File::mkdir($cache_dir);
434f1af7e1cSGreg Roach                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
435f1af7e1cSGreg Roach            } catch (Exception $ex) {
4367c2999b4SGreg Roach                // During setup, we may not have been able to create it.
437c85fb0c4SGreg Roach            }
4383bdc890bSGreg Roach        } else {
4393bdc890bSGreg Roach            $translations = include $cache_file;
440a25f0a04SGreg Roach        }
441a25f0a04SGreg Roach
4423bdc890bSGreg Roach        // Create a translator
4433bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
444a25f0a04SGreg Roach
445bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
446bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
447a25f0a04SGreg Roach
448991b93ddSGreg Roach        // Create a collator
449991b93ddSGreg Roach        try {
450444a65ecSGreg Roach            if (class_exists('Collator')) {
451c9ec599fSGreg Roach                // Symfony provides a very incomplete polyfill - which cannot be used.
452991b93ddSGreg Roach                self::$collator = new Collator(self::$locale->code());
453991b93ddSGreg Roach                // Ignore upper/lower case differences
454991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
455444a65ecSGreg Roach            }
456991b93ddSGreg Roach        } catch (Exception $ex) {
457991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
458c9ec599fSGreg Roach            self::$collator = null;
459991b93ddSGreg Roach        }
460991b93ddSGreg Roach
4615331c5eaSGreg Roach        return self::$locale->languageTag();
462a25f0a04SGreg Roach    }
463a25f0a04SGreg Roach
464a25f0a04SGreg Roach    /**
465c999a340SGreg Roach     * All locales for which a translation file exists.
466c999a340SGreg Roach     *
46715834aaeSGreg Roach     * @return LocaleInterface[]
468c999a340SGreg Roach     */
4698f53f488SRico Sonntag    public static function installedLocales(): array
470c1010edaSGreg Roach    {
47113abd6f3SGreg Roach        $locales = [];
472362b8464SGreg Roach
473362b8464SGreg Roach        foreach (glob(WT_ROOT . 'resources/lang/*/messages.mo') as $file) {
474c999a340SGreg Roach            try {
475c999a340SGreg Roach                $locales[] = Locale::create(basename($file, '.mo'));
476362b8464SGreg Roach            } catch (DomainException $ex) {
4773bdc890bSGreg Roach                // Not a recognised locale
478a25f0a04SGreg Roach            }
479a25f0a04SGreg Roach        }
480c999a340SGreg Roach        usort($locales, '\Fisharebest\Localization\Locale::compare');
481c999a340SGreg Roach
482c999a340SGreg Roach        return $locales;
483a25f0a04SGreg Roach    }
484a25f0a04SGreg Roach
485a25f0a04SGreg Roach    /**
486a25f0a04SGreg Roach     * Return the endonym for a given language - as per http://cldr.unicode.org/
487a25f0a04SGreg Roach     *
488a25f0a04SGreg Roach     * @param string $locale
489a25f0a04SGreg Roach     *
490a25f0a04SGreg Roach     * @return string
491a25f0a04SGreg Roach     */
49255664801SGreg Roach    public static function languageName(string $locale): string
493c1010edaSGreg Roach    {
494c999a340SGreg Roach        return Locale::create($locale)->endonym();
495a25f0a04SGreg Roach    }
496a25f0a04SGreg Roach
497a25f0a04SGreg Roach    /**
498a25f0a04SGreg Roach     * Return the script used by a given language
499a25f0a04SGreg Roach     *
500a25f0a04SGreg Roach     * @param string $locale
501a25f0a04SGreg Roach     *
502a25f0a04SGreg Roach     * @return string
503a25f0a04SGreg Roach     */
50455664801SGreg Roach    public static function languageScript(string $locale): string
505c1010edaSGreg Roach    {
506c999a340SGreg Roach        return Locale::create($locale)->script()->code();
507a25f0a04SGreg Roach    }
508a25f0a04SGreg Roach
509a25f0a04SGreg Roach    /**
510dfeee0a8SGreg Roach     * Translate a number into the local representation.
511dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
512dfeee0a8SGreg Roach     * en: 12,345.67
513dfeee0a8SGreg Roach     * fr: 12 345,67
514dfeee0a8SGreg Roach     * de: 12.345,67
515dfeee0a8SGreg Roach     *
516dfeee0a8SGreg Roach     * @param float $n
517cbc1590aSGreg Roach     * @param int   $precision
518a25f0a04SGreg Roach     *
519a25f0a04SGreg Roach     * @return string
520a25f0a04SGreg Roach     */
52155664801SGreg Roach    public static function number(float $n, int $precision = 0): string
522c1010edaSGreg Roach    {
523dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
524dfeee0a8SGreg Roach    }
525dfeee0a8SGreg Roach
526dfeee0a8SGreg Roach    /**
527dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
528dfeee0a8SGreg Roach     * e.g. 0.123 becomes
529dfeee0a8SGreg Roach     * en: 12.3%
530dfeee0a8SGreg Roach     * fr: 12,3 %
531dfeee0a8SGreg Roach     * de: 12,3%
532dfeee0a8SGreg Roach     *
533dfeee0a8SGreg Roach     * @param float $n
534cbc1590aSGreg Roach     * @param int   $precision
535dfeee0a8SGreg Roach     *
536dfeee0a8SGreg Roach     * @return string
537dfeee0a8SGreg Roach     */
53855664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
539c1010edaSGreg Roach    {
540dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
541dfeee0a8SGreg Roach    }
542dfeee0a8SGreg Roach
543dfeee0a8SGreg Roach    /**
544dfeee0a8SGreg Roach     * Translate a plural string
545dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
546dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
547dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
548dfeee0a8SGreg Roach     *
549924d091bSGreg Roach     * @param string $singular
550924d091bSGreg Roach     * @param string $plural
551924d091bSGreg Roach     * @param int    $count
552a515be7cSGreg Roach     * @param string ...$args
553e93111adSRico Sonntag     *
554dfeee0a8SGreg Roach     * @return string
555dfeee0a8SGreg Roach     */
556924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
557c1010edaSGreg Roach    {
558924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
559dfeee0a8SGreg Roach
560924d091bSGreg Roach        return sprintf($message, ...$args);
561dfeee0a8SGreg Roach    }
562dfeee0a8SGreg Roach
563dfeee0a8SGreg Roach    /**
564dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
565dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
566dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
567dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
568dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
569dfeee0a8SGreg Roach     *
570dfeee0a8SGreg Roach     * @param string $text Text to be reversed
571dfeee0a8SGreg Roach     *
572dfeee0a8SGreg Roach     * @return string
573dfeee0a8SGreg Roach     */
5748f53f488SRico Sonntag    public static function reverseText($text): string
575c1010edaSGreg Roach    {
576dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
5779524b7b5SGreg Roach        $text = strip_tags($text);
5789524b7b5SGreg Roach        // Remove HTML entities.
5799524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
580dfeee0a8SGreg Roach
581dfeee0a8SGreg Roach        // LTR text doesn't need reversing
582dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
583dfeee0a8SGreg Roach            return $text;
584dfeee0a8SGreg Roach        }
585dfeee0a8SGreg Roach
586dfeee0a8SGreg Roach        // Mirrored characters
587991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
588dfeee0a8SGreg Roach
589dfeee0a8SGreg Roach        $reversed = '';
590dfeee0a8SGreg Roach        $digits   = '';
591dfeee0a8SGreg Roach        while ($text != '') {
592dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
593dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
594dfeee0a8SGreg Roach            if (strpos(self::DIGITS, $letter) !== false) {
595dfeee0a8SGreg Roach                $digits .= $letter;
596a25f0a04SGreg Roach            } else {
597dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
598dfeee0a8SGreg Roach                $digits   = '';
599dfeee0a8SGreg Roach            }
600a25f0a04SGreg Roach        }
601a25f0a04SGreg Roach
602dfeee0a8SGreg Roach        return $digits . $reversed;
603a25f0a04SGreg Roach    }
604a25f0a04SGreg Roach
605a25f0a04SGreg Roach    /**
606a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
607a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
608a25f0a04SGreg Roach     * our own lookup table.
609a25f0a04SGreg Roach     *
610a25f0a04SGreg Roach     * @param string $script
611a25f0a04SGreg Roach     *
612a25f0a04SGreg Roach     * @return string
613a25f0a04SGreg Roach     */
614c1010edaSGreg Roach    public static function scriptDirection($script)
615c1010edaSGreg Roach    {
616a25f0a04SGreg Roach        switch ($script) {
617a25f0a04SGreg Roach            case 'Arab':
618a25f0a04SGreg Roach            case 'Hebr':
619a25f0a04SGreg Roach            case 'Mong':
620a25f0a04SGreg Roach            case 'Thaa':
621a25f0a04SGreg Roach                return 'rtl';
622a25f0a04SGreg Roach            default:
623a25f0a04SGreg Roach                return 'ltr';
624a25f0a04SGreg Roach        }
625a25f0a04SGreg Roach    }
626a25f0a04SGreg Roach
627a25f0a04SGreg Roach    /**
628991b93ddSGreg Roach     * Perform a case-insensitive comparison of two strings.
629a25f0a04SGreg Roach     *
630a25f0a04SGreg Roach     * @param string $string1
631a25f0a04SGreg Roach     * @param string $string2
632a25f0a04SGreg Roach     *
633cbc1590aSGreg Roach     * @return int
634a25f0a04SGreg Roach     */
635c1010edaSGreg Roach    public static function strcasecmp($string1, $string2)
636c1010edaSGreg Roach    {
637991b93ddSGreg Roach        if (self::$collator instanceof Collator) {
638991b93ddSGreg Roach            return self::$collator->compare($string1, $string2);
639c9ec599fSGreg Roach        } else {
640b2ce94c6SRico Sonntag            return strcmp(self::strtolower($string1), self::strtolower($string2));
641a25f0a04SGreg Roach        }
642c9ec599fSGreg Roach    }
643a25f0a04SGreg Roach
644a25f0a04SGreg Roach    /**
645991b93ddSGreg Roach     * Convert a string to lower case.
646a25f0a04SGreg Roach     *
647dfeee0a8SGreg Roach     * @param string $string
648a25f0a04SGreg Roach     *
649a25f0a04SGreg Roach     * @return string
650a25f0a04SGreg Roach     */
6518f53f488SRico Sonntag    public static function strtolower($string): string
652c1010edaSGreg Roach    {
653991b93ddSGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
654991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
655a25f0a04SGreg Roach        }
6565ddad20bSGreg Roach
6575ddad20bSGreg Roach        return mb_strtolower($string);
658a25f0a04SGreg Roach    }
659a25f0a04SGreg Roach
660a25f0a04SGreg Roach    /**
661991b93ddSGreg Roach     * Convert a string to upper case.
662dfeee0a8SGreg Roach     *
663dfeee0a8SGreg Roach     * @param string $string
664a25f0a04SGreg Roach     *
665a25f0a04SGreg Roach     * @return string
666a25f0a04SGreg Roach     */
6678f53f488SRico Sonntag    public static function strtoupper($string): string
668c1010edaSGreg Roach    {
669991b93ddSGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
670991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
671a25f0a04SGreg Roach        }
6725ddad20bSGreg Roach
6735ddad20bSGreg Roach        return mb_strtoupper($string);
674a25f0a04SGreg Roach    }
675a25f0a04SGreg Roach
676dfeee0a8SGreg Roach    /**
677dfeee0a8SGreg Roach     * Identify the script used for a piece of text
678dfeee0a8SGreg Roach     *
679d0bfc631SGreg Roach     * @param string $string
680dfeee0a8SGreg Roach     *
681dfeee0a8SGreg Roach     * @return string
682dfeee0a8SGreg Roach     */
6838f53f488SRico Sonntag    public static function textScript($string): string
684c1010edaSGreg Roach    {
685dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
686dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
687c1010edaSGreg Roach        $string = str_replace([
688c1010edaSGreg Roach            '@N.N.',
689c1010edaSGreg Roach            '@P.N.',
690c1010edaSGreg Roach        ], '', $string); // otherwise unknown names show up as latin
691dfeee0a8SGreg Roach        $pos    = 0;
692dfeee0a8SGreg Roach        $strlen = strlen($string);
693dfeee0a8SGreg Roach        while ($pos < $strlen) {
694dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
695dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
696dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
697dfeee0a8SGreg Roach                $code_point = $byte1;
698dfeee0a8SGreg Roach                $chrlen     = 1;
699dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
700dfeee0a8SGreg Roach                // Invalid continuation character
701dfeee0a8SGreg Roach                return 'Latn';
702dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
703dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
704dfeee0a8SGreg Roach                $chrlen     = 2;
705dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
706dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
707dfeee0a8SGreg Roach                $chrlen     = 3;
708dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
709dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
710dfeee0a8SGreg Roach                $chrlen     = 3;
711dfeee0a8SGreg Roach            } else {
712dfeee0a8SGreg Roach                // Invalid UTF
713dfeee0a8SGreg Roach                return 'Latn';
714dfeee0a8SGreg Roach            }
715dfeee0a8SGreg Roach
716991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
717dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
718dfeee0a8SGreg Roach                    return $range[0];
719dfeee0a8SGreg Roach                }
720dfeee0a8SGreg Roach            }
721dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
722dfeee0a8SGreg Roach            $pos += $chrlen;
723dfeee0a8SGreg Roach        }
724dfeee0a8SGreg Roach
725dfeee0a8SGreg Roach        return 'Latn';
726dfeee0a8SGreg Roach    }
727dfeee0a8SGreg Roach
728dfeee0a8SGreg Roach    /**
729dfeee0a8SGreg Roach     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
730dfeee0a8SGreg Roach     *
731cbc1590aSGreg Roach     * @param int $seconds
732dfeee0a8SGreg Roach     *
733dfeee0a8SGreg Roach     * @return string
734dfeee0a8SGreg Roach     */
735c1010edaSGreg Roach    public static function timeAgo($seconds)
736c1010edaSGreg Roach    {
737dfeee0a8SGreg Roach        $minute = 60;
738dfeee0a8SGreg Roach        $hour   = 60 * $minute;
739dfeee0a8SGreg Roach        $day    = 24 * $hour;
740dfeee0a8SGreg Roach        $month  = 30 * $day;
741dfeee0a8SGreg Roach        $year   = 365 * $day;
742dfeee0a8SGreg Roach
743dfeee0a8SGreg Roach        if ($seconds > $year) {
744cdaafeeeSGreg Roach            $years = intdiv($seconds, $year);
745cbc1590aSGreg Roach
746dfeee0a8SGreg Roach            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
747b2ce94c6SRico Sonntag        }
748b2ce94c6SRico Sonntag
749b2ce94c6SRico Sonntag        if ($seconds > $month) {
750cdaafeeeSGreg Roach            $months = intdiv($seconds, $month);
751cbc1590aSGreg Roach
752dfeee0a8SGreg Roach            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
753b2ce94c6SRico Sonntag        }
754b2ce94c6SRico Sonntag
755b2ce94c6SRico Sonntag        if ($seconds > $day) {
756cdaafeeeSGreg Roach            $days = intdiv($seconds, $day);
757cbc1590aSGreg Roach
758dfeee0a8SGreg Roach            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
759b2ce94c6SRico Sonntag        }
760b2ce94c6SRico Sonntag
761b2ce94c6SRico Sonntag        if ($seconds > $hour) {
762cdaafeeeSGreg Roach            $hours = intdiv($seconds, $hour);
763cbc1590aSGreg Roach
764dfeee0a8SGreg Roach            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
765b2ce94c6SRico Sonntag        }
766b2ce94c6SRico Sonntag
767b2ce94c6SRico Sonntag        if ($seconds > $minute) {
768cdaafeeeSGreg Roach            $minutes = intdiv($seconds, $minute);
769cbc1590aSGreg Roach
770dfeee0a8SGreg Roach            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
771dfeee0a8SGreg Roach        }
772b2ce94c6SRico Sonntag
773b2ce94c6SRico Sonntag        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
774dfeee0a8SGreg Roach    }
775dfeee0a8SGreg Roach
776dfeee0a8SGreg Roach    /**
777dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
778dfeee0a8SGreg Roach     *
779dfeee0a8SGreg Roach     * @return string
780dfeee0a8SGreg Roach     */
7818f53f488SRico Sonntag    public static function timeFormat(): string
782c1010edaSGreg Roach    {
783bbb76c12SGreg Roach        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
784bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
785dfeee0a8SGreg Roach    }
786dfeee0a8SGreg Roach
787dfeee0a8SGreg Roach    /**
788dfeee0a8SGreg Roach     * Translate a string, and then substitute placeholders
789dfeee0a8SGreg Roach     * echo I18N::translate('Hello World!');
790dfeee0a8SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
791dfeee0a8SGreg Roach     *
792924d091bSGreg Roach     * @param string $message
793a515be7cSGreg Roach     * @param string ...$args
794c3283ed7SGreg Roach     *
795dfeee0a8SGreg Roach     * @return string
796dfeee0a8SGreg Roach     */
797924d091bSGreg Roach    public static function translate(string $message, ...$args): string
798c1010edaSGreg Roach    {
799924d091bSGreg Roach        $message = self::$translator->translate($message);
800dfeee0a8SGreg Roach
801924d091bSGreg Roach        return sprintf($message, ...$args);
802dfeee0a8SGreg Roach    }
803dfeee0a8SGreg Roach
804dfeee0a8SGreg Roach    /**
805dfeee0a8SGreg Roach     * Context sensitive version of translate.
806a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
807a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
808dfeee0a8SGreg Roach     *
809924d091bSGreg Roach     * @param string $context
810924d091bSGreg Roach     * @param string $message
811a515be7cSGreg Roach     * @param string ...$args
812c3283ed7SGreg Roach     *
813dfeee0a8SGreg Roach     * @return string
814dfeee0a8SGreg Roach     */
815924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
816c1010edaSGreg Roach    {
817924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
818dfeee0a8SGreg Roach
819924d091bSGreg Roach        return sprintf($message, ...$args);
820a25f0a04SGreg Roach    }
821a25f0a04SGreg Roach}
822