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