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