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