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