xref: /webtrees/app/Date/AbstractCalendarDate.php (revision b56fa9dd6a5e9075de5337fd34748412e9e559a7)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Date;
21
22use Fisharebest\ExtCalendar\CalendarInterface;
23use Fisharebest\ExtCalendar\JewishCalendar;
24use Fisharebest\Webtrees\Carbon;
25use Fisharebest\Webtrees\Http\RequestHandlers\CalendarPage;
26use Fisharebest\Webtrees\I18N;
27use Fisharebest\Webtrees\Tree;
28use InvalidArgumentException;
29
30use function array_key_exists;
31use function array_search;
32use function get_class;
33use function intdiv;
34use function is_array;
35use function is_int;
36use function max;
37use function preg_match;
38use function route;
39use function sprintf;
40use function str_contains;
41use function strpbrk;
42use function strtr;
43use function trigger_error;
44use function trim;
45use function view;
46
47use const E_USER_DEPRECATED;
48
49/**
50 * Classes for Gedcom Date/Calendar functionality.
51 *
52 * CalendarDate is a base class for classes such as GregorianDate, etc.
53 * + All supported calendars have non-zero days/months/years.
54 * + We store dates as both Y/M/D and Julian Days.
55 * + For imprecise dates such as "JAN 2000" we store the start/end julian day.
56 *
57 * NOTE: Since different calendars start their days at different times, (civil
58 * midnight, solar midnight, sunset, sunrise, etc.), we convert on the basis of
59 * midday.
60 */
61abstract class AbstractCalendarDate
62{
63    // GEDCOM calendar escape
64    public const ESCAPE = '@#DUNKNOWN@';
65
66    // Convert GEDCOM month names to month numbers.
67    protected const MONTH_ABBREVIATIONS = [];
68
69    /** @var CalendarInterface The calendar system used to represent this date */
70    protected $calendar;
71
72    /** @var int Year number */
73    public $year;
74
75    /** @var int Month number */
76    public $month;
77
78    /** @var int Day number */
79    public $day;
80
81    /** @var int Earliest Julian day number (start of month/year for imprecise dates) */
82    private $minimum_julian_day;
83
84    /** @var int Latest Julian day number (end of month/year for imprecise dates) */
85    private $maximum_julian_day;
86
87    /**
88     * Create a date from either:
89     * a Julian day number
90     * day/month/year strings from a GEDCOM date
91     * another CalendarDate object
92     *
93     * @param array<string>|int|AbstractCalendarDate $date
94     */
95    protected function __construct($date)
96    {
97        // Construct from an integer (a julian day number)
98        if (is_int($date)) {
99            $this->minimum_julian_day = $date;
100            $this->maximum_julian_day = $date;
101            [$this->year, $this->month, $this->day] = $this->calendar->jdToYmd($date);
102
103            return;
104        }
105
106        // Construct from an array (of three gedcom-style strings: "1900", "FEB", "4")
107        if (is_array($date)) {
108            $this->day = (int) $date[2];
109            if (array_key_exists($date[1], static::MONTH_ABBREVIATIONS)) {
110                $this->month = static::MONTH_ABBREVIATIONS[$date[1]];
111            } else {
112                $this->month = 0;
113                $this->day   = 0;
114            }
115            $this->year = $this->extractYear($date[0]);
116
117            // Our simple lookup table above does not take into account Adar and leap-years.
118            if ($this->month === 6 && $this->calendar instanceof JewishCalendar && !$this->calendar->isLeapYear($this->year)) {
119                $this->month = 7;
120            }
121
122            $this->setJdFromYmd();
123
124            return;
125        }
126
127        // Construct from a CalendarDate
128        $this->minimum_julian_day = $date->minimum_julian_day;
129        $this->maximum_julian_day = $date->maximum_julian_day;
130
131        // Construct from an equivalent xxxxDate object
132        if (get_class($this) === get_class($date)) {
133            $this->year  = $date->year;
134            $this->month = $date->month;
135            $this->day   = $date->day;
136
137            return;
138        }
139
140        // Not all dates can be converted
141        if (!$this->inValidRange()) {
142            $this->year  = 0;
143            $this->month = 0;
144            $this->day   = 0;
145
146            return;
147        }
148
149        // ...else construct an inequivalent xxxxDate object
150        if ($date->year === 0) {
151            // Incomplete date - convert on basis of anniversary in current year
152            $today = $date->calendar->jdToYmd(Carbon::now()->julianDay());
153            $jd    = $date->calendar->ymdToJd($today[0], $date->month, $date->day === 0 ? $today[2] : $date->day);
154        } else {
155            // Complete date
156            $jd = intdiv($date->maximum_julian_day + $date->minimum_julian_day, 2);
157        }
158        [$this->year, $this->month, $this->day] = $this->calendar->jdToYmd($jd);
159        // New date has same precision as original date
160        if ($date->year === 0) {
161            $this->year = 0;
162        }
163        if ($date->month === 0) {
164            $this->month = 0;
165        }
166        if ($date->day === 0) {
167            $this->day = 0;
168        }
169        $this->setJdFromYmd();
170    }
171
172    /**
173     * @return int
174     */
175    public function maximumJulianDay(): int
176    {
177        return $this->maximum_julian_day;
178    }
179
180    /**
181     * @return int
182     */
183    public function year(): int
184    {
185        return $this->year;
186    }
187
188    /**
189     * @return int
190     */
191    public function month(): int
192    {
193        return $this->month;
194    }
195
196    /**
197     * @return int
198     */
199    public function day(): int
200    {
201        return $this->day;
202    }
203
204    /**
205     * @return int
206     */
207    public function minimumJulianDay(): int
208    {
209        return $this->minimum_julian_day;
210    }
211
212    /**
213     * Is the current year a leap year?
214     *
215     * @return bool
216     */
217    public function isLeapYear(): bool
218    {
219        return $this->calendar->isLeapYear($this->year);
220    }
221
222    /**
223     * Set the object’s Julian day number from a potentially incomplete year/month/day
224     *
225     * @return void
226     */
227    public function setJdFromYmd(): void
228    {
229        if ($this->year === 0) {
230            $this->minimum_julian_day = 0;
231            $this->maximum_julian_day = 0;
232        } elseif ($this->month === 0) {
233            $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, 1, 1);
234            $this->maximum_julian_day = $this->calendar->ymdToJd($this->nextYear($this->year), 1, 1) - 1;
235        } elseif ($this->day === 0) {
236            [$ny, $nm] = $this->nextMonth();
237            $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, $this->month, 1);
238            $this->maximum_julian_day = $this->calendar->ymdToJd($ny, $nm, 1) - 1;
239        } else {
240            $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, $this->month, $this->day);
241            $this->maximum_julian_day = $this->minimum_julian_day;
242        }
243    }
244
245    /**
246     * Full day of the week
247     *
248     * @param int $day_number
249     *
250     * @return string
251     */
252    public function dayNames(int $day_number): string
253    {
254        static $translated_day_names;
255
256        if ($translated_day_names === null) {
257            $translated_day_names = [
258                0 => I18N::translate('Monday'),
259                1 => I18N::translate('Tuesday'),
260                2 => I18N::translate('Wednesday'),
261                3 => I18N::translate('Thursday'),
262                4 => I18N::translate('Friday'),
263                5 => I18N::translate('Saturday'),
264                6 => I18N::translate('Sunday'),
265            ];
266        }
267
268        return $translated_day_names[$day_number];
269    }
270
271    /**
272     * Abbreviated day of the week
273     *
274     * @param int $day_number
275     *
276     * @return string
277     */
278    protected function dayNamesAbbreviated(int $day_number): string
279    {
280        static $translated_day_names;
281
282        if ($translated_day_names === null) {
283            $translated_day_names = [
284                /* I18N: abbreviation for Monday */
285                0 => I18N::translate('Mon'),
286                /* I18N: abbreviation for Tuesday */
287                1 => I18N::translate('Tue'),
288                /* I18N: abbreviation for Wednesday */
289                2 => I18N::translate('Wed'),
290                /* I18N: abbreviation for Thursday */
291                3 => I18N::translate('Thu'),
292                /* I18N: abbreviation for Friday */
293                4 => I18N::translate('Fri'),
294                /* I18N: abbreviation for Saturday */
295                5 => I18N::translate('Sat'),
296                /* I18N: abbreviation for Sunday */
297                6 => I18N::translate('Sun'),
298            ];
299        }
300
301        return $translated_day_names[$day_number];
302    }
303
304    /**
305     * Most years are 1 more than the previous, but not always (e.g. 1BC->1AD)
306     *
307     * @param int $year
308     *
309     * @return int
310     */
311    protected function nextYear(int $year): int
312    {
313        return $year + 1;
314    }
315
316    /**
317     * Calendars that use suffixes, etc. (e.g. “B.C.”) or OS/NS notation should redefine this.
318     *
319     * @param string $year
320     *
321     * @return int
322     */
323    protected function extractYear(string $year): int
324    {
325        return (int) $year;
326    }
327
328    /**
329     * Compare two dates, for sorting
330     *
331     * @param AbstractCalendarDate $d1
332     * @param AbstractCalendarDate $d2
333     *
334     * @return int
335     */
336    public static function compare(AbstractCalendarDate $d1, AbstractCalendarDate $d2): int
337    {
338        if ($d1->maximum_julian_day < $d2->minimum_julian_day) {
339            return -1;
340        }
341
342        if ($d2->maximum_julian_day < $d1->minimum_julian_day) {
343            return 1;
344        }
345
346        return 0;
347    }
348
349    /**
350     * Calculate the years/months/days between this date and another date.
351     * Results assume you add the days first, then the months.
352     * 4 February -> 3 July is 27 days (3 March) and 4 months.
353     * It is not 4 months (4 June) and 29 days.
354     *
355     * @param AbstractCalendarDate $date
356     *
357     * @return int[] Age in years/months/days
358     */
359    public function ageDifference(AbstractCalendarDate $date): array
360    {
361        // Incomplete dates
362        if ($this->year === 0 || $date->year === 0) {
363            return [-1, -1, -1];
364        }
365
366        // Overlapping dates
367        if (self::compare($this, $date) === 0) {
368            return [0, 0, 0];
369        }
370
371        // Perform all calculations using the calendar of the first date
372        [$year1, $month1, $day1] = $this->calendar->jdToYmd($this->minimum_julian_day);
373        [$year2, $month2, $day2] = $this->calendar->jdToYmd($date->minimum_julian_day);
374
375        $years  = $year2 - $year1;
376        $months = $month2 - $month1;
377        $days   = $day2 - $day1;
378
379        if ($days < 0) {
380            $days += $this->calendar->daysInMonth($year1, $month1);
381            $months--;
382        }
383
384        if ($months < 0) {
385            $months += $this->calendar->monthsInYear($year2);
386            $years--;
387        }
388
389        return [$years, $months, $days];
390    }
391
392    /**
393     * How long between an event and a given julian day
394     * Return result as a number of years.
395     *
396     * @param int $jd date for calculation
397     *
398     * @return int
399     *
400     * @deprecated since 2.0.4.  Will be removed in 2.1.0
401     */
402    public function getAge(int $jd): int
403    {
404        trigger_error('AbstractCalendarDate::getAge() is deprecated. Use class Age instead.', E_USER_DEPRECATED);
405
406        if ($this->year === 0 || $jd === 0) {
407            return 0;
408        }
409        if ($this->minimum_julian_day < $jd && $this->maximum_julian_day > $jd) {
410            return 0;
411        }
412        if ($this->minimum_julian_day === $jd) {
413            return 0;
414        }
415        [$y, $m, $d] = $this->calendar->jdToYmd($jd);
416        $dy = $y - $this->year;
417        $dm = $m - max($this->month, 1);
418        $dd = $d - max($this->day, 1);
419        if ($dd < 0) {
420            $dm--;
421        }
422        if ($dm < 0) {
423            $dy--;
424        }
425
426        // Not a full age? Then just the years
427        return $dy;
428    }
429
430    /**
431     * How long between an event and a given julian day
432     * Return result as a gedcom-style age string.
433     *
434     * @param int $jd date for calculation
435     *
436     * @return string
437     *
438     * @deprecated since 2.0.4.  Will be removed in 2.1.0
439     */
440    public function getAgeFull(int $jd): string
441    {
442        trigger_error('AbstractCalendarDate::getAge() is deprecated. Use class Age instead.', E_USER_DEPRECATED);
443
444        if ($this->year === 0 || $jd === 0) {
445            return '';
446        }
447        if ($this->minimum_julian_day < $jd && $this->maximum_julian_day > $jd) {
448            return '';
449        }
450        if ($this->minimum_julian_day === $jd) {
451            return '';
452        }
453        if ($jd < $this->minimum_julian_day) {
454            return view('icons/warning');
455        }
456        [$y, $m, $d] = $this->calendar->jdToYmd($jd);
457        $dy = $y - $this->year;
458        $dm = $m - max($this->month, 1);
459        $dd = $d - max($this->day, 1);
460        if ($dd < 0) {
461            $dm--;
462        }
463        if ($dm < 0) {
464            $dm += $this->calendar->monthsInYear();
465            $dy--;
466        }
467        // Age in years?
468        if ($dy > 1) {
469            return $dy . 'y';
470        }
471        $dm += $dy * $this->calendar->monthsInYear();
472        // Age in months?
473        if ($dm > 1) {
474            return $dm . 'm';
475        }
476
477        // Age in days?
478        return ($jd - $this->minimum_julian_day) . 'd';
479    }
480
481    /**
482     * Convert a date from one calendar to another.
483     *
484     * @param string $calendar
485     *
486     * @return AbstractCalendarDate
487     */
488    public function convertToCalendar(string $calendar): AbstractCalendarDate
489    {
490        switch ($calendar) {
491            case 'gregorian':
492                return new GregorianDate($this);
493            case 'julian':
494                return new JulianDate($this);
495            case 'jewish':
496                return new JewishDate($this);
497            case 'french':
498                return new FrenchDate($this);
499            case 'hijri':
500                return new HijriDate($this);
501            case 'jalali':
502                return new JalaliDate($this);
503            default:
504                return $this;
505        }
506    }
507
508    /**
509     * Is this date within the valid range of the calendar
510     *
511     * @return bool
512     */
513    public function inValidRange(): bool
514    {
515        return $this->minimum_julian_day >= $this->calendar->jdStart() && $this->maximum_julian_day <= $this->calendar->jdEnd();
516    }
517
518    /**
519     * How many months in a year
520     *
521     * @return int
522     */
523    public function monthsInYear(): int
524    {
525        return $this->calendar->monthsInYear();
526    }
527
528    /**
529     * How many days in the current month
530     *
531     * @return int
532     */
533    public function daysInMonth(): int
534    {
535        try {
536            return $this->calendar->daysInMonth($this->year, $this->month);
537        } catch (InvalidArgumentException $ex) {
538            // calendar.php calls this with "DD MMM" dates, for which we cannot calculate
539            // the length of a month. Should we validate this before calling this function?
540            return 0;
541        }
542    }
543
544    /**
545     * How many days in the current week
546     *
547     * @return int
548     */
549    public function daysInWeek(): int
550    {
551        return $this->calendar->daysInWeek();
552    }
553
554    /**
555     * Format a date, using similar codes to the PHP date() function.
556     *
557     * @param string $format    See http://php.net/date
558     * @param string $qualifier GEDCOM qualifier, so we can choose the right case for the month name.
559     *
560     * @return string
561     */
562    public function format(string $format, string $qualifier = ''): string
563    {
564        // Dates can include additional punctuation and symbols. e.g.
565        // %F %j, %Y
566        // %Y. %F %d.
567        // %Y年 %n月 %j日
568        // %j. %F %Y
569        // Don’t show exact details or unnecessary punctuation for inexact dates.
570        if ($this->day === 0) {
571            $format = strtr($format, ['%d' => '', '%j日' => '', '%j,' => '', '%j' => '', '%l' => '', '%D' => '', '%N' => '', '%S' => '', '%w' => '', '%z' => '']);
572        }
573        if ($this->month === 0) {
574            $format = strtr($format, ['%F' => '', '%m' => '', '%M' => '', '年 %n月' => '', '%n' => '', '%t' => '']);
575        }
576        if ($this->year === 0) {
577            $format = strtr($format, ['%t' => '', '%L' => '', '%G' => '', '%y' => '', '%Y年' => '', '%Y' => '']);
578        }
579        $format = trim($format, ',. /-');
580
581        if ($this->day !== 0 && preg_match('/%[djlDNSwz]/', $format)) {
582            // If we have a day-number *and* we are being asked to display it, then genitive
583            $case = 'GENITIVE';
584        } else {
585            switch ($qualifier) {
586                case 'TO':
587                case 'ABT':
588                case 'FROM':
589                    $case = 'GENITIVE';
590                    break;
591                case 'AFT':
592                    $case = 'LOCATIVE';
593                    break;
594                case 'BEF':
595                case 'BET':
596                case 'AND':
597                    $case = 'INSTRUMENTAL';
598                    break;
599                case '':
600                case 'INT':
601                case 'EST':
602                case 'CAL':
603                default: // There shouldn't be any other options...
604                    $case = 'NOMINATIVE';
605                    break;
606            }
607        }
608        // Build up the formatted date, character at a time
609        if (str_contains($format, '%d')) {
610            $format = strtr($format, ['%d' => $this->formatDayZeros()]);
611        }
612        if (str_contains($format, '%j')) {
613            $format = strtr($format, ['%j' => $this->formatDay()]);
614        }
615        if (str_contains($format, '%l')) {
616            $format = strtr($format, ['%l' => $this->formatLongWeekday()]);
617        }
618        if (str_contains($format, '%D')) {
619            $format = strtr($format, ['%D' => $this->formatShortWeekday()]);
620        }
621        if (str_contains($format, '%N')) {
622            $format = strtr($format, ['%N' => $this->formatIsoWeekday()]);
623        }
624        if (str_contains($format, '%w')) {
625            $format = strtr($format, ['%w' => $this->formatNumericWeekday()]);
626        }
627        if (str_contains($format, '%z')) {
628            $format = strtr($format, ['%z' => $this->formatDayOfYear()]);
629        }
630        if (str_contains($format, '%F')) {
631            $format = strtr($format, ['%F' => $this->formatLongMonth($case)]);
632        }
633        if (str_contains($format, '%m')) {
634            $format = strtr($format, ['%m' => $this->formatMonthZeros()]);
635        }
636        if (str_contains($format, '%M')) {
637            $format = strtr($format, ['%M' => $this->formatShortMonth()]);
638        }
639        if (str_contains($format, '%n')) {
640            $format = strtr($format, ['%n' => $this->formatMonth()]);
641        }
642        if (str_contains($format, '%t')) {
643            $format = strtr($format, ['%t' => (string) $this->daysInMonth()]);
644        }
645        if (str_contains($format, '%L')) {
646            $format = strtr($format, ['%L' => $this->isLeapYear() ? '1' : '0']);
647        }
648        if (str_contains($format, '%Y')) {
649            $format = strtr($format, ['%Y' => $this->formatLongYear()]);
650        }
651        if (str_contains($format, '%y')) {
652            $format = strtr($format, ['%y' => $this->formatShortYear()]);
653        }
654        // These 4 extensions are useful for re-formatting gedcom dates.
655        if (str_contains($format, '%@')) {
656            $format = strtr($format, ['%@' => $this->formatGedcomCalendarEscape()]);
657        }
658        if (str_contains($format, '%A')) {
659            $format = strtr($format, ['%A' => $this->formatGedcomDay()]);
660        }
661        if (str_contains($format, '%O')) {
662            $format = strtr($format, ['%O' => $this->formatGedcomMonth()]);
663        }
664        if (str_contains($format, '%E')) {
665            $format = strtr($format, ['%E' => $this->formatGedcomYear()]);
666        }
667
668        return $format;
669    }
670
671    /**
672     * Generate the %d format for a date.
673     *
674     * @return string
675     */
676    protected function formatDayZeros(): string
677    {
678        if ($this->day > 9) {
679            return I18N::digits($this->day);
680        }
681
682        return I18N::digits('0' . $this->day);
683    }
684
685    /**
686     * Generate the %j format for a date.
687     *
688     * @return string
689     */
690    protected function formatDay(): string
691    {
692        return I18N::digits($this->day);
693    }
694
695    /**
696     * Generate the %l format for a date.
697     *
698     * @return string
699     */
700    protected function formatLongWeekday(): string
701    {
702        return $this->dayNames($this->minimum_julian_day % $this->calendar->daysInWeek());
703    }
704
705    /**
706     * Generate the %D format for a date.
707     *
708     * @return string
709     */
710    protected function formatShortWeekday(): string
711    {
712        return $this->dayNamesAbbreviated($this->minimum_julian_day % $this->calendar->daysInWeek());
713    }
714
715    /**
716     * Generate the %N format for a date.
717     *
718     * @return string
719     */
720    protected function formatIsoWeekday(): string
721    {
722        return I18N::digits($this->minimum_julian_day % 7 + 1);
723    }
724
725    /**
726     * Generate the %w format for a date.
727     *
728     * @return string
729     */
730    protected function formatNumericWeekday(): string
731    {
732        return I18N::digits(($this->minimum_julian_day + 1) % $this->calendar->daysInWeek());
733    }
734
735    /**
736     * Generate the %z format for a date.
737     *
738     * @return string
739     */
740    protected function formatDayOfYear(): string
741    {
742        return I18N::digits($this->minimum_julian_day - $this->calendar->ymdToJd($this->year, 1, 1));
743    }
744
745    /**
746     * Generate the %n format for a date.
747     *
748     * @return string
749     */
750    protected function formatMonth(): string
751    {
752        return I18N::digits($this->month);
753    }
754
755    /**
756     * Generate the %m format for a date.
757     *
758     * @return string
759     */
760    protected function formatMonthZeros(): string
761    {
762        if ($this->month > 9) {
763            return I18N::digits($this->month);
764        }
765
766        return I18N::digits('0' . $this->month);
767    }
768
769    /**
770     * Generate the %F format for a date.
771     *
772     * @param string $case Which grammatical case shall we use
773     *
774     * @return string
775     */
776    protected function formatLongMonth($case = 'NOMINATIVE'): string
777    {
778        switch ($case) {
779            case 'GENITIVE':
780                return $this->monthNameGenitiveCase($this->month, $this->isLeapYear());
781            case 'NOMINATIVE':
782                return $this->monthNameNominativeCase($this->month, $this->isLeapYear());
783            case 'LOCATIVE':
784                return $this->monthNameLocativeCase($this->month, $this->isLeapYear());
785            case 'INSTRUMENTAL':
786                return $this->monthNameInstrumentalCase($this->month, $this->isLeapYear());
787            default:
788                throw new InvalidArgumentException($case);
789        }
790    }
791
792    /**
793     * Full month name in genitive case.
794     *
795     * @param int  $month
796     * @param bool $leap_year Some calendars use leap months
797     *
798     * @return string
799     */
800    abstract protected function monthNameGenitiveCase(int $month, bool $leap_year): string;
801
802    /**
803     * Full month name in nominative case.
804     *
805     * @param int  $month
806     * @param bool $leap_year Some calendars use leap months
807     *
808     * @return string
809     */
810    abstract protected function monthNameNominativeCase(int $month, bool $leap_year): string;
811
812    /**
813     * Full month name in locative case.
814     *
815     * @param int  $month
816     * @param bool $leap_year Some calendars use leap months
817     *
818     * @return string
819     */
820    abstract protected function monthNameLocativeCase(int $month, bool $leap_year): string;
821
822    /**
823     * Full month name in instrumental case.
824     *
825     * @param int  $month
826     * @param bool $leap_year Some calendars use leap months
827     *
828     * @return string
829     */
830    abstract protected function monthNameInstrumentalCase(int $month, bool $leap_year): string;
831
832    /**
833     * Abbreviated month name
834     *
835     * @param int  $month
836     * @param bool $leap_year Some calendars use leap months
837     *
838     * @return string
839     */
840    abstract protected function monthNameAbbreviated(int $month, bool $leap_year): string;
841
842    /**
843     * Generate the %M format for a date.
844     *
845     * @return string
846     */
847    protected function formatShortMonth(): string
848    {
849        return $this->monthNameAbbreviated($this->month, $this->isLeapYear());
850    }
851
852    /**
853     * Generate the %y format for a date.
854     * NOTE Short year is NOT a 2-digit year. It is for calendars such as hebrew
855     * which have a 3-digit form of 4-digit years.
856     *
857     * @return string
858     */
859    protected function formatShortYear(): string
860    {
861        return $this->formatLongYear();
862    }
863
864    /**
865     * Generate the %A format for a date.
866     *
867     * @return string
868     */
869    protected function formatGedcomDay(): string
870    {
871        if ($this->day === 0) {
872            return '';
873        }
874
875        return sprintf('%02d', $this->day);
876    }
877
878    /**
879     * Generate the %O format for a date.
880     *
881     * @return string
882     */
883    protected function formatGedcomMonth(): string
884    {
885        // Our simple lookup table doesn't work correctly for Adar on leap years
886        if ($this->month === 7 && $this->calendar instanceof JewishCalendar && !$this->calendar->isLeapYear($this->year)) {
887            return 'ADR';
888        }
889
890        return array_search($this->month, static::MONTH_ABBREVIATIONS, true);
891    }
892
893    /**
894     * Generate the %E format for a date.
895     *
896     * @return string
897     */
898    protected function formatGedcomYear(): string
899    {
900        if ($this->year === 0) {
901            return '';
902        }
903
904        return sprintf('%04d', $this->year);
905    }
906
907    /**
908     * Generate the %@ format for a calendar escape.
909     *
910     * @return string
911     */
912    protected function formatGedcomCalendarEscape(): string
913    {
914        return static::ESCAPE;
915    }
916
917    /**
918     * Generate the %Y format for a date.
919     *
920     * @return string
921     */
922    protected function formatLongYear(): string
923    {
924        return I18N::digits($this->year);
925    }
926
927    /**
928     * Which months follows this one? Calendars with leap-months should provide their own implementation.
929     *
930     * @return int[]
931     */
932    protected function nextMonth(): array
933    {
934        return [
935            $this->month === $this->calendar->monthsInYear() ? $this->nextYear($this->year) : $this->year,
936            $this->month % $this->calendar->monthsInYear() + 1,
937        ];
938    }
939
940    /**
941     * Get today’s date in the current calendar.
942     *
943     * @return int[]
944     */
945    public function todayYmd(): array
946    {
947        return $this->calendar->jdToYmd(Carbon::now()->julianDay());
948    }
949
950    /**
951     * Convert to today’s date.
952     *
953     * @return AbstractCalendarDate
954     */
955    public function today(): AbstractCalendarDate
956    {
957        $tmp        = clone $this;
958        $ymd        = $tmp->todayYmd();
959        $tmp->year  = $ymd[0];
960        $tmp->month = $ymd[1];
961        $tmp->day   = $ymd[2];
962        $tmp->setJdFromYmd();
963
964        return $tmp;
965    }
966
967    /**
968     * Create a URL that links this date to the WT calendar
969     *
970     * @param string $date_format
971     * @param Tree   $tree
972     *
973     * @return string
974     */
975    public function calendarUrl(string $date_format, Tree $tree): string
976    {
977        if ($this->day !== 0 && strpbrk($date_format, 'dDj')) {
978            // If the format includes a day, and the date also includes a day, then use the day view
979            $view = 'day';
980        } elseif ($this->month !== 0 && strpbrk($date_format, 'FMmn')) {
981            // If the format includes a month, and the date also includes a month, then use the month view
982            $view = 'month';
983        } else {
984            // Use the year view
985            $view = 'year';
986        }
987
988        return route(CalendarPage::class, [
989            'cal'   => $this->calendar->gedcomCalendarEscape(),
990            'year'  => $this->formatGedcomYear(),
991            'month' => $this->formatGedcomMonth(),
992            'day'   => $this->formatGedcomDay(),
993            'view'  => $view,
994            'tree'  => $tree->name(),
995        ]);
996    }
997}
998