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