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