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