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