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