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