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