xref: /webtrees/app/I18N.php (revision 67992b6a3e78399bd33189954a5f08bb23b02503)
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