1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2020 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 <http://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 strpos; 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 'MARR_CIVIL', 79 'MARR_RELIGIOUS', 80 'MARR_PARTNERS', 81 'MARR_UNKNOWN', 82 '_COML', 83 '_STAT', 84 '_SEPR', 85 'DIVF', 86 'MARS', 87 'DIV', 88 'ANUL', 89 'CENS', 90 'OCCU', 91 'RESI', 92 'PROP', 93 'CHRA', 94 'RETI', 95 'FACT', 96 'EVEN', 97 '_NMR', 98 '_NMAR', 99 'NMR', 100 'NCHI', 101 'WILL', 102 '_HOL', 103 '_????_', 104 'DEAT', 105 '_FNRL', 106 'CREM', 107 'BURI', 108 '_INTE', 109 '_YART', 110 '_NLIV', 111 'PROB', 112 'TITL', 113 'COMM', 114 'NATI', 115 'CITN', 116 'CAST', 117 'RELI', 118 'SSN', 119 'IDNO', 120 'TEMP', 121 'SLGC', 122 'BAPL', 123 'CONL', 124 'ENDL', 125 'SLGS', 126 'ADDR', 127 'PHON', 128 'EMAIL', 129 '_EMAIL', 130 'EMAL', 131 'FAX', 132 'WWW', 133 'URL', 134 '_URL', 135 'AFN', 136 'REFN', 137 '_PRMN', 138 'REF', 139 'RIN', 140 '_UID', 141 'OBJE', 142 'NOTE', 143 'SOUR', 144 'CHAN', 145 '_TODO', 146 ]; 147 148 /** @var string Unique identifier for this fact (currently implemented as a hash of the raw data). */ 149 private $id; 150 151 /** @var GedcomRecord The GEDCOM record from which this fact is taken */ 152 private $record; 153 154 /** @var string The raw GEDCOM data for this fact */ 155 private $gedcom; 156 157 /** @var string The GEDCOM tag for this record */ 158 private $tag; 159 160 /** @var bool Is this a recently deleted fact, pending approval? */ 161 private $pending_deletion = false; 162 163 /** @var bool Is this a recently added fact, pending approval? */ 164 private $pending_addition = false; 165 166 /** @var Date The date of this fact, from the “2 DATE …” attribute */ 167 private $date; 168 169 /** @var Place The place of this fact, from the “2 PLAC …” attribute */ 170 private $place; 171 172 /** @var int Used by Functions::sortFacts() */ 173 private $sortOrder; 174 175 /** 176 * Create an event object from a gedcom fragment. 177 * We need the parent object (to check privacy) and a (pseudo) fact ID to 178 * identify the fact within the record. 179 * 180 * @param string $gedcom 181 * @param GedcomRecord $parent 182 * @param string $id 183 * 184 * @throws InvalidArgumentException 185 */ 186 public function __construct($gedcom, GedcomRecord $parent, $id) 187 { 188 if (preg_match('/^1 (' . Gedcom::REGEX_TAG . ')/', $gedcom, $match)) { 189 $this->gedcom = $gedcom; 190 $this->record = $parent; 191 $this->id = $id; 192 $this->tag = $match[1]; 193 } else { 194 throw new InvalidArgumentException('Invalid GEDCOM data passed to Fact::_construct(' . $gedcom . ')'); 195 } 196 } 197 198 /** 199 * Get the value of level 1 data in the fact 200 * Allow for multi-line values 201 * 202 * @return string 203 */ 204 public function value(): string 205 { 206 if (preg_match('/^1 (?:' . $this->tag . ') ?(.*(?:(?:\n2 CONT ?.*)*))/', $this->gedcom, $match)) { 207 return preg_replace("/\n2 CONT ?/", "\n", $match[1]); 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 Factory::family()->make($xref, $this->record()->tree()); 230 case 'HUSB': 231 case 'WIFE': 232 case 'ALIA': 233 case 'CHIL': 234 case '_ASSO': 235 return Factory::individual()->make($xref, $this->record()->tree()); 236 case 'ASSO': 237 return 238 Factory::individual()->make($xref, $this->record()->tree()) ?? 239 Factory::submitter()->make($xref, $this->record()->tree()); 240 case 'SOUR': 241 return Factory::source()->make($xref, $this->record()->tree()); 242 case 'OBJE': 243 return Factory::media()->make($xref, $this->record()->tree()); 244 case 'REPO': 245 return Factory::repository()->make($xref, $this->record()->tree()); 246 case 'NOTE': 247 return Factory::note()->make($xref, $this->record()->tree()); 248 case 'ANCI': 249 case 'DESI': 250 case 'SUBM': 251 return Factory::submitter()->make($xref, $this->record()->tree()); 252 case 'SUBN': 253 return Factory::submission()->make($xref, $this->record()->tree()); 254 case '_LOC': 255 return Factory::location()->make($xref, $this->record()->tree()); 256 default: 257 return Factory::gedcomRecord()->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($tag): string 269 { 270 if (preg_match('/\n2 (?:' . $tag . ') ?(.*(?:(?:\n3 CONT ?.*)*)*)/', $this->gedcom, $match)) { 271 return preg_replace("/\n3 CONT ?/", "\n", $match[1]); 272 } 273 274 return ''; 275 } 276 277 /** 278 * Get the PLAC:MAP:LATI for the fact. 279 * 280 * @return float 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 0.0; 291 } 292 293 /** 294 * Get the PLAC:MAP:LONG for the fact. 295 * 296 * @return float 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 0.0; 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 $access_level = null): bool 317 { 318 $access_level = $access_level ?? Auth::accessLevel($this->record->tree()); 319 320 // Does this record have an explicit RESN? 321 if (strpos($this->gedcom, "\n2 RESN confidential") !== false) { 322 return Auth::PRIV_NONE >= $access_level; 323 } 324 if (strpos($this->gedcom, "\n2 RESN privacy") !== false) { 325 return Auth::PRIV_USER >= $access_level; 326 } 327 if (strpos($this->gedcom, "\n2 RESN none") !== false) { 328 return true; 329 } 330 331 // Does this record have a default RESN? 332 $xref = $this->record->xref(); 333 $fact_privacy = $this->record->tree()->getFactPrivacy(); 334 $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy(); 335 if (isset($individual_fact_privacy[$xref][$this->tag])) { 336 return $individual_fact_privacy[$xref][$this->tag] >= $access_level; 337 } 338 if (isset($fact_privacy[$this->tag])) { 339 return $fact_privacy[$this->tag] >= $access_level; 340 } 341 342 // No restrictions - it must be public 343 return true; 344 } 345 346 /** 347 * Check whether this fact is protected against edit 348 * 349 * @return bool 350 */ 351 public function canEdit(): bool 352 { 353 if ($this->isPendingDeletion()) { 354 return false; 355 } 356 357 if (Auth::isManager($this->record->tree())) { 358 return true; 359 } 360 361 // Members cannot edit RESN, CHAN and locked records 362 return Auth::isEditor($this->record->tree()) && strpos($this->gedcom, "\n2 RESN locked") === false && $this->tag() !== 'RESN' && $this->tag() !== 'CHAN'; 363 } 364 365 /** 366 * The place where the event occured. 367 * 368 * @return Place 369 */ 370 public function place(): Place 371 { 372 if ($this->place === null) { 373 $this->place = new Place($this->attribute('PLAC'), $this->record()->tree()); 374 } 375 376 return $this->place; 377 } 378 379 /** 380 * Get the date for this fact. 381 * We can call this function many times, especially when sorting, 382 * so keep a copy of the date. 383 * 384 * @return Date 385 */ 386 public function date(): Date 387 { 388 if ($this->date === null) { 389 $this->date = new Date($this->attribute('DATE')); 390 } 391 392 return $this->date; 393 } 394 395 /** 396 * The raw GEDCOM data for this fact 397 * 398 * @return string 399 */ 400 public function gedcom(): string 401 { 402 return $this->gedcom; 403 } 404 405 /** 406 * Get a (pseudo) primary key for this fact. 407 * 408 * @return string 409 */ 410 public function id(): string 411 { 412 return $this->id; 413 } 414 415 /** 416 * What is the tag (type) of this fact, such as BIRT, MARR or DEAT. 417 * 418 * @return string 419 */ 420 public function tag(): string 421 { 422 return $this->tag; 423 } 424 425 /** 426 * What is the tag (type) of this fact, such as BIRT, MARR or DEAT. 427 * 428 * @return string 429 * 430 * @deprecated since 2.0.5. Will be removed in 2.1.0 431 */ 432 public function getTag(): string 433 { 434 return $this->tag(); 435 } 436 437 /** 438 * Used to convert a real fact (e.g. BIRT) into a close-relative’s fact (e.g. _BIRT_CHIL) 439 * 440 * @param string $tag 441 * 442 * @return void 443 * 444 * @deprecated since 2.0.5. Will be removed in 2.1.0 445 */ 446 public function setTag($tag): void 447 { 448 $this->tag = $tag; 449 } 450 451 /** 452 * The Person/Family record where this Fact came from 453 * 454 * @return Individual|Family|Source|Repository|Media|Note|Submitter|Submission|Location|Header|GedcomRecord 455 */ 456 public function record() 457 { 458 return $this->record; 459 } 460 461 /** 462 * Get the name of this fact type, for use as a label. 463 * 464 * @return string 465 */ 466 public function label(): string 467 { 468 // Custom FACT/EVEN - with a TYPE 469 if (($this->tag === 'FACT' || $this->tag === 'EVEN') && $this->attribute('TYPE') !== '') { 470 return I18N::translate(e($this->attribute('TYPE'))); 471 } 472 473 return GedcomTag::getLabel($this->tag, $this->record); 474 } 475 476 /** 477 * This is a newly deleted fact, pending approval. 478 * 479 * @return void 480 */ 481 public function setPendingDeletion(): void 482 { 483 $this->pending_deletion = true; 484 $this->pending_addition = false; 485 } 486 487 /** 488 * Is this a newly deleted fact, pending approval. 489 * 490 * @return bool 491 */ 492 public function isPendingDeletion(): bool 493 { 494 return $this->pending_deletion; 495 } 496 497 /** 498 * This is a newly added fact, pending approval. 499 * 500 * @return void 501 */ 502 public function setPendingAddition(): void 503 { 504 $this->pending_addition = true; 505 $this->pending_deletion = false; 506 } 507 508 /** 509 * Is this a newly added fact, pending approval. 510 * 511 * @return bool 512 */ 513 public function isPendingAddition(): bool 514 { 515 return $this->pending_addition; 516 } 517 518 /** 519 * Source citations linked to this fact 520 * 521 * @return string[] 522 */ 523 public function getCitations(): array 524 { 525 preg_match_all('/\n(2 SOUR @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->gedcom(), $matches, PREG_SET_ORDER); 526 $citations = []; 527 foreach ($matches as $match) { 528 $source = Factory::source()->make($match[2], $this->record()->tree()); 529 if ($source && $source->canShow()) { 530 $citations[] = $match[1]; 531 } 532 } 533 534 return $citations; 535 } 536 537 /** 538 * Notes (inline and objects) linked to this fact 539 * 540 * @return string[]|Note[] 541 */ 542 public function getNotes(): array 543 { 544 $notes = []; 545 preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->gedcom(), $matches); 546 foreach ($matches[1] as $match) { 547 $note = preg_replace("/\n3 CONT ?/", "\n", $match); 548 if (preg_match('/@(' . Gedcom::REGEX_XREF . ')@/', $note, $nmatch)) { 549 $note = Factory::note()->make($nmatch[1], $this->record()->tree()); 550 if ($note && $note->canShow()) { 551 // A note object 552 $notes[] = $note; 553 } 554 } else { 555 // An inline note 556 $notes[] = $note; 557 } 558 } 559 560 return $notes; 561 } 562 563 /** 564 * Media objects linked to this fact 565 * 566 * @return Media[] 567 */ 568 public function getMedia(): array 569 { 570 $media = []; 571 preg_match_all('/\n2 OBJE @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom(), $matches); 572 foreach ($matches[1] as $match) { 573 $obje = Factory::media()->make($match, $this->record()->tree()); 574 if ($obje && $obje->canShow()) { 575 $media[] = $obje; 576 } 577 } 578 579 return $media; 580 } 581 582 /** 583 * A one-line summary of the fact - for charts, etc. 584 * 585 * @return string 586 */ 587 public function summary(): string 588 { 589 $attributes = []; 590 $target = $this->target(); 591 if ($target instanceof GedcomRecord) { 592 $attributes[] = $target->fullName(); 593 } else { 594 // Fact value 595 $value = $this->value(); 596 if ($value !== '' && $value !== 'Y') { 597 $attributes[] = '<span dir="auto">' . e($value) . '</span>'; 598 } 599 // Fact date 600 $date = $this->date(); 601 if ($date->isOK()) { 602 if ($this->record() instanceof Individual && in_array($this->tag(), Gedcom::BIRTH_EVENTS, true) && $this->record()->tree()->getPreference('SHOW_PARENTS_AGE')) { 603 $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->record(), $date); 604 } else { 605 $attributes[] = $date->display(); 606 } 607 } 608 // Fact place 609 if ($this->place()->gedcomName() !== '') { 610 $attributes[] = $this->place()->shortName(); 611 } 612 } 613 614 $class = 'fact_' . $this->tag(); 615 if ($this->isPendingAddition()) { 616 $class .= ' wt-new'; 617 } elseif ($this->isPendingDeletion()) { 618 $class .= ' wt-old'; 619 } 620 621 return 622 '<div class="' . $class . '">' . 623 /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */ 624 I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->label(), implode(' — ', $attributes)) . 625 '</div>'; 626 } 627 628 /** 629 * Helper functions to sort facts 630 * 631 * @return Closure 632 */ 633 private static function dateComparator(): Closure 634 { 635 return static function (Fact $a, Fact $b): int { 636 if ($a->date()->isOK() && $b->date()->isOK()) { 637 // If both events have dates, compare by date 638 $ret = Date::compare($a->date(), $b->date()); 639 640 if ($ret === 0) { 641 // If dates overlap, compare by fact type 642 $ret = self::typeComparator()($a, $b); 643 644 // If the fact type is also the same, retain the initial order 645 if ($ret === 0) { 646 $ret = $a->sortOrder <=> $b->sortOrder; 647 } 648 } 649 650 return $ret; 651 } 652 653 // One or both events have no date - retain the initial order 654 return $a->sortOrder <=> $b->sortOrder; 655 }; 656 } 657 658 /** 659 * Helper functions to sort facts. 660 * 661 * @return Closure 662 */ 663 public static function typeComparator(): Closure 664 { 665 static $factsort = []; 666 667 if ($factsort === []) { 668 $factsort = array_flip(self::FACT_ORDER); 669 } 670 671 return static function (Fact $a, Fact $b) use ($factsort): int { 672 // Facts from same families stay grouped together 673 // Keep MARR and DIV from the same families from mixing with events from other FAMs 674 // Use the original order in which the facts were added 675 if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) { 676 return $a->sortOrder - $b->sortOrder; 677 } 678 679 $atag = $a->tag(); 680 $btag = $b->tag(); 681 682 // Events not in the above list get mapped onto one that is. 683 if (!array_key_exists($atag, $factsort)) { 684 $atag = '_????_'; 685 } 686 687 if (!array_key_exists($btag, $factsort)) { 688 $btag = '_????_'; 689 } 690 691 // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI 692 // - Treat dated after BURI facts as BURI instead 693 if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) { 694 $atag = 'BURI'; 695 } 696 697 if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) { 698 $btag = 'BURI'; 699 } 700 701 $ret = $factsort[$atag] - $factsort[$btag]; 702 703 // If facts are the same then put dated facts before non-dated facts 704 if ($ret == 0) { 705 if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') { 706 return -1; 707 } 708 709 if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') { 710 return 1; 711 } 712 713 // If no sorting preference, then keep original ordering 714 $ret = $a->sortOrder - $b->sortOrder; 715 } 716 717 return $ret; 718 }; 719 } 720 721 /** 722 * A multi-key sort 723 * 1. First divide the facts into two arrays one set with dates and one set without dates 724 * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated 725 * using the compare type function 726 * 3. Then merge the arrays back into the original array using the compare type function 727 * 728 * @param Collection<Fact> $unsorted 729 * 730 * @return Collection<Fact> 731 */ 732 public static function sortFacts(Collection $unsorted): Collection 733 { 734 $dated = []; 735 $nondated = []; 736 $sorted = []; 737 738 // Split the array into dated and non-dated arrays 739 $order = 0; 740 741 foreach ($unsorted as $fact) { 742 $fact->sortOrder = $order; 743 $order++; 744 745 if ($fact->date()->isOK()) { 746 $dated[] = $fact; 747 } else { 748 $nondated[] = $fact; 749 } 750 } 751 752 usort($dated, self::dateComparator()); 753 usort($nondated, self::typeComparator()); 754 755 // Merge the arrays 756 $dc = count($dated); 757 $nc = count($nondated); 758 $i = 0; 759 $j = 0; 760 761 // while there is anything in the dated array continue merging 762 while ($i < $dc) { 763 // compare each fact by type to merge them in order 764 if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) { 765 $sorted[] = $nondated[$j]; 766 $j++; 767 } else { 768 $sorted[] = $dated[$i]; 769 $i++; 770 } 771 } 772 773 // get anything that might be left in the nondated array 774 while ($j < $nc) { 775 $sorted[] = $nondated[$j]; 776 $j++; 777 } 778 779 return new Collection($sorted); 780 } 781 782 /** 783 * Sort fact/event tags using the same order that we use for facts. 784 * 785 * @param Collection<string> $unsorted 786 * 787 * @return Collection<string> 788 */ 789 public static function sortFactTags(Collection $unsorted): Collection 790 { 791 $tag_order = array_flip(self::FACT_ORDER); 792 793 return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int { 794 $sort_x = $tag_order[$x] ?? $tag_order['_????_']; 795 $sort_y = $tag_order[$y] ?? $tag_order['_????_']; 796 797 return $sort_x - $sort_y; 798 }); 799 } 800 801 /** 802 * Allow native PHP functions such as array_unique() to work with objects 803 * 804 * @return string 805 */ 806 public function __toString(): string 807 { 808 return $this->id . '@' . $this->record->xref(); 809 } 810} 811