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