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