1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2018 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees; 19 20use Collator; 21use Exception; 22use Fisharebest\Localization\Locale; 23use Fisharebest\Localization\Locale\LocaleEnUs; 24use Fisharebest\Localization\Locale\LocaleInterface; 25use Fisharebest\Localization\Translation; 26use Fisharebest\Localization\Translator; 27use Fisharebest\Webtrees\Functions\FunctionsEdit; 28 29/** 30 * Internationalization (i18n) and localization (l10n). 31 */ 32class I18N 33{ 34 /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 35 private static $locale; 36 37 /** @var Translator An object that performs translation */ 38 private static $translator; 39 40 /** @var Collator|null From the php-intl library */ 41 private static $collator; 42 43 // Digits are always rendered LTR, even in RTL text. 44 const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 45 46 // These locales need special handling for the dotless letter I. 47 const DOTLESS_I_LOCALES = [ 48 'az', 49 'tr', 50 ]; 51 const DOTLESS_I_TOLOWER = [ 52 'I' => 'ı', 53 'İ' => 'i', 54 ]; 55 const DOTLESS_I_TOUPPER = [ 56 'ı' => 'I', 57 'i' => 'İ', 58 ]; 59 60 // The ranges of characters used by each script. 61 const SCRIPT_CHARACTER_RANGES = [ 62 [ 63 'Latn', 64 0x0041, 65 0x005A, 66 ], 67 [ 68 'Latn', 69 0x0061, 70 0x007A, 71 ], 72 [ 73 'Latn', 74 0x0100, 75 0x02AF, 76 ], 77 [ 78 'Grek', 79 0x0370, 80 0x03FF, 81 ], 82 [ 83 'Cyrl', 84 0x0400, 85 0x052F, 86 ], 87 [ 88 'Hebr', 89 0x0590, 90 0x05FF, 91 ], 92 [ 93 'Arab', 94 0x0600, 95 0x06FF, 96 ], 97 [ 98 'Arab', 99 0x0750, 100 0x077F, 101 ], 102 [ 103 'Arab', 104 0x08A0, 105 0x08FF, 106 ], 107 [ 108 'Deva', 109 0x0900, 110 0x097F, 111 ], 112 [ 113 'Taml', 114 0x0B80, 115 0x0BFF, 116 ], 117 [ 118 'Sinh', 119 0x0D80, 120 0x0DFF, 121 ], 122 [ 123 'Thai', 124 0x0E00, 125 0x0E7F, 126 ], 127 [ 128 'Geor', 129 0x10A0, 130 0x10FF, 131 ], 132 [ 133 'Grek', 134 0x1F00, 135 0x1FFF, 136 ], 137 [ 138 'Deva', 139 0xA8E0, 140 0xA8FF, 141 ], 142 [ 143 'Hans', 144 0x3000, 145 0x303F, 146 ], 147 // Mixed CJK, not just Hans 148 [ 149 'Hans', 150 0x3400, 151 0xFAFF, 152 ], 153 // Mixed CJK, not just Hans 154 [ 155 'Hans', 156 0x20000, 157 0x2FA1F, 158 ], 159 // Mixed CJK, not just Hans 160 ]; 161 162 // Characters that are displayed in mirror form in RTL text. 163 const MIRROR_CHARACTERS = [ 164 '(' => ')', 165 ')' => '(', 166 '[' => ']', 167 ']' => '[', 168 '{' => '}', 169 '}' => '{', 170 '<' => '>', 171 '>' => '<', 172 '‹ ' => '›', 173 '› ' => '‹', 174 '«' => '»', 175 '»' => '«', 176 '﴾ ' => '﴿', 177 '﴿ ' => '﴾', 178 '“ ' => '”', 179 '” ' => '“', 180 '‘ ' => '’', 181 '’ ' => '‘', 182 ]; 183 184 // Default list of locales to show in the menu. 185 const DEFAULT_LOCALES = [ 186 'ar', 187 'bg', 188 'bs', 189 'ca', 190 'cs', 191 'da', 192 'de', 193 'el', 194 'en-GB', 195 'en-US', 196 'es', 197 'et', 198 'fi', 199 'fr', 200 'he', 201 'hr', 202 'hu', 203 'is', 204 'it', 205 'ka', 206 'kk', 207 'lt', 208 'mr', 209 'nb', 210 'nl', 211 'nn', 212 'pl', 213 'pt', 214 'ru', 215 'sk', 216 'sv', 217 'tr', 218 'uk', 219 'vi', 220 'zh-Hans', 221 ]; 222 223 /** @var string Punctuation used to separate list items, typically a comma */ 224 public static $list_separator; 225 226 /** 227 * The prefered locales for this site, or a default list if no preference. 228 * 229 * @return LocaleInterface[] 230 */ 231 public static function activeLocales(): array 232 { 233 $code_list = Site::getPreference('LANGUAGES'); 234 235 if ($code_list === '') { 236 $codes = self::DEFAULT_LOCALES; 237 } else { 238 $codes = explode(',', $code_list); 239 } 240 241 $locales = []; 242 foreach ($codes as $code) { 243 if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) { 244 try { 245 $locales[] = Locale::create($code); 246 } catch (\Exception $ex) { 247 DebugBar::addThrowable($ex); 248 249 // No such locale exists? 250 } 251 } 252 } 253 usort($locales, '\Fisharebest\Localization\Locale::compare'); 254 255 return $locales; 256 } 257 258 /** 259 * Which MySQL collation should be used for this locale? 260 * 261 * @return string 262 */ 263 public static function collation() 264 { 265 $collation = self::$locale->collation(); 266 switch ($collation) { 267 case 'croatian_ci': 268 case 'german2_ci': 269 case 'vietnamese_ci': 270 // Only available in MySQL 5.6 271 return 'utf8_unicode_ci'; 272 default: 273 return 'utf8_' . $collation; 274 } 275 } 276 277 /** 278 * What format is used to display dates in the current locale? 279 * 280 * @return string 281 */ 282 public static function dateFormat(): string 283 { 284 /* I18N: This is the format string for full dates. See http://php.net/date for codes */ 285 return self::$translator->translate('%j %F %Y'); 286 } 287 288 /** 289 * Generate consistent I18N for datatables.js 290 * 291 * @param int[] $lengths An optional array of page lengths 292 * 293 * @return string 294 */ 295 public static function datatablesI18N(array $lengths = [ 296 10, 297 20, 298 30, 299 50, 300 100, 301 -1, 302 ]): string 303 { 304 $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), '10'); 305 306 return 307 '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' . 308 '"language": {' . 309 ' "paginate": {' . 310 ' "first": "' . self::translate('first') . '",' . 311 ' "last": "' . self::translate('last') . '",' . 312 ' "next": "' . self::translate('next') . '",' . 313 ' "previous": "' . self::translate('previous') . '"' . 314 ' },' . 315 ' "emptyTable": "' . self::translate('No records to display') . '",' . 316 ' "info": "' . /* I18N: %s are placeholders for numbers */ 317 self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' . 318 ' "infoEmpty": "' . self::translate('Showing %1$s to %2$s of %3$s', self::$locale->digits('0'), self::$locale->digits('0'), self::$locale->digits('0')) . '",' . 319 ' "infoFiltered": "' . /* I18N: %s is a placeholder for a number */ 320 self::translate('(filtered from %s total entries)', '_MAX_') . '",' . 321 ' "lengthMenu": "' . /* I18N: %s is a number of records per page */ 322 self::translate('Display %s', addslashes($length_options)) . '",' . 323 ' "loadingRecords": "' . self::translate('Loading…') . '",' . 324 ' "processing": "' . self::translate('Loading…') . '",' . 325 ' "search": "' . self::translate('Filter') . '",' . 326 ' "zeroRecords": "' . self::translate('No records to display') . '"' . 327 '}'; 328 } 329 330 /** 331 * Convert the digits 0-9 into the local script 332 * 333 * Used for years, etc., where we do not want thousands-separators, decimals, etc. 334 * 335 * @param string|int $n 336 * 337 * @return string 338 */ 339 public static function digits($n): string 340 { 341 return self::$locale->digits((string) $n); 342 } 343 344 /** 345 * What is the direction of the current locale 346 * 347 * @return string "ltr" or "rtl" 348 */ 349 public static function direction(): string 350 { 351 return self::$locale->direction(); 352 } 353 354 /** 355 * What is the first day of the week. 356 * 357 * @return int Sunday=0, Monday=1, etc. 358 */ 359 public static function firstDay(): int 360 { 361 return self::$locale->territory()->firstDay(); 362 } 363 364 /** 365 * Convert a GEDCOM age string into translated_text 366 * 367 * NB: The import function will have normalised this, so we don't need to worry about badly formatted strings 368 * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event() 369 * 370 * @param string $string 371 * 372 * @return string 373 */ 374 public static function gedcomAge(string $string): string 375 { 376 switch ($string) { 377 case 'STILLBORN': 378 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 379 return self::translate('(stillborn)'); 380 case 'INFANT': 381 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 382 return self::translate('(in infancy)'); 383 case 'CHILD': 384 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 385 return self::translate('(in childhood)'); 386 } 387 388 $age = []; 389 390 if (preg_match('/(\d+)y/', $string, $match)) { 391 $years = (int) $match[1]; 392 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 393 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 394 } else { 395 $years = -1; 396 } 397 398 if (preg_match('/(\d+)m/', $string, $match)) { 399 $months = (int) $match[1]; 400 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 401 $age[] = self::plural('%s month', '%s months', $months, self::number($months)); 402 } 403 404 if (preg_match('/(\d+)w/', $string, $match)) { 405 $weeks = (int) $match[1]; 406 // I18N: Part of an age string. e.g. 7 weeks and 3 days 407 $age[] = self::plural('%s week', '%s weeks', $weeks, self::number($weeks)); 408 } 409 410 if (preg_match('/(\d+)d/', $string, $match)) { 411 $days = (int) $match[1]; 412 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 413 $age[] = self::plural('%s day', '%s days', $days, self::number($days)); 414 } 415 416 // If an age is just a number of years, only show the number 417 if (count($age) === 1 && $years >= 0) { 418 $age = [self::number($years)]; 419 } 420 421 $age_string = implode(self::$list_separator, $age); 422 423 // No valid d/m/y values? Show the original string. 424 if ($age_string === '') { 425 $age_string = $string; 426 } 427 428 if (!substr_compare($string, '<', 0, 1)) { 429 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years) 430 return self::translate('(aged less than %s)', $age_string); 431 } 432 433 if (!substr_compare($string, '>', 0, 1)) { 434 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years) 435 return self::translate('(aged more than %s)', $age_string); 436 } 437 438 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 439 return self::translate('(aged %s)', $age_string); 440 } 441 442 /** 443 * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 444 * 445 * @return string 446 */ 447 public static function htmlAttributes(): string 448 { 449 return self::$locale->htmlAttributes(); 450 } 451 452 /** 453 * Initialise the translation adapter with a locale setting. 454 * 455 * @param string $code Use this locale/language code, or choose one automatically 456 * @param Tree|null $tree 457 * 458 * @return string $string 459 */ 460 public static function init(string $code = '', Tree $tree = null): string 461 { 462 mb_internal_encoding('UTF-8'); 463 464 if ($code !== '') { 465 // Create the specified locale 466 self::$locale = Locale::create($code); 467 } elseif (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.mo')) { 468 // Select a previously used locale 469 self::$locale = Locale::create(Session::get('locale')); 470 } else { 471 if ($tree instanceof Tree) { 472 $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US')); 473 } else { 474 $default_locale = new LocaleEnUs(); 475 } 476 477 // Negotiate with the browser. 478 // Search engines don't negotiate. They get the default locale of the tree. 479 self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale); 480 } 481 482 $cache_dir = WT_DATA_DIR . 'cache/'; 483 $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php'; 484 if (file_exists($cache_file)) { 485 $filemtime = filemtime($cache_file); 486 } else { 487 $filemtime = 0; 488 } 489 490 // Load the translation file(s) 491 // Note that glob() returns false instead of an empty array when open_basedir_restriction 492 // is in force and no files are found. See PHP bug #47358. 493 if (defined('GLOB_BRACE')) { 494 $translation_files = array_merge( 495 [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 496 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [], 497 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [] 498 ); 499 } else { 500 // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php 501 $translation_files = array_merge( 502 [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 503 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.csv') ?: [], 504 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.php') ?: [], 505 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.mo') ?: [], 506 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: [], 507 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: [], 508 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: [] 509 ); 510 } 511 // Rebuild files after one hour 512 $rebuild_cache = time() > $filemtime + 3600; 513 // Rebuild files if any translation file has been updated 514 foreach ($translation_files as $translation_file) { 515 if (filemtime($translation_file) > $filemtime) { 516 $rebuild_cache = true; 517 break; 518 } 519 } 520 521 if ($rebuild_cache) { 522 $translations = []; 523 foreach ($translation_files as $translation_file) { 524 $translation = new Translation($translation_file); 525 $translations = array_merge($translations, $translation->asArray()); 526 } 527 try { 528 File::mkdir($cache_dir); 529 file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';'); 530 } catch (Exception $ex) { 531 DebugBar::addThrowable($ex); 532 533 // During setup, we may not have been able to create it. 534 } 535 } else { 536 $translations = include $cache_file; 537 } 538 539 // Create a translator 540 self::$translator = new Translator($translations, self::$locale->pluralRule()); 541 542 /* I18N: This punctuation is used to separate lists of items */ 543 self::$list_separator = self::translate(', '); 544 545 // Create a collator 546 try { 547 if (class_exists('Collator')) { 548 // Symfony provides a very incomplete polyfill - which cannot be used. 549 self::$collator = new Collator(self::$locale->code()); 550 // Ignore upper/lower case differences 551 self::$collator->setStrength(Collator::SECONDARY); 552 } 553 } catch (Exception $ex) { 554 // PHP-INTL is not installed? We'll use a fallback later. 555 self::$collator = null; 556 } 557 558 return self::$locale->languageTag(); 559 } 560 561 /** 562 * All locales for which a translation file exists. 563 * 564 * @return LocaleInterface[] 565 */ 566 public static function installedLocales(): array 567 { 568 $locales = []; 569 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 570 try { 571 $locales[] = Locale::create(basename($file, '.mo')); 572 } catch (\Exception $ex) { 573 DebugBar::addThrowable($ex); 574 575 // Not a recognised locale 576 } 577 } 578 usort($locales, '\Fisharebest\Localization\Locale::compare'); 579 580 return $locales; 581 } 582 583 /** 584 * Return the endonym for a given language - as per http://cldr.unicode.org/ 585 * 586 * @param string $locale 587 * 588 * @return string 589 */ 590 public static function languageName(string $locale): string 591 { 592 return Locale::create($locale)->endonym(); 593 } 594 595 /** 596 * Return the script used by a given language 597 * 598 * @param string $locale 599 * 600 * @return string 601 */ 602 public static function languageScript(string $locale): string 603 { 604 return Locale::create($locale)->script()->code(); 605 } 606 607 /** 608 * Translate a number into the local representation. 609 * 610 * e.g. 12345.67 becomes 611 * en: 12,345.67 612 * fr: 12 345,67 613 * de: 12.345,67 614 * 615 * @param float $n 616 * @param int $precision 617 * 618 * @return string 619 */ 620 public static function number(float $n, int $precision = 0): string 621 { 622 return self::$locale->number(round($n, $precision)); 623 } 624 625 /** 626 * Translate a fraction into a percentage. 627 * 628 * e.g. 0.123 becomes 629 * en: 12.3% 630 * fr: 12,3 % 631 * de: 12,3% 632 * 633 * @param float $n 634 * @param int $precision 635 * 636 * @return string 637 */ 638 public static function percentage(float $n, int $precision = 0): string 639 { 640 return self::$locale->percent(round($n, $precision + 2)); 641 } 642 643 /** 644 * Translate a plural string 645 * echo self::plural('There is an error', 'There are errors', $num_errors); 646 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 647 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 648 * 649 * @param string $singular 650 * @param string $plural 651 * @param int $count 652 * @param string ...$args 653 * 654 * @return string 655 */ 656 public static function plural(string $singular, string $plural, int $count, ...$args): string 657 { 658 $message = self::$translator->translatePlural($singular, $plural, $count); 659 660 return sprintf($message, ...$args); 661 } 662 663 /** 664 * UTF8 version of PHP::strrev() 665 * 666 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 667 * 668 * These do not support UTF8 text direction, so we must mimic it for them. 669 * 670 * Numbers are always rendered LTR, even in RTL text. 671 * The visual direction of characters such as parentheses should be reversed. 672 * 673 * @param string $text Text to be reversed 674 * 675 * @return string 676 */ 677 public static function reverseText($text): string 678 { 679 // Remove HTML markup - we can't display it and it is LTR. 680 $text = strip_tags($text); 681 // Remove HTML entities. 682 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); 683 684 // LTR text doesn't need reversing 685 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 686 return $text; 687 } 688 689 // Mirrored characters 690 $text = strtr($text, self::MIRROR_CHARACTERS); 691 692 $reversed = ''; 693 $digits = ''; 694 while ($text != '') { 695 $letter = mb_substr($text, 0, 1); 696 $text = mb_substr($text, 1); 697 if (strpos(self::DIGITS, $letter) !== false) { 698 $digits .= $letter; 699 } else { 700 $reversed = $letter . $digits . $reversed; 701 $digits = ''; 702 } 703 } 704 705 return $digits . $reversed; 706 } 707 708 /** 709 * Return the direction (ltr or rtl) for a given script 710 * 711 * The PHP/intl library does not provde this information, so we need 712 * our own lookup table. 713 * 714 * @param string $script 715 * 716 * @return string 717 */ 718 public static function scriptDirection($script) 719 { 720 switch ($script) { 721 case 'Arab': 722 case 'Hebr': 723 case 'Mong': 724 case 'Thaa': 725 return 'rtl'; 726 default: 727 return 'ltr'; 728 } 729 } 730 731 /** 732 * Perform a case-insensitive comparison of two strings. 733 * 734 * @param string $string1 735 * @param string $string2 736 * 737 * @return int 738 */ 739 public static function strcasecmp($string1, $string2) 740 { 741 if (self::$collator instanceof Collator) { 742 return self::$collator->compare($string1, $string2); 743 } else { 744 return strcmp(self::strtolower($string1), self::strtolower($string2)); 745 } 746 } 747 748 /** 749 * Convert a string to lower case. 750 * 751 * @param string $string 752 * 753 * @return string 754 */ 755 public static function strtolower($string): string 756 { 757 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 758 $string = strtr($string, self::DOTLESS_I_TOLOWER); 759 } 760 761 return mb_strtolower($string); 762 } 763 764 /** 765 * Convert a string to upper case. 766 * 767 * @param string $string 768 * 769 * @return string 770 */ 771 public static function strtoupper($string): string 772 { 773 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 774 $string = strtr($string, self::DOTLESS_I_TOUPPER); 775 } 776 777 return mb_strtoupper($string); 778 } 779 780 /** 781 * Identify the script used for a piece of text 782 * 783 * @param $string 784 * 785 * @return string 786 */ 787 public static function textScript($string): string 788 { 789 $string = strip_tags($string); // otherwise HTML tags show up as latin 790 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 791 $string = str_replace([ 792 '@N.N.', 793 '@P.N.', 794 ], '', $string); // otherwise unknown names show up as latin 795 $pos = 0; 796 $strlen = strlen($string); 797 while ($pos < $strlen) { 798 // get the Unicode Code Point for the character at position $pos 799 $byte1 = ord($string[$pos]); 800 if ($byte1 < 0x80) { 801 $code_point = $byte1; 802 $chrlen = 1; 803 } elseif ($byte1 < 0xC0) { 804 // Invalid continuation character 805 return 'Latn'; 806 } elseif ($byte1 < 0xE0) { 807 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 808 $chrlen = 2; 809 } elseif ($byte1 < 0xF0) { 810 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 811 $chrlen = 3; 812 } elseif ($byte1 < 0xF8) { 813 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 814 $chrlen = 3; 815 } else { 816 // Invalid UTF 817 return 'Latn'; 818 } 819 820 foreach (self::SCRIPT_CHARACTER_RANGES as $range) { 821 if ($code_point >= $range[1] && $code_point <= $range[2]) { 822 return $range[0]; 823 } 824 } 825 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 826 $pos += $chrlen; 827 } 828 829 return 'Latn'; 830 } 831 832 /** 833 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 834 * 835 * @param int $seconds 836 * 837 * @return string 838 */ 839 public static function timeAgo($seconds) 840 { 841 $minute = 60; 842 $hour = 60 * $minute; 843 $day = 24 * $hour; 844 $month = 30 * $day; 845 $year = 365 * $day; 846 847 if ($seconds > $year) { 848 $years = intdiv($seconds, $year); 849 850 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 851 } 852 853 if ($seconds > $month) { 854 $months = intdiv($seconds, $month); 855 856 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 857 } 858 859 if ($seconds > $day) { 860 $days = intdiv($seconds, $day); 861 862 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 863 } 864 865 if ($seconds > $hour) { 866 $hours = intdiv($seconds, $hour); 867 868 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 869 } 870 871 if ($seconds > $minute) { 872 $minutes = intdiv($seconds, $minute); 873 874 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 875 } 876 877 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 878 } 879 880 /** 881 * What format is used to display dates in the current locale? 882 * 883 * @return string 884 */ 885 public static function timeFormat(): string 886 { 887 /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ 888 return self::$translator->translate('%H:%i:%s'); 889 } 890 891 /** 892 * Translate a string, and then substitute placeholders 893 * 894 * echo I18N::translate('Hello World!'); 895 * echo I18N::translate('The %s sat on the mat', 'cat'); 896 * 897 * @param string $message 898 * @param string ...$args 899 * 900 * @return string 901 */ 902 public static function translate(string $message, ...$args): string 903 { 904 $message = self::$translator->translate($message); 905 906 return sprintf($message, ...$args); 907 } 908 909 /** 910 * Context sensitive version of translate. 911 * echo I18N::translateContext('NOMINATIVE', 'January'); 912 * echo I18N::translateContext('GENITIVE', 'January'); 913 * 914 * @param string $context 915 * @param string $message 916 * @param string ...$args 917 * 918 * @return string 919 */ 920 public static function translateContext(string $context, string $message, ...$args): string 921 { 922 $message = self::$translator->translateContext($context, $message); 923 924 return sprintf($message, ...$args); 925 } 926} 927