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