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