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