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