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