1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2019 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees; 19 20use Fisharebest\Webtrees\Functions\FunctionsPrint; 21use InvalidArgumentException; 22 23/** 24 * A GEDCOM fact or event object. 25 */ 26class Fact 27{ 28 private const FACT_ORDER = [ 29 'BIRT', 30 '_HNM', 31 'ALIA', 32 '_AKA', 33 '_AKAN', 34 'ADOP', 35 '_ADPF', 36 '_ADPF', 37 '_BRTM', 38 'CHR', 39 'BAPM', 40 'FCOM', 41 'CONF', 42 'BARM', 43 'BASM', 44 'EDUC', 45 'GRAD', 46 '_DEG', 47 'EMIG', 48 'IMMI', 49 'NATU', 50 '_MILI', 51 '_MILT', 52 'ENGA', 53 'MARB', 54 'MARC', 55 'MARL', 56 '_MARI', 57 '_MBON', 58 'MARR', 59 'MARR_CIVIL', 60 'MARR_RELIGIOUS', 61 'MARR_PARTNERS', 62 'MARR_UNKNOWN', 63 '_COML', 64 '_STAT', 65 '_SEPR', 66 'DIVF', 67 'MARS', 68 '_BIRT_CHIL', 69 'DIV', 70 'ANUL', 71 '_BIRT_', 72 '_MARR_', 73 '_DEAT_', 74 '_BURI_', 75 'CENS', 76 'OCCU', 77 'RESI', 78 'PROP', 79 'CHRA', 80 'RETI', 81 'FACT', 82 'EVEN', 83 '_NMR', 84 '_NMAR', 85 'NMR', 86 'NCHI', 87 'WILL', 88 '_HOL', 89 '_????_', 90 'DEAT', 91 '_FNRL', 92 'CREM', 93 'BURI', 94 '_INTE', 95 '_YART', 96 '_NLIV', 97 'PROB', 98 'TITL', 99 'COMM', 100 'NATI', 101 'CITN', 102 'CAST', 103 'RELI', 104 'SSN', 105 'IDNO', 106 'TEMP', 107 'SLGC', 108 'BAPL', 109 'CONL', 110 'ENDL', 111 'SLGS', 112 'ADDR', 113 'PHON', 114 'EMAIL', 115 '_EMAIL', 116 'EMAL', 117 'FAX', 118 'WWW', 119 'URL', 120 '_URL', 121 'AFN', 122 'REFN', 123 '_PRMN', 124 'REF', 125 'RIN', 126 '_UID', 127 'OBJE', 128 'NOTE', 129 'SOUR', 130 'CHAN', 131 '_TODO', 132 ]; 133 134 /** @var string Unique identifier for this fact (currently implemented as a hash of the raw data). */ 135 private $id; 136 137 /** @var GedcomRecord The GEDCOM record from which this fact is taken */ 138 private $record; 139 140 /** @var string The raw GEDCOM data for this fact */ 141 private $gedcom; 142 143 /** @var string The GEDCOM tag for this record */ 144 private $tag; 145 146 /** @var bool Is this a recently deleted fact, pending approval? */ 147 private $pending_deletion = false; 148 149 /** @var bool Is this a recently added fact, pending approval? */ 150 private $pending_addition = false; 151 152 /** @var Date The date of this fact, from the “2 DATE …” attribute */ 153 private $date; 154 155 /** @var Place The place of this fact, from the “2 PLAC …” attribute */ 156 private $place; 157 158 /** @var int Temporary(!) variable Used by Functions::sortFacts() */ 159 public $sortOrder; 160 161 /** 162 * Create an event object from a gedcom fragment. 163 * We need the parent object (to check privacy) and a (pseudo) fact ID to 164 * identify the fact within the record. 165 * 166 * @param string $gedcom 167 * @param GedcomRecord $parent 168 * @param string $id 169 * 170 * @throws InvalidArgumentException 171 */ 172 public function __construct($gedcom, GedcomRecord $parent, $id) 173 { 174 if (preg_match('/^1 (' . Gedcom::REGEX_TAG . ')/', $gedcom, $match)) { 175 $this->gedcom = $gedcom; 176 $this->record = $parent; 177 $this->id = $id; 178 $this->tag = $match[1]; 179 } else { 180 throw new InvalidArgumentException('Invalid GEDCOM data passed to Fact::_construct(' . $gedcom . ')'); 181 } 182 } 183 184 /** 185 * Get the value of level 1 data in the fact 186 * Allow for multi-line values 187 * 188 * @return string 189 */ 190 public function value(): string 191 { 192 if (preg_match('/^1 (?:' . $this->tag . ') ?(.*(?:(?:\n2 CONT ?.*)*))/', $this->gedcom, $match)) { 193 return preg_replace("/\n2 CONT ?/", "\n", $match[1]); 194 } 195 196 return ''; 197 } 198 199 /** 200 * Get the record to which this fact links 201 * 202 * @return Individual|Family|Source|Repository|Media|Note|GedcomRecord|null 203 */ 204 public function target() 205 { 206 $xref = trim($this->value(), '@'); 207 switch ($this->tag) { 208 case 'FAMC': 209 case 'FAMS': 210 return Family::getInstance($xref, $this->record()->tree()); 211 case 'HUSB': 212 case 'WIFE': 213 case 'CHIL': 214 return Individual::getInstance($xref, $this->record()->tree()); 215 case 'SOUR': 216 return Source::getInstance($xref, $this->record()->tree()); 217 case 'OBJE': 218 return Media::getInstance($xref, $this->record()->tree()); 219 case 'REPO': 220 return Repository::getInstance($xref, $this->record()->tree()); 221 case 'NOTE': 222 return Note::getInstance($xref, $this->record()->tree()); 223 default: 224 return GedcomRecord::getInstance($xref, $this->record()->tree()); 225 } 226 } 227 228 /** 229 * Get the value of level 2 data in the fact 230 * 231 * @param string $tag 232 * 233 * @return string 234 */ 235 public function attribute($tag): string 236 { 237 if (preg_match('/\n2 (?:' . $tag . ') ?(.*(?:(?:\n3 CONT ?.*)*)*)/', $this->gedcom, $match)) { 238 return preg_replace("/\n3 CONT ?/", "\n", $match[1]); 239 } 240 241 return ''; 242 } 243 244 /** 245 * Do the privacy rules allow us to display this fact to the current user 246 * 247 * @param int|null $access_level 248 * 249 * @return bool 250 */ 251 public function canShow(int $access_level = null): bool 252 { 253 if ($access_level === null) { 254 $access_level = Auth::accessLevel($this->record()->tree()); 255 } 256 257 // Does this record have an explicit RESN? 258 if (strpos($this->gedcom, "\n2 RESN confidential") !== false) { 259 return Auth::PRIV_NONE >= $access_level; 260 } 261 if (strpos($this->gedcom, "\n2 RESN privacy") !== false) { 262 return Auth::PRIV_USER >= $access_level; 263 } 264 if (strpos($this->gedcom, "\n2 RESN none") !== false) { 265 return true; 266 } 267 268 // Does this record have a default RESN? 269 $xref = $this->record->xref(); 270 $fact_privacy = $this->record->tree()->getFactPrivacy(); 271 $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy(); 272 if (isset($individual_fact_privacy[$xref][$this->tag])) { 273 return $individual_fact_privacy[$xref][$this->tag] >= $access_level; 274 } 275 if (isset($fact_privacy[$this->tag])) { 276 return $fact_privacy[$this->tag] >= $access_level; 277 } 278 279 // No restrictions - it must be public 280 return true; 281 } 282 283 /** 284 * Check whether this fact is protected against edit 285 * 286 * @return bool 287 */ 288 public function canEdit(): bool 289 { 290 // Managers can edit anything 291 // Members cannot edit RESN, CHAN and locked records 292 return 293 $this->record->canEdit() && !$this->isPendingDeletion() && ( 294 Auth::isManager($this->record->tree()) || 295 Auth::isEditor($this->record->tree()) && strpos($this->gedcom, "\n2 RESN locked") === false && $this->getTag() != 'RESN' && $this->getTag() != 'CHAN' 296 ); 297 } 298 299 /** 300 * The place where the event occured. 301 * 302 * @return Place 303 */ 304 public function place(): Place 305 { 306 if ($this->place === null) { 307 $this->place = new Place($this->attribute('PLAC'), $this->record()->tree()); 308 } 309 310 return $this->place; 311 } 312 313 /** 314 * Get the date for this fact. 315 * We can call this function many times, especially when sorting, 316 * so keep a copy of the date. 317 * 318 * @return Date 319 */ 320 public function date(): Date 321 { 322 if ($this->date === null) { 323 $this->date = new Date($this->attribute('DATE')); 324 } 325 326 return $this->date; 327 } 328 329 /** 330 * The raw GEDCOM data for this fact 331 * 332 * @return string 333 */ 334 public function gedcom(): string 335 { 336 return $this->gedcom; 337 } 338 339 /** 340 * Get a (pseudo) primary key for this fact. 341 * 342 * @return string 343 */ 344 public function id(): string 345 { 346 return $this->id; 347 } 348 349 /** 350 * What is the tag (type) of this fact, such as BIRT, MARR or DEAT. 351 * 352 * @return string 353 */ 354 public function getTag(): string 355 { 356 return $this->tag; 357 } 358 359 /** 360 * Used to convert a real fact (e.g. BIRT) into a close-relative’s fact (e.g. _BIRT_CHIL) 361 * 362 * @param string $tag 363 * 364 * @return void 365 */ 366 public function setTag($tag) 367 { 368 $this->tag = $tag; 369 } 370 371 /** 372 * The Person/Family record where this Fact came from 373 * 374 * @return Individual|Family|Source|Repository|Media|Note|GedcomRecord 375 */ 376 public function record() 377 { 378 return $this->record; 379 } 380 381 /** 382 * Get the name of this fact type, for use as a label. 383 * 384 * @return string 385 */ 386 public function label(): string 387 { 388 // Custom FACT/EVEN - with a TYPE 389 if (($this->tag === 'FACT' || $this->tag === 'EVEN') && $this->attribute('TYPE') !== '') { 390 return I18N::translate(e($this->attribute('TYPE'))); 391 } 392 393 return GedcomTag::getLabel($this->tag, $this->record); 394 } 395 396 /** 397 * This is a newly deleted fact, pending approval. 398 * 399 * @return void 400 */ 401 public function setPendingDeletion() 402 { 403 $this->pending_deletion = true; 404 $this->pending_addition = false; 405 } 406 407 /** 408 * Is this a newly deleted fact, pending approval. 409 * 410 * @return bool 411 */ 412 public function isPendingDeletion(): bool 413 { 414 return $this->pending_deletion; 415 } 416 417 /** 418 * This is a newly added fact, pending approval. 419 * 420 * @return void 421 */ 422 public function setPendingAddition() 423 { 424 $this->pending_addition = true; 425 $this->pending_deletion = false; 426 } 427 428 /** 429 * Is this a newly added fact, pending approval. 430 * 431 * @return bool 432 */ 433 public function isPendingAddition(): bool 434 { 435 return $this->pending_addition; 436 } 437 438 /** 439 * Source citations linked to this fact 440 * 441 * @return string[] 442 */ 443 public function getCitations(): array 444 { 445 preg_match_all('/\n(2 SOUR @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->gedcom(), $matches, PREG_SET_ORDER); 446 $citations = []; 447 foreach ($matches as $match) { 448 $source = Source::getInstance($match[2], $this->record()->tree()); 449 if ($source && $source->canShow()) { 450 $citations[] = $match[1]; 451 } 452 } 453 454 return $citations; 455 } 456 457 /** 458 * Notes (inline and objects) linked to this fact 459 * 460 * @return string[]|Note[] 461 */ 462 public function getNotes(): array 463 { 464 $notes = []; 465 preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->gedcom(), $matches); 466 foreach ($matches[1] as $match) { 467 $note = preg_replace("/\n3 CONT ?/", "\n", $match); 468 if (preg_match('/@(' . Gedcom::REGEX_XREF . ')@/', $note, $nmatch)) { 469 $note = Note::getInstance($nmatch[1], $this->record()->tree()); 470 if ($note && $note->canShow()) { 471 // A note object 472 $notes[] = $note; 473 } 474 } else { 475 // An inline note 476 $notes[] = $note; 477 } 478 } 479 480 return $notes; 481 } 482 483 /** 484 * Media objects linked to this fact 485 * 486 * @return Media[] 487 */ 488 public function getMedia(): array 489 { 490 $media = []; 491 preg_match_all('/\n2 OBJE @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom(), $matches); 492 foreach ($matches[1] as $match) { 493 $obje = Media::getInstance($match, $this->record()->tree()); 494 if ($obje && $obje->canShow()) { 495 $media[] = $obje; 496 } 497 } 498 499 return $media; 500 } 501 502 /** 503 * A one-line summary of the fact - for charts, etc. 504 * 505 * @return string 506 */ 507 public function summary(): string 508 { 509 $attributes = []; 510 $target = $this->target(); 511 if ($target instanceof GedcomRecord) { 512 $attributes[] = $target->getFullName(); 513 } else { 514 // Fact value 515 $value = $this->value(); 516 if ($value !== '' && $value !== 'Y') { 517 $attributes[] = '<span dir="auto">' . e($value) . '</span>'; 518 } 519 // Fact date 520 $date = $this->date(); 521 if ($date->isOK()) { 522 if (in_array($this->getTag(), Gedcom::BIRTH_EVENTS) && $this->record() instanceof Individual && $this->record()->tree()->getPreference('SHOW_PARENTS_AGE')) { 523 $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->record(), $date); 524 } else { 525 $attributes[] = $date->display(); 526 } 527 } 528 // Fact place 529 if (!$this->place()->isEmpty()) { 530 $attributes[] = $this->place()->getShortName(); 531 } 532 } 533 534 $class = 'fact_' . $this->getTag(); 535 if ($this->isPendingAddition()) { 536 $class .= ' new'; 537 } elseif ($this->isPendingDeletion()) { 538 $class .= ' old'; 539 } 540 541 return 542 '<div class="' . $class . '">' . 543 /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */ 544 I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->label(), implode(' — ', $attributes)) . 545 '</div>'; 546 } 547 548 /** 549 * Static Helper functions to sort events 550 * 551 * @param Fact $a Fact one 552 * @param Fact $b Fact two 553 * 554 * @return int 555 */ 556 public static function compareDate(Fact $a, Fact $b): int 557 { 558 if ($a->date()->isOK() && $b->date()->isOK()) { 559 // If both events have dates, compare by date 560 $ret = Date::compare($a->date(), $b->date()); 561 562 if ($ret === 0) { 563 // If dates are the same, compare by fact type 564 $ret = self::compareType($a, $b); 565 566 // If the fact type is also the same, retain the initial order 567 if ($ret === 0) { 568 $ret = $a->sortOrder - $b->sortOrder; 569 } 570 } 571 572 return $ret; 573 } 574 575 // One or both events have no date - retain the initial order 576 return $a->sortOrder - $b->sortOrder; 577 } 578 579 /** 580 * Static method to compare two events by their type. 581 * 582 * @param Fact $a Fact one 583 * @param Fact $b Fact two 584 * 585 * @return int 586 */ 587 public static function compareType(Fact $a, Fact $b): int 588 { 589 static $factsort = []; 590 591 if (empty($factsort)) { 592 $factsort = array_flip(self::FACT_ORDER); 593 } 594 595 // Facts from same families stay grouped together 596 // Keep MARR and DIV from the same families from mixing with events from other FAMs 597 // Use the original order in which the facts were added 598 if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) { 599 return $a->sortOrder - $b->sortOrder; 600 } 601 602 $atag = $a->getTag(); 603 $btag = $b->getTag(); 604 605 // Events not in the above list get mapped onto one that is. 606 if (!array_key_exists($atag, $factsort)) { 607 if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $atag, $match)) { 608 $atag = $match[1]; 609 } else { 610 $atag = '_????_'; 611 } 612 } 613 614 if (!array_key_exists($btag, $factsort)) { 615 if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $btag, $match)) { 616 $btag = $match[1]; 617 } else { 618 $btag = '_????_'; 619 } 620 } 621 622 // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI 623 // - Treat dated after BURI facts as BURI instead 624 if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) { 625 $atag = 'BURI'; 626 } 627 628 if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) { 629 $btag = 'BURI'; 630 } 631 632 $ret = $factsort[$atag] - $factsort[$btag]; 633 634 // If facts are the same then put dated facts before non-dated facts 635 if ($ret == 0) { 636 if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') { 637 return -1; 638 } 639 640 if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') { 641 return 1; 642 } 643 644 // If no sorting preference, then keep original ordering 645 $ret = $a->sortOrder - $b->sortOrder; 646 } 647 648 return $ret; 649 } 650 651 /** 652 * Allow native PHP functions such as array_unique() to work with objects 653 * 654 * @return string 655 */ 656 public function __toString(): string 657 { 658 return $this->id . '@' . $this->record->xref(); 659 } 660} 661