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