xref: /webtrees/app/Date.php (revision 1baf69dec287f195fbbefc3bae3c0b33957a89c3)
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;
21
22use DomainException;
23use Fisharebest\ExtCalendar\GregorianCalendar;
24use Fisharebest\Webtrees\Date\AbstractCalendarDate;
25use Fisharebest\Webtrees\Date\FrenchDate;
26use Fisharebest\Webtrees\Date\GregorianDate;
27use Fisharebest\Webtrees\Date\HijriDate;
28use Fisharebest\Webtrees\Date\JalaliDate;
29use Fisharebest\Webtrees\Date\JewishDate;
30use Fisharebest\Webtrees\Date\JulianDate;
31use Fisharebest\Webtrees\Date\RomanDate;
32
33/**
34 * A representation of GEDCOM dates and date ranges.
35 *
36 * Since different calendars start their days at different times, (civil
37 * midnight, solar midnight, sunset, sunrise, etc.), we convert on the basis of
38 * midday.
39 *
40 * We assume that years start on the first day of the first month. Where
41 * this is not the case (e.g. England prior to 1752), we need to use modified
42 * years or the OS/NS notation "4 FEB 1750/51".
43 */
44class Date
45{
46    // Optional qualifier, such as BEF, FROM, ABT
47    public string $qual1 = '';
48
49    // The first (or only) date
50    private AbstractCalendarDate $date1;
51
52    // Optional qualifier, such as TO, AND
53    public string $qual2 = '';
54
55    // Optional second date
56    private ?AbstractCalendarDate $date2 = null;
57
58    // Optional text, as included with an INTerpreted date
59    private string $text = '';
60
61    /**
62     * Create a date, from GEDCOM data.
63     *
64     * @param string $date A date in GEDCOM format
65     */
66    public function __construct(string $date)
67    {
68        $calendar_date_factory = Registry::calendarDateFactory();
69
70        // Extract any explanatory text
71        if (preg_match('/^(.*) ?[(](.*)[)]/', $date, $match)) {
72            $date       = $match[1];
73            $this->text = $match[2];
74        }
75        if (preg_match('/^(FROM|BET) (.+) (AND|TO) (.+)/', $date, $match)) {
76            $this->qual1 = $match[1];
77            $this->date1 = $calendar_date_factory->make($match[2]);
78            $this->qual2 = $match[3];
79            $this->date2 = $calendar_date_factory->make($match[4]);
80        } elseif (preg_match('/^(TO|FROM|BEF|AFT|CAL|EST|INT|ABT) (.+)/', $date, $match)) {
81            $this->qual1 = $match[1];
82            $this->date1 = $calendar_date_factory->make($match[2]);
83        } else {
84            $this->date1 = $calendar_date_factory->make($date);
85        }
86    }
87
88    /**
89     * When we copy a date object, we need to create copies of
90     * its child objects.
91     */
92    public function __clone()
93    {
94        $this->date1 = clone $this->date1;
95        if ($this->date2 !== null) {
96            $this->date2 = clone $this->date2;
97        }
98    }
99
100    /**
101     * Convert a date to the preferred format and calendar(s) display.
102     *
103     * @param Tree|null   $tree              Wrap the date in a link to the calendar page for the tree
104     * @param string|null $date_format       Override the default date format
105     * @param bool        $convert_calendars Convert the date into other calendars (requires a tree)
106     *
107     * @return string
108     */
109    public function display(Tree $tree = null, string $date_format = null, bool $convert_calendars = false): string
110    {
111        if ($tree instanceof Tree) {
112            $CALENDAR_FORMAT = $tree->getPreference('CALENDAR_FORMAT');
113        } else {
114            $CALENDAR_FORMAT = 'none';
115        }
116
117        $date_format = $date_format ?? I18N::dateFormat();
118
119        if ($convert_calendars) {
120            $calendar_format = explode('_and_', $CALENDAR_FORMAT);
121        } else {
122            $calendar_format = [];
123        }
124
125        // Two dates with text before, between and after
126        $q1 = $this->qual1;
127        $d1 = $this->date1->format($date_format, $this->qual1);
128        $q2 = $this->qual2;
129        if ($this->date2 === null) {
130            $d2 = '';
131        } else {
132            $d2 = $this->date2->format($date_format, $this->qual2);
133        }
134        // Con vert to other calendars, if requested
135        $conv1 = '';
136        $conv2 = '';
137        foreach ($calendar_format as $cal_fmt) {
138            if ($cal_fmt !== 'none') {
139                $d1conv = $this->date1->convertToCalendar($cal_fmt);
140                if ($d1conv->inValidRange()) {
141                    $d1tmp = $d1conv->format($date_format, $this->qual1);
142                } else {
143                    $d1tmp = '';
144                }
145                if ($this->date2 === null) {
146                    $d2conv = null;
147                    $d2tmp  = '';
148                } else {
149                    $d2conv = $this->date2->convertToCalendar($cal_fmt);
150                    if ($d2conv->inValidRange()) {
151                        $d2tmp = $d2conv->format($date_format, $this->qual2);
152                    } else {
153                        $d2tmp = '';
154                    }
155                }
156                // If the date is different from the unconverted date, add it to the date string.
157                if ($d1 != $d1tmp && $d1tmp !== '') {
158                    if ($tree instanceof Tree) {
159                        if ($CALENDAR_FORMAT !== 'none') {
160                            $conv1 .= ' <span dir="' . I18N::direction() . '">(<a href="' . e($d1conv->calendarUrl($date_format, $tree)) . '" rel="nofollow">' . $d1tmp . '</a>)</span>';
161                        } else {
162                            $conv1 .= ' <span dir="' . I18N::direction() . '"><br><a href="' . e($d1conv->calendarUrl($date_format, $tree)) . '" rel="nofollow">' . $d1tmp . '</a></span>';
163                        }
164                    } else {
165                        $conv1 .= ' <span dir="' . I18N::direction() . '">(' . $d1tmp . ')</span>';
166                    }
167                }
168                if ($this->date2 !== null && $d2 != $d2tmp && $d1tmp != '') {
169                    if ($tree instanceof Tree) {
170                        $conv2 .= ' <span dir="' . I18N::direction() . '">(<a href="' . e($d2conv->calendarUrl($date_format, $tree)) . '" rel="nofollow">' . $d2tmp . '</a>)</span>';
171                    } else {
172                        $conv2 .= ' <span dir="' . I18N::direction() . '">(' . $d2tmp . ')</span>';
173                    }
174                }
175            }
176        }
177
178        // Add URLs, if requested
179        if ($tree instanceof Tree) {
180            $d1 = '<a href="' . e($this->date1->calendarUrl($date_format, $tree)) . '" rel="nofollow">' . $d1 . '</a>';
181            if ($this->date2 instanceof AbstractCalendarDate) {
182                $d2 = '<a href="' . e($this->date2->calendarUrl($date_format, $tree)) . '" rel="nofollow">' . $d2 . '</a>';
183            }
184        }
185
186        // Localise the date
187        switch ($q1 . $q2) {
188            case '':
189                $tmp = $d1 . $conv1;
190                break;
191            case 'ABT':
192                /* I18N: Gedcom ABT dates */
193                $tmp = I18N::translate('about %s', $d1 . $conv1);
194                break;
195            case 'CAL':
196                /* I18N: Gedcom CAL dates */
197                $tmp = I18N::translate('calculated %s', $d1 . $conv1);
198                break;
199            case 'EST':
200                /* I18N: Gedcom EST dates */
201                $tmp = I18N::translate('estimated %s', $d1 . $conv1);
202                break;
203            case 'INT':
204                /* I18N: Gedcom INT dates */
205                $tmp = I18N::translate('interpreted %s (%s)', $d1 . $conv1, e($this->text));
206                break;
207            case 'BEF':
208                /* I18N: Gedcom BEF dates */
209                $tmp = I18N::translate('before %s', $d1 . $conv1);
210                break;
211            case 'AFT':
212                /* I18N: Gedcom AFT dates */
213                $tmp = I18N::translate('after %s', $d1 . $conv1);
214                break;
215            case 'FROM':
216                /* I18N: Gedcom FROM dates */
217                $tmp = I18N::translate('from %s', $d1 . $conv1);
218                break;
219            case 'TO':
220                /* I18N: Gedcom TO dates */
221                $tmp = I18N::translate('to %s', $d1 . $conv1);
222                break;
223            case 'BETAND':
224                /* I18N: Gedcom BET-AND dates */
225                $tmp = I18N::translate('between %s and %s', $d1 . $conv1, $d2 . $conv2);
226                break;
227            case 'FROMTO':
228                /* I18N: Gedcom FROM-TO dates */
229                $tmp = I18N::translate('from %s to %s', $d1 . $conv1, $d2 . $conv2);
230                break;
231            default:
232                $tmp = I18N::translate('Invalid date');
233                break;
234        }
235
236        if (strip_tags($tmp) === '') {
237            return '';
238        }
239
240        return '<span class="date">' . $tmp . '</span>';
241    }
242
243    /**
244     * Get the earliest calendar date from this GEDCOM date.
245     *
246     * In the date “FROM 1900 TO 1910”, this would be 1900.
247     *
248     * @return AbstractCalendarDate
249     */
250    public function minimumDate(): AbstractCalendarDate
251    {
252        return $this->date1;
253    }
254
255    /**
256     * Get the latest calendar date from this GEDCOM date.
257     *
258     * In the date “FROM 1900 TO 1910”, this would be 1910.
259     *
260     * @return AbstractCalendarDate
261     */
262    public function maximumDate(): AbstractCalendarDate
263    {
264        return $this->date2 ?? $this->date1;
265    }
266
267    /**
268     * Get the earliest Julian day number from this GEDCOM date.
269     *
270     * @return int
271     */
272    public function minimumJulianDay(): int
273    {
274        return $this->minimumDate()->minimumJulianDay();
275    }
276
277    /**
278     * Get the latest Julian day number from this GEDCOM date.
279     *
280     * @return int
281     */
282    public function maximumJulianDay(): int
283    {
284        return $this->maximumDate()->maximumJulianDay();
285    }
286
287    /**
288     * Get the middle Julian day number from the GEDCOM date.
289     *
290     * For a month-only date, this would be somewhere around the 16th day.
291     * For a year-only date, this would be somewhere around 1st July.
292     *
293     * @return int
294     */
295    public function julianDay(): int
296    {
297        return intdiv($this->minimumJulianDay() + $this->maximumJulianDay(), 2);
298    }
299
300    /**
301     * Offset this date by N years, and round to the whole year.
302     *
303     * This is typically used to create an estimated death date,
304     * which is before a certain number of years after the birth date.
305     *
306     * @param int    $years     a number of years, positive or negative
307     * @param string $qualifier typically “BEF” or “AFT”
308     *
309     * @return Date
310     */
311    public function addYears(int $years, string $qualifier = ''): Date
312    {
313        $tmp               = clone $this;
314        $tmp->date1->year  += $years;
315        $tmp->date1->month = 0;
316        $tmp->date1->day   = 0;
317        $tmp->date1->setJdFromYmd();
318        $tmp->qual1 = $qualifier;
319        $tmp->qual2 = '';
320        $tmp->date2 = null;
321
322        return $tmp;
323    }
324
325    /**
326     * Compare two dates, so they can be sorted.
327     *
328     * return -1 if $a<$b
329     * return +1 if $b>$a
330     * return  0 if dates same/overlap
331     * BEF/AFT sort as the day before/after
332     *
333     * @param Date $a
334     * @param Date $b
335     *
336     * @return int
337     */
338    public static function compare(Date $a, Date $b): int
339    {
340        // Get min/max JD for each date.
341        switch ($a->qual1) {
342            case 'BEF':
343                $amin = $a->minimumJulianDay() - 1;
344                $amax = $amin;
345                break;
346            case 'AFT':
347                $amax = $a->maximumJulianDay() + 1;
348                $amin = $amax;
349                break;
350            default:
351                $amin = $a->minimumJulianDay();
352                $amax = $a->maximumJulianDay();
353                break;
354        }
355        switch ($b->qual1) {
356            case 'BEF':
357                $bmin = $b->minimumJulianDay() - 1;
358                $bmax = $bmin;
359                break;
360            case 'AFT':
361                $bmax = $b->maximumJulianDay() + 1;
362                $bmin = $bmax;
363                break;
364            default:
365                $bmin = $b->minimumJulianDay();
366                $bmax = $b->maximumJulianDay();
367                break;
368        }
369        if ($amax < $bmin) {
370            return -1;
371        }
372
373        if ($amin > $bmax && $bmax > 0) {
374            return 1;
375        }
376
377        if ($amin < $bmin && $amax <= $bmax) {
378            return -1;
379        }
380
381        if ($amin > $bmin && $amax >= $bmax && $bmax > 0) {
382            return 1;
383        }
384
385        return 0;
386    }
387
388    /**
389     * Check whether a gedcom date contains usable calendar date(s).
390     *
391     * An incomplete date such as "12 AUG" would be invalid, as
392     * we cannot sort it.
393     *
394     * @return bool
395     */
396    public function isOK(): bool
397    {
398        return $this->minimumJulianDay() && $this->maximumJulianDay();
399    }
400
401    /**
402     * Calculate the gregorian year for a date. This should NOT be used internally
403     * within WT - we should keep the code "calendar neutral" to allow support for
404     * jewish/arabic users. This is only for interfacing with external entities,
405     * such as the ancestry.com search interface or the dated fact icons.
406     *
407     * @return int
408     */
409    public function gregorianYear(): int
410    {
411        if ($this->isOK()) {
412            $gregorian_calendar = new GregorianCalendar();
413            [$year] = $gregorian_calendar->jdToYmd($this->julianDay());
414
415            return $year;
416        }
417
418        return 0;
419    }
420}
421