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