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