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