xref: /webtrees/app/I18N.php (revision 9e24ac1c2798c66991db013ef7ec2a8789f86a70)
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;
29*9e24ac1cSGreg Roachuse const GLOB_NOSORT;
30a25f0a04SGreg Roach
31a25f0a04SGreg Roach/**
3276692c8bSGreg Roach * Internationalization (i18n) and localization (l10n).
33a25f0a04SGreg Roach */
34c1010edaSGreg Roachclass I18N
35c1010edaSGreg Roach{
3615834aaeSGreg Roach    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
37c999a340SGreg Roach    private static $locale;
38c999a340SGreg Roach
3976692c8bSGreg Roach    /** @var Translator An object that performs translation */
403bdc890bSGreg Roach    private static $translator;
413bdc890bSGreg Roach
42c9ec599fSGreg Roach    /** @var  Collator|null From the php-intl library */
43991b93ddSGreg Roach    private static $collator;
44991b93ddSGreg Roach
45a25f0a04SGreg Roach    // Digits are always rendered LTR, even in RTL text.
4616d6367aSGreg Roach    private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
47a25f0a04SGreg Roach
48991b93ddSGreg Roach    // These locales need special handling for the dotless letter I.
4916d6367aSGreg Roach    private const DOTLESS_I_LOCALES = [
50c1010edaSGreg Roach        'az',
51c1010edaSGreg Roach        'tr',
52c1010edaSGreg Roach    ];
5316d6367aSGreg Roach    private const DOTLESS_I_TOLOWER = [
54c1010edaSGreg Roach        'I' => 'ı',
55c1010edaSGreg Roach        'İ' => 'i',
56c1010edaSGreg Roach    ];
5716d6367aSGreg Roach    private const DOTLESS_I_TOUPPER = [
58c1010edaSGreg Roach        'ı' => 'I',
59c1010edaSGreg Roach        'i' => 'İ',
60c1010edaSGreg Roach    ];
61a25f0a04SGreg Roach
62991b93ddSGreg Roach    // The ranges of characters used by each script.
6316d6367aSGreg Roach    private const SCRIPT_CHARACTER_RANGES = [
64c1010edaSGreg Roach        [
65c1010edaSGreg Roach            'Latn',
66c1010edaSGreg Roach            0x0041,
67c1010edaSGreg Roach            0x005A,
68c1010edaSGreg Roach        ],
69c1010edaSGreg Roach        [
70c1010edaSGreg Roach            'Latn',
71c1010edaSGreg Roach            0x0061,
72c1010edaSGreg Roach            0x007A,
73c1010edaSGreg Roach        ],
74c1010edaSGreg Roach        [
75c1010edaSGreg Roach            'Latn',
76c1010edaSGreg Roach            0x0100,
77c1010edaSGreg Roach            0x02AF,
78c1010edaSGreg Roach        ],
79c1010edaSGreg Roach        [
80c1010edaSGreg Roach            'Grek',
81c1010edaSGreg Roach            0x0370,
82c1010edaSGreg Roach            0x03FF,
83c1010edaSGreg Roach        ],
84c1010edaSGreg Roach        [
85c1010edaSGreg Roach            'Cyrl',
86c1010edaSGreg Roach            0x0400,
87c1010edaSGreg Roach            0x052F,
88c1010edaSGreg Roach        ],
89c1010edaSGreg Roach        [
90c1010edaSGreg Roach            'Hebr',
91c1010edaSGreg Roach            0x0590,
92c1010edaSGreg Roach            0x05FF,
93c1010edaSGreg Roach        ],
94c1010edaSGreg Roach        [
95c1010edaSGreg Roach            'Arab',
96c1010edaSGreg Roach            0x0600,
97c1010edaSGreg Roach            0x06FF,
98c1010edaSGreg Roach        ],
99c1010edaSGreg Roach        [
100c1010edaSGreg Roach            'Arab',
101c1010edaSGreg Roach            0x0750,
102c1010edaSGreg Roach            0x077F,
103c1010edaSGreg Roach        ],
104c1010edaSGreg Roach        [
105c1010edaSGreg Roach            'Arab',
106c1010edaSGreg Roach            0x08A0,
107c1010edaSGreg Roach            0x08FF,
108c1010edaSGreg Roach        ],
109c1010edaSGreg Roach        [
110c1010edaSGreg Roach            'Deva',
111c1010edaSGreg Roach            0x0900,
112c1010edaSGreg Roach            0x097F,
113c1010edaSGreg Roach        ],
114c1010edaSGreg Roach        [
115c1010edaSGreg Roach            'Taml',
116c1010edaSGreg Roach            0x0B80,
117c1010edaSGreg Roach            0x0BFF,
118c1010edaSGreg Roach        ],
119c1010edaSGreg Roach        [
120c1010edaSGreg Roach            'Sinh',
121c1010edaSGreg Roach            0x0D80,
122c1010edaSGreg Roach            0x0DFF,
123c1010edaSGreg Roach        ],
124c1010edaSGreg Roach        [
125c1010edaSGreg Roach            'Thai',
126c1010edaSGreg Roach            0x0E00,
127c1010edaSGreg Roach            0x0E7F,
128c1010edaSGreg Roach        ],
129c1010edaSGreg Roach        [
130c1010edaSGreg Roach            'Geor',
131c1010edaSGreg Roach            0x10A0,
132c1010edaSGreg Roach            0x10FF,
133c1010edaSGreg Roach        ],
134c1010edaSGreg Roach        [
135c1010edaSGreg Roach            'Grek',
136c1010edaSGreg Roach            0x1F00,
137c1010edaSGreg Roach            0x1FFF,
138c1010edaSGreg Roach        ],
139c1010edaSGreg Roach        [
140c1010edaSGreg Roach            'Deva',
141c1010edaSGreg Roach            0xA8E0,
142c1010edaSGreg Roach            0xA8FF,
143c1010edaSGreg Roach        ],
144c1010edaSGreg Roach        [
145c1010edaSGreg Roach            'Hans',
146c1010edaSGreg Roach            0x3000,
147c1010edaSGreg Roach            0x303F,
148c1010edaSGreg Roach        ],
149c1010edaSGreg Roach        // Mixed CJK, not just Hans
150c1010edaSGreg Roach        [
151c1010edaSGreg Roach            'Hans',
152c1010edaSGreg Roach            0x3400,
153c1010edaSGreg Roach            0xFAFF,
154c1010edaSGreg Roach        ],
155c1010edaSGreg Roach        // Mixed CJK, not just Hans
156c1010edaSGreg Roach        [
157c1010edaSGreg Roach            'Hans',
158c1010edaSGreg Roach            0x20000,
159c1010edaSGreg Roach            0x2FA1F,
160c1010edaSGreg Roach        ],
161c1010edaSGreg Roach        // Mixed CJK, not just Hans
16213abd6f3SGreg Roach    ];
163a25f0a04SGreg Roach
164991b93ddSGreg Roach    // Characters that are displayed in mirror form in RTL text.
16516d6367aSGreg Roach    private const MIRROR_CHARACTERS = [
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        '‘ ' => '’',
183a25f0a04SGreg Roach        '’ ' => '‘',
18413abd6f3SGreg Roach    ];
185a25f0a04SGreg Roach
186991b93ddSGreg Roach    // Default list of locales to show in the menu.
18716d6367aSGreg Roach    private const DEFAULT_LOCALES = [
188c1010edaSGreg Roach        'ar',
189c1010edaSGreg Roach        'bg',
190c1010edaSGreg Roach        'bs',
191c1010edaSGreg Roach        'ca',
192c1010edaSGreg Roach        'cs',
193c1010edaSGreg Roach        'da',
194c1010edaSGreg Roach        'de',
195c1010edaSGreg Roach        'el',
196c1010edaSGreg Roach        'en-GB',
197c1010edaSGreg Roach        'en-US',
198c1010edaSGreg Roach        'es',
199c1010edaSGreg Roach        'et',
200c1010edaSGreg Roach        'fi',
201c1010edaSGreg Roach        'fr',
202c1010edaSGreg Roach        'he',
203c1010edaSGreg Roach        'hr',
204c1010edaSGreg Roach        'hu',
205c1010edaSGreg Roach        'is',
206c1010edaSGreg Roach        'it',
207c1010edaSGreg Roach        'ka',
208c1010edaSGreg Roach        'kk',
209c1010edaSGreg Roach        'lt',
210c1010edaSGreg Roach        'mr',
211c1010edaSGreg Roach        'nb',
212c1010edaSGreg Roach        'nl',
213c1010edaSGreg Roach        'nn',
214c1010edaSGreg Roach        'pl',
215c1010edaSGreg Roach        'pt',
216c1010edaSGreg Roach        'ru',
217c1010edaSGreg Roach        'sk',
218c1010edaSGreg Roach        'sv',
219c1010edaSGreg Roach        'tr',
220c1010edaSGreg Roach        'uk',
221c1010edaSGreg Roach        'vi',
222c1010edaSGreg Roach        'zh-Hans',
223991b93ddSGreg Roach    ];
224991b93ddSGreg Roach
225a25f0a04SGreg Roach    /** @var string Punctuation used to separate list items, typically a comma */
226a25f0a04SGreg Roach    public static $list_separator;
227a25f0a04SGreg Roach
228a25f0a04SGreg Roach    /**
229dfeee0a8SGreg Roach     * The prefered locales for this site, or a default list if no preference.
230dfeee0a8SGreg Roach     *
231dfeee0a8SGreg Roach     * @return LocaleInterface[]
232dfeee0a8SGreg Roach     */
2338f53f488SRico Sonntag    public static function activeLocales(): array
234c1010edaSGreg Roach    {
235dfeee0a8SGreg Roach        $code_list = Site::getPreference('LANGUAGES');
236dfeee0a8SGreg Roach
23715d603e7SGreg Roach        if ($code_list === '') {
238991b93ddSGreg Roach            $codes = self::DEFAULT_LOCALES;
239dfeee0a8SGreg Roach        } else {
240991b93ddSGreg Roach            $codes = explode(',', $code_list);
241dfeee0a8SGreg Roach        }
242dfeee0a8SGreg Roach
24313abd6f3SGreg Roach        $locales = [];
244dfeee0a8SGreg Roach        foreach ($codes as $code) {
245362b8464SGreg Roach            if (file_exists(WT_ROOT . 'resources/lang/' . $code . '/messages.mo')) {
246dfeee0a8SGreg Roach                try {
247dfeee0a8SGreg Roach                    $locales[] = Locale::create($code);
24891495569SGreg Roach                } catch (Exception $ex) {
249dfeee0a8SGreg Roach                    // No such locale exists?
250dfeee0a8SGreg Roach                }
251dfeee0a8SGreg Roach            }
252dfeee0a8SGreg Roach        }
253362b8464SGreg Roach
254dfeee0a8SGreg Roach        usort($locales, '\Fisharebest\Localization\Locale::compare');
255dfeee0a8SGreg Roach
256dfeee0a8SGreg Roach        return $locales;
257dfeee0a8SGreg Roach    }
258dfeee0a8SGreg Roach
259dfeee0a8SGreg Roach    /**
260dfeee0a8SGreg Roach     * Which MySQL collation should be used for this locale?
261dfeee0a8SGreg Roach     *
262dfeee0a8SGreg Roach     * @return string
263dfeee0a8SGreg Roach     */
264e364afe4SGreg Roach    public static function collation(): string
265c1010edaSGreg Roach    {
266dfeee0a8SGreg Roach        $collation = self::$locale->collation();
267dfeee0a8SGreg Roach        switch ($collation) {
268dfeee0a8SGreg Roach            case 'croatian_ci':
269dfeee0a8SGreg Roach            case 'german2_ci':
270dfeee0a8SGreg Roach            case 'vietnamese_ci':
271dfeee0a8SGreg Roach                // Only available in MySQL 5.6
272dfeee0a8SGreg Roach                return 'utf8_unicode_ci';
273dfeee0a8SGreg Roach            default:
274dfeee0a8SGreg Roach                return 'utf8_' . $collation;
275dfeee0a8SGreg Roach        }
276dfeee0a8SGreg Roach    }
277dfeee0a8SGreg Roach
278dfeee0a8SGreg Roach    /**
279dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
280dfeee0a8SGreg Roach     *
281dfeee0a8SGreg Roach     * @return string
282dfeee0a8SGreg Roach     */
2838f53f488SRico Sonntag    public static function dateFormat(): string
284c1010edaSGreg Roach    {
285bbb76c12SGreg Roach        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
286bbb76c12SGreg Roach        return self::$translator->translate('%j %F %Y');
287dfeee0a8SGreg Roach    }
288dfeee0a8SGreg Roach
289dfeee0a8SGreg Roach    /**
290dfeee0a8SGreg Roach     * Generate consistent I18N for datatables.js
291dfeee0a8SGreg Roach     *
29255664801SGreg Roach     * @param int[] $lengths An optional array of page lengths
293dfeee0a8SGreg Roach     *
294dfeee0a8SGreg Roach     * @return string
295dfeee0a8SGreg Roach     */
296c1010edaSGreg Roach    public static function datatablesI18N(array $lengths = [
297c1010edaSGreg Roach        10,
298c1010edaSGreg Roach        20,
299c1010edaSGreg Roach        30,
300c1010edaSGreg Roach        50,
301c1010edaSGreg Roach        100,
302c1010edaSGreg Roach        -1,
30355664801SGreg Roach    ]): string
30455664801SGreg Roach    {
30555664801SGreg Roach        $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), '10');
306dfeee0a8SGreg Roach
307dfeee0a8SGreg Roach        return
3082d9b2ebaSGreg Roach            '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' .
309dfeee0a8SGreg Roach            '"language": {' .
310dfeee0a8SGreg Roach            ' "paginate": {' .
311bbb76c12SGreg Roach            '  "first":    "' . self::translate('first') . '",' .
312bbb76c12SGreg Roach            '  "last":     "' . self::translate('last') . '",' .
313bbb76c12SGreg Roach            '  "next":     "' . self::translate('next') . '",' .
314bbb76c12SGreg Roach            '  "previous": "' . self::translate('previous') . '"' .
315dfeee0a8SGreg Roach            ' },' .
316dfeee0a8SGreg Roach            ' "emptyTable":     "' . self::translate('No records to display') . '",' .
317c1010edaSGreg Roach            ' "info":           "' . /* I18N: %s are placeholders for numbers */
318c1010edaSGreg Roach            self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' .
3190b6446e1SGreg 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')) . '",' .
320c1010edaSGreg Roach            ' "infoFiltered":   "' . /* I18N: %s is a placeholder for a number */
321c1010edaSGreg Roach            self::translate('(filtered from %s total entries)', '_MAX_') . '",' .
322c1010edaSGreg Roach            ' "lengthMenu":     "' . /* I18N: %s is a number of records per page */
323c1010edaSGreg Roach            self::translate('Display %s', addslashes($length_options)) . '",' .
324dfeee0a8SGreg Roach            ' "loadingRecords": "' . self::translate('Loading…') . '",' .
325dfeee0a8SGreg Roach            ' "processing":     "' . self::translate('Loading…') . '",' .
326dfeee0a8SGreg Roach            ' "search":         "' . self::translate('Filter') . '",' .
327dfeee0a8SGreg Roach            ' "zeroRecords":    "' . self::translate('No records to display') . '"' .
3282d9b2ebaSGreg Roach            '}';
329dfeee0a8SGreg Roach    }
330dfeee0a8SGreg Roach
331dfeee0a8SGreg Roach    /**
332dfeee0a8SGreg Roach     * Convert the digits 0-9 into the local script
333dfeee0a8SGreg Roach     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
334dfeee0a8SGreg Roach     *
33555664801SGreg Roach     * @param string|int $n
336dfeee0a8SGreg Roach     *
337dfeee0a8SGreg Roach     * @return string
338dfeee0a8SGreg Roach     */
3398f53f488SRico Sonntag    public static function digits($n): string
340c1010edaSGreg Roach    {
34155664801SGreg Roach        return self::$locale->digits((string) $n);
342dfeee0a8SGreg Roach    }
343dfeee0a8SGreg Roach
344dfeee0a8SGreg Roach    /**
345dfeee0a8SGreg Roach     * What is the direction of the current locale
346dfeee0a8SGreg Roach     *
347dfeee0a8SGreg Roach     * @return string "ltr" or "rtl"
348dfeee0a8SGreg Roach     */
3498f53f488SRico Sonntag    public static function direction(): string
350c1010edaSGreg Roach    {
351dfeee0a8SGreg Roach        return self::$locale->direction();
352dfeee0a8SGreg Roach    }
353dfeee0a8SGreg Roach
354dfeee0a8SGreg Roach    /**
3557231a557SGreg Roach     * What is the first day of the week.
3567231a557SGreg Roach     *
357cbc1590aSGreg Roach     * @return int Sunday=0, Monday=1, etc.
3587231a557SGreg Roach     */
3598f53f488SRico Sonntag    public static function firstDay(): int
360c1010edaSGreg Roach    {
3617231a557SGreg Roach        return self::$locale->territory()->firstDay();
3627231a557SGreg Roach    }
3637231a557SGreg Roach
3647231a557SGreg Roach    /**
365dfeee0a8SGreg Roach     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
366dfeee0a8SGreg Roach     *
367dfeee0a8SGreg Roach     * @return string
368dfeee0a8SGreg Roach     */
3698f53f488SRico Sonntag    public static function htmlAttributes(): string
370c1010edaSGreg Roach    {
371dfeee0a8SGreg Roach        return self::$locale->htmlAttributes();
372dfeee0a8SGreg Roach    }
373dfeee0a8SGreg Roach
374dfeee0a8SGreg Roach    /**
375a25f0a04SGreg Roach     * Initialise the translation adapter with a locale setting.
376a25f0a04SGreg Roach     *
37715d603e7SGreg Roach     * @param string    $code Use this locale/language code, or choose one automatically
378e58a20ffSGreg Roach     * @param Tree|null $tree
379a25f0a04SGreg Roach     *
380a25f0a04SGreg Roach     * @return string $string
381a25f0a04SGreg Roach     */
382e58a20ffSGreg Roach    public static function init(string $code = '', Tree $tree = null): string
383c1010edaSGreg Roach    {
38415d603e7SGreg Roach        if ($code !== '') {
3853bdc890bSGreg Roach            // Create the specified locale
3863bdc890bSGreg Roach            self::$locale = Locale::create($code);
3874ee95e68SRico Sonntag        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) {
388e58a20ffSGreg Roach            // Select a previously used locale
38931bc7874SGreg Roach            self::$locale = Locale::create(Session::get('locale'));
3903bdc890bSGreg Roach        } else {
391e58a20ffSGreg Roach            if ($tree instanceof Tree) {
392e58a20ffSGreg Roach                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
393e58a20ffSGreg Roach            } else {
39459f2f229SGreg Roach                $default_locale = new LocaleEnUs();
3953bdc890bSGreg Roach            }
396e58a20ffSGreg Roach
397e58a20ffSGreg Roach            // Negotiate with the browser.
398e58a20ffSGreg Roach            // Search engines don't negotiate.  They get the default locale of the tree.
399149573a1SGreg Roach            self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale);
4003bdc890bSGreg Roach        }
4013bdc890bSGreg Roach
402f1af7e1cSGreg Roach        $cache_dir  = WT_DATA_DIR . 'cache/';
403f1af7e1cSGreg Roach        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
4043bdc890bSGreg Roach        if (file_exists($cache_file)) {
4053bdc890bSGreg Roach            $filemtime = filemtime($cache_file);
4063bdc890bSGreg Roach        } else {
4073bdc890bSGreg Roach            $filemtime = 0;
4083bdc890bSGreg Roach        }
4093bdc890bSGreg Roach
4103bdc890bSGreg Roach        // Load the translation file(s)
411362b8464SGreg Roach        $translation_files = [
412362b8464SGreg Roach            WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo',
413362b8464SGreg Roach        ];
414362b8464SGreg Roach
4157a7f87d7SGreg Roach        // Rebuild files after one hour
4167a7f87d7SGreg Roach        $rebuild_cache = time() > $filemtime + 3600;
4171e71bdc0SGreg Roach        // Rebuild files if any translation file has been updated
4183bdc890bSGreg Roach        foreach ($translation_files as $translation_file) {
4193bdc890bSGreg Roach            if (filemtime($translation_file) > $filemtime) {
4203bdc890bSGreg Roach                $rebuild_cache = true;
421a25f0a04SGreg Roach                break;
422a25f0a04SGreg Roach            }
423a25f0a04SGreg Roach        }
4243bdc890bSGreg Roach
4253bdc890bSGreg Roach        if ($rebuild_cache) {
42613abd6f3SGreg Roach            $translations = [];
4273bdc890bSGreg Roach            foreach ($translation_files as $translation_file) {
4283bdc890bSGreg Roach                $translation  = new Translation($translation_file);
4293bdc890bSGreg Roach                $translations = array_merge($translations, $translation->asArray());
430a25f0a04SGreg Roach            }
431f1af7e1cSGreg Roach            try {
432f1af7e1cSGreg Roach                File::mkdir($cache_dir);
433f1af7e1cSGreg Roach                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
434f1af7e1cSGreg Roach            } catch (Exception $ex) {
4357c2999b4SGreg Roach                // During setup, we may not have been able to create it.
436c85fb0c4SGreg Roach            }
4373bdc890bSGreg Roach        } else {
4383bdc890bSGreg Roach            $translations = include $cache_file;
439a25f0a04SGreg Roach        }
440a25f0a04SGreg Roach
4413bdc890bSGreg Roach        // Create a translator
4423bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
443a25f0a04SGreg Roach
444bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
445bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
446a25f0a04SGreg Roach
447991b93ddSGreg Roach        // Create a collator
448991b93ddSGreg Roach        try {
449444a65ecSGreg Roach            if (class_exists('Collator')) {
450c9ec599fSGreg Roach                // Symfony provides a very incomplete polyfill - which cannot be used.
451991b93ddSGreg Roach                self::$collator = new Collator(self::$locale->code());
452991b93ddSGreg Roach                // Ignore upper/lower case differences
453991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
454444a65ecSGreg Roach            }
455991b93ddSGreg Roach        } catch (Exception $ex) {
456991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
457c9ec599fSGreg Roach            self::$collator = null;
458991b93ddSGreg Roach        }
459991b93ddSGreg Roach
4605331c5eaSGreg Roach        return self::$locale->languageTag();
461a25f0a04SGreg Roach    }
462a25f0a04SGreg Roach
463a25f0a04SGreg Roach    /**
464c999a340SGreg Roach     * All locales for which a translation file exists.
465c999a340SGreg Roach     *
46615834aaeSGreg Roach     * @return LocaleInterface[]
467c999a340SGreg Roach     */
4688f53f488SRico Sonntag    public static function installedLocales(): array
469c1010edaSGreg Roach    {
47013abd6f3SGreg Roach        $locales = [];
471362b8464SGreg Roach
472*9e24ac1cSGreg Roach        foreach (glob(WT_ROOT . 'resources/lang/*/messages.mo', GLOB_NOSORT) as $file) {
473c999a340SGreg Roach            try {
474f8e24896SGreg Roach                $locales[] = Locale::create(basename(dirname($file)));
475362b8464SGreg Roach            } catch (DomainException $ex) {
4763bdc890bSGreg Roach                // Not a recognised locale
477a25f0a04SGreg Roach            }
478a25f0a04SGreg Roach        }
479c999a340SGreg Roach        usort($locales, '\Fisharebest\Localization\Locale::compare');
480c999a340SGreg Roach
481c999a340SGreg Roach        return $locales;
482a25f0a04SGreg Roach    }
483a25f0a04SGreg Roach
484a25f0a04SGreg Roach    /**
485a25f0a04SGreg Roach     * Return the endonym for a given language - as per http://cldr.unicode.org/
486a25f0a04SGreg Roach     *
487a25f0a04SGreg Roach     * @param string $locale
488a25f0a04SGreg Roach     *
489a25f0a04SGreg Roach     * @return string
490a25f0a04SGreg Roach     */
49155664801SGreg Roach    public static function languageName(string $locale): string
492c1010edaSGreg Roach    {
493c999a340SGreg Roach        return Locale::create($locale)->endonym();
494a25f0a04SGreg Roach    }
495a25f0a04SGreg Roach
496a25f0a04SGreg Roach    /**
497a25f0a04SGreg Roach     * Return the script used by a given language
498a25f0a04SGreg Roach     *
499a25f0a04SGreg Roach     * @param string $locale
500a25f0a04SGreg Roach     *
501a25f0a04SGreg Roach     * @return string
502a25f0a04SGreg Roach     */
50355664801SGreg Roach    public static function languageScript(string $locale): string
504c1010edaSGreg Roach    {
505c999a340SGreg Roach        return Locale::create($locale)->script()->code();
506a25f0a04SGreg Roach    }
507a25f0a04SGreg Roach
508a25f0a04SGreg Roach    /**
509dfeee0a8SGreg Roach     * Translate a number into the local representation.
510dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
511dfeee0a8SGreg Roach     * en: 12,345.67
512dfeee0a8SGreg Roach     * fr: 12 345,67
513dfeee0a8SGreg Roach     * de: 12.345,67
514dfeee0a8SGreg Roach     *
515dfeee0a8SGreg Roach     * @param float $n
516cbc1590aSGreg Roach     * @param int   $precision
517a25f0a04SGreg Roach     *
518a25f0a04SGreg Roach     * @return string
519a25f0a04SGreg Roach     */
52055664801SGreg Roach    public static function number(float $n, int $precision = 0): string
521c1010edaSGreg Roach    {
522dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
523dfeee0a8SGreg Roach    }
524dfeee0a8SGreg Roach
525dfeee0a8SGreg Roach    /**
526dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
527dfeee0a8SGreg Roach     * e.g. 0.123 becomes
528dfeee0a8SGreg Roach     * en: 12.3%
529dfeee0a8SGreg Roach     * fr: 12,3 %
530dfeee0a8SGreg Roach     * de: 12,3%
531dfeee0a8SGreg Roach     *
532dfeee0a8SGreg Roach     * @param float $n
533cbc1590aSGreg Roach     * @param int   $precision
534dfeee0a8SGreg Roach     *
535dfeee0a8SGreg Roach     * @return string
536dfeee0a8SGreg Roach     */
53755664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
538c1010edaSGreg Roach    {
539dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
540dfeee0a8SGreg Roach    }
541dfeee0a8SGreg Roach
542dfeee0a8SGreg Roach    /**
543dfeee0a8SGreg Roach     * Translate a plural string
544dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
545dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
546dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
547dfeee0a8SGreg Roach     *
548924d091bSGreg Roach     * @param string $singular
549924d091bSGreg Roach     * @param string $plural
550924d091bSGreg Roach     * @param int    $count
551a515be7cSGreg Roach     * @param string ...$args
552e93111adSRico Sonntag     *
553dfeee0a8SGreg Roach     * @return string
554dfeee0a8SGreg Roach     */
555924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
556c1010edaSGreg Roach    {
557924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
558dfeee0a8SGreg Roach
559924d091bSGreg Roach        return sprintf($message, ...$args);
560dfeee0a8SGreg Roach    }
561dfeee0a8SGreg Roach
562dfeee0a8SGreg Roach    /**
563dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
564dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
565dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
566dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
567dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
568dfeee0a8SGreg Roach     *
569dfeee0a8SGreg Roach     * @param string $text Text to be reversed
570dfeee0a8SGreg Roach     *
571dfeee0a8SGreg Roach     * @return string
572dfeee0a8SGreg Roach     */
5738f53f488SRico Sonntag    public static function reverseText($text): string
574c1010edaSGreg Roach    {
575dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
5769524b7b5SGreg Roach        $text = strip_tags($text);
5779524b7b5SGreg Roach        // Remove HTML entities.
5789524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
579dfeee0a8SGreg Roach
580dfeee0a8SGreg Roach        // LTR text doesn't need reversing
581dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
582dfeee0a8SGreg Roach            return $text;
583dfeee0a8SGreg Roach        }
584dfeee0a8SGreg Roach
585dfeee0a8SGreg Roach        // Mirrored characters
586991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
587dfeee0a8SGreg Roach
588dfeee0a8SGreg Roach        $reversed = '';
589dfeee0a8SGreg Roach        $digits   = '';
590e364afe4SGreg Roach        while ($text !== '') {
591dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
592dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
593dfeee0a8SGreg Roach            if (strpos(self::DIGITS, $letter) !== false) {
594dfeee0a8SGreg Roach                $digits .= $letter;
595a25f0a04SGreg Roach            } else {
596dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
597dfeee0a8SGreg Roach                $digits   = '';
598dfeee0a8SGreg Roach            }
599a25f0a04SGreg Roach        }
600a25f0a04SGreg Roach
601dfeee0a8SGreg Roach        return $digits . $reversed;
602a25f0a04SGreg Roach    }
603a25f0a04SGreg Roach
604a25f0a04SGreg Roach    /**
605a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
606a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
607a25f0a04SGreg Roach     * our own lookup table.
608a25f0a04SGreg Roach     *
609a25f0a04SGreg Roach     * @param string $script
610a25f0a04SGreg Roach     *
611a25f0a04SGreg Roach     * @return string
612a25f0a04SGreg Roach     */
613e364afe4SGreg Roach    public static function scriptDirection($script): string
614c1010edaSGreg Roach    {
615a25f0a04SGreg Roach        switch ($script) {
616a25f0a04SGreg Roach            case 'Arab':
617a25f0a04SGreg Roach            case 'Hebr':
618a25f0a04SGreg Roach            case 'Mong':
619a25f0a04SGreg Roach            case 'Thaa':
620a25f0a04SGreg Roach                return 'rtl';
621a25f0a04SGreg Roach            default:
622a25f0a04SGreg Roach                return 'ltr';
623a25f0a04SGreg Roach        }
624a25f0a04SGreg Roach    }
625a25f0a04SGreg Roach
626a25f0a04SGreg Roach    /**
627991b93ddSGreg Roach     * Perform a case-insensitive comparison of two strings.
628a25f0a04SGreg Roach     *
629a25f0a04SGreg Roach     * @param string $string1
630a25f0a04SGreg Roach     * @param string $string2
631a25f0a04SGreg Roach     *
632cbc1590aSGreg Roach     * @return int
633a25f0a04SGreg Roach     */
634e364afe4SGreg Roach    public static function strcasecmp($string1, $string2): int
635c1010edaSGreg Roach    {
636991b93ddSGreg Roach        if (self::$collator instanceof Collator) {
637991b93ddSGreg Roach            return self::$collator->compare($string1, $string2);
638a25f0a04SGreg Roach        }
639e364afe4SGreg Roach
640e364afe4SGreg Roach        return strcmp(self::strtolower($string1), self::strtolower($string2));
641c9ec599fSGreg Roach    }
642a25f0a04SGreg Roach
643a25f0a04SGreg Roach    /**
644991b93ddSGreg Roach     * Convert a string to lower case.
645a25f0a04SGreg Roach     *
646dfeee0a8SGreg Roach     * @param string $string
647a25f0a04SGreg Roach     *
648a25f0a04SGreg Roach     * @return string
649a25f0a04SGreg Roach     */
6508f53f488SRico Sonntag    public static function strtolower($string): string
651c1010edaSGreg Roach    {
652991b93ddSGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
653991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
654a25f0a04SGreg Roach        }
6555ddad20bSGreg Roach
6565ddad20bSGreg Roach        return mb_strtolower($string);
657a25f0a04SGreg Roach    }
658a25f0a04SGreg Roach
659a25f0a04SGreg Roach    /**
660991b93ddSGreg Roach     * Convert a string to upper case.
661dfeee0a8SGreg Roach     *
662dfeee0a8SGreg Roach     * @param string $string
663a25f0a04SGreg Roach     *
664a25f0a04SGreg Roach     * @return string
665a25f0a04SGreg Roach     */
6668f53f488SRico Sonntag    public static function strtoupper($string): string
667c1010edaSGreg Roach    {
668991b93ddSGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) {
669991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
670a25f0a04SGreg Roach        }
6715ddad20bSGreg Roach
6725ddad20bSGreg Roach        return mb_strtoupper($string);
673a25f0a04SGreg Roach    }
674a25f0a04SGreg Roach
675dfeee0a8SGreg Roach    /**
676dfeee0a8SGreg Roach     * Identify the script used for a piece of text
677dfeee0a8SGreg Roach     *
678d0bfc631SGreg Roach     * @param string $string
679dfeee0a8SGreg Roach     *
680dfeee0a8SGreg Roach     * @return string
681dfeee0a8SGreg Roach     */
6828f53f488SRico Sonntag    public static function textScript($string): string
683c1010edaSGreg Roach    {
684dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
685dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
686c1010edaSGreg Roach        $string = str_replace([
687c1010edaSGreg Roach            '@N.N.',
688c1010edaSGreg Roach            '@P.N.',
689c1010edaSGreg Roach        ], '', $string); // otherwise unknown names show up as latin
690dfeee0a8SGreg Roach        $pos    = 0;
691dfeee0a8SGreg Roach        $strlen = strlen($string);
692dfeee0a8SGreg Roach        while ($pos < $strlen) {
693dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
694dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
695dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
696dfeee0a8SGreg Roach                $code_point = $byte1;
697dfeee0a8SGreg Roach                $chrlen     = 1;
698dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
699dfeee0a8SGreg Roach                // Invalid continuation character
700dfeee0a8SGreg Roach                return 'Latn';
701dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
702dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
703dfeee0a8SGreg Roach                $chrlen     = 2;
704dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
705dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
706dfeee0a8SGreg Roach                $chrlen     = 3;
707dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
708dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
709dfeee0a8SGreg Roach                $chrlen     = 3;
710dfeee0a8SGreg Roach            } else {
711dfeee0a8SGreg Roach                // Invalid UTF
712dfeee0a8SGreg Roach                return 'Latn';
713dfeee0a8SGreg Roach            }
714dfeee0a8SGreg Roach
715991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
716dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
717dfeee0a8SGreg Roach                    return $range[0];
718dfeee0a8SGreg Roach                }
719dfeee0a8SGreg Roach            }
720dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
721dfeee0a8SGreg Roach            $pos += $chrlen;
722dfeee0a8SGreg Roach        }
723dfeee0a8SGreg Roach
724dfeee0a8SGreg Roach        return 'Latn';
725dfeee0a8SGreg Roach    }
726dfeee0a8SGreg Roach
727dfeee0a8SGreg Roach    /**
728dfeee0a8SGreg Roach     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
729dfeee0a8SGreg Roach     *
730cbc1590aSGreg Roach     * @param int $seconds
731dfeee0a8SGreg Roach     *
732dfeee0a8SGreg Roach     * @return string
733dfeee0a8SGreg Roach     */
734e364afe4SGreg Roach    public static function timeAgo($seconds): string
735c1010edaSGreg Roach    {
736dfeee0a8SGreg Roach        $minute = 60;
737dfeee0a8SGreg Roach        $hour   = 60 * $minute;
738dfeee0a8SGreg Roach        $day    = 24 * $hour;
739dfeee0a8SGreg Roach        $month  = 30 * $day;
740dfeee0a8SGreg Roach        $year   = 365 * $day;
741dfeee0a8SGreg Roach
742dfeee0a8SGreg Roach        if ($seconds > $year) {
743cdaafeeeSGreg Roach            $years = intdiv($seconds, $year);
744cbc1590aSGreg Roach
745dfeee0a8SGreg Roach            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
746b2ce94c6SRico Sonntag        }
747b2ce94c6SRico Sonntag
748b2ce94c6SRico Sonntag        if ($seconds > $month) {
749cdaafeeeSGreg Roach            $months = intdiv($seconds, $month);
750cbc1590aSGreg Roach
751dfeee0a8SGreg Roach            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
752b2ce94c6SRico Sonntag        }
753b2ce94c6SRico Sonntag
754b2ce94c6SRico Sonntag        if ($seconds > $day) {
755cdaafeeeSGreg Roach            $days = intdiv($seconds, $day);
756cbc1590aSGreg Roach
757dfeee0a8SGreg Roach            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
758b2ce94c6SRico Sonntag        }
759b2ce94c6SRico Sonntag
760b2ce94c6SRico Sonntag        if ($seconds > $hour) {
761cdaafeeeSGreg Roach            $hours = intdiv($seconds, $hour);
762cbc1590aSGreg Roach
763dfeee0a8SGreg Roach            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
764b2ce94c6SRico Sonntag        }
765b2ce94c6SRico Sonntag
766b2ce94c6SRico Sonntag        if ($seconds > $minute) {
767cdaafeeeSGreg Roach            $minutes = intdiv($seconds, $minute);
768cbc1590aSGreg Roach
769dfeee0a8SGreg Roach            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
770dfeee0a8SGreg Roach        }
771b2ce94c6SRico Sonntag
772b2ce94c6SRico Sonntag        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
773dfeee0a8SGreg Roach    }
774dfeee0a8SGreg Roach
775dfeee0a8SGreg Roach    /**
776dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
777dfeee0a8SGreg Roach     *
778dfeee0a8SGreg Roach     * @return string
779dfeee0a8SGreg Roach     */
7808f53f488SRico Sonntag    public static function timeFormat(): string
781c1010edaSGreg Roach    {
782bbb76c12SGreg Roach        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
783bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
784dfeee0a8SGreg Roach    }
785dfeee0a8SGreg Roach
786dfeee0a8SGreg Roach    /**
787dfeee0a8SGreg Roach     * Translate a string, and then substitute placeholders
788dfeee0a8SGreg Roach     * echo I18N::translate('Hello World!');
789dfeee0a8SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
790dfeee0a8SGreg Roach     *
791924d091bSGreg Roach     * @param string $message
792a515be7cSGreg Roach     * @param string ...$args
793c3283ed7SGreg Roach     *
794dfeee0a8SGreg Roach     * @return string
795dfeee0a8SGreg Roach     */
796924d091bSGreg Roach    public static function translate(string $message, ...$args): string
797c1010edaSGreg Roach    {
798924d091bSGreg Roach        $message = self::$translator->translate($message);
799dfeee0a8SGreg Roach
800924d091bSGreg Roach        return sprintf($message, ...$args);
801dfeee0a8SGreg Roach    }
802dfeee0a8SGreg Roach
803dfeee0a8SGreg Roach    /**
804dfeee0a8SGreg Roach     * Context sensitive version of translate.
805a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
806a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
807dfeee0a8SGreg Roach     *
808924d091bSGreg Roach     * @param string $context
809924d091bSGreg Roach     * @param string $message
810a515be7cSGreg Roach     * @param string ...$args
811c3283ed7SGreg Roach     *
812dfeee0a8SGreg Roach     * @return string
813dfeee0a8SGreg Roach     */
814924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
815c1010edaSGreg Roach    {
816924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
817dfeee0a8SGreg Roach
818924d091bSGreg Roach        return sprintf($message, ...$args);
819a25f0a04SGreg Roach    }
820a25f0a04SGreg Roach}
821