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