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