1a25f0a04SGreg Roach<?php 2a25f0a04SGreg Roach/** 3a25f0a04SGreg Roach * webtrees: online genealogy 4*8fcd0d32SGreg 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; 2715d603e7SGreg Roachuse Fisharebest\Webtrees\Functions\FunctionsEdit; 28a25f0a04SGreg Roach 29a25f0a04SGreg Roach/** 3076692c8bSGreg Roach * Internationalization (i18n) and localization (l10n). 31a25f0a04SGreg Roach */ 32c1010edaSGreg Roachclass I18N 33c1010edaSGreg Roach{ 3415834aaeSGreg Roach /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 35c999a340SGreg Roach private static $locale; 36c999a340SGreg Roach 3776692c8bSGreg Roach /** @var Translator An object that performs translation */ 383bdc890bSGreg Roach private static $translator; 393bdc890bSGreg Roach 40c9ec599fSGreg Roach /** @var Collator|null From the php-intl library */ 41991b93ddSGreg Roach private static $collator; 42991b93ddSGreg Roach 43a25f0a04SGreg Roach // Digits are always rendered LTR, even in RTL text. 44a25f0a04SGreg Roach const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 45a25f0a04SGreg Roach 46991b93ddSGreg Roach // These locales need special handling for the dotless letter I. 47c1010edaSGreg Roach const DOTLESS_I_LOCALES = [ 48c1010edaSGreg Roach 'az', 49c1010edaSGreg Roach 'tr', 50c1010edaSGreg Roach ]; 51c1010edaSGreg Roach const DOTLESS_I_TOLOWER = [ 52c1010edaSGreg Roach 'I' => 'ı', 53c1010edaSGreg Roach 'İ' => 'i', 54c1010edaSGreg Roach ]; 55c1010edaSGreg Roach const DOTLESS_I_TOUPPER = [ 56c1010edaSGreg Roach 'ı' => 'I', 57c1010edaSGreg Roach 'i' => 'İ', 58c1010edaSGreg Roach ]; 59a25f0a04SGreg Roach 60991b93ddSGreg Roach // The ranges of characters used by each script. 61991b93ddSGreg Roach const SCRIPT_CHARACTER_RANGES = [ 62c1010edaSGreg Roach [ 63c1010edaSGreg Roach 'Latn', 64c1010edaSGreg Roach 0x0041, 65c1010edaSGreg Roach 0x005A, 66c1010edaSGreg Roach ], 67c1010edaSGreg Roach [ 68c1010edaSGreg Roach 'Latn', 69c1010edaSGreg Roach 0x0061, 70c1010edaSGreg Roach 0x007A, 71c1010edaSGreg Roach ], 72c1010edaSGreg Roach [ 73c1010edaSGreg Roach 'Latn', 74c1010edaSGreg Roach 0x0100, 75c1010edaSGreg Roach 0x02AF, 76c1010edaSGreg Roach ], 77c1010edaSGreg Roach [ 78c1010edaSGreg Roach 'Grek', 79c1010edaSGreg Roach 0x0370, 80c1010edaSGreg Roach 0x03FF, 81c1010edaSGreg Roach ], 82c1010edaSGreg Roach [ 83c1010edaSGreg Roach 'Cyrl', 84c1010edaSGreg Roach 0x0400, 85c1010edaSGreg Roach 0x052F, 86c1010edaSGreg Roach ], 87c1010edaSGreg Roach [ 88c1010edaSGreg Roach 'Hebr', 89c1010edaSGreg Roach 0x0590, 90c1010edaSGreg Roach 0x05FF, 91c1010edaSGreg Roach ], 92c1010edaSGreg Roach [ 93c1010edaSGreg Roach 'Arab', 94c1010edaSGreg Roach 0x0600, 95c1010edaSGreg Roach 0x06FF, 96c1010edaSGreg Roach ], 97c1010edaSGreg Roach [ 98c1010edaSGreg Roach 'Arab', 99c1010edaSGreg Roach 0x0750, 100c1010edaSGreg Roach 0x077F, 101c1010edaSGreg Roach ], 102c1010edaSGreg Roach [ 103c1010edaSGreg Roach 'Arab', 104c1010edaSGreg Roach 0x08A0, 105c1010edaSGreg Roach 0x08FF, 106c1010edaSGreg Roach ], 107c1010edaSGreg Roach [ 108c1010edaSGreg Roach 'Deva', 109c1010edaSGreg Roach 0x0900, 110c1010edaSGreg Roach 0x097F, 111c1010edaSGreg Roach ], 112c1010edaSGreg Roach [ 113c1010edaSGreg Roach 'Taml', 114c1010edaSGreg Roach 0x0B80, 115c1010edaSGreg Roach 0x0BFF, 116c1010edaSGreg Roach ], 117c1010edaSGreg Roach [ 118c1010edaSGreg Roach 'Sinh', 119c1010edaSGreg Roach 0x0D80, 120c1010edaSGreg Roach 0x0DFF, 121c1010edaSGreg Roach ], 122c1010edaSGreg Roach [ 123c1010edaSGreg Roach 'Thai', 124c1010edaSGreg Roach 0x0E00, 125c1010edaSGreg Roach 0x0E7F, 126c1010edaSGreg Roach ], 127c1010edaSGreg Roach [ 128c1010edaSGreg Roach 'Geor', 129c1010edaSGreg Roach 0x10A0, 130c1010edaSGreg Roach 0x10FF, 131c1010edaSGreg Roach ], 132c1010edaSGreg Roach [ 133c1010edaSGreg Roach 'Grek', 134c1010edaSGreg Roach 0x1F00, 135c1010edaSGreg Roach 0x1FFF, 136c1010edaSGreg Roach ], 137c1010edaSGreg Roach [ 138c1010edaSGreg Roach 'Deva', 139c1010edaSGreg Roach 0xA8E0, 140c1010edaSGreg Roach 0xA8FF, 141c1010edaSGreg Roach ], 142c1010edaSGreg Roach [ 143c1010edaSGreg Roach 'Hans', 144c1010edaSGreg Roach 0x3000, 145c1010edaSGreg Roach 0x303F, 146c1010edaSGreg Roach ], 147c1010edaSGreg Roach // Mixed CJK, not just Hans 148c1010edaSGreg Roach [ 149c1010edaSGreg Roach 'Hans', 150c1010edaSGreg Roach 0x3400, 151c1010edaSGreg Roach 0xFAFF, 152c1010edaSGreg Roach ], 153c1010edaSGreg Roach // Mixed CJK, not just Hans 154c1010edaSGreg Roach [ 155c1010edaSGreg Roach 'Hans', 156c1010edaSGreg Roach 0x20000, 157c1010edaSGreg Roach 0x2FA1F, 158c1010edaSGreg Roach ], 159c1010edaSGreg Roach // Mixed CJK, not just Hans 16013abd6f3SGreg Roach ]; 161a25f0a04SGreg Roach 162991b93ddSGreg Roach // Characters that are displayed in mirror form in RTL text. 163991b93ddSGreg Roach const MIRROR_CHARACTERS = [ 164a25f0a04SGreg Roach '(' => ')', 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 '’ ' => '‘', 18213abd6f3SGreg Roach ]; 183a25f0a04SGreg Roach 184991b93ddSGreg Roach // Default list of locales to show in the menu. 185991b93ddSGreg Roach const DEFAULT_LOCALES = [ 186c1010edaSGreg Roach 'ar', 187c1010edaSGreg Roach 'bg', 188c1010edaSGreg Roach 'bs', 189c1010edaSGreg Roach 'ca', 190c1010edaSGreg Roach 'cs', 191c1010edaSGreg Roach 'da', 192c1010edaSGreg Roach 'de', 193c1010edaSGreg Roach 'el', 194c1010edaSGreg Roach 'en-GB', 195c1010edaSGreg Roach 'en-US', 196c1010edaSGreg Roach 'es', 197c1010edaSGreg Roach 'et', 198c1010edaSGreg Roach 'fi', 199c1010edaSGreg Roach 'fr', 200c1010edaSGreg Roach 'he', 201c1010edaSGreg Roach 'hr', 202c1010edaSGreg Roach 'hu', 203c1010edaSGreg Roach 'is', 204c1010edaSGreg Roach 'it', 205c1010edaSGreg Roach 'ka', 206c1010edaSGreg Roach 'kk', 207c1010edaSGreg Roach 'lt', 208c1010edaSGreg Roach 'mr', 209c1010edaSGreg Roach 'nb', 210c1010edaSGreg Roach 'nl', 211c1010edaSGreg Roach 'nn', 212c1010edaSGreg Roach 'pl', 213c1010edaSGreg Roach 'pt', 214c1010edaSGreg Roach 'ru', 215c1010edaSGreg Roach 'sk', 216c1010edaSGreg Roach 'sv', 217c1010edaSGreg Roach 'tr', 218c1010edaSGreg Roach 'uk', 219c1010edaSGreg Roach 'vi', 220c1010edaSGreg Roach 'zh-Hans', 221991b93ddSGreg Roach ]; 222991b93ddSGreg Roach 223a25f0a04SGreg Roach /** @var string Punctuation used to separate list items, typically a comma */ 224a25f0a04SGreg Roach public static $list_separator; 225a25f0a04SGreg Roach 226a25f0a04SGreg Roach /** 227dfeee0a8SGreg Roach * The prefered locales for this site, or a default list if no preference. 228dfeee0a8SGreg Roach * 229dfeee0a8SGreg Roach * @return LocaleInterface[] 230dfeee0a8SGreg Roach */ 2318f53f488SRico Sonntag public static function activeLocales(): array 232c1010edaSGreg Roach { 233dfeee0a8SGreg Roach $code_list = Site::getPreference('LANGUAGES'); 234dfeee0a8SGreg Roach 23515d603e7SGreg Roach if ($code_list === '') { 236991b93ddSGreg Roach $codes = self::DEFAULT_LOCALES; 237dfeee0a8SGreg Roach } else { 238991b93ddSGreg Roach $codes = explode(',', $code_list); 239dfeee0a8SGreg Roach } 240dfeee0a8SGreg Roach 24113abd6f3SGreg Roach $locales = []; 242dfeee0a8SGreg Roach foreach ($codes as $code) { 243dfeee0a8SGreg Roach if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) { 244dfeee0a8SGreg Roach try { 245dfeee0a8SGreg Roach $locales[] = Locale::create($code); 24691495569SGreg Roach } catch (Exception $ex) { 247dfeee0a8SGreg Roach // No such locale exists? 248dfeee0a8SGreg Roach } 249dfeee0a8SGreg Roach } 250dfeee0a8SGreg Roach } 251dfeee0a8SGreg Roach usort($locales, '\Fisharebest\Localization\Locale::compare'); 252dfeee0a8SGreg Roach 253dfeee0a8SGreg Roach return $locales; 254dfeee0a8SGreg Roach } 255dfeee0a8SGreg Roach 256dfeee0a8SGreg Roach /** 257dfeee0a8SGreg Roach * Which MySQL collation should be used for this locale? 258dfeee0a8SGreg Roach * 259dfeee0a8SGreg Roach * @return string 260dfeee0a8SGreg Roach */ 261c1010edaSGreg Roach public static function collation() 262c1010edaSGreg Roach { 263dfeee0a8SGreg Roach $collation = self::$locale->collation(); 264dfeee0a8SGreg Roach switch ($collation) { 265dfeee0a8SGreg Roach case 'croatian_ci': 266dfeee0a8SGreg Roach case 'german2_ci': 267dfeee0a8SGreg Roach case 'vietnamese_ci': 268dfeee0a8SGreg Roach // Only available in MySQL 5.6 269dfeee0a8SGreg Roach return 'utf8_unicode_ci'; 270dfeee0a8SGreg Roach default: 271dfeee0a8SGreg Roach return 'utf8_' . $collation; 272dfeee0a8SGreg Roach } 273dfeee0a8SGreg Roach } 274dfeee0a8SGreg Roach 275dfeee0a8SGreg Roach /** 276dfeee0a8SGreg Roach * What format is used to display dates in the current locale? 277dfeee0a8SGreg Roach * 278dfeee0a8SGreg Roach * @return string 279dfeee0a8SGreg Roach */ 2808f53f488SRico Sonntag public static function dateFormat(): string 281c1010edaSGreg Roach { 282bbb76c12SGreg Roach /* I18N: This is the format string for full dates. See http://php.net/date for codes */ 283bbb76c12SGreg Roach return self::$translator->translate('%j %F %Y'); 284dfeee0a8SGreg Roach } 285dfeee0a8SGreg Roach 286dfeee0a8SGreg Roach /** 287dfeee0a8SGreg Roach * Generate consistent I18N for datatables.js 288dfeee0a8SGreg Roach * 28955664801SGreg Roach * @param int[] $lengths An optional array of page lengths 290dfeee0a8SGreg Roach * 291dfeee0a8SGreg Roach * @return string 292dfeee0a8SGreg Roach */ 293c1010edaSGreg Roach public static function datatablesI18N(array $lengths = [ 294c1010edaSGreg Roach 10, 295c1010edaSGreg Roach 20, 296c1010edaSGreg Roach 30, 297c1010edaSGreg Roach 50, 298c1010edaSGreg Roach 100, 299c1010edaSGreg Roach -1, 30055664801SGreg Roach ]): string 30155664801SGreg Roach { 30255664801SGreg Roach $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), '10'); 303dfeee0a8SGreg Roach 304dfeee0a8SGreg Roach return 3052d9b2ebaSGreg Roach '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' . 306dfeee0a8SGreg Roach '"language": {' . 307dfeee0a8SGreg Roach ' "paginate": {' . 308bbb76c12SGreg Roach ' "first": "' . self::translate('first') . '",' . 309bbb76c12SGreg Roach ' "last": "' . self::translate('last') . '",' . 310bbb76c12SGreg Roach ' "next": "' . self::translate('next') . '",' . 311bbb76c12SGreg Roach ' "previous": "' . self::translate('previous') . '"' . 312dfeee0a8SGreg Roach ' },' . 313dfeee0a8SGreg Roach ' "emptyTable": "' . self::translate('No records to display') . '",' . 314c1010edaSGreg Roach ' "info": "' . /* I18N: %s are placeholders for numbers */ 315c1010edaSGreg Roach self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' . 3160b6446e1SGreg 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')) . '",' . 317c1010edaSGreg Roach ' "infoFiltered": "' . /* I18N: %s is a placeholder for a number */ 318c1010edaSGreg Roach self::translate('(filtered from %s total entries)', '_MAX_') . '",' . 319c1010edaSGreg Roach ' "lengthMenu": "' . /* I18N: %s is a number of records per page */ 320c1010edaSGreg Roach self::translate('Display %s', addslashes($length_options)) . '",' . 321dfeee0a8SGreg Roach ' "loadingRecords": "' . self::translate('Loading…') . '",' . 322dfeee0a8SGreg Roach ' "processing": "' . self::translate('Loading…') . '",' . 323dfeee0a8SGreg Roach ' "search": "' . self::translate('Filter') . '",' . 324dfeee0a8SGreg Roach ' "zeroRecords": "' . self::translate('No records to display') . '"' . 3252d9b2ebaSGreg Roach '}'; 326dfeee0a8SGreg Roach } 327dfeee0a8SGreg Roach 328dfeee0a8SGreg Roach /** 329dfeee0a8SGreg Roach * Convert the digits 0-9 into the local script 330dfeee0a8SGreg Roach * 331dfeee0a8SGreg Roach * Used for years, etc., where we do not want thousands-separators, decimals, etc. 332dfeee0a8SGreg Roach * 33355664801SGreg Roach * @param string|int $n 334dfeee0a8SGreg Roach * 335dfeee0a8SGreg Roach * @return string 336dfeee0a8SGreg Roach */ 3378f53f488SRico Sonntag public static function digits($n): string 338c1010edaSGreg Roach { 33955664801SGreg Roach return self::$locale->digits((string) $n); 340dfeee0a8SGreg Roach } 341dfeee0a8SGreg Roach 342dfeee0a8SGreg Roach /** 343dfeee0a8SGreg Roach * What is the direction of the current locale 344dfeee0a8SGreg Roach * 345dfeee0a8SGreg Roach * @return string "ltr" or "rtl" 346dfeee0a8SGreg Roach */ 3478f53f488SRico Sonntag public static function direction(): string 348c1010edaSGreg Roach { 349dfeee0a8SGreg Roach return self::$locale->direction(); 350dfeee0a8SGreg Roach } 351dfeee0a8SGreg Roach 352dfeee0a8SGreg Roach /** 3537231a557SGreg Roach * What is the first day of the week. 3547231a557SGreg Roach * 355cbc1590aSGreg Roach * @return int Sunday=0, Monday=1, etc. 3567231a557SGreg Roach */ 3578f53f488SRico Sonntag public static function firstDay(): int 358c1010edaSGreg Roach { 3597231a557SGreg Roach return self::$locale->territory()->firstDay(); 3607231a557SGreg Roach } 3617231a557SGreg Roach 3627231a557SGreg Roach /** 363dfeee0a8SGreg Roach * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 364dfeee0a8SGreg Roach * 365dfeee0a8SGreg Roach * @return string 366dfeee0a8SGreg Roach */ 3678f53f488SRico Sonntag public static function htmlAttributes(): string 368c1010edaSGreg Roach { 369dfeee0a8SGreg Roach return self::$locale->htmlAttributes(); 370dfeee0a8SGreg Roach } 371dfeee0a8SGreg Roach 372dfeee0a8SGreg Roach /** 373a25f0a04SGreg Roach * Initialise the translation adapter with a locale setting. 374a25f0a04SGreg Roach * 37515d603e7SGreg Roach * @param string $code Use this locale/language code, or choose one automatically 376e58a20ffSGreg Roach * @param Tree|null $tree 377a25f0a04SGreg Roach * 378a25f0a04SGreg Roach * @return string $string 379a25f0a04SGreg Roach */ 380e58a20ffSGreg Roach public static function init(string $code = '', Tree $tree = null): string 381c1010edaSGreg Roach { 382c314ecc9SGreg Roach mb_internal_encoding('UTF-8'); 383c314ecc9SGreg Roach 38415d603e7SGreg Roach if ($code !== '') { 3853bdc890bSGreg Roach // Create the specified locale 3863bdc890bSGreg Roach self::$locale = Locale::create($code); 387e58a20ffSGreg Roach } elseif (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.mo')) { 388e58a20ffSGreg Roach // Select a previously used locale 38931bc7874SGreg Roach self::$locale = Locale::create(Session::get('locale')); 3903bdc890bSGreg Roach } else { 391e58a20ffSGreg Roach if ($tree instanceof Tree) { 392e58a20ffSGreg Roach $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US')); 393e58a20ffSGreg Roach } else { 39459f2f229SGreg Roach $default_locale = new LocaleEnUs(); 3953bdc890bSGreg Roach } 396e58a20ffSGreg Roach 397e58a20ffSGreg Roach // Negotiate with the browser. 398e58a20ffSGreg Roach // Search engines don't negotiate. They get the default locale of the tree. 399149573a1SGreg Roach self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale); 4003bdc890bSGreg Roach } 4013bdc890bSGreg Roach 402f1af7e1cSGreg Roach $cache_dir = WT_DATA_DIR . 'cache/'; 403f1af7e1cSGreg Roach $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php'; 4043bdc890bSGreg Roach if (file_exists($cache_file)) { 4053bdc890bSGreg Roach $filemtime = filemtime($cache_file); 4063bdc890bSGreg Roach } else { 4073bdc890bSGreg Roach $filemtime = 0; 4083bdc890bSGreg Roach } 4093bdc890bSGreg Roach 4103bdc890bSGreg Roach // Load the translation file(s) 4117d6e38dfSGreg Roach // Note that glob() returns false instead of an empty array when open_basedir_restriction 4127d6e38dfSGreg Roach // is in force and no files are found. See PHP bug #47358. 4137a7f87d7SGreg Roach if (defined('GLOB_BRACE')) { 4143bdc890bSGreg Roach $translation_files = array_merge( 41513abd6f3SGreg Roach [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 4168d0ebef0SGreg Roach glob(Webtrees::MODULES_PATH . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [], 41713abd6f3SGreg Roach glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [] 418a25f0a04SGreg Roach ); 4197a7f87d7SGreg Roach } else { 4207a7f87d7SGreg Roach // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php 4217a7f87d7SGreg Roach $translation_files = array_merge( 42213abd6f3SGreg Roach [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 4238d0ebef0SGreg Roach glob(Webtrees::MODULES_PATH . '*/language/' . self::$locale->languageTag() . '.csv') ?: [], 4248d0ebef0SGreg Roach glob(Webtrees::MODULES_PATH . '*/language/' . self::$locale->languageTag() . '.php') ?: [], 4258d0ebef0SGreg Roach glob(Webtrees::MODULES_PATH . '*/language/' . self::$locale->languageTag() . '.mo') ?: [], 42613abd6f3SGreg Roach glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: [], 42713abd6f3SGreg Roach glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: [], 42813abd6f3SGreg Roach glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: [] 4297a7f87d7SGreg Roach ); 4307a7f87d7SGreg Roach } 4317a7f87d7SGreg Roach // Rebuild files after one hour 4327a7f87d7SGreg Roach $rebuild_cache = time() > $filemtime + 3600; 4331e71bdc0SGreg Roach // Rebuild files if any translation file has been updated 4343bdc890bSGreg Roach foreach ($translation_files as $translation_file) { 4353bdc890bSGreg Roach if (filemtime($translation_file) > $filemtime) { 4363bdc890bSGreg Roach $rebuild_cache = true; 437a25f0a04SGreg Roach break; 438a25f0a04SGreg Roach } 439a25f0a04SGreg Roach } 4403bdc890bSGreg Roach 4413bdc890bSGreg Roach if ($rebuild_cache) { 44213abd6f3SGreg Roach $translations = []; 4433bdc890bSGreg Roach foreach ($translation_files as $translation_file) { 4443bdc890bSGreg Roach $translation = new Translation($translation_file); 4453bdc890bSGreg Roach $translations = array_merge($translations, $translation->asArray()); 446a25f0a04SGreg Roach } 447f1af7e1cSGreg Roach try { 448f1af7e1cSGreg Roach File::mkdir($cache_dir); 449f1af7e1cSGreg Roach file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';'); 450f1af7e1cSGreg Roach } catch (Exception $ex) { 4517c2999b4SGreg Roach // During setup, we may not have been able to create it. 452c85fb0c4SGreg Roach } 4533bdc890bSGreg Roach } else { 4543bdc890bSGreg Roach $translations = include $cache_file; 455a25f0a04SGreg Roach } 456a25f0a04SGreg Roach 4573bdc890bSGreg Roach // Create a translator 4583bdc890bSGreg Roach self::$translator = new Translator($translations, self::$locale->pluralRule()); 459a25f0a04SGreg Roach 460bbb76c12SGreg Roach /* I18N: This punctuation is used to separate lists of items */ 461bbb76c12SGreg Roach self::$list_separator = self::translate(', '); 462a25f0a04SGreg Roach 463991b93ddSGreg Roach // Create a collator 464991b93ddSGreg Roach try { 465444a65ecSGreg Roach if (class_exists('Collator')) { 466c9ec599fSGreg Roach // Symfony provides a very incomplete polyfill - which cannot be used. 467991b93ddSGreg Roach self::$collator = new Collator(self::$locale->code()); 468991b93ddSGreg Roach // Ignore upper/lower case differences 469991b93ddSGreg Roach self::$collator->setStrength(Collator::SECONDARY); 470444a65ecSGreg Roach } 471991b93ddSGreg Roach } catch (Exception $ex) { 472991b93ddSGreg Roach // PHP-INTL is not installed? We'll use a fallback later. 473c9ec599fSGreg Roach self::$collator = null; 474991b93ddSGreg Roach } 475991b93ddSGreg Roach 4765331c5eaSGreg Roach return self::$locale->languageTag(); 477a25f0a04SGreg Roach } 478a25f0a04SGreg Roach 479a25f0a04SGreg Roach /** 480c999a340SGreg Roach * All locales for which a translation file exists. 481c999a340SGreg Roach * 48215834aaeSGreg Roach * @return LocaleInterface[] 483c999a340SGreg Roach */ 4848f53f488SRico Sonntag public static function installedLocales(): array 485c1010edaSGreg Roach { 48613abd6f3SGreg Roach $locales = []; 487c999a340SGreg Roach foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 488c999a340SGreg Roach try { 489c999a340SGreg Roach $locales[] = Locale::create(basename($file, '.mo')); 49091495569SGreg Roach } catch (Exception $ex) { 4913bdc890bSGreg Roach // Not a recognised locale 492a25f0a04SGreg Roach } 493a25f0a04SGreg Roach } 494c999a340SGreg Roach usort($locales, '\Fisharebest\Localization\Locale::compare'); 495c999a340SGreg Roach 496c999a340SGreg Roach return $locales; 497a25f0a04SGreg Roach } 498a25f0a04SGreg Roach 499a25f0a04SGreg Roach /** 500a25f0a04SGreg Roach * Return the endonym for a given language - as per http://cldr.unicode.org/ 501a25f0a04SGreg Roach * 502a25f0a04SGreg Roach * @param string $locale 503a25f0a04SGreg Roach * 504a25f0a04SGreg Roach * @return string 505a25f0a04SGreg Roach */ 50655664801SGreg Roach public static function languageName(string $locale): string 507c1010edaSGreg Roach { 508c999a340SGreg Roach return Locale::create($locale)->endonym(); 509a25f0a04SGreg Roach } 510a25f0a04SGreg Roach 511a25f0a04SGreg Roach /** 512a25f0a04SGreg Roach * Return the script used by a given language 513a25f0a04SGreg Roach * 514a25f0a04SGreg Roach * @param string $locale 515a25f0a04SGreg Roach * 516a25f0a04SGreg Roach * @return string 517a25f0a04SGreg Roach */ 51855664801SGreg Roach public static function languageScript(string $locale): string 519c1010edaSGreg Roach { 520c999a340SGreg Roach return Locale::create($locale)->script()->code(); 521a25f0a04SGreg Roach } 522a25f0a04SGreg Roach 523a25f0a04SGreg Roach /** 524dfeee0a8SGreg Roach * Translate a number into the local representation. 525a25f0a04SGreg Roach * 526dfeee0a8SGreg Roach * e.g. 12345.67 becomes 527dfeee0a8SGreg Roach * en: 12,345.67 528dfeee0a8SGreg Roach * fr: 12 345,67 529dfeee0a8SGreg Roach * de: 12.345,67 530dfeee0a8SGreg Roach * 531dfeee0a8SGreg Roach * @param float $n 532cbc1590aSGreg Roach * @param int $precision 533a25f0a04SGreg Roach * 534a25f0a04SGreg Roach * @return string 535a25f0a04SGreg Roach */ 53655664801SGreg Roach public static function number(float $n, int $precision = 0): string 537c1010edaSGreg Roach { 538dfeee0a8SGreg Roach return self::$locale->number(round($n, $precision)); 539dfeee0a8SGreg Roach } 540dfeee0a8SGreg Roach 541dfeee0a8SGreg Roach /** 542dfeee0a8SGreg Roach * Translate a fraction into a percentage. 543dfeee0a8SGreg Roach * 544dfeee0a8SGreg Roach * e.g. 0.123 becomes 545dfeee0a8SGreg Roach * en: 12.3% 546dfeee0a8SGreg Roach * fr: 12,3 % 547dfeee0a8SGreg Roach * de: 12,3% 548dfeee0a8SGreg Roach * 549dfeee0a8SGreg Roach * @param float $n 550cbc1590aSGreg Roach * @param int $precision 551dfeee0a8SGreg Roach * 552dfeee0a8SGreg Roach * @return string 553dfeee0a8SGreg Roach */ 55455664801SGreg Roach public static function percentage(float $n, int $precision = 0): string 555c1010edaSGreg Roach { 556dfeee0a8SGreg Roach return self::$locale->percent(round($n, $precision + 2)); 557dfeee0a8SGreg Roach } 558dfeee0a8SGreg Roach 559dfeee0a8SGreg Roach /** 560dfeee0a8SGreg Roach * Translate a plural string 561dfeee0a8SGreg Roach * echo self::plural('There is an error', 'There are errors', $num_errors); 562dfeee0a8SGreg Roach * echo self::plural('There is one error', 'There are %s errors', $num_errors); 563dfeee0a8SGreg Roach * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 564dfeee0a8SGreg Roach * 565924d091bSGreg Roach * @param string $singular 566924d091bSGreg Roach * @param string $plural 567924d091bSGreg Roach * @param int $count 568a515be7cSGreg Roach * @param string ...$args 569e93111adSRico Sonntag * 570dfeee0a8SGreg Roach * @return string 571dfeee0a8SGreg Roach */ 572924d091bSGreg Roach public static function plural(string $singular, string $plural, int $count, ...$args): string 573c1010edaSGreg Roach { 574924d091bSGreg Roach $message = self::$translator->translatePlural($singular, $plural, $count); 575dfeee0a8SGreg Roach 576924d091bSGreg Roach return sprintf($message, ...$args); 577dfeee0a8SGreg Roach } 578dfeee0a8SGreg Roach 579dfeee0a8SGreg Roach /** 580dfeee0a8SGreg Roach * UTF8 version of PHP::strrev() 581dfeee0a8SGreg Roach * 582dfeee0a8SGreg Roach * Reverse RTL text for third-party libraries such as GD2 and googlechart. 583dfeee0a8SGreg Roach * 584dfeee0a8SGreg Roach * These do not support UTF8 text direction, so we must mimic it for them. 585dfeee0a8SGreg Roach * 586dfeee0a8SGreg Roach * Numbers are always rendered LTR, even in RTL text. 587dfeee0a8SGreg Roach * The visual direction of characters such as parentheses should be reversed. 588dfeee0a8SGreg Roach * 589dfeee0a8SGreg Roach * @param string $text Text to be reversed 590dfeee0a8SGreg Roach * 591dfeee0a8SGreg Roach * @return string 592dfeee0a8SGreg Roach */ 5938f53f488SRico Sonntag public static function reverseText($text): string 594c1010edaSGreg Roach { 595dfeee0a8SGreg Roach // Remove HTML markup - we can't display it and it is LTR. 5969524b7b5SGreg Roach $text = strip_tags($text); 5979524b7b5SGreg Roach // Remove HTML entities. 5989524b7b5SGreg Roach $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); 599dfeee0a8SGreg Roach 600dfeee0a8SGreg Roach // LTR text doesn't need reversing 601dfeee0a8SGreg Roach if (self::scriptDirection(self::textScript($text)) === 'ltr') { 602dfeee0a8SGreg Roach return $text; 603dfeee0a8SGreg Roach } 604dfeee0a8SGreg Roach 605dfeee0a8SGreg Roach // Mirrored characters 606991b93ddSGreg Roach $text = strtr($text, self::MIRROR_CHARACTERS); 607dfeee0a8SGreg Roach 608dfeee0a8SGreg Roach $reversed = ''; 609dfeee0a8SGreg Roach $digits = ''; 610dfeee0a8SGreg Roach while ($text != '') { 611dfeee0a8SGreg Roach $letter = mb_substr($text, 0, 1); 612dfeee0a8SGreg Roach $text = mb_substr($text, 1); 613dfeee0a8SGreg Roach if (strpos(self::DIGITS, $letter) !== false) { 614dfeee0a8SGreg Roach $digits .= $letter; 615a25f0a04SGreg Roach } else { 616dfeee0a8SGreg Roach $reversed = $letter . $digits . $reversed; 617dfeee0a8SGreg Roach $digits = ''; 618dfeee0a8SGreg Roach } 619a25f0a04SGreg Roach } 620a25f0a04SGreg Roach 621dfeee0a8SGreg Roach return $digits . $reversed; 622a25f0a04SGreg Roach } 623a25f0a04SGreg Roach 624a25f0a04SGreg Roach /** 625a25f0a04SGreg Roach * Return the direction (ltr or rtl) for a given script 626a25f0a04SGreg Roach * 627a25f0a04SGreg Roach * The PHP/intl library does not provde this information, so we need 628a25f0a04SGreg Roach * our own lookup table. 629a25f0a04SGreg Roach * 630a25f0a04SGreg Roach * @param string $script 631a25f0a04SGreg Roach * 632a25f0a04SGreg Roach * @return string 633a25f0a04SGreg Roach */ 634c1010edaSGreg Roach public static function scriptDirection($script) 635c1010edaSGreg Roach { 636a25f0a04SGreg Roach switch ($script) { 637a25f0a04SGreg Roach case 'Arab': 638a25f0a04SGreg Roach case 'Hebr': 639a25f0a04SGreg Roach case 'Mong': 640a25f0a04SGreg Roach case 'Thaa': 641a25f0a04SGreg Roach return 'rtl'; 642a25f0a04SGreg Roach default: 643a25f0a04SGreg Roach return 'ltr'; 644a25f0a04SGreg Roach } 645a25f0a04SGreg Roach } 646a25f0a04SGreg Roach 647a25f0a04SGreg Roach /** 648991b93ddSGreg Roach * Perform a case-insensitive comparison of two strings. 649a25f0a04SGreg Roach * 650a25f0a04SGreg Roach * @param string $string1 651a25f0a04SGreg Roach * @param string $string2 652a25f0a04SGreg Roach * 653cbc1590aSGreg Roach * @return int 654a25f0a04SGreg Roach */ 655c1010edaSGreg Roach public static function strcasecmp($string1, $string2) 656c1010edaSGreg Roach { 657991b93ddSGreg Roach if (self::$collator instanceof Collator) { 658991b93ddSGreg Roach return self::$collator->compare($string1, $string2); 659c9ec599fSGreg Roach } else { 660b2ce94c6SRico Sonntag return strcmp(self::strtolower($string1), self::strtolower($string2)); 661a25f0a04SGreg Roach } 662c9ec599fSGreg Roach } 663a25f0a04SGreg Roach 664a25f0a04SGreg Roach /** 665991b93ddSGreg Roach * Convert a string to lower case. 666a25f0a04SGreg Roach * 667dfeee0a8SGreg Roach * @param string $string 668a25f0a04SGreg Roach * 669a25f0a04SGreg Roach * @return string 670a25f0a04SGreg Roach */ 6718f53f488SRico Sonntag public static function strtolower($string): string 672c1010edaSGreg Roach { 673991b93ddSGreg Roach if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 674991b93ddSGreg Roach $string = strtr($string, self::DOTLESS_I_TOLOWER); 675a25f0a04SGreg Roach } 6765ddad20bSGreg Roach 6775ddad20bSGreg Roach return mb_strtolower($string); 678a25f0a04SGreg Roach } 679a25f0a04SGreg Roach 680a25f0a04SGreg Roach /** 681991b93ddSGreg Roach * Convert a string to upper case. 682dfeee0a8SGreg Roach * 683dfeee0a8SGreg Roach * @param string $string 684a25f0a04SGreg Roach * 685a25f0a04SGreg Roach * @return string 686a25f0a04SGreg Roach */ 6878f53f488SRico Sonntag public static function strtoupper($string): string 688c1010edaSGreg Roach { 689991b93ddSGreg Roach if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 690991b93ddSGreg Roach $string = strtr($string, self::DOTLESS_I_TOUPPER); 691a25f0a04SGreg Roach } 6925ddad20bSGreg Roach 6935ddad20bSGreg Roach return mb_strtoupper($string); 694a25f0a04SGreg Roach } 695a25f0a04SGreg Roach 696dfeee0a8SGreg Roach /** 697dfeee0a8SGreg Roach * Identify the script used for a piece of text 698dfeee0a8SGreg Roach * 699d0bfc631SGreg Roach * @param string $string 700dfeee0a8SGreg Roach * 701dfeee0a8SGreg Roach * @return string 702dfeee0a8SGreg Roach */ 7038f53f488SRico Sonntag public static function textScript($string): string 704c1010edaSGreg Roach { 705dfeee0a8SGreg Roach $string = strip_tags($string); // otherwise HTML tags show up as latin 706dfeee0a8SGreg Roach $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 707c1010edaSGreg Roach $string = str_replace([ 708c1010edaSGreg Roach '@N.N.', 709c1010edaSGreg Roach '@P.N.', 710c1010edaSGreg Roach ], '', $string); // otherwise unknown names show up as latin 711dfeee0a8SGreg Roach $pos = 0; 712dfeee0a8SGreg Roach $strlen = strlen($string); 713dfeee0a8SGreg Roach while ($pos < $strlen) { 714dfeee0a8SGreg Roach // get the Unicode Code Point for the character at position $pos 715dfeee0a8SGreg Roach $byte1 = ord($string[$pos]); 716dfeee0a8SGreg Roach if ($byte1 < 0x80) { 717dfeee0a8SGreg Roach $code_point = $byte1; 718dfeee0a8SGreg Roach $chrlen = 1; 719dfeee0a8SGreg Roach } elseif ($byte1 < 0xC0) { 720dfeee0a8SGreg Roach // Invalid continuation character 721dfeee0a8SGreg Roach return 'Latn'; 722dfeee0a8SGreg Roach } elseif ($byte1 < 0xE0) { 723dfeee0a8SGreg Roach $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 724dfeee0a8SGreg Roach $chrlen = 2; 725dfeee0a8SGreg Roach } elseif ($byte1 < 0xF0) { 726dfeee0a8SGreg Roach $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 727dfeee0a8SGreg Roach $chrlen = 3; 728dfeee0a8SGreg Roach } elseif ($byte1 < 0xF8) { 729dfeee0a8SGreg Roach $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 730dfeee0a8SGreg Roach $chrlen = 3; 731dfeee0a8SGreg Roach } else { 732dfeee0a8SGreg Roach // Invalid UTF 733dfeee0a8SGreg Roach return 'Latn'; 734dfeee0a8SGreg Roach } 735dfeee0a8SGreg Roach 736991b93ddSGreg Roach foreach (self::SCRIPT_CHARACTER_RANGES as $range) { 737dfeee0a8SGreg Roach if ($code_point >= $range[1] && $code_point <= $range[2]) { 738dfeee0a8SGreg Roach return $range[0]; 739dfeee0a8SGreg Roach } 740dfeee0a8SGreg Roach } 741dfeee0a8SGreg Roach // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 742dfeee0a8SGreg Roach $pos += $chrlen; 743dfeee0a8SGreg Roach } 744dfeee0a8SGreg Roach 745dfeee0a8SGreg Roach return 'Latn'; 746dfeee0a8SGreg Roach } 747dfeee0a8SGreg Roach 748dfeee0a8SGreg Roach /** 749dfeee0a8SGreg Roach * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 750dfeee0a8SGreg Roach * 751cbc1590aSGreg Roach * @param int $seconds 752dfeee0a8SGreg Roach * 753dfeee0a8SGreg Roach * @return string 754dfeee0a8SGreg Roach */ 755c1010edaSGreg Roach public static function timeAgo($seconds) 756c1010edaSGreg Roach { 757dfeee0a8SGreg Roach $minute = 60; 758dfeee0a8SGreg Roach $hour = 60 * $minute; 759dfeee0a8SGreg Roach $day = 24 * $hour; 760dfeee0a8SGreg Roach $month = 30 * $day; 761dfeee0a8SGreg Roach $year = 365 * $day; 762dfeee0a8SGreg Roach 763dfeee0a8SGreg Roach if ($seconds > $year) { 764cdaafeeeSGreg Roach $years = intdiv($seconds, $year); 765cbc1590aSGreg Roach 766dfeee0a8SGreg Roach return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 767b2ce94c6SRico Sonntag } 768b2ce94c6SRico Sonntag 769b2ce94c6SRico Sonntag if ($seconds > $month) { 770cdaafeeeSGreg Roach $months = intdiv($seconds, $month); 771cbc1590aSGreg Roach 772dfeee0a8SGreg Roach return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 773b2ce94c6SRico Sonntag } 774b2ce94c6SRico Sonntag 775b2ce94c6SRico Sonntag if ($seconds > $day) { 776cdaafeeeSGreg Roach $days = intdiv($seconds, $day); 777cbc1590aSGreg Roach 778dfeee0a8SGreg Roach return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 779b2ce94c6SRico Sonntag } 780b2ce94c6SRico Sonntag 781b2ce94c6SRico Sonntag if ($seconds > $hour) { 782cdaafeeeSGreg Roach $hours = intdiv($seconds, $hour); 783cbc1590aSGreg Roach 784dfeee0a8SGreg Roach return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 785b2ce94c6SRico Sonntag } 786b2ce94c6SRico Sonntag 787b2ce94c6SRico Sonntag if ($seconds > $minute) { 788cdaafeeeSGreg Roach $minutes = intdiv($seconds, $minute); 789cbc1590aSGreg Roach 790dfeee0a8SGreg Roach return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 791dfeee0a8SGreg Roach } 792b2ce94c6SRico Sonntag 793b2ce94c6SRico Sonntag return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 794dfeee0a8SGreg Roach } 795dfeee0a8SGreg Roach 796dfeee0a8SGreg Roach /** 797dfeee0a8SGreg Roach * What format is used to display dates in the current locale? 798dfeee0a8SGreg Roach * 799dfeee0a8SGreg Roach * @return string 800dfeee0a8SGreg Roach */ 8018f53f488SRico Sonntag public static function timeFormat(): string 802c1010edaSGreg Roach { 803bbb76c12SGreg Roach /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ 804bbb76c12SGreg Roach return self::$translator->translate('%H:%i:%s'); 805dfeee0a8SGreg Roach } 806dfeee0a8SGreg Roach 807dfeee0a8SGreg Roach /** 808dfeee0a8SGreg Roach * Translate a string, and then substitute placeholders 809dfeee0a8SGreg Roach * 810dfeee0a8SGreg Roach * echo I18N::translate('Hello World!'); 811dfeee0a8SGreg Roach * echo I18N::translate('The %s sat on the mat', 'cat'); 812dfeee0a8SGreg Roach * 813924d091bSGreg Roach * @param string $message 814a515be7cSGreg Roach * @param string ...$args 815c3283ed7SGreg Roach * 816dfeee0a8SGreg Roach * @return string 817dfeee0a8SGreg Roach */ 818924d091bSGreg Roach public static function translate(string $message, ...$args): string 819c1010edaSGreg Roach { 820924d091bSGreg Roach $message = self::$translator->translate($message); 821dfeee0a8SGreg Roach 822924d091bSGreg Roach return sprintf($message, ...$args); 823dfeee0a8SGreg Roach } 824dfeee0a8SGreg Roach 825dfeee0a8SGreg Roach /** 826dfeee0a8SGreg Roach * Context sensitive version of translate. 827a4956c0eSGreg Roach * echo I18N::translateContext('NOMINATIVE', 'January'); 828a4956c0eSGreg Roach * echo I18N::translateContext('GENITIVE', 'January'); 829dfeee0a8SGreg Roach * 830924d091bSGreg Roach * @param string $context 831924d091bSGreg Roach * @param string $message 832a515be7cSGreg Roach * @param string ...$args 833c3283ed7SGreg Roach * 834dfeee0a8SGreg Roach * @return string 835dfeee0a8SGreg Roach */ 836924d091bSGreg Roach public static function translateContext(string $context, string $message, ...$args): string 837c1010edaSGreg Roach { 838924d091bSGreg Roach $message = self::$translator->translateContext($context, $message); 839dfeee0a8SGreg Roach 840924d091bSGreg Roach return sprintf($message, ...$args); 841a25f0a04SGreg Roach } 842a25f0a04SGreg Roach} 843