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