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; 21*362b8464SGreg 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) { 244*362b8464SGreg 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 } 252*362b8464SGreg 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); 388e58a20ffSGreg Roach } elseif (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.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) 412*362b8464SGreg Roach $translation_files = [ 413*362b8464SGreg Roach WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo', 414*362b8464SGreg Roach ]; 415*362b8464SGreg 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 = []; 472*362b8464SGreg Roach 473*362b8464SGreg Roach foreach (glob(WT_ROOT . 'resources/lang/*/messages.mo') as $file) { 474c999a340SGreg Roach try { 475c999a340SGreg Roach $locales[] = Locale::create(basename($file, '.mo')); 476*362b8464SGreg 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