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