AbstractCalendarDate::today()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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