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