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