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