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