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