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