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