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 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 368 * to worry about badly formatted strings 369 * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event() 370 * 371 * @param $string 372 * 373 * @return string 374 */ 375 public static function gedcomAge(string $string): string 376 { 377 switch ($string) { 378 case 'STILLBORN': 379 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 380 return self::translate('(stillborn)'); 381 case 'INFANT': 382 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 383 return self::translate('(in infancy)'); 384 case 'CHILD': 385 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 386 return self::translate('(in childhood)'); 387 } 388 $age = []; 389 if (preg_match('/(\d+)y/', $string, $match)) { 390 $years = (int) $match[1]; 391 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 392 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 393 } else { 394 $years = -1; 395 } 396 if (preg_match('/(\d+)m/', $string, $match)) { 397 $months = (int) $match[1]; 398 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 399 $age[] = self::plural('%s month', '%s months', $months, self::number($months)); 400 } 401 if (preg_match('/(\d+)w/', $string, $match)) { 402 $weeks = (int) $match[1]; 403 // I18N: Part of an age string. e.g. 7 weeks and 3 days 404 $age[] = self::plural('%s week', '%s weeks', $weeks, self::number($weeks)); 405 } 406 if (preg_match('/(\d+)d/', $string, $match)) { 407 $days = (int) $match[1]; 408 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 409 $age[] = self::plural('%s day', '%s days', $days, self::number($days)); 410 } 411 // If an age is just a number of years, only show the number 412 if (count($age) === 1 && $years >= 0) { 413 $age = $years; 414 } 415 if ($age) { 416 if (!substr_compare($string, '<', 0, 1)) { 417 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years) 418 return self::translate('(aged less than %s)', $age); 419 } 420 421 if (!substr_compare($string, '>', 0, 1)) { 422 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years) 423 return self::translate('(aged more than %s)', $age); 424 } 425 426 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 427 return self::translate('(aged %s)', $age); 428 } 429 430 // Not a valid string? 431 return self::translate('(aged %s)', $string); 432 } 433 434 /** 435 * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 436 * 437 * @return string 438 */ 439 public static function htmlAttributes(): string 440 { 441 return self::$locale->htmlAttributes(); 442 } 443 444 /** 445 * Initialise the translation adapter with a locale setting. 446 * 447 * @param string $code Use this locale/language code, or choose one automatically 448 * 449 * @return string $string 450 */ 451 public static function init(string $code = ''): string 452 { 453 mb_internal_encoding('UTF-8'); 454 455 if ($code !== '') { 456 // Create the specified locale 457 self::$locale = Locale::create($code); 458 } else { 459 // Negotiate a locale, but if we can't then use a failsafe 460 self::$locale = new LocaleEnUs(); 461 if (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.mo')) { 462 // Previously used 463 self::$locale = Locale::create(Session::get('locale')); 464 } else { 465 // Browser negotiation 466 $default_locale = new LocaleEnUs(); 467 try { 468 // @TODO, when no language is requested by the user (e.g. search engines), we should use 469 // the tree's default language. However, we currently initialise languages before trees, 470 // so there is no tree available for us to use. 471 } catch (\Exception $ex) { 472 DebugBar::addThrowable($ex); 473 } 474 self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale); 475 } 476 } 477 478 $cache_dir = WT_DATA_DIR . 'cache/'; 479 $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php'; 480 if (file_exists($cache_file)) { 481 $filemtime = filemtime($cache_file); 482 } else { 483 $filemtime = 0; 484 } 485 486 // Load the translation file(s) 487 // Note that glob() returns false instead of an empty array when open_basedir_restriction 488 // is in force and no files are found. See PHP bug #47358. 489 if (defined('GLOB_BRACE')) { 490 $translation_files = array_merge( 491 [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 492 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [], 493 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [] 494 ); 495 } else { 496 // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php 497 $translation_files = array_merge( 498 [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 499 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.csv') ?: [], 500 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.php') ?: [], 501 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.mo') ?: [], 502 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: [], 503 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: [], 504 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: [] 505 ); 506 } 507 // Rebuild files after one hour 508 $rebuild_cache = time() > $filemtime + 3600; 509 // Rebuild files if any translation file has been updated 510 foreach ($translation_files as $translation_file) { 511 if (filemtime($translation_file) > $filemtime) { 512 $rebuild_cache = true; 513 break; 514 } 515 } 516 517 if ($rebuild_cache) { 518 $translations = []; 519 foreach ($translation_files as $translation_file) { 520 $translation = new Translation($translation_file); 521 $translations = array_merge($translations, $translation->asArray()); 522 } 523 try { 524 File::mkdir($cache_dir); 525 file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';'); 526 } catch (Exception $ex) { 527 DebugBar::addThrowable($ex); 528 529 // During setup, we may not have been able to create it. 530 } 531 } else { 532 $translations = include $cache_file; 533 } 534 535 // Create a translator 536 self::$translator = new Translator($translations, self::$locale->pluralRule()); 537 538 /* I18N: This punctuation is used to separate lists of items */ 539 self::$list_separator = self::translate(', '); 540 541 // Create a collator 542 try { 543 // PHP 5.6 cannot catch errors, so test first 544 if (class_exists('Collator')) { 545 self::$collator = new Collator(self::$locale->code()); 546 // Ignore upper/lower case differences 547 self::$collator->setStrength(Collator::SECONDARY); 548 } 549 } catch (Exception $ex) { 550 DebugBar::addThrowable($ex); 551 552 // PHP-INTL is not installed? We'll use a fallback later. 553 } 554 555 return self::$locale->languageTag(); 556 } 557 558 /** 559 * All locales for which a translation file exists. 560 * 561 * @return LocaleInterface[] 562 */ 563 public static function installedLocales(): array 564 { 565 $locales = []; 566 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 567 try { 568 $locales[] = Locale::create(basename($file, '.mo')); 569 } catch (\Exception $ex) { 570 DebugBar::addThrowable($ex); 571 572 // Not a recognised locale 573 } 574 } 575 usort($locales, '\Fisharebest\Localization\Locale::compare'); 576 577 return $locales; 578 } 579 580 /** 581 * Return the endonym for a given language - as per http://cldr.unicode.org/ 582 * 583 * @param string $locale 584 * 585 * @return string 586 */ 587 public static function languageName(string $locale): string 588 { 589 return Locale::create($locale)->endonym(); 590 } 591 592 /** 593 * Return the script used by a given language 594 * 595 * @param string $locale 596 * 597 * @return string 598 */ 599 public static function languageScript(string $locale): string 600 { 601 return Locale::create($locale)->script()->code(); 602 } 603 604 /** 605 * Translate a number into the local representation. 606 * 607 * e.g. 12345.67 becomes 608 * en: 12,345.67 609 * fr: 12 345,67 610 * de: 12.345,67 611 * 612 * @param float $n 613 * @param int $precision 614 * 615 * @return string 616 */ 617 public static function number(float $n, int $precision = 0): string 618 { 619 return self::$locale->number(round($n, $precision)); 620 } 621 622 /** 623 * Translate a fraction into a percentage. 624 * 625 * e.g. 0.123 becomes 626 * en: 12.3% 627 * fr: 12,3 % 628 * de: 12,3% 629 * 630 * @param float $n 631 * @param int $precision 632 * 633 * @return string 634 */ 635 public static function percentage(float $n, int $precision = 0): string 636 { 637 return self::$locale->percent(round($n, $precision + 2)); 638 } 639 640 /** 641 * Translate a plural string 642 * echo self::plural('There is an error', 'There are errors', $num_errors); 643 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 644 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 645 * 646 * @param string $singular 647 * @param string $plural 648 * @param int $count 649 * @param string ...$args 650 * 651 * @return string 652 */ 653 public static function plural(string $singular, string $plural, int $count, ...$args): string 654 { 655 $message = self::$translator->translatePlural($singular, $plural, $count); 656 657 return sprintf($message, ...$args); 658 } 659 660 /** 661 * UTF8 version of PHP::strrev() 662 * 663 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 664 * 665 * These do not support UTF8 text direction, so we must mimic it for them. 666 * 667 * Numbers are always rendered LTR, even in RTL text. 668 * The visual direction of characters such as parentheses should be reversed. 669 * 670 * @param string $text Text to be reversed 671 * 672 * @return string 673 */ 674 public static function reverseText($text): string 675 { 676 // Remove HTML markup - we can't display it and it is LTR. 677 $text = strip_tags($text); 678 // Remove HTML entities. 679 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); 680 681 // LTR text doesn't need reversing 682 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 683 return $text; 684 } 685 686 // Mirrored characters 687 $text = strtr($text, self::MIRROR_CHARACTERS); 688 689 $reversed = ''; 690 $digits = ''; 691 while ($text != '') { 692 $letter = mb_substr($text, 0, 1); 693 $text = mb_substr($text, 1); 694 if (strpos(self::DIGITS, $letter) !== false) { 695 $digits .= $letter; 696 } else { 697 $reversed = $letter . $digits . $reversed; 698 $digits = ''; 699 } 700 } 701 702 return $digits . $reversed; 703 } 704 705 /** 706 * Return the direction (ltr or rtl) for a given script 707 * 708 * The PHP/intl library does not provde this information, so we need 709 * our own lookup table. 710 * 711 * @param string $script 712 * 713 * @return string 714 */ 715 public static function scriptDirection($script) 716 { 717 switch ($script) { 718 case 'Arab': 719 case 'Hebr': 720 case 'Mong': 721 case 'Thaa': 722 return 'rtl'; 723 default: 724 return 'ltr'; 725 } 726 } 727 728 /** 729 * Perform a case-insensitive comparison of two strings. 730 * 731 * @param string $string1 732 * @param string $string2 733 * 734 * @return int 735 */ 736 public static function strcasecmp($string1, $string2) 737 { 738 if (self::$collator instanceof Collator) { 739 return self::$collator->compare($string1, $string2); 740 } 741 742 return strcmp(self::strtolower($string1), self::strtolower($string2)); 743 } 744 745 /** 746 * Convert a string to lower case. 747 * 748 * @param string $string 749 * 750 * @return string 751 */ 752 public static function strtolower($string): string 753 { 754 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 755 $string = strtr($string, self::DOTLESS_I_TOLOWER); 756 } 757 758 return mb_strtolower($string); 759 } 760 761 /** 762 * Convert a string to upper case. 763 * 764 * @param string $string 765 * 766 * @return string 767 */ 768 public static function strtoupper($string): string 769 { 770 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 771 $string = strtr($string, self::DOTLESS_I_TOUPPER); 772 } 773 774 return mb_strtoupper($string); 775 } 776 777 /** 778 * Identify the script used for a piece of text 779 * 780 * @param $string 781 * 782 * @return string 783 */ 784 public static function textScript($string): string 785 { 786 $string = strip_tags($string); // otherwise HTML tags show up as latin 787 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 788 $string = str_replace([ 789 '@N.N.', 790 '@P.N.', 791 ], '', $string); // otherwise unknown names show up as latin 792 $pos = 0; 793 $strlen = strlen($string); 794 while ($pos < $strlen) { 795 // get the Unicode Code Point for the character at position $pos 796 $byte1 = ord($string[$pos]); 797 if ($byte1 < 0x80) { 798 $code_point = $byte1; 799 $chrlen = 1; 800 } elseif ($byte1 < 0xC0) { 801 // Invalid continuation character 802 return 'Latn'; 803 } elseif ($byte1 < 0xE0) { 804 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 805 $chrlen = 2; 806 } elseif ($byte1 < 0xF0) { 807 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 808 $chrlen = 3; 809 } elseif ($byte1 < 0xF8) { 810 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 811 $chrlen = 3; 812 } else { 813 // Invalid UTF 814 return 'Latn'; 815 } 816 817 foreach (self::SCRIPT_CHARACTER_RANGES as $range) { 818 if ($code_point >= $range[1] && $code_point <= $range[2]) { 819 return $range[0]; 820 } 821 } 822 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 823 $pos += $chrlen; 824 } 825 826 return 'Latn'; 827 } 828 829 /** 830 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 831 * 832 * @param int $seconds 833 * 834 * @return string 835 */ 836 public static function timeAgo($seconds) 837 { 838 $minute = 60; 839 $hour = 60 * $minute; 840 $day = 24 * $hour; 841 $month = 30 * $day; 842 $year = 365 * $day; 843 844 if ($seconds > $year) { 845 $years = intdiv($seconds, $year); 846 847 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 848 } 849 850 if ($seconds > $month) { 851 $months = intdiv($seconds, $month); 852 853 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 854 } 855 856 if ($seconds > $day) { 857 $days = intdiv($seconds, $day); 858 859 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 860 } 861 862 if ($seconds > $hour) { 863 $hours = intdiv($seconds, $hour); 864 865 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 866 } 867 868 if ($seconds > $minute) { 869 $minutes = intdiv($seconds, $minute); 870 871 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 872 } 873 874 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 875 } 876 877 /** 878 * What format is used to display dates in the current locale? 879 * 880 * @return string 881 */ 882 public static function timeFormat(): string 883 { 884 /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ 885 return self::$translator->translate('%H:%i:%s'); 886 } 887 888 /** 889 * Translate a string, and then substitute placeholders 890 * 891 * echo I18N::translate('Hello World!'); 892 * echo I18N::translate('The %s sat on the mat', 'cat'); 893 * 894 * @param string $message 895 * @param string ...$args 896 * 897 * @return string 898 */ 899 public static function translate(string $message, ...$args): string 900 { 901 $message = self::$translator->translate($message); 902 903 return sprintf($message, ...$args); 904 } 905 906 /** 907 * Context sensitive version of translate. 908 * echo I18N::translateContext('NOMINATIVE', 'January'); 909 * echo I18N::translateContext('GENITIVE', 'January'); 910 * 911 * @param string $context 912 * @param string $message 913 * @param string ...$args 914 * 915 * @return string 916 */ 917 public static function translateContext(string $context, string $message, ...$args): string 918 { 919 $message = self::$translator->translateContext($context, $message); 920 921 return sprintf($message, ...$args); 922 } 923} 924