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