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\ExtCalendar\ArabicCalendar; 21use Fisharebest\ExtCalendar\CalendarInterface; 22use Fisharebest\ExtCalendar\GregorianCalendar; 23use Fisharebest\ExtCalendar\JewishCalendar; 24use Fisharebest\ExtCalendar\PersianCalendar; 25use Fisharebest\Localization\Locale; 26use Fisharebest\Localization\Locale\LocaleEnUs; 27use Fisharebest\Localization\Locale\LocaleInterface; 28use Fisharebest\Localization\Translation; 29use Fisharebest\Localization\Translator; 30use Fisharebest\Webtrees\Functions\FunctionsEdit; 31 32/** 33 * Internationalization (i18n) and localization (l10n). 34 */ 35class I18N 36{ 37 /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 38 private static $locale; 39 40 /** @var Translator An object that performs translation */ 41 private static $translator; 42 43 /** @var Collator From the php-intl library */ 44 private static $collator; 45 46 // Digits are always rendered LTR, even in RTL text. 47 const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 48 49 // These locales need special handling for the dotless letter I. 50 const DOTLESS_I_LOCALES = [ 51 'az', 52 'tr', 53 ]; 54 const DOTLESS_I_TOLOWER = [ 55 'I' => 'ı', 56 'İ' => 'i', 57 ]; 58 const DOTLESS_I_TOUPPER = [ 59 'ı' => 'I', 60 'i' => 'İ', 61 ]; 62 63 // The ranges of characters used by each script. 64 const SCRIPT_CHARACTER_RANGES = [ 65 [ 66 'Latn', 67 0x0041, 68 0x005A, 69 ], 70 [ 71 'Latn', 72 0x0061, 73 0x007A, 74 ], 75 [ 76 'Latn', 77 0x0100, 78 0x02AF, 79 ], 80 [ 81 'Grek', 82 0x0370, 83 0x03FF, 84 ], 85 [ 86 'Cyrl', 87 0x0400, 88 0x052F, 89 ], 90 [ 91 'Hebr', 92 0x0590, 93 0x05FF, 94 ], 95 [ 96 'Arab', 97 0x0600, 98 0x06FF, 99 ], 100 [ 101 'Arab', 102 0x0750, 103 0x077F, 104 ], 105 [ 106 'Arab', 107 0x08A0, 108 0x08FF, 109 ], 110 [ 111 'Deva', 112 0x0900, 113 0x097F, 114 ], 115 [ 116 'Taml', 117 0x0B80, 118 0x0BFF, 119 ], 120 [ 121 'Sinh', 122 0x0D80, 123 0x0DFF, 124 ], 125 [ 126 'Thai', 127 0x0E00, 128 0x0E7F, 129 ], 130 [ 131 'Geor', 132 0x10A0, 133 0x10FF, 134 ], 135 [ 136 'Grek', 137 0x1F00, 138 0x1FFF, 139 ], 140 [ 141 'Deva', 142 0xA8E0, 143 0xA8FF, 144 ], 145 [ 146 'Hans', 147 0x3000, 148 0x303F, 149 ], 150 // Mixed CJK, not just Hans 151 [ 152 'Hans', 153 0x3400, 154 0xFAFF, 155 ], 156 // Mixed CJK, not just Hans 157 [ 158 'Hans', 159 0x20000, 160 0x2FA1F, 161 ], 162 // Mixed CJK, not just Hans 163 ]; 164 165 // Characters that are displayed in mirror form in RTL text. 166 const MIRROR_CHARACTERS = [ 167 '(' => ')', 168 ')' => '(', 169 '[' => ']', 170 ']' => '[', 171 '{' => '}', 172 '}' => '{', 173 '<' => '>', 174 '>' => '<', 175 '‹ ' => '›', 176 '› ' => '‹', 177 '«' => '»', 178 '»' => '«', 179 '﴾ ' => '﴿', 180 '﴿ ' => '﴾', 181 '“ ' => '”', 182 '” ' => '“', 183 '‘ ' => '’', 184 '’ ' => '‘', 185 ]; 186 187 // Default list of locales to show in the menu. 188 const DEFAULT_LOCALES = [ 189 'ar', 190 'bg', 191 'bs', 192 'ca', 193 'cs', 194 'da', 195 'de', 196 'el', 197 'en-GB', 198 'en-US', 199 'es', 200 'et', 201 'fi', 202 'fr', 203 'he', 204 'hr', 205 'hu', 206 'is', 207 'it', 208 'ka', 209 'kk', 210 'lt', 211 'mr', 212 'nb', 213 'nl', 214 'nn', 215 'pl', 216 'pt', 217 'ru', 218 'sk', 219 'sv', 220 'tr', 221 'uk', 222 'vi', 223 'zh-Hans', 224 ]; 225 226 /** @var string Punctuation used to separate list items, typically a comma */ 227 public static $list_separator; 228 229 /** 230 * The prefered locales for this site, or a default list if no preference. 231 * 232 * @return LocaleInterface[] 233 */ 234 public static function activeLocales() 235 { 236 $code_list = Site::getPreference('LANGUAGES'); 237 238 if ($code_list === '') { 239 $codes = self::DEFAULT_LOCALES; 240 } else { 241 $codes = explode(',', $code_list); 242 } 243 244 $locales = []; 245 foreach ($codes as $code) { 246 if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) { 247 try { 248 $locales[] = Locale::create($code); 249 } catch (\Exception $ex) { 250 DebugBar::addThrowable($ex); 251 252 // No such locale exists? 253 } 254 } 255 } 256 usort($locales, '\Fisharebest\Localization\Locale::compare'); 257 258 return $locales; 259 } 260 261 /** 262 * Which MySQL collation should be used for this locale? 263 * 264 * @return string 265 */ 266 public static function collation() 267 { 268 $collation = self::$locale->collation(); 269 switch ($collation) { 270 case 'croatian_ci': 271 case 'german2_ci': 272 case 'vietnamese_ci': 273 // Only available in MySQL 5.6 274 return 'utf8_unicode_ci'; 275 default: 276 return 'utf8_' . $collation; 277 } 278 } 279 280 /** 281 * What format is used to display dates in the current locale? 282 * 283 * @return string 284 */ 285 public static function dateFormat() 286 { 287 /* I18N: This is the format string for full dates. See http://php.net/date for codes */ 288 return self::$translator->translate('%j %F %Y'); 289 } 290 291 /** 292 * Generate consistent I18N for datatables.js 293 * 294 * @param array|null $lengths An optional array of page lengths 295 * 296 * @return string 297 */ 298 public static function datatablesI18N(array $lengths = [ 299 10, 300 20, 301 30, 302 50, 303 100, 304 -1, 305 ]) 306 { 307 $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), 10); 308 309 return 310 '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' . 311 '"language": {' . 312 ' "paginate": {' . 313 ' "first": "' . self::translate('first') . '",' . 314 ' "last": "' . self::translate('last') . '",' . 315 ' "next": "' . self::translate('next') . '",' . 316 ' "previous": "' . self::translate('previous') . '"' . 317 ' },' . 318 ' "emptyTable": "' . self::translate('No records to display') . '",' . 319 ' "info": "' . /* I18N: %s are placeholders for numbers */ 320 self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' . 321 ' "infoEmpty": "' . self::translate('Showing %1$s to %2$s of %3$s', self::$locale->digits('0'), self::$locale->digits('0'), self::$locale->digits('0')) . '",' . 322 ' "infoFiltered": "' . /* I18N: %s is a placeholder for a number */ 323 self::translate('(filtered from %s total entries)', '_MAX_') . '",' . 324 ' "lengthMenu": "' . /* I18N: %s is a number of records per page */ 325 self::translate('Display %s', addslashes($length_options)) . '",' . 326 ' "loadingRecords": "' . self::translate('Loading…') . '",' . 327 ' "processing": "' . self::translate('Loading…') . '",' . 328 ' "search": "' . self::translate('Filter') . '",' . 329 ' "zeroRecords": "' . self::translate('No records to display') . '"' . 330 '}'; 331 } 332 333 /** 334 * Convert the digits 0-9 into the local script 335 * 336 * Used for years, etc., where we do not want thousands-separators, decimals, etc. 337 * 338 * @param int $n 339 * 340 * @return string 341 */ 342 public static function digits($n) 343 { 344 return self::$locale->digits($n); 345 } 346 347 /** 348 * What is the direction of the current locale 349 * 350 * @return string "ltr" or "rtl" 351 */ 352 public static function direction() 353 { 354 return self::$locale->direction(); 355 } 356 357 /** 358 * What is the first day of the week. 359 * 360 * @return int Sunday=0, Monday=1, etc. 361 */ 362 public static function firstDay() 363 { 364 return self::$locale->territory()->firstDay(); 365 } 366 367 /** 368 * Convert a GEDCOM age string into translated_text 369 * 370 * NB: The import function will have normalised this, so we don't need 371 * to worry about badly formatted strings 372 * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event() 373 * 374 * @param $string 375 * 376 * @return string 377 */ 378 public static function gedcomAge($string) 379 { 380 switch ($string) { 381 case 'STILLBORN': 382 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 383 return self::translate('(stillborn)'); 384 case 'INFANT': 385 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 386 return self::translate('(in infancy)'); 387 case 'CHILD': 388 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 389 return self::translate('(in childhood)'); 390 } 391 $age = []; 392 if (preg_match('/(\d+)y/', $string, $match)) { 393 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 394 $years = $match[1]; 395 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 396 } else { 397 $years = -1; 398 } 399 if (preg_match('/(\d+)m/', $string, $match)) { 400 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 401 $age[] = self::plural('%s month', '%s months', $match[1], self::number($match[1])); 402 } 403 if (preg_match('/(\d+)w/', $string, $match)) { 404 // I18N: Part of an age string. e.g. 7 weeks and 3 days 405 $age[] = self::plural('%s week', '%s weeks', $match[1], self::number($match[1])); 406 } 407 if (preg_match('/(\d+)d/', $string, $match)) { 408 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 409 $age[] = self::plural('%s day', '%s days', $match[1], self::number($match[1])); 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 } elseif (!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 } else { 423 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 424 return self::translate('(aged %s)', $age); 425 } 426 } else { 427 // Not a valid string? 428 return self::translate('(aged %s)', $string); 429 } 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() 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($code = '') 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() 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($locale) 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($locale) 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($n, $precision = 0) 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($n, $precision = 0) 634 { 635 return self::$locale->percent(round($n, $precision + 2)); 636 } 637 638 /** 639 * Translate a plural string 640 * 641 * echo self::plural('There is an error', 'There are errors', $num_errors); 642 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 643 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 644 * 645 * @return string 646 */ 647 public static function plural(...$args) 648 { 649 $args[0] = self::$translator->translatePlural($args[0], $args[1], (int) $args[2]); 650 unset($args[1], $args[2]); 651 652 return sprintf(...$args); 653 } 654 655 /** 656 * UTF8 version of PHP::strrev() 657 * 658 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 659 * 660 * These do not support UTF8 text direction, so we must mimic it for them. 661 * 662 * Numbers are always rendered LTR, even in RTL text. 663 * The visual direction of characters such as parentheses should be reversed. 664 * 665 * @param string $text Text to be reversed 666 * 667 * @return string 668 */ 669 public static function reverseText($text) 670 { 671 // Remove HTML markup - we can't display it and it is LTR. 672 $text = strip_tags($text); 673 // Remove HTML entities. 674 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); 675 676 // LTR text doesn't need reversing 677 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 678 return $text; 679 } 680 681 // Mirrored characters 682 $text = strtr($text, self::MIRROR_CHARACTERS); 683 684 $reversed = ''; 685 $digits = ''; 686 while ($text != '') { 687 $letter = mb_substr($text, 0, 1); 688 $text = mb_substr($text, 1); 689 if (strpos(self::DIGITS, $letter) !== false) { 690 $digits .= $letter; 691 } else { 692 $reversed = $letter . $digits . $reversed; 693 $digits = ''; 694 } 695 } 696 697 return $digits . $reversed; 698 } 699 700 /** 701 * Return the direction (ltr or rtl) for a given script 702 * 703 * The PHP/intl library does not provde this information, so we need 704 * our own lookup table. 705 * 706 * @param string $script 707 * 708 * @return string 709 */ 710 public static function scriptDirection($script) 711 { 712 switch ($script) { 713 case 'Arab': 714 case 'Hebr': 715 case 'Mong': 716 case 'Thaa': 717 return 'rtl'; 718 default: 719 return 'ltr'; 720 } 721 } 722 723 /** 724 * Perform a case-insensitive comparison of two strings. 725 * 726 * @param string $string1 727 * @param string $string2 728 * 729 * @return int 730 */ 731 public static function strcasecmp($string1, $string2) 732 { 733 if (self::$collator instanceof Collator) { 734 return self::$collator->compare($string1, $string2); 735 } else { 736 return strcmp(self::strtolower($string1), self::strtolower($string2)); 737 } 738 } 739 740 /** 741 * Convert a string to lower case. 742 * 743 * @param string $string 744 * 745 * @return string 746 */ 747 public static function strtolower($string) 748 { 749 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 750 $string = strtr($string, self::DOTLESS_I_TOLOWER); 751 } 752 753 return mb_strtolower($string); 754 } 755 756 /** 757 * Convert a string to upper case. 758 * 759 * @param string $string 760 * 761 * @return string 762 */ 763 public static function strtoupper($string) 764 { 765 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 766 $string = strtr($string, self::DOTLESS_I_TOUPPER); 767 } 768 769 return mb_strtoupper($string); 770 } 771 772 /** 773 * Identify the script used for a piece of text 774 * 775 * @param $string 776 * 777 * @return string 778 */ 779 public static function textScript($string) 780 { 781 $string = strip_tags($string); // otherwise HTML tags show up as latin 782 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 783 $string = str_replace([ 784 '@N.N.', 785 '@P.N.', 786 ], '', $string); // otherwise unknown names show up as latin 787 $pos = 0; 788 $strlen = strlen($string); 789 while ($pos < $strlen) { 790 // get the Unicode Code Point for the character at position $pos 791 $byte1 = ord($string[$pos]); 792 if ($byte1 < 0x80) { 793 $code_point = $byte1; 794 $chrlen = 1; 795 } elseif ($byte1 < 0xC0) { 796 // Invalid continuation character 797 return 'Latn'; 798 } elseif ($byte1 < 0xE0) { 799 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 800 $chrlen = 2; 801 } elseif ($byte1 < 0xF0) { 802 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 803 $chrlen = 3; 804 } elseif ($byte1 < 0xF8) { 805 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 806 $chrlen = 3; 807 } else { 808 // Invalid UTF 809 return 'Latn'; 810 } 811 812 foreach (self::SCRIPT_CHARACTER_RANGES as $range) { 813 if ($code_point >= $range[1] && $code_point <= $range[2]) { 814 return $range[0]; 815 } 816 } 817 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 818 $pos += $chrlen; 819 } 820 821 return 'Latn'; 822 } 823 824 /** 825 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 826 * 827 * @param int $seconds 828 * 829 * @return string 830 */ 831 public static function timeAgo($seconds) 832 { 833 $minute = 60; 834 $hour = 60 * $minute; 835 $day = 24 * $hour; 836 $month = 30 * $day; 837 $year = 365 * $day; 838 839 if ($seconds > $year) { 840 $years = (int)($seconds / $year); 841 842 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 843 } elseif ($seconds > $month) { 844 $months = (int)($seconds / $month); 845 846 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 847 } elseif ($seconds > $day) { 848 $days = (int)($seconds / $day); 849 850 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 851 } elseif ($seconds > $hour) { 852 $hours = (int)($seconds / $hour); 853 854 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 855 } elseif ($seconds > $minute) { 856 $minutes = (int)($seconds / $minute); 857 858 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 859 } else { 860 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 861 } 862 } 863 864 /** 865 * What format is used to display dates in the current locale? 866 * 867 * @return string 868 */ 869 public static function timeFormat() 870 { 871 /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ 872 return self::$translator->translate('%H:%i:%s'); 873 } 874 875 /** 876 * Translate a string, and then substitute placeholders 877 * 878 * echo I18N::translate('Hello World!'); 879 * echo I18N::translate('The %s sat on the mat', 'cat'); 880 * 881 * @return string 882 */ 883 public static function translate(...$args) 884 { 885 $args[0] = self::$translator->translate($args[0]); 886 887 return sprintf(...$args); 888 } 889 890 /** 891 * Context sensitive version of translate. 892 * 893 * echo I18N::translateContext('NOMINATIVE', 'January'); 894 * echo I18N::translateContext('GENITIVE', 'January'); 895 * 896 * @return string 897 */ 898 public static function translateContext(...$args) 899 { 900 $args[1] = self::$translator->translateContext($args[0], $args[1]); 901 unset($args[0]); 902 903 return sprintf(...$args); 904 } 905} 906