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