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