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