xref: /webtrees/app/I18N.php (revision 0b5fd0a636fa959f5279ee28ebd2f27e921c091e)
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;
21f1af7e1cSGreg Roachuse Exception;
22c999a340SGreg Roachuse Fisharebest\Localization\Locale;
231e71bdc0SGreg Roachuse Fisharebest\Localization\Locale\LocaleEnUs;
2415834aaeSGreg Roachuse Fisharebest\Localization\Locale\LocaleInterface;
253bdc890bSGreg Roachuse Fisharebest\Localization\Translation;
263bdc890bSGreg Roachuse Fisharebest\Localization\Translator;
27d37db671SGreg Roachuse Fisharebest\Webtrees\Module\ModuleCustomInterface;
2802086832SGreg Roachuse Fisharebest\Webtrees\Module\ModuleLanguageInterface;
29d37db671SGreg Roachuse Fisharebest\Webtrees\Services\ModuleService;
306cd97bf6SGreg Roachuse Illuminate\Support\Collection;
31a25f0a04SGreg Roach
32a25f0a04SGreg Roach/**
3376692c8bSGreg Roach * Internationalization (i18n) and localization (l10n).
34a25f0a04SGreg Roach */
35c1010edaSGreg Roachclass I18N
36c1010edaSGreg Roach{
37d37db671SGreg Roach    // MO files use special characters for plurals and context.
38d37db671SGreg Roach    public const PLURAL  = '\x00';
39d37db671SGreg Roach    public const CONTEXT = '\x04';
40d37db671SGreg Roach
4115834aaeSGreg Roach    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
42c999a340SGreg Roach    private static $locale;
43c999a340SGreg Roach
4476692c8bSGreg Roach    /** @var Translator An object that performs translation */
453bdc890bSGreg Roach    private static $translator;
463bdc890bSGreg Roach
47c9ec599fSGreg Roach    /** @var  Collator|null From the php-intl library */
48991b93ddSGreg Roach    private static $collator;
49991b93ddSGreg Roach
50a25f0a04SGreg Roach    // Digits are always rendered LTR, even in RTL text.
5116d6367aSGreg Roach    private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
52a25f0a04SGreg Roach
53991b93ddSGreg Roach    // These locales need special handling for the dotless letter I.
5416d6367aSGreg Roach    private const DOTLESS_I_LOCALES = [
55c1010edaSGreg Roach        'az',
56c1010edaSGreg Roach        'tr',
57c1010edaSGreg Roach    ];
5816d6367aSGreg Roach    private const DOTLESS_I_TOLOWER = [
59c1010edaSGreg Roach        'I' => 'ı',
60c1010edaSGreg Roach        'İ' => 'i',
61c1010edaSGreg Roach    ];
6216d6367aSGreg Roach    private const DOTLESS_I_TOUPPER = [
63c1010edaSGreg Roach        'ı' => 'I',
64c1010edaSGreg Roach        'i' => 'İ',
65c1010edaSGreg Roach    ];
66a25f0a04SGreg Roach
67991b93ddSGreg Roach    // The ranges of characters used by each script.
6816d6367aSGreg Roach    private const SCRIPT_CHARACTER_RANGES = [
69c1010edaSGreg Roach        [
70c1010edaSGreg Roach            'Latn',
71c1010edaSGreg Roach            0x0041,
72c1010edaSGreg Roach            0x005A,
73c1010edaSGreg Roach        ],
74c1010edaSGreg Roach        [
75c1010edaSGreg Roach            'Latn',
76c1010edaSGreg Roach            0x0061,
77c1010edaSGreg Roach            0x007A,
78c1010edaSGreg Roach        ],
79c1010edaSGreg Roach        [
80c1010edaSGreg Roach            'Latn',
81c1010edaSGreg Roach            0x0100,
82c1010edaSGreg Roach            0x02AF,
83c1010edaSGreg Roach        ],
84c1010edaSGreg Roach        [
85c1010edaSGreg Roach            'Grek',
86c1010edaSGreg Roach            0x0370,
87c1010edaSGreg Roach            0x03FF,
88c1010edaSGreg Roach        ],
89c1010edaSGreg Roach        [
90c1010edaSGreg Roach            'Cyrl',
91c1010edaSGreg Roach            0x0400,
92c1010edaSGreg Roach            0x052F,
93c1010edaSGreg Roach        ],
94c1010edaSGreg Roach        [
95c1010edaSGreg Roach            'Hebr',
96c1010edaSGreg Roach            0x0590,
97c1010edaSGreg Roach            0x05FF,
98c1010edaSGreg Roach        ],
99c1010edaSGreg Roach        [
100c1010edaSGreg Roach            'Arab',
101c1010edaSGreg Roach            0x0600,
102c1010edaSGreg Roach            0x06FF,
103c1010edaSGreg Roach        ],
104c1010edaSGreg Roach        [
105c1010edaSGreg Roach            'Arab',
106c1010edaSGreg Roach            0x0750,
107c1010edaSGreg Roach            0x077F,
108c1010edaSGreg Roach        ],
109c1010edaSGreg Roach        [
110c1010edaSGreg Roach            'Arab',
111c1010edaSGreg Roach            0x08A0,
112c1010edaSGreg Roach            0x08FF,
113c1010edaSGreg Roach        ],
114c1010edaSGreg Roach        [
115c1010edaSGreg Roach            'Deva',
116c1010edaSGreg Roach            0x0900,
117c1010edaSGreg Roach            0x097F,
118c1010edaSGreg Roach        ],
119c1010edaSGreg Roach        [
120c1010edaSGreg Roach            'Taml',
121c1010edaSGreg Roach            0x0B80,
122c1010edaSGreg Roach            0x0BFF,
123c1010edaSGreg Roach        ],
124c1010edaSGreg Roach        [
125c1010edaSGreg Roach            'Sinh',
126c1010edaSGreg Roach            0x0D80,
127c1010edaSGreg Roach            0x0DFF,
128c1010edaSGreg Roach        ],
129c1010edaSGreg Roach        [
130c1010edaSGreg Roach            'Thai',
131c1010edaSGreg Roach            0x0E00,
132c1010edaSGreg Roach            0x0E7F,
133c1010edaSGreg Roach        ],
134c1010edaSGreg Roach        [
135c1010edaSGreg Roach            'Geor',
136c1010edaSGreg Roach            0x10A0,
137c1010edaSGreg Roach            0x10FF,
138c1010edaSGreg Roach        ],
139c1010edaSGreg Roach        [
140c1010edaSGreg Roach            'Grek',
141c1010edaSGreg Roach            0x1F00,
142c1010edaSGreg Roach            0x1FFF,
143c1010edaSGreg Roach        ],
144c1010edaSGreg Roach        [
145c1010edaSGreg Roach            'Deva',
146c1010edaSGreg Roach            0xA8E0,
147c1010edaSGreg Roach            0xA8FF,
148c1010edaSGreg Roach        ],
149c1010edaSGreg Roach        [
150c1010edaSGreg Roach            'Hans',
151c1010edaSGreg Roach            0x3000,
152c1010edaSGreg Roach            0x303F,
153c1010edaSGreg Roach        ],
154c1010edaSGreg Roach        // Mixed CJK, not just Hans
155c1010edaSGreg Roach        [
156c1010edaSGreg Roach            'Hans',
157c1010edaSGreg Roach            0x3400,
158c1010edaSGreg Roach            0xFAFF,
159c1010edaSGreg Roach        ],
160c1010edaSGreg Roach        // Mixed CJK, not just Hans
161c1010edaSGreg Roach        [
162c1010edaSGreg Roach            'Hans',
163c1010edaSGreg Roach            0x20000,
164c1010edaSGreg Roach            0x2FA1F,
165c1010edaSGreg Roach        ],
166c1010edaSGreg Roach        // Mixed CJK, not just Hans
16713abd6f3SGreg Roach    ];
168a25f0a04SGreg Roach
169991b93ddSGreg Roach    // Characters that are displayed in mirror form in RTL text.
17016d6367aSGreg Roach    private const MIRROR_CHARACTERS = [
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        '﴾ ' => '﴿',
184a25f0a04SGreg Roach        '﴿ ' => '﴾',
185a25f0a04SGreg Roach        '“ ' => '”',
186a25f0a04SGreg Roach        '” ' => '“',
187a25f0a04SGreg Roach        '‘ ' => '’',
188a25f0a04SGreg Roach        '’ ' => '‘',
18913abd6f3SGreg Roach    ];
190a25f0a04SGreg Roach
191a25f0a04SGreg Roach    /** @var string Punctuation used to separate list items, typically a comma */
192a25f0a04SGreg Roach    public static $list_separator;
193a25f0a04SGreg Roach
194a25f0a04SGreg Roach    /**
19502086832SGreg Roach     * The preferred locales for this site, or a default list if no preference.
196dfeee0a8SGreg Roach     *
197dfeee0a8SGreg Roach     * @return LocaleInterface[]
198dfeee0a8SGreg Roach     */
1998f53f488SRico Sonntag    public static function activeLocales(): array
200c1010edaSGreg Roach    {
20102086832SGreg Roach        $locales = app(ModuleService::class)
202d6137952SGreg Roach            ->findByInterface(ModuleLanguageInterface::class, false, true)
203*0b5fd0a6SGreg Roach            ->map(static function (ModuleLanguageInterface $module): LocaleInterface {
20402086832SGreg Roach                return $module->locale();
20502086832SGreg Roach            });
206dfeee0a8SGreg Roach
20702086832SGreg Roach        if ($locales->isEmpty()) {
20802086832SGreg Roach            return [new LocaleEnUs()];
209dfeee0a8SGreg Roach        }
210dfeee0a8SGreg Roach
21102086832SGreg Roach        return $locales->all();
212dfeee0a8SGreg Roach    }
213dfeee0a8SGreg Roach
214dfeee0a8SGreg Roach    /**
215dfeee0a8SGreg Roach     * Which MySQL collation should be used for this locale?
216dfeee0a8SGreg Roach     *
217dfeee0a8SGreg Roach     * @return string
218dfeee0a8SGreg Roach     */
219e364afe4SGreg Roach    public static function collation(): string
220c1010edaSGreg Roach    {
221dfeee0a8SGreg Roach        $collation = self::$locale->collation();
222dfeee0a8SGreg Roach        switch ($collation) {
223dfeee0a8SGreg Roach            case 'croatian_ci':
224dfeee0a8SGreg Roach            case 'german2_ci':
225dfeee0a8SGreg Roach            case 'vietnamese_ci':
226dfeee0a8SGreg Roach                // Only available in MySQL 5.6
227dfeee0a8SGreg Roach                return 'utf8_unicode_ci';
228dfeee0a8SGreg Roach            default:
229dfeee0a8SGreg Roach                return 'utf8_' . $collation;
230dfeee0a8SGreg Roach        }
231dfeee0a8SGreg Roach    }
232dfeee0a8SGreg Roach
233dfeee0a8SGreg Roach    /**
234dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
235dfeee0a8SGreg Roach     *
236dfeee0a8SGreg Roach     * @return string
237dfeee0a8SGreg Roach     */
2388f53f488SRico Sonntag    public static function dateFormat(): string
239c1010edaSGreg Roach    {
240bbb76c12SGreg Roach        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
241bbb76c12SGreg Roach        return self::$translator->translate('%j %F %Y');
242dfeee0a8SGreg Roach    }
243dfeee0a8SGreg Roach
244dfeee0a8SGreg Roach    /**
245dfeee0a8SGreg Roach     * Convert the digits 0-9 into the local script
246dfeee0a8SGreg Roach     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
247dfeee0a8SGreg Roach     *
24855664801SGreg Roach     * @param string|int $n
249dfeee0a8SGreg Roach     *
250dfeee0a8SGreg Roach     * @return string
251dfeee0a8SGreg Roach     */
2528f53f488SRico Sonntag    public static function digits($n): string
253c1010edaSGreg Roach    {
25455664801SGreg Roach        return self::$locale->digits((string) $n);
255dfeee0a8SGreg Roach    }
256dfeee0a8SGreg Roach
257dfeee0a8SGreg Roach    /**
258dfeee0a8SGreg Roach     * What is the direction of the current locale
259dfeee0a8SGreg Roach     *
260dfeee0a8SGreg Roach     * @return string "ltr" or "rtl"
261dfeee0a8SGreg Roach     */
2628f53f488SRico Sonntag    public static function direction(): string
263c1010edaSGreg Roach    {
264dfeee0a8SGreg Roach        return self::$locale->direction();
265dfeee0a8SGreg Roach    }
266dfeee0a8SGreg Roach
267dfeee0a8SGreg Roach    /**
2687231a557SGreg Roach     * What is the first day of the week.
2697231a557SGreg Roach     *
270cbc1590aSGreg Roach     * @return int Sunday=0, Monday=1, etc.
2717231a557SGreg Roach     */
2728f53f488SRico Sonntag    public static function firstDay(): int
273c1010edaSGreg Roach    {
2747231a557SGreg Roach        return self::$locale->territory()->firstDay();
2757231a557SGreg Roach    }
2767231a557SGreg Roach
2777231a557SGreg Roach    /**
278dfeee0a8SGreg Roach     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
279dfeee0a8SGreg Roach     *
280dfeee0a8SGreg Roach     * @return string
281dfeee0a8SGreg Roach     */
2828f53f488SRico Sonntag    public static function htmlAttributes(): string
283c1010edaSGreg Roach    {
284dfeee0a8SGreg Roach        return self::$locale->htmlAttributes();
285dfeee0a8SGreg Roach    }
286dfeee0a8SGreg Roach
287dfeee0a8SGreg Roach    /**
288a25f0a04SGreg Roach     * Initialise the translation adapter with a locale setting.
289a25f0a04SGreg Roach     *
29015d603e7SGreg Roach     * @param string    $code  Use this locale/language code, or choose one automatically
291e58a20ffSGreg Roach     * @param Tree|null $tree
292c116a5ccSGreg Roach     * @param bool      $setup During setup, we cannot access the database.
293a25f0a04SGreg Roach     *
294a25f0a04SGreg Roach     * @return string $string
295a25f0a04SGreg Roach     */
296081ddc56SGreg Roach    public static function init(string $code = '', Tree $tree = null, $setup = false): string
297c1010edaSGreg Roach    {
29815d603e7SGreg Roach        if ($code !== '') {
2993bdc890bSGreg Roach            // Create the specified locale
3003bdc890bSGreg Roach            self::$locale = Locale::create($code);
3014ee95e68SRico Sonntag        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) {
302e58a20ffSGreg Roach            // Select a previously used locale
30331bc7874SGreg Roach            self::$locale = Locale::create(Session::get('locale'));
3043bdc890bSGreg Roach        } else {
305e58a20ffSGreg Roach            if ($tree instanceof Tree) {
306e58a20ffSGreg Roach                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
307e58a20ffSGreg Roach            } else {
30859f2f229SGreg Roach                $default_locale = new LocaleEnUs();
3093bdc890bSGreg Roach            }
310e58a20ffSGreg Roach
311e58a20ffSGreg Roach            // Negotiate with the browser.
312e58a20ffSGreg Roach            // Search engines don't negotiate.  They get the default locale of the tree.
313c116a5ccSGreg Roach            if ($setup) {
314c116a5ccSGreg Roach                $installed_locales = app(ModuleService::class)->setupLanguages()
315*0b5fd0a6SGreg Roach                    ->map(static function (ModuleLanguageInterface $module): LocaleInterface {
316c116a5ccSGreg Roach                        return $module->locale();
317c116a5ccSGreg Roach                    });
318c116a5ccSGreg Roach            } else {
319c116a5ccSGreg Roach                $installed_locales = self::installedLocales();
320c116a5ccSGreg Roach            }
321c116a5ccSGreg Roach
322c116a5ccSGreg Roach            self::$locale = Locale::httpAcceptLanguage($_SERVER, $installed_locales->all(), $default_locale);
3233bdc890bSGreg Roach        }
3243bdc890bSGreg Roach
325f1af7e1cSGreg Roach        $cache_dir  = WT_DATA_DIR . 'cache/';
326f1af7e1cSGreg Roach        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
3273bdc890bSGreg Roach        if (file_exists($cache_file)) {
3283bdc890bSGreg Roach            $filemtime = filemtime($cache_file);
3293bdc890bSGreg Roach        } else {
3303bdc890bSGreg Roach            $filemtime = 0;
3313bdc890bSGreg Roach        }
3323bdc890bSGreg Roach
3333bdc890bSGreg Roach        // Load the translation file(s)
334362b8464SGreg Roach        $translation_files = [
335362b8464SGreg Roach            WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo',
336362b8464SGreg Roach        ];
337362b8464SGreg Roach
3387a7f87d7SGreg Roach        // Rebuild files after one hour
3397a7f87d7SGreg Roach        $rebuild_cache = time() > $filemtime + 3600;
3401e71bdc0SGreg Roach        // Rebuild files if any translation file has been updated
3413bdc890bSGreg Roach        foreach ($translation_files as $translation_file) {
3423bdc890bSGreg Roach            if (filemtime($translation_file) > $filemtime) {
3433bdc890bSGreg Roach                $rebuild_cache = true;
344a25f0a04SGreg Roach                break;
345a25f0a04SGreg Roach            }
346a25f0a04SGreg Roach        }
3473bdc890bSGreg Roach
3483bdc890bSGreg Roach        if ($rebuild_cache) {
34913abd6f3SGreg Roach            $translations = [];
3503bdc890bSGreg Roach            foreach ($translation_files as $translation_file) {
3513bdc890bSGreg Roach                $translation  = new Translation($translation_file);
3523bdc890bSGreg Roach                $translations = array_merge($translations, $translation->asArray());
353a25f0a04SGreg Roach            }
354f1af7e1cSGreg Roach            try {
355f1af7e1cSGreg Roach                File::mkdir($cache_dir);
356f1af7e1cSGreg Roach                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
357f1af7e1cSGreg Roach            } catch (Exception $ex) {
3587c2999b4SGreg Roach                // During setup, we may not have been able to create it.
359c85fb0c4SGreg Roach            }
3603bdc890bSGreg Roach        } else {
3613bdc890bSGreg Roach            $translations = include $cache_file;
362a25f0a04SGreg Roach        }
363a25f0a04SGreg Roach
364d37db671SGreg Roach        // Add translations from custom modules (but not during setup)
365c116a5ccSGreg Roach        if (!$setup) {
36602086832SGreg Roach            $custom_modules = app(ModuleService::class)
36702086832SGreg Roach                ->findByInterface(ModuleCustomInterface::class);
368d37db671SGreg Roach
369d37db671SGreg Roach            foreach ($custom_modules as $custom_module) {
370d37db671SGreg Roach                $custom_translations = $custom_module->customTranslations(self::$locale->languageTag());
371d37db671SGreg Roach                $translations        = array_merge($translations, $custom_translations);
372d37db671SGreg Roach            }
373d37db671SGreg Roach        }
374d37db671SGreg Roach
3753bdc890bSGreg Roach        // Create a translator
3763bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
377a25f0a04SGreg Roach
378bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
379bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
380a25f0a04SGreg Roach
381991b93ddSGreg Roach        // Create a collator
382991b93ddSGreg Roach        try {
383444a65ecSGreg Roach            if (class_exists('Collator')) {
384c9ec599fSGreg Roach                // Symfony provides a very incomplete polyfill - which cannot be used.
385991b93ddSGreg Roach                self::$collator = new Collator(self::$locale->code());
386991b93ddSGreg Roach                // Ignore upper/lower case differences
387991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
388444a65ecSGreg Roach            }
389991b93ddSGreg Roach        } catch (Exception $ex) {
390991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
391c9ec599fSGreg Roach            self::$collator = null;
392991b93ddSGreg Roach        }
393991b93ddSGreg Roach
3945331c5eaSGreg Roach        return self::$locale->languageTag();
395a25f0a04SGreg Roach    }
396a25f0a04SGreg Roach
397a25f0a04SGreg Roach    /**
398c999a340SGreg Roach     * All locales for which a translation file exists.
399c999a340SGreg Roach     *
400c116a5ccSGreg Roach     * @return Collection
40115834aaeSGreg Roach     * @return LocaleInterface[]
402c999a340SGreg Roach     */
403c116a5ccSGreg Roach    public static function installedLocales(): Collection
404c1010edaSGreg Roach    {
40502086832SGreg Roach        return app(ModuleService::class)
40602086832SGreg Roach            ->findByInterface(ModuleLanguageInterface::class, true)
407*0b5fd0a6SGreg Roach            ->map(static function (ModuleLanguageInterface $module): LocaleInterface {
40802086832SGreg Roach                return $module->locale();
409c116a5ccSGreg Roach            });
410a25f0a04SGreg Roach    }
411a25f0a04SGreg Roach
412a25f0a04SGreg Roach    /**
413a25f0a04SGreg Roach     * Return the endonym for a given language - as per http://cldr.unicode.org/
414a25f0a04SGreg Roach     *
415a25f0a04SGreg Roach     * @param string $locale
416a25f0a04SGreg Roach     *
417a25f0a04SGreg Roach     * @return string
418a25f0a04SGreg Roach     */
41955664801SGreg Roach    public static function languageName(string $locale): string
420c1010edaSGreg Roach    {
421c999a340SGreg Roach        return Locale::create($locale)->endonym();
422a25f0a04SGreg Roach    }
423a25f0a04SGreg Roach
424a25f0a04SGreg Roach    /**
425a25f0a04SGreg Roach     * Return the script used by a given language
426a25f0a04SGreg Roach     *
427a25f0a04SGreg Roach     * @param string $locale
428a25f0a04SGreg Roach     *
429a25f0a04SGreg Roach     * @return string
430a25f0a04SGreg Roach     */
43155664801SGreg Roach    public static function languageScript(string $locale): string
432c1010edaSGreg Roach    {
433c999a340SGreg Roach        return Locale::create($locale)->script()->code();
434a25f0a04SGreg Roach    }
435a25f0a04SGreg Roach
436a25f0a04SGreg Roach    /**
437dfeee0a8SGreg Roach     * Translate a number into the local representation.
438dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
439dfeee0a8SGreg Roach     * en: 12,345.67
440dfeee0a8SGreg Roach     * fr: 12 345,67
441dfeee0a8SGreg Roach     * de: 12.345,67
442dfeee0a8SGreg Roach     *
443dfeee0a8SGreg Roach     * @param float $n
444cbc1590aSGreg Roach     * @param int   $precision
445a25f0a04SGreg Roach     *
446a25f0a04SGreg Roach     * @return string
447a25f0a04SGreg Roach     */
44855664801SGreg Roach    public static function number(float $n, int $precision = 0): string
449c1010edaSGreg Roach    {
450dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
451dfeee0a8SGreg Roach    }
452dfeee0a8SGreg Roach
453dfeee0a8SGreg Roach    /**
454dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
455dfeee0a8SGreg Roach     * e.g. 0.123 becomes
456dfeee0a8SGreg Roach     * en: 12.3%
457dfeee0a8SGreg Roach     * fr: 12,3 %
458dfeee0a8SGreg Roach     * de: 12,3%
459dfeee0a8SGreg Roach     *
460dfeee0a8SGreg Roach     * @param float $n
461cbc1590aSGreg Roach     * @param int   $precision
462dfeee0a8SGreg Roach     *
463dfeee0a8SGreg Roach     * @return string
464dfeee0a8SGreg Roach     */
46555664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
466c1010edaSGreg Roach    {
467dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
468dfeee0a8SGreg Roach    }
469dfeee0a8SGreg Roach
470dfeee0a8SGreg Roach    /**
471dfeee0a8SGreg Roach     * Translate a plural string
472dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
473dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
474dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
475dfeee0a8SGreg Roach     *
476924d091bSGreg Roach     * @param string $singular
477924d091bSGreg Roach     * @param string $plural
478924d091bSGreg Roach     * @param int    $count
479a515be7cSGreg Roach     * @param string ...$args
480e93111adSRico Sonntag     *
481dfeee0a8SGreg Roach     * @return string
482dfeee0a8SGreg Roach     */
483924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
484c1010edaSGreg Roach    {
485924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
486dfeee0a8SGreg Roach
487924d091bSGreg Roach        return sprintf($message, ...$args);
488dfeee0a8SGreg Roach    }
489dfeee0a8SGreg Roach
490dfeee0a8SGreg Roach    /**
491dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
492dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
493dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
494dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
495dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
496dfeee0a8SGreg Roach     *
497dfeee0a8SGreg Roach     * @param string $text Text to be reversed
498dfeee0a8SGreg Roach     *
499dfeee0a8SGreg Roach     * @return string
500dfeee0a8SGreg Roach     */
5018f53f488SRico Sonntag    public static function reverseText($text): string
502c1010edaSGreg Roach    {
503dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
5049524b7b5SGreg Roach        $text = strip_tags($text);
5059524b7b5SGreg Roach        // Remove HTML entities.
5069524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
507dfeee0a8SGreg Roach
508dfeee0a8SGreg Roach        // LTR text doesn't need reversing
509dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
510dfeee0a8SGreg Roach            return $text;
511dfeee0a8SGreg Roach        }
512dfeee0a8SGreg Roach
513dfeee0a8SGreg Roach        // Mirrored characters
514991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
515dfeee0a8SGreg Roach
516dfeee0a8SGreg Roach        $reversed = '';
517dfeee0a8SGreg Roach        $digits   = '';
518e364afe4SGreg Roach        while ($text !== '') {
519dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
520dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
521dfeee0a8SGreg Roach            if (strpos(self::DIGITS, $letter) !== false) {
522dfeee0a8SGreg Roach                $digits .= $letter;
523a25f0a04SGreg Roach            } else {
524dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
525dfeee0a8SGreg Roach                $digits   = '';
526dfeee0a8SGreg Roach            }
527a25f0a04SGreg Roach        }
528a25f0a04SGreg Roach
529dfeee0a8SGreg Roach        return $digits . $reversed;
530a25f0a04SGreg Roach    }
531a25f0a04SGreg Roach
532a25f0a04SGreg Roach    /**
533a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
534a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
535a25f0a04SGreg Roach     * our own lookup table.
536a25f0a04SGreg Roach     *
537a25f0a04SGreg Roach     * @param string $script
538a25f0a04SGreg Roach     *
539a25f0a04SGreg Roach     * @return string
540a25f0a04SGreg Roach     */
541e364afe4SGreg Roach    public static function scriptDirection($script): string
542c1010edaSGreg Roach    {
543a25f0a04SGreg Roach        switch ($script) {
544a25f0a04SGreg Roach            case 'Arab':
545a25f0a04SGreg Roach            case 'Hebr':
546a25f0a04SGreg Roach            case 'Mong':
547a25f0a04SGreg Roach            case 'Thaa':
548a25f0a04SGreg Roach                return 'rtl';
549a25f0a04SGreg Roach            default:
550a25f0a04SGreg Roach                return 'ltr';
551a25f0a04SGreg Roach        }
552a25f0a04SGreg Roach    }
553a25f0a04SGreg Roach
554a25f0a04SGreg Roach    /**
555991b93ddSGreg Roach     * Perform a case-insensitive comparison of two strings.
556a25f0a04SGreg Roach     *
557a25f0a04SGreg Roach     * @param string $string1
558a25f0a04SGreg Roach     * @param string $string2
559a25f0a04SGreg Roach     *
560cbc1590aSGreg Roach     * @return int
561a25f0a04SGreg Roach     */
562e364afe4SGreg Roach    public static function strcasecmp($string1, $string2): int
563c1010edaSGreg Roach    {
564991b93ddSGreg Roach        if (self::$collator instanceof Collator) {
565991b93ddSGreg Roach            return self::$collator->compare($string1, $string2);
566a25f0a04SGreg Roach        }
567e364afe4SGreg Roach
568e364afe4SGreg Roach        return strcmp(self::strtolower($string1), self::strtolower($string2));
569c9ec599fSGreg Roach    }
570a25f0a04SGreg Roach
571a25f0a04SGreg Roach    /**
572991b93ddSGreg Roach     * Convert a string to lower case.
573a25f0a04SGreg Roach     *
574dfeee0a8SGreg Roach     * @param string $string
575a25f0a04SGreg Roach     *
576a25f0a04SGreg Roach     * @return string
577a25f0a04SGreg Roach     */
5788f53f488SRico Sonntag    public static function strtolower($string): string
579c1010edaSGreg Roach    {
58002086832SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
581991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
582a25f0a04SGreg Roach        }
5835ddad20bSGreg Roach
5845ddad20bSGreg Roach        return mb_strtolower($string);
585a25f0a04SGreg Roach    }
586a25f0a04SGreg Roach
587a25f0a04SGreg Roach    /**
588991b93ddSGreg Roach     * Convert a string to upper case.
589dfeee0a8SGreg Roach     *
590dfeee0a8SGreg Roach     * @param string $string
591a25f0a04SGreg Roach     *
592a25f0a04SGreg Roach     * @return string
593a25f0a04SGreg Roach     */
5948f53f488SRico Sonntag    public static function strtoupper($string): string
595c1010edaSGreg Roach    {
59602086832SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
597991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
598a25f0a04SGreg Roach        }
5995ddad20bSGreg Roach
6005ddad20bSGreg Roach        return mb_strtoupper($string);
601a25f0a04SGreg Roach    }
602a25f0a04SGreg Roach
603dfeee0a8SGreg Roach    /**
604dfeee0a8SGreg Roach     * Identify the script used for a piece of text
605dfeee0a8SGreg Roach     *
606d0bfc631SGreg Roach     * @param string $string
607dfeee0a8SGreg Roach     *
608dfeee0a8SGreg Roach     * @return string
609dfeee0a8SGreg Roach     */
6108f53f488SRico Sonntag    public static function textScript($string): string
611c1010edaSGreg Roach    {
612dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
613dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
614c1010edaSGreg Roach        $string = str_replace([
615c1010edaSGreg Roach            '@N.N.',
616c1010edaSGreg Roach            '@P.N.',
617c1010edaSGreg Roach        ], '', $string); // otherwise unknown names show up as latin
618dfeee0a8SGreg Roach        $pos    = 0;
619dfeee0a8SGreg Roach        $strlen = strlen($string);
620dfeee0a8SGreg Roach        while ($pos < $strlen) {
621dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
622dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
623dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
624dfeee0a8SGreg Roach                $code_point = $byte1;
625dfeee0a8SGreg Roach                $chrlen     = 1;
626dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
627dfeee0a8SGreg Roach                // Invalid continuation character
628dfeee0a8SGreg Roach                return 'Latn';
629dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
630dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
631dfeee0a8SGreg Roach                $chrlen     = 2;
632dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
633dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
634dfeee0a8SGreg Roach                $chrlen     = 3;
635dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
636dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
637dfeee0a8SGreg Roach                $chrlen     = 3;
638dfeee0a8SGreg Roach            } else {
639dfeee0a8SGreg Roach                // Invalid UTF
640dfeee0a8SGreg Roach                return 'Latn';
641dfeee0a8SGreg Roach            }
642dfeee0a8SGreg Roach
643991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
644dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
645dfeee0a8SGreg Roach                    return $range[0];
646dfeee0a8SGreg Roach                }
647dfeee0a8SGreg Roach            }
648dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
649dfeee0a8SGreg Roach            $pos += $chrlen;
650dfeee0a8SGreg Roach        }
651dfeee0a8SGreg Roach
652dfeee0a8SGreg Roach        return 'Latn';
653dfeee0a8SGreg Roach    }
654dfeee0a8SGreg Roach
655dfeee0a8SGreg Roach    /**
656dfeee0a8SGreg Roach     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
657dfeee0a8SGreg Roach     *
658cbc1590aSGreg Roach     * @param int $seconds
659dfeee0a8SGreg Roach     *
660dfeee0a8SGreg Roach     * @return string
661dfeee0a8SGreg Roach     */
662e364afe4SGreg Roach    public static function timeAgo($seconds): string
663c1010edaSGreg Roach    {
664dfeee0a8SGreg Roach        $minute = 60;
665dfeee0a8SGreg Roach        $hour   = 60 * $minute;
666dfeee0a8SGreg Roach        $day    = 24 * $hour;
667dfeee0a8SGreg Roach        $month  = 30 * $day;
668dfeee0a8SGreg Roach        $year   = 365 * $day;
669dfeee0a8SGreg Roach
670dfeee0a8SGreg Roach        if ($seconds > $year) {
671cdaafeeeSGreg Roach            $years = intdiv($seconds, $year);
672cbc1590aSGreg Roach
673dfeee0a8SGreg Roach            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
674b2ce94c6SRico Sonntag        }
675b2ce94c6SRico Sonntag
676b2ce94c6SRico Sonntag        if ($seconds > $month) {
677cdaafeeeSGreg Roach            $months = intdiv($seconds, $month);
678cbc1590aSGreg Roach
679dfeee0a8SGreg Roach            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
680b2ce94c6SRico Sonntag        }
681b2ce94c6SRico Sonntag
682b2ce94c6SRico Sonntag        if ($seconds > $day) {
683cdaafeeeSGreg Roach            $days = intdiv($seconds, $day);
684cbc1590aSGreg Roach
685dfeee0a8SGreg Roach            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
686b2ce94c6SRico Sonntag        }
687b2ce94c6SRico Sonntag
688b2ce94c6SRico Sonntag        if ($seconds > $hour) {
689cdaafeeeSGreg Roach            $hours = intdiv($seconds, $hour);
690cbc1590aSGreg Roach
691dfeee0a8SGreg Roach            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
692b2ce94c6SRico Sonntag        }
693b2ce94c6SRico Sonntag
694b2ce94c6SRico Sonntag        if ($seconds > $minute) {
695cdaafeeeSGreg Roach            $minutes = intdiv($seconds, $minute);
696cbc1590aSGreg Roach
697dfeee0a8SGreg Roach            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
698dfeee0a8SGreg Roach        }
699b2ce94c6SRico Sonntag
700b2ce94c6SRico Sonntag        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
701dfeee0a8SGreg Roach    }
702dfeee0a8SGreg Roach
703dfeee0a8SGreg Roach    /**
704dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
705dfeee0a8SGreg Roach     *
706dfeee0a8SGreg Roach     * @return string
707dfeee0a8SGreg Roach     */
7088f53f488SRico Sonntag    public static function timeFormat(): string
709c1010edaSGreg Roach    {
710bbb76c12SGreg Roach        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
711bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
712dfeee0a8SGreg Roach    }
713dfeee0a8SGreg Roach
714dfeee0a8SGreg Roach    /**
715dfeee0a8SGreg Roach     * Translate a string, and then substitute placeholders
716dfeee0a8SGreg Roach     * echo I18N::translate('Hello World!');
717dfeee0a8SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
718dfeee0a8SGreg Roach     *
719924d091bSGreg Roach     * @param string $message
720a515be7cSGreg Roach     * @param string ...$args
721c3283ed7SGreg Roach     *
722dfeee0a8SGreg Roach     * @return string
723dfeee0a8SGreg Roach     */
724924d091bSGreg Roach    public static function translate(string $message, ...$args): string
725c1010edaSGreg Roach    {
726924d091bSGreg Roach        $message = self::$translator->translate($message);
727dfeee0a8SGreg Roach
728924d091bSGreg Roach        return sprintf($message, ...$args);
729dfeee0a8SGreg Roach    }
730dfeee0a8SGreg Roach
731dfeee0a8SGreg Roach    /**
732dfeee0a8SGreg Roach     * Context sensitive version of translate.
733a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
734a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
735dfeee0a8SGreg Roach     *
736924d091bSGreg Roach     * @param string $context
737924d091bSGreg Roach     * @param string $message
738a515be7cSGreg Roach     * @param string ...$args
739c3283ed7SGreg Roach     *
740dfeee0a8SGreg Roach     * @return string
741dfeee0a8SGreg Roach     */
742924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
743c1010edaSGreg Roach    {
744924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
745dfeee0a8SGreg Roach
746924d091bSGreg Roach        return sprintf($message, ...$args);
747a25f0a04SGreg Roach    }
748a25f0a04SGreg Roach}
749