xref: /webtrees/app/Date.php (revision b9a3cd35b03788204f73481cf07bb14e1dabd221)
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                if ($this->text !== '') {
183                    $tmp .= '(' . e($this->text) . ')';
184                }
185                break;
186            case 'ABT':
187                /* I18N: Gedcom ABT dates */
188                $tmp = I18N::translate('about %s', $d1 . $conv1);
189                break;
190            case 'CAL':
191                /* I18N: Gedcom CAL dates */
192                $tmp = I18N::translate('calculated %s', $d1 . $conv1);
193                break;
194            case 'EST':
195                /* I18N: Gedcom EST dates */
196                $tmp = I18N::translate('estimated %s', $d1 . $conv1);
197                break;
198            case 'INT':
199                /* I18N: Gedcom INT dates */
200                $tmp = I18N::translate('interpreted %s (%s)', $d1 . $conv1, e($this->text));
201                break;
202            case 'BEF':
203                /* I18N: Gedcom BEF dates */
204                $tmp = I18N::translate('before %s', $d1 . $conv1);
205                break;
206            case 'AFT':
207                /* I18N: Gedcom AFT dates */
208                $tmp = I18N::translate('after %s', $d1 . $conv1);
209                break;
210            case 'FROM':
211                /* I18N: Gedcom FROM dates */
212                $tmp = I18N::translate('from %s', $d1 . $conv1);
213                break;
214            case 'TO':
215                /* I18N: Gedcom TO dates */
216                $tmp = I18N::translate('to %s', $d1 . $conv1);
217                break;
218            case 'BETAND':
219                /* I18N: Gedcom BET-AND dates */
220                $tmp = I18N::translate('between %s and %s', $d1 . $conv1, $d2 . $conv2);
221                break;
222            case 'FROMTO':
223                /* I18N: Gedcom FROM-TO dates */
224                $tmp = I18N::translate('from %s to %s', $d1 . $conv1, $d2 . $conv2);
225                break;
226            default:
227                $tmp = I18N::translate('Invalid date');
228                break;
229        }
230
231        if (strip_tags($tmp) === '') {
232            return '';
233        }
234
235        return '<span class="date">' . $tmp . '</span>';
236    }
237
238    /**
239     * Get the earliest calendar date from this GEDCOM date.
240     *
241     * In the date “FROM 1900 TO 1910”, this would be 1900.
242     *
243     * @return AbstractCalendarDate
244     */
245    public function minimumDate(): AbstractCalendarDate
246    {
247        return $this->date1;
248    }
249
250    /**
251     * Get the latest calendar date from this GEDCOM date.
252     *
253     * In the date “FROM 1900 TO 1910”, this would be 1910.
254     *
255     * @return AbstractCalendarDate
256     */
257    public function maximumDate(): AbstractCalendarDate
258    {
259        return $this->date2 ?? $this->date1;
260    }
261
262    /**
263     * Get the earliest Julian day number from this GEDCOM date.
264     *
265     * @return int
266     */
267    public function minimumJulianDay(): int
268    {
269        return $this->minimumDate()->minimumJulianDay();
270    }
271
272    /**
273     * Get the latest Julian day number from this GEDCOM date.
274     *
275     * @return int
276     */
277    public function maximumJulianDay(): int
278    {
279        return $this->maximumDate()->maximumJulianDay();
280    }
281
282    /**
283     * Get the middle Julian day number from the GEDCOM date.
284     *
285     * For a month-only date, this would be somewhere around the 16th day.
286     * For a year-only date, this would be somewhere around 1st July.
287     *
288     * @return int
289     */
290    public function julianDay(): int
291    {
292        return intdiv($this->minimumJulianDay() + $this->maximumJulianDay(), 2);
293    }
294
295    /**
296     * Offset this date by N years, and round to the whole year.
297     *
298     * This is typically used to create an estimated death date,
299     * which is before a certain number of years after the birth date.
300     *
301     * @param int    $years     a number of years, positive or negative
302     * @param string $qualifier typically “BEF” or “AFT”
303     *
304     * @return Date
305     */
306    public function addYears(int $years, string $qualifier = ''): Date
307    {
308        $tmp               = clone $this;
309        $tmp->date1->year  += $years;
310        $tmp->date1->month = 0;
311        $tmp->date1->day   = 0;
312        $tmp->date1->setJdFromYmd();
313        $tmp->qual1 = $qualifier;
314        $tmp->qual2 = '';
315        $tmp->date2 = null;
316
317        return $tmp;
318    }
319
320    /**
321     * Compare two dates, so they can be sorted.
322     *
323     * return -1 if $a<$b
324     * return +1 if $b>$a
325     * return  0 if dates same/overlap
326     * BEF/AFT sort as the day before/after
327     *
328     * @param Date $a
329     * @param Date $b
330     *
331     * @return int
332     */
333    public static function compare(Date $a, Date $b): int
334    {
335        // Get min/max JD for each date.
336        switch ($a->qual1) {
337            case 'BEF':
338                $amin = $a->minimumJulianDay() - 1;
339                $amax = $amin;
340                break;
341            case 'AFT':
342                $amax = $a->maximumJulianDay() + 1;
343                $amin = $amax;
344                break;
345            default:
346                $amin = $a->minimumJulianDay();
347                $amax = $a->maximumJulianDay();
348                break;
349        }
350        switch ($b->qual1) {
351            case 'BEF':
352                $bmin = $b->minimumJulianDay() - 1;
353                $bmax = $bmin;
354                break;
355            case 'AFT':
356                $bmax = $b->maximumJulianDay() + 1;
357                $bmin = $bmax;
358                break;
359            default:
360                $bmin = $b->minimumJulianDay();
361                $bmax = $b->maximumJulianDay();
362                break;
363        }
364        if ($amax < $bmin) {
365            return -1;
366        }
367
368        if ($amin > $bmax && $bmax > 0) {
369            return 1;
370        }
371
372        if ($amin < $bmin && $amax <= $bmax) {
373            return -1;
374        }
375
376        if ($amin > $bmin && $amax >= $bmax && $bmax > 0) {
377            return 1;
378        }
379
380        return 0;
381    }
382
383    /**
384     * Check whether a gedcom date contains usable calendar date(s).
385     *
386     * An incomplete date such as "12 AUG" would be invalid, as
387     * we cannot sort it.
388     *
389     * @return bool
390     */
391    public function isOK(): bool
392    {
393        return $this->minimumJulianDay() && $this->maximumJulianDay();
394    }
395
396    /**
397     * Calculate the gregorian year for a date. This should NOT be used internally
398     * within WT - we should keep the code "calendar neutral" to allow support for
399     * jewish/arabic users. This is only for interfacing with external entities,
400     * such as the ancestry.com search interface or the dated fact icons.
401     *
402     * @return int
403     */
404    public function gregorianYear(): int
405    {
406        if ($this->isOK()) {
407            $gregorian_calendar = new GregorianCalendar();
408            [$year] = $gregorian_calendar->jdToYmd($this->julianDay());
409
410            return $year;
411        }
412
413        return 0;
414    }
415}
416