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