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