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) 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() 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() 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() 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() 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() 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() 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 public function setTag($tag) 363 { 364 $this->tag = $tag; 365 } 366 367 /** 368 * The Person/Family record where this Fact came from 369 * 370 * @return Individual|Family|Source|Repository|Media|Note|GedcomRecord 371 */ 372 public function getParent() 373 { 374 return $this->parent; 375 } 376 377 /** 378 * Get the name of this fact type, for use as a label. 379 * 380 * @return string 381 */ 382 public function getLabel() 383 { 384 // Custom FACT/EVEN - with a TYPE 385 if (($this->tag === 'FACT' || $this->tag === 'EVEN') && $this->getAttribute('TYPE') !== '') { 386 return I18N::translate(e($this->getAttribute('TYPE'))); 387 } 388 389 return GedcomTag::getLabel($this->tag, $this->parent); 390 } 391 392 /** 393 * This is a newly deleted fact, pending approval. 394 */ 395 public function setPendingDeletion() 396 { 397 $this->pending_deletion = true; 398 $this->pending_addition = false; 399 } 400 401 /** 402 * Is this a newly deleted fact, pending approval. 403 * 404 * @return bool 405 */ 406 public function isPendingDeletion() 407 { 408 return $this->pending_deletion; 409 } 410 411 /** 412 * This is a newly added fact, pending approval. 413 */ 414 public function setPendingAddition() 415 { 416 $this->pending_addition = true; 417 $this->pending_deletion = false; 418 } 419 420 /** 421 * Is this a newly added fact, pending approval. 422 * 423 * @return bool 424 */ 425 public function isPendingAddition() 426 { 427 return $this->pending_addition; 428 } 429 430 /** 431 * Source citations linked to this fact 432 * 433 * @return string[] 434 */ 435 public function getCitations() 436 { 437 preg_match_all('/\n(2 SOUR @(' . WT_REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->getGedcom(), $matches, PREG_SET_ORDER); 438 $citations = []; 439 foreach ($matches as $match) { 440 $source = Source::getInstance($match[2], $this->getParent()->getTree()); 441 if ($source->canShow()) { 442 $citations[] = $match[1]; 443 } 444 } 445 446 return $citations; 447 } 448 449 /** 450 * Notes (inline and objects) linked to this fact 451 * 452 * @return string[]|Note[] 453 */ 454 public function getNotes() 455 { 456 $notes = []; 457 preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->getGedcom(), $matches); 458 foreach ($matches[1] as $match) { 459 $note = preg_replace("/\n3 CONT ?/", "\n", $match); 460 if (preg_match('/@(' . WT_REGEX_XREF . ')@/', $note, $nmatch)) { 461 $note = Note::getInstance($nmatch[1], $this->getParent()->getTree()); 462 if ($note && $note->canShow()) { 463 // A note object 464 $notes[] = $note; 465 } 466 } else { 467 // An inline note 468 $notes[] = $note; 469 } 470 } 471 472 return $notes; 473 } 474 475 /** 476 * Media objects linked to this fact 477 * 478 * @return Media[] 479 */ 480 public function getMedia() 481 { 482 $media = []; 483 preg_match_all('/\n2 OBJE @(' . WT_REGEX_XREF . ')@/', $this->getGedcom(), $matches); 484 foreach ($matches[1] as $match) { 485 $obje = Media::getInstance($match, $this->getParent()->getTree()); 486 if ($obje->canShow()) { 487 $media[] = $obje; 488 } 489 } 490 491 return $media; 492 } 493 494 /** 495 * A one-line summary of the fact - for charts, etc. 496 * 497 * @return string 498 */ 499 public function summary() 500 { 501 $attributes = []; 502 $target = $this->getTarget(); 503 if ($target) { 504 $attributes[] = $target->getFullName(); 505 } else { 506 // Fact value 507 $value = $this->getValue(); 508 if ($value !== '' && $value !== 'Y') { 509 $attributes[] = '<span dir="auto">' . e($value) . '</span>'; 510 } 511 // Fact date 512 $date = $this->getDate(); 513 if ($date->isOK()) { 514 if (in_array($this->getTag(), explode('|', WT_EVENTS_BIRT)) && $this->getParent() instanceof Individual && $this->getParent()->getTree()->getPreference('SHOW_PARENTS_AGE')) { 515 $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->getParent(), $date); 516 } else { 517 $attributes[] = $date->display(); 518 } 519 } 520 // Fact place 521 if (!$this->getPlace()->isEmpty()) { 522 $attributes[] = $this->getPlace()->getShortName(); 523 } 524 } 525 526 $class = 'fact_' . $this->getTag(); 527 if ($this->isPendingAddition()) { 528 $class .= ' new'; 529 } elseif ($this->isPendingDeletion()) { 530 $class .= ' old'; 531 } 532 533 return 534 '<div class="' . $class . '">' . 535 /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */ 536 I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->getLabel(), implode(' — ', $attributes)) . 537 '</div>'; 538 } 539 540 /** 541 * Static Helper functions to sort events 542 * 543 * @param Fact $a Fact one 544 * @param Fact $b Fact two 545 * 546 * @return int 547 */ 548 public static function compareDate(Fact $a, Fact $b) 549 { 550 if ($a->getDate()->isOK() && $b->getDate()->isOK()) { 551 // If both events have dates, compare by date 552 $ret = Date::compare($a->getDate(), $b->getDate()); 553 554 if ($ret == 0) { 555 // If dates are the same, compare by fact type 556 $ret = self::compareType($a, $b); 557 558 // If the fact type is also the same, retain the initial order 559 if ($ret == 0) { 560 $ret = $a->sortOrder - $b->sortOrder; 561 } 562 } 563 564 return $ret; 565 } else { 566 // One or both events have no date - retain the initial order 567 return $a->sortOrder - $b->sortOrder; 568 } 569 } 570 571 /** 572 * Static method to compare two events by their type. 573 * 574 * @param Fact $a Fact one 575 * @param Fact $b Fact two 576 * 577 * @return int 578 */ 579 public static function compareType(Fact $a, Fact $b) 580 { 581 static $factsort = []; 582 583 if (empty($factsort)) { 584 $factsort = array_flip(self::FACT_ORDER); 585 } 586 587 // Facts from same families stay grouped together 588 // Keep MARR and DIV from the same families from mixing with events from other FAMs 589 // Use the original order in which the facts were added 590 if ($a->parent instanceof Family && $b->parent instanceof Family && $a->parent !== $b->parent) { 591 return $a->sortOrder - $b->sortOrder; 592 } 593 594 $atag = $a->getTag(); 595 $btag = $b->getTag(); 596 597 // Events not in the above list get mapped onto one that is. 598 if (!array_key_exists($atag, $factsort)) { 599 if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $atag, $match)) { 600 $atag = $match[1]; 601 } else { 602 $atag = '_????_'; 603 } 604 } 605 606 if (!array_key_exists($btag, $factsort)) { 607 if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $btag, $match)) { 608 $btag = $match[1]; 609 } else { 610 $btag = '_????_'; 611 } 612 } 613 614 // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI 615 // - Treat dated after BURI facts as BURI instead 616 if ($a->getAttribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) { 617 $atag = 'BURI'; 618 } 619 620 if ($b->getAttribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) { 621 $btag = 'BURI'; 622 } 623 624 $ret = $factsort[$atag] - $factsort[$btag]; 625 626 // If facts are the same then put dated facts before non-dated facts 627 if ($ret == 0) { 628 if ($a->getAttribute('DATE') !== '' && $b->getAttribute('DATE') === '') { 629 return -1; 630 } 631 632 if ($b->getAttribute('DATE') !== '' && $a->getAttribute('DATE') === '') { 633 return 1; 634 } 635 636 // If no sorting preference, then keep original ordering 637 $ret = $a->sortOrder - $b->sortOrder; 638 } 639 640 return $ret; 641 } 642 643 /** 644 * Allow native PHP functions such as array_unique() to work with objects 645 * 646 * @return string 647 */ 648 public function __toString() 649 { 650 return $this->fact_id . '@' . $this->parent->getXref(); 651 } 652} 653