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