1<?php 2namespace Fisharebest\Webtrees; 3 4/** 5 * webtrees: online genealogy 6 * Copyright (C) 2015 webtrees development team 7 * This program is free software: you can redistribute it and/or modify 8 * it under the terms of the GNU General Public License as published by 9 * the Free Software Foundation, either version 3 of the License, or 10 * (at your option) any later version. 11 * This program is distributed in the hope that it will be useful, 12 * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 * GNU General Public License for more details. 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 */ 18 19/** 20 * Class GedcomRecord - Base class for all gedcom records 21 */ 22class GedcomRecord { 23 const RECORD_TYPE = 'UNKNOWN'; 24 const URL_PREFIX = 'gedrecord.php?pid='; 25 26 /** @var string The record identifier */ 27 protected $xref; 28 29 /** @var Tree The family tree to which this record belongs */ 30 protected $tree; 31 32 /** @var string GEDCOM data (before any pending edits) */ 33 protected $gedcom; 34 35 /** @var string|null GEDCOM data (after any pending edits) */ 36 protected $pending; 37 38 /** @var Fact[] facts extracted from $gedcom/$pending */ 39 protected $facts; 40 41 /** @var bool Can we display details of this record to WT_PRIV_PUBLIC */ 42 private $disp_public; 43 44 /** @var bool Can we display details of this record to WT_PRIV_USER */ 45 private $disp_user; 46 47 /** @var bool Can we display details of this record to WT_PRIV_NONE */ 48 private $disp_none; 49 50 /** @var string[][] All the names of this individual */ 51 protected $_getAllNames; 52 53 /** @var int Cached result */ 54 protected $_getPrimaryName; 55 56 /** @var int Cached result */ 57 protected $_getSecondaryName; 58 59 // Allow getInstance() to return references to existing objects 60 private static $gedcom_record_cache; 61 // Fetch all pending edits in one database query 62 private static $pending_record_cache; 63 64 /** 65 * Create a GedcomRecord object from raw GEDCOM data. 66 * 67 * @param string $xref 68 * @param string $gedcom an empty string for new/pending records 69 * @param string|null $pending null for a record with no pending edits, 70 * empty string for records with pending deletions 71 * @param integer $tree_id 72 */ 73 public function __construct($xref, $gedcom, $pending, $tree_id) { 74 $this->xref = $xref; 75 $this->gedcom = $gedcom; 76 $this->pending = $pending; 77 $this->tree = Tree::get($tree_id); 78 79 $this->parseFacts(); 80 } 81 82 /** 83 * Split the record into facts 84 */ 85 private function parseFacts() { 86 // Split the record into facts 87 if ($this->gedcom) { 88 $gedcom_facts = preg_split('/\n(?=1)/s', $this->gedcom); 89 array_shift($gedcom_facts); 90 } else { 91 $gedcom_facts = array(); 92 } 93 if ($this->pending) { 94 $pending_facts = preg_split('/\n(?=1)/s', $this->pending); 95 array_shift($pending_facts); 96 } else { 97 $pending_facts = array(); 98 } 99 100 $this->facts = array(); 101 102 foreach ($gedcom_facts as $gedcom_fact) { 103 $fact = new Fact($gedcom_fact, $this, md5($gedcom_fact)); 104 if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts)) { 105 $fact->setPendingDeletion(); 106 } 107 $this->facts[] = $fact; 108 } 109 foreach ($pending_facts as $pending_fact) { 110 if (!in_array($pending_fact, $gedcom_facts)) { 111 $fact = new Fact($pending_fact, $this, md5($pending_fact)); 112 $fact->setPendingAddition(); 113 $this->facts[] = $fact; 114 } 115 } 116 } 117 118 /** 119 * Get an instance of a GedcomRecord object. For single records, 120 * we just receive the XREF. For bulk records (such as lists 121 * and search results) we can receive the GEDCOM data as well. 122 * 123 * @param string $xref 124 * @param integer|null $gedcom_id 125 * @param string|null $gedcom 126 * 127 * @return GedcomRecord|null 128 * @throws \Exception 129 */ 130 public static function getInstance($xref, $gedcom_id = WT_GED_ID, $gedcom = null) { 131 // Is this record already in the cache? 132 if (isset(self::$gedcom_record_cache[$xref][$gedcom_id])) { 133 return self::$gedcom_record_cache[$xref][$gedcom_id]; 134 } 135 136 // Do we need to fetch the record from the database? 137 if ($gedcom === null) { 138 $gedcom = static::fetchGedcomRecord($xref, $gedcom_id); 139 } 140 141 // If we can edit, then we also need to be able to see pending records. 142 if (WT_USER_CAN_EDIT) { 143 if (!isset(self::$pending_record_cache[$gedcom_id])) { 144 // Fetch all pending records in one database query 145 self::$pending_record_cache[$gedcom_id] = array(); 146 $rows = Database::prepare( 147 "SELECT xref, new_gedcom FROM `##change` WHERE status='pending' AND gedcom_id=?" 148 )->execute(array($gedcom_id))->fetchAll(); 149 foreach ($rows as $row) { 150 self::$pending_record_cache[$gedcom_id][$row->xref] = $row->new_gedcom; 151 } 152 } 153 154 if (isset(self::$pending_record_cache[$gedcom_id][$xref])) { 155 // A pending edit exists for this record 156 $pending = self::$pending_record_cache[$gedcom_id][$xref]; 157 } else { 158 $pending = null; 159 } 160 } else { 161 // There are no pending changes for this record 162 $pending = null; 163 } 164 165 // No such record exists 166 if ($gedcom === null && $pending === null) { 167 return null; 168 } 169 170 // Create the object 171 if (preg_match('/^0 @(' . WT_REGEX_XREF . ')@ (' . WT_REGEX_TAG . ')/', $gedcom . $pending, $match)) { 172 $xref = $match[1]; // Collation - we may have requested I123 and found i123 173 $type = $match[2]; 174 } elseif (preg_match('/^0 (HEAD|TRLR)/', $gedcom . $pending, $match)) { 175 $xref = $match[1]; 176 $type = $match[1]; 177 } elseif ($gedcom . $pending) { 178 throw new \Exception('Unrecognized GEDCOM record: ' . $gedcom); 179 } else { 180 // A record with both pending creation and pending deletion 181 $type = static::RECORD_TYPE; 182 } 183 184 switch ($type) { 185 case 'INDI': 186 $record = new Individual($xref, $gedcom, $pending, $gedcom_id); 187 break; 188 case 'FAM': 189 $record = new Family($xref, $gedcom, $pending, $gedcom_id); 190 break; 191 case 'SOUR': 192 $record = new Source($xref, $gedcom, $pending, $gedcom_id); 193 break; 194 case 'OBJE': 195 $record = new Media($xref, $gedcom, $pending, $gedcom_id); 196 break; 197 case 'REPO': 198 $record = new Repository($xref, $gedcom, $pending, $gedcom_id); 199 break; 200 case 'NOTE': 201 $record = new Note($xref, $gedcom, $pending, $gedcom_id); 202 break; 203 default: 204 $record = new GedcomRecord($xref, $gedcom, $pending, $gedcom_id); 205 break; 206 } 207 208 // Store it in the cache 209 self::$gedcom_record_cache[$xref][$gedcom_id] = $record; 210 211 return $record; 212 } 213 214 /** 215 * Fetch data from the database 216 * 217 * @param string $xref 218 * @param integer $gedcom_id 219 * 220 * @return null|string 221 */ 222 protected static function fetchGedcomRecord($xref, $gedcom_id) { 223 static $statement = null; 224 225 // We don't know what type of object this is. Try each one in turn. 226 $data = Individual::fetchGedcomRecord($xref, $gedcom_id); 227 if ($data) { 228 return $data; 229 } 230 $data = Family::fetchGedcomRecord($xref, $gedcom_id); 231 if ($data) { 232 return $data; 233 } 234 $data = Source::fetchGedcomRecord($xref, $gedcom_id); 235 if ($data) { 236 return $data; 237 } 238 $data = Repository::fetchGedcomRecord($xref, $gedcom_id); 239 if ($data) { 240 return $data; 241 } 242 $data = Media::fetchGedcomRecord($xref, $gedcom_id); 243 if ($data) { 244 return $data; 245 } 246 $data = Note::fetchGedcomRecord($xref, $gedcom_id); 247 if ($data) { 248 return $data; 249 } 250 // Some other type of record... 251 if (is_null($statement)) { 252 $statement = Database::prepare("SELECT o_gedcom FROM `##other` WHERE o_id=? AND o_file=?"); 253 } 254 return $statement->execute(array($xref, $gedcom_id))->fetchOne(); 255 256 } 257 258 /** 259 * Get the XREF for this record 260 * 261 * @return string 262 */ 263 public function getXref() { 264 return $this->xref; 265 } 266 267 /** 268 * Get the tree to which this record belongs 269 * 270 * @return Tree 271 */ 272 public function getTree() { 273 return $this->tree; 274 } 275 276 /** 277 * Get the tree ID for this record 278 * 279 * @return integer 280 */ 281 public function getGedcomId() { 282 return $this->tree->getTreeId(); 283 } 284 285 /** 286 * Application code should access data via Fact objects. 287 * This function exists to support old code. 288 * 289 * @return string 290 */ 291 public function getGedcom() { 292 if ($this->pending === null) { 293 return $this->gedcom; 294 } else { 295 return $this->pending; 296 } 297 } 298 299 /** 300 * Does this record have a pending change? 301 * 302 * @return boolean 303 */ 304 public function isPendingAddtion() { 305 return $this->pending !== null; 306 } 307 308 /** 309 * Does this record have a pending deletion? 310 * 311 * @return boolean 312 */ 313 public function isPendingDeletion() { 314 return $this->pending === ''; 315 } 316 317 /** 318 * Generate a URL to this record, suitable for use in HTML, etc. 319 * 320 * @return string 321 */ 322 public function getHtmlUrl() { 323 return $this->getLinkUrl(static::URL_PREFIX, '&'); 324 } 325 326 /** 327 * Generate a URL to this record, suitable for use in javascript, HTTP headers, etc. 328 * 329 * @return string 330 */ 331 public function getRawUrl() { 332 return $this->getLinkUrl(static::URL_PREFIX, '&'); 333 } 334 335 /** 336 * Generate an absolute URL for this record, suitable for sitemap.xml, RSS feeds, etc. 337 * 338 * @return string 339 */ 340 public function getAbsoluteLinkUrl() { 341 return WT_BASE_URL . $this->getHtmlUrl(); 342 } 343 344 /** 345 * Generate a URL to this record. 346 * 347 * @param string $link 348 * @param string $separator 349 * 350 * @return string 351 */ 352 private function getLinkUrl($link, $separator) { 353 if ($this->tree->getTreeId() == WT_GED_ID) { 354 return $link . $this->getXref() . $separator . 'ged=' . WT_GEDURL; 355 } elseif ($this->tree->getTreeId() == 0) { 356 return '#'; 357 } else { 358 return $link . $this->getXref() . $separator . 'ged=' . rawurlencode(get_gedcom_from_id($this->tree->getTreeId())); 359 } 360 } 361 362 /** 363 * Work out whether this record can be shown to a user with a given access level 364 * 365 * @param integer $access_level 366 * 367 * @return boolean 368 */ 369 private function _canShow($access_level) { 370 // This setting would better be called "$ENABLE_PRIVACY" 371 if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) { 372 return true; 373 } 374 375 // We should always be able to see our own record (unless an admin is applying download restrictions) 376 if ($this->getXref() === WT_USER_GEDCOM_ID && $this->tree->getTreeId() === WT_GED_ID && $access_level === WT_USER_ACCESS_LEVEL) { 377 return true; 378 } 379 380 // Does this record have a RESN? 381 if (strpos($this->gedcom, "\n1 RESN confidential")) { 382 return WT_PRIV_NONE >= $access_level; 383 } 384 if (strpos($this->gedcom, "\n1 RESN privacy")) { 385 return WT_PRIV_USER >= $access_level; 386 } 387 if (strpos($this->gedcom, "\n1 RESN none")) { 388 return true; 389 } 390 391 // Does this record have a default RESN? 392 $individual_privacy = $this->tree->getIndividualPrivacy(); 393 if (isset($individual_privacy[$this->getXref()])) { 394 return $individual_privacy[$this->getXref()] >= $access_level; 395 } 396 397 // Privacy rules do not apply to admins 398 if (WT_PRIV_NONE >= $access_level) { 399 return true; 400 } 401 402 // Different types of record have different privacy rules 403 return $this->canShowByType($access_level); 404 } 405 406 /** 407 * Each object type may have its own special rules, and re-implement this function. 408 * 409 * @param integer $access_level 410 * 411 * @return boolean 412 */ 413 protected function canShowByType($access_level) { 414 $fact_privacy = $this->tree->getFactPrivacy(); 415 416 if (isset($fact_privacy[static::RECORD_TYPE])) { 417 // Restriction found 418 return $fact_privacy[static::RECORD_TYPE] >= $access_level; 419 } else { 420 // No restriction found - must be public: 421 return true; 422 } 423 } 424 425 /** 426 * Can the details of this record be shown? 427 * 428 * @param integer $access_level 429 * 430 * @return boolean 431 */ 432 public function canShow($access_level = WT_USER_ACCESS_LEVEL) { 433 // CACHING: this function can take three different parameters, 434 // and therefore needs three different caches for the result. 435 switch ($access_level) { 436 case WT_PRIV_PUBLIC: // visitor 437 if ($this->disp_public === null) { 438 $this->disp_public = $this->_canShow(WT_PRIV_PUBLIC); 439 } 440 return $this->disp_public; 441 case WT_PRIV_USER: // member 442 if ($this->disp_user === null) { 443 $this->disp_user = $this->_canShow(WT_PRIV_USER); 444 } 445 return $this->disp_user; 446 case WT_PRIV_NONE: // admin 447 if ($this->disp_none === null) { 448 $this->disp_none = $this->_canShow(WT_PRIV_NONE); 449 } 450 return $this->disp_none; 451 case WT_PRIV_HIDE: // hidden from admins 452 // We use this value to bypass privacy checks. For example, 453 // when downloading data or when calculating privacy itself. 454 return true; 455 default: 456 // Should never get here. 457 return false; 458 } 459 } 460 461 /** 462 * Can the name of this record be shown? 463 * 464 * @param integer $access_level 465 * 466 * @return boolean 467 */ 468 public function canShowName($access_level = WT_USER_ACCESS_LEVEL) { 469 return $this->canShow($access_level); 470 } 471 472 /** 473 * Can we edit this record? 474 * 475 * @return boolean 476 */ 477 public function canEdit() { 478 return WT_USER_GEDCOM_ADMIN || WT_USER_CAN_EDIT && strpos($this->gedcom, "\n1 RESN locked") === false; 479 } 480 481 /** 482 * Remove private data from the raw gedcom record. 483 * Return both the visible and invisible data. We need the invisible data when editing. 484 * 485 * @param integer $access_level 486 * 487 * @return string 488 */ 489 public function privatizeGedcom($access_level) { 490 if ($access_level == WT_PRIV_HIDE) { 491 // We may need the original record, for example when downloading a GEDCOM or clippings cart 492 return $this->gedcom; 493 } elseif ($this->canShow($access_level)) { 494 // The record is not private, but the individual facts may be. 495 496 // Include the entire first line (for NOTE records) 497 list($gedrec) = explode("\n", $this->gedcom, 2); 498 499 // Check each of the facts for access 500 foreach ($this->getFacts(null, false, $access_level) as $fact) { 501 $gedrec .= "\n" . $fact->getGedcom(); 502 } 503 return $gedrec; 504 } else { 505 // We cannot display the details, but we may be able to display 506 // limited data, such as links to other records. 507 return $this->createPrivateGedcomRecord($access_level); 508 } 509 } 510 511 /** 512 * Generate a private version of this record 513 * 514 * @param integer $access_level 515 * 516 * @return string 517 */ 518 protected function createPrivateGedcomRecord($access_level) { 519 return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE . "\n1 NOTE " . I18N::translate('Private'); 520 } 521 522 /** 523 * Convert a name record into sortable and full/display versions. This default 524 * should be OK for simple record types. INDI/FAM records will need to redefine it. 525 * 526 * @param string $type 527 * @param string $value 528 * @param string $gedcom 529 */ 530 protected function addName($type, $value, $gedcom) { 531 $this->_getAllNames[] = array( 532 'type' => $type, 533 'sort' => preg_replace_callback('/([0-9]+)/', function($matches) { return str_pad($matches[0], 10, '0', STR_PAD_LEFT); }, $value), 534 'full' => '<span dir="auto">' . Filter::escapeHtml($value) . '</span>', // This is used for display 535 'fullNN' => $value, // This goes into the database 536 ); 537 } 538 539 /** 540 * Get all the names of a record, including ROMN, FONE and _HEB alternatives. 541 * Records without a name (e.g. FAM) will need to redefine this function. 542 * Parameters: the level 1 fact containing the name. 543 * Return value: an array of name structures, each containing 544 * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc. 545 * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown' 546 * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John' 547 * 548 * @param integer $level 549 * @param string $fact_type 550 * @param Fact[] $facts 551 */ 552 protected function _extractNames($level, $fact_type, $facts) { 553 $sublevel = $level + 1; 554 $subsublevel = $sublevel + 1; 555 foreach ($facts as $fact) { 556 if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->getGedcom(), $matches, PREG_SET_ORDER)) { 557 foreach ($matches as $match) { 558 // Treat 1 NAME / 2 TYPE married the same as _MARNM 559 if ($match[1] == 'NAME' && strpos($match[3], "\n2 TYPE married") !== false) { 560 $this->addName('_MARNM', $match[2], $fact->getGedcom()); 561 } else { 562 $this->addName($match[1], $match[2], $fact->getGedcom()); 563 } 564 if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) { 565 foreach ($submatches as $submatch) { 566 $this->addName($submatch[1], $submatch[2], $match[3]); 567 } 568 } 569 } 570 } 571 } 572 } 573 574 /** 575 * Default for "other" object types 576 */ 577 public function extractNames() { 578 $this->addName(static::RECORD_TYPE, $this->getFallBackName(), null); 579 } 580 581 /** 582 * Derived classes should redefine this function, otherwise the object will have no name 583 * 584 * @return string[][] 585 */ 586 public function getAllNames() { 587 if ($this->_getAllNames === null) { 588 $this->_getAllNames = array(); 589 if ($this->canShowName()) { 590 // Ask the record to extract its names 591 $this->extractNames(); 592 // No name found? Use a fallback. 593 if (!$this->_getAllNames) { 594 $this->addName(static::RECORD_TYPE, $this->getFallBackName(), null); 595 } 596 } else { 597 $this->addName(static::RECORD_TYPE, I18N::translate('Private'), null); 598 } 599 } 600 return $this->_getAllNames; 601 } 602 603 /** 604 * If this object has no name, what do we call it? 605 * 606 * @return string 607 */ 608 public function getFallBackName() { 609 return $this->getXref(); 610 } 611 612 /** 613 * Which of the (possibly several) names of this record is the primary one. 614 * 615 * @return integer 616 */ 617 public function getPrimaryName() { 618 static $language_script; 619 620 if ($language_script === null) { 621 $language_script = I18N::languageScript(WT_LOCALE); 622 } 623 624 if ($this->_getPrimaryName === null) { 625 // Generally, the first name is the primary one.... 626 $this->_getPrimaryName = 0; 627 // ...except when the language/name use different character sets 628 if (count($this->getAllNames()) > 1) { 629 foreach ($this->getAllNames() as $n => $name) { 630 if ($name['type'] !== '_MARNM' && I18N::textScript($name['sort']) === $language_script) { 631 $this->_getPrimaryName = $n; 632 break; 633 } 634 } 635 } 636 } 637 638 return $this->_getPrimaryName; 639 } 640 641 /** 642 * Which of the (possibly several) names of this record is the secondary one. 643 * 644 * @return integer 645 */ 646 public function getSecondaryName() { 647 if (is_null($this->_getSecondaryName)) { 648 // Generally, the primary and secondary names are the same 649 $this->_getSecondaryName = $this->getPrimaryName(); 650 // ....except when there are names with different character sets 651 $all_names = $this->getAllNames(); 652 if (count($all_names) > 1) { 653 $primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']); 654 foreach ($all_names as $n=>$name) { 655 if ($n != $this->getPrimaryName() && $name['type'] != '_MARNM' && I18N::textScript($name['sort']) != $primary_script) { 656 $this->_getSecondaryName = $n; 657 break; 658 } 659 } 660 } 661 } 662 return $this->_getSecondaryName; 663 } 664 665 /** 666 * Allow the choice of primary name to be overidden, e.g. in a search result 667 * 668 * @param integer $n 669 */ 670 public function setPrimaryName($n) { 671 $this->_getPrimaryName = $n; 672 $this->_getSecondaryName = null; 673 } 674 675 /** 676 * Allow native PHP functions such as array_unique() to work with objects 677 * 678 * @return string 679 */ 680 public function __toString() { 681 return $this->xref . '@' . $this->tree->getTreeId(); 682 } 683 684 /** 685 * Static helper function to sort an array of objects by name 686 * Records whose names cannot be displayed are sorted at the end. 687 * 688 * @param GedcomRecord $x 689 * @param GedcomRecord $y 690 * 691 * @return integer 692 */ 693 public static function compare(GedcomRecord $x, GedcomRecord $y) { 694 if ($x->canShowName()) { 695 if ($y->canShowName()) { 696 return I18N::strcasecmp($x->getSortName(), $y->getSortName()); 697 } else { 698 return -1; // only $y is private 699 } 700 } else { 701 if ($y->canShowName()) { 702 return 1; // only $x is private 703 } else { 704 return 0; // both $x and $y private 705 } 706 } 707 } 708 709 /** 710 * Get variants of the name 711 * 712 * @return string 713 */ 714 public function getFullName() { 715 if ($this->canShowName()) { 716 $tmp = $this->getAllNames(); 717 return $tmp[$this->getPrimaryName()]['full']; 718 } else { 719 return I18N::translate('Private'); 720 } 721 } 722 723 /** 724 * Get a sortable version of the name. Do not display this! 725 * 726 * @return string 727 */ 728 public function getSortName() { 729 // The sortable name is never displayed, no need to call canShowName() 730 $tmp = $this->getAllNames(); 731 return $tmp[$this->getPrimaryName()]['sort']; 732 } 733 734 /** 735 * Get the full name in an alternative character set 736 * 737 * @return null|string 738 */ 739 public function getAddName() { 740 if ($this->canShowName() && $this->getPrimaryName() != $this->getSecondaryName()) { 741 $all_names = $this->getAllNames(); 742 return $all_names[$this->getSecondaryName()]['full']; 743 } else { 744 return null; 745 } 746 } 747 748 /** 749 * Format this object for display in a list 750 * If $find is set, then we are displaying items from a selection list. 751 * $name allows us to use something other than the record name. 752 * 753 * @param string $tag 754 * @param boolean $find 755 * @param null $name 756 * 757 * @return string 758 */ 759 public function format_list($tag = 'li', $find = false, $name = null) { 760 if (is_null($name)) { 761 $name = $this->getFullName(); 762 } 763 $html = '<a href="' . $this->getHtmlUrl() . '"'; 764 if ($find) { 765 $html .= ' onclick="pasteid(\'' . $this->getXref() . '\', \'' . htmlentities($name) . '\');"'; 766 } 767 $html .= ' class="list_item"><b>' . $name . '</b>'; 768 $html .= $this->formatListDetails(); 769 $html = '<' . $tag . '>' . $html . '</a></' . $tag . '>'; 770 return $html; 771 } 772 773 /** 774 * This function should be redefined in derived classes to show any major 775 * identifying characteristics of this record. 776 * 777 * @return string 778 */ 779 public function formatListDetails() { 780 return ''; 781 } 782 783 /** 784 * Extract/format the first fact from a list of facts. 785 * 786 * @param string $facts 787 * @param integer $style 788 * 789 * @return string 790 */ 791 public function format_first_major_fact($facts, $style) { 792 foreach ($this->getFacts($facts, true) as $event) { 793 // Only display if it has a date or place (or both) 794 if ($event->getDate()->isOK() || !$event->getPlace()->isEmpty()) { 795 switch ($style) { 796 case 1: 797 return '<br><em>' . $event->getLabel() . ' ' . format_fact_date($event, $this, false, false) . ' ' . format_fact_place($event) . '</em>'; 798 case 2: 799 return '<dl><dt class="label">' . $event->getLabel() . '</dt><dd class="field">' . format_fact_date($event, $this, false, false) . ' ' . format_fact_place($event) . '</dd></dl>'; 800 } 801 } 802 } 803 return ''; 804 } 805 806 /** 807 * Find individuals linked to this record. 808 * 809 * @param string $link 810 * 811 * @return Individual[] 812 */ 813 public function linkedIndividuals($link) { 814 $rows = Database::prepare( 815 "SELECT i_id AS xref, i_file AS gedcom_id, i_gedcom AS gedcom" . 816 " FROM `##individuals`" . 817 " JOIN `##link` ON (i_file=l_file AND i_id=l_from)" . 818 " LEFT JOIN `##name` ON (i_file=n_file AND i_id=n_id AND n_num=0)" . 819 " WHERE i_file=? AND l_type=? AND l_to=?" . 820 " ORDER BY n_sort COLLATE '" . I18N::$collation . "'" 821 )->execute(array($this->tree->getTreeId(), $link, $this->xref))->fetchAll(); 822 823 $list = array(); 824 foreach ($rows as $row) { 825 $record = Individual::getInstance($row->xref, $row->gedcom_id, $row->gedcom); 826 if ($record->canShowName()) { 827 $list[] = $record; 828 } 829 } 830 return $list; 831 } 832 833 /** 834 * Find families linked to this record. 835 * 836 * @param string $link 837 * 838 * @return Family[] 839 */ 840 public function linkedFamilies($link) { 841 $rows = Database::prepare( 842 "SELECT f_id AS xref, f_file AS gedcom_id, f_gedcom AS gedcom" . 843 " FROM `##families`" . 844 " JOIN `##link` ON (f_file=l_file AND f_id=l_from)" . 845 " LEFT JOIN `##name` ON (f_file=n_file AND f_id=n_id AND n_num=0)" . 846 " WHERE f_file=? AND l_type=? AND l_to=?" 847 )->execute(array($this->tree->getTreeId(), $link, $this->xref))->fetchAll(); 848 849 $list = array(); 850 foreach ($rows as $row) { 851 $record = Family::getInstance($row->xref, $row->gedcom_id, $row->gedcom); 852 if ($record->canShowName()) { 853 $list[] = $record; 854 } 855 } 856 return $list; 857 } 858 859 /** 860 * Find sources linked to this record. 861 * 862 * @param string $link 863 * 864 * @return Source[] 865 */ 866 public function linkedSources($link) { 867 $rows = Database::prepare( 868 "SELECT s_id AS xref, s_file AS gedcom_id, s_gedcom AS gedcom" . 869 " FROM `##sources`" . 870 " JOIN `##link` ON (s_file=l_file AND s_id=l_from)" . 871 " WHERE s_file=? AND l_type=? AND l_to=?" . 872 " ORDER BY s_name COLLATE '" . I18N::$collation . "'" 873 )->execute(array($this->tree->getTreeId(), $link, $this->xref))->fetchAll(); 874 875 $list = array(); 876 foreach ($rows as $row) { 877 $record = Source::getInstance($row->xref, $row->gedcom_id, $row->gedcom); 878 if ($record->canShowName()) { 879 $list[] = $record; 880 } 881 } 882 return $list; 883 } 884 885 /** 886 * Find media objects linked to this record. 887 * 888 * @param string $link 889 * 890 * @return Media[] 891 */ 892 public function linkedMedia($link) { 893 $rows = Database::prepare( 894 "SELECT m_id AS xref, m_file AS gedcom_id, m_gedcom AS gedcom" . 895 " FROM `##media`" . 896 " JOIN `##link` ON (m_file=l_file AND m_id=l_from)" . 897 " WHERE m_file=? AND l_type=? AND l_to=?" . 898 " ORDER BY m_titl COLLATE '" . I18N::$collation . "'" 899 )->execute(array($this->tree->getTreeId(), $link, $this->xref))->fetchAll(); 900 901 $list = array(); 902 foreach ($rows as $row) { 903 $record = Media::getInstance($row->xref, $row->gedcom_id, $row->gedcom); 904 if ($record->canShowName()) { 905 $list[] = $record; 906 } 907 } 908 return $list; 909 } 910 911 /** 912 * Find notes linked to this record. 913 * 914 * @param string $link 915 * 916 * @return Note[] 917 */ 918 public function linkedNotes($link) { 919 $rows = Database::prepare( 920 "SELECT o_id AS xref, o_file AS gedcom_id, o_gedcom AS gedcom" . 921 " FROM `##other`" . 922 " JOIN `##link` ON (o_file=l_file AND o_id=l_from)" . 923 " LEFT JOIN `##name` ON (o_file=n_file AND o_id=n_id AND n_num=0)" . 924 " WHERE o_file=? AND o_type='NOTE' AND l_type=? AND l_to=?" . 925 " ORDER BY n_sort COLLATE '" . I18N::$collation . "'" 926 )->execute(array($this->tree->getTreeId(), $link, $this->xref))->fetchAll(); 927 928 $list = array(); 929 foreach ($rows as $row) { 930 $record = Note::getInstance($row->xref, $row->gedcom_id, $row->gedcom); 931 if ($record->canShowName()) { 932 $list[] = $record; 933 } 934 } 935 return $list; 936 } 937 938 /** 939 * Find repositories linked to this record. 940 * 941 * @param string $link 942 * 943 * @return Repository[] 944 */ 945 public function linkedRepositories($link) { 946 $rows = Database::prepare( 947 "SELECT o_id AS xref, o_file AS gedcom_id, o_gedcom AS gedcom" . 948 " FROM `##other`" . 949 " JOIN `##link` ON (o_file=l_file AND o_id=l_from)" . 950 " LEFT JOIN `##name` ON (o_file=n_file AND o_id=n_id AND n_num=0)" . 951 " WHERE o_file=? AND o_type='REPO' AND l_type=? AND l_to=?" . 952 " ORDER BY n_sort COLLATE '" . I18N::$collation . "'" 953 )->execute(array($this->tree->getTreeId(), $link, $this->xref))->fetchAll(); 954 955 $list = array(); 956 foreach ($rows as $row) { 957 $record = Repository::getInstance($row->xref, $row->gedcom_id, $row->gedcom); 958 if ($record->canShowName()) { 959 $list[] = $record; 960 } 961 } 962 return $list; 963 } 964 965 /** 966 * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR). 967 * This is used to display multiple events on the individual/family lists. 968 * Multiple events can exist because of uncertainty in dates, dates in different 969 * calendars, place-names in both latin and hebrew character sets, etc. 970 * It also allows us to combine dates/places from different events in the summaries. 971 * 972 * @param string $event_type 973 * 974 * @return Date[] 975 */ 976 public function getAllEventDates($event_type) { 977 $dates = array(); 978 foreach ($this->getFacts($event_type) as $event) { 979 if ($event->getDate()->isOK()) { 980 $dates[] = $event->getDate(); 981 } 982 } 983 984 return $dates; 985 } 986 987 /** 988 * Get all the places for a particular type of event 989 * 990 * @param string $event_type 991 * 992 * @return array 993 */ 994 public function getAllEventPlaces($event_type) { 995 $places = array(); 996 foreach ($this->getFacts($event_type) as $event) { 997 if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->getGedcom(), $ged_places)) { 998 foreach ($ged_places[1] as $ged_place) { 999 $places[] = $ged_place; 1000 } 1001 } 1002 } 1003 1004 return $places; 1005 } 1006 1007 /** 1008 * Get the first (i.e. prefered) Fact for the given fact type 1009 * 1010 * @param string $tag 1011 * 1012 * @return Fact|null 1013 */ 1014 public function getFirstFact($tag) { 1015 foreach ($this->getFacts() as $fact) { 1016 if ($fact->getTag() === $tag) { 1017 return $fact; 1018 } 1019 } 1020 1021 return null; 1022 } 1023 1024 /** 1025 * The facts and events for this record. 1026 * 1027 * @param string $filter 1028 * @param boolean $sort 1029 * @param integer $access_level 1030 * @param boolean $override Include private records, to allow us to implement $SHOW_PRIVATE_RELATIONSHIPS and $SHOW_LIVING_NAMES. 1031 * 1032 * @return Fact[] 1033 */ 1034 public function getFacts($filter = null, $sort = false, $access_level = WT_USER_ACCESS_LEVEL, $override = false) { 1035 $facts = array(); 1036 if ($this->canShow($access_level) || $override) { 1037 foreach ($this->facts as $fact) { 1038 if (($filter == null || preg_match('/^' . $filter . '$/', $fact->getTag())) && $fact->canShow($access_level)) { 1039 $facts[] = $fact; 1040 } 1041 } 1042 } 1043 if ($sort) { 1044 sort_facts($facts); 1045 } 1046 return $facts; 1047 } 1048 1049 /** 1050 * Get the last-change timestamp for this record, either as a formatted string 1051 * (for display) or as a unix timestamp (for sorting) 1052 * 1053 * @param boolean $sorting 1054 * 1055 * @return string 1056 */ 1057 public function lastChangeTimestamp($sorting = false) { 1058 $chan = $this->getFirstFact('CHAN'); 1059 1060 if ($chan) { 1061 // The record does have a CHAN event 1062 $d = $chan->getDate()->MinDate(); 1063 if (preg_match('/\n3 TIME (\d\d):(\d\d):(\d\d)/', $chan->getGedcom(), $match)) { 1064 $t = mktime((int) $match[1], (int) $match[2], (int) $match[3], (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y')); 1065 } elseif (preg_match('/\n3 TIME (\d\d):(\d\d)/', $chan->getGedcom(), $match)) { 1066 $t = mktime((int) $match[1], (int) $match[2], 0, (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y')); 1067 } else { 1068 $t = mktime(0, 0, 0, (int) $d->format('%n'), (int) $d->format('%j'), (int) $d->format('%Y')); 1069 } 1070 if ($sorting) { 1071 return $t; 1072 } else { 1073 return strip_tags(format_timestamp($t)); 1074 } 1075 } else { 1076 // The record does not have a CHAN event 1077 if ($sorting) { 1078 return '0'; 1079 } else { 1080 return ' '; 1081 } 1082 } 1083 } 1084 1085 /** 1086 * Get the last-change user for this record 1087 * 1088 * @return string 1089 */ 1090 public function lastChangeUser() { 1091 $chan = $this->getFirstFact('CHAN'); 1092 1093 if ($chan === null) { 1094 return I18N::translate('Unknown'); 1095 } else { 1096 $chan_user = $chan->getAttribute('_WT_USER'); 1097 if ($chan_user === null) { 1098 return I18N::translate('Unknown'); 1099 } else { 1100 return $chan_user; 1101 } 1102 } 1103 } 1104 1105 /** 1106 * Add a new fact to this record 1107 * 1108 * @param string $gedcom 1109 * @param boolean $update_chan 1110 */ 1111 public function createFact($gedcom, $update_chan) { 1112 $this->updateFact(null, $gedcom, $update_chan); 1113 } 1114 1115 /** 1116 * Delete a fact from this record 1117 * 1118 * @param string $fact_id 1119 * @param boolean $update_chan 1120 */ 1121 public function deleteFact($fact_id, $update_chan) { 1122 $this->updateFact($fact_id, null, $update_chan); 1123 } 1124 1125 /** 1126 * Replace a fact with a new gedcom data. 1127 * 1128 * @param string $fact_id 1129 * @param string $gedcom 1130 * @param boolean $update_chan 1131 * 1132 * @throws \Exception 1133 */ 1134 public function updateFact($fact_id, $gedcom, $update_chan) { 1135 // MSDOS line endings will break things in horrible ways 1136 $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom); 1137 $gedcom = trim($gedcom); 1138 1139 if ($this->pending === '') { 1140 throw new \Exception('Cannot edit a deleted record'); 1141 } 1142 if ($gedcom && !preg_match('/^1 ' . WT_REGEX_TAG . '/', $gedcom)) { 1143 throw new \Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')'); 1144 } 1145 1146 if ($this->pending) { 1147 $old_gedcom = $this->pending; 1148 } else { 1149 $old_gedcom = $this->gedcom; 1150 } 1151 1152 // First line of record may contain data - e.g. NOTE records. 1153 list($new_gedcom) = explode("\n", $old_gedcom, 2); 1154 1155 // Replacing (or deleting) an existing fact 1156 foreach ($this->getFacts(null, false, WT_PRIV_HIDE) as $fact) { 1157 if (!$fact->isPendingDeletion()) { 1158 if ($fact->getFactId() === $fact_id) { 1159 if ($gedcom) { 1160 $new_gedcom .= "\n" . $gedcom; 1161 } 1162 $fact_id = true; // Only replace/delete one copy of a duplicate fact 1163 } elseif ($fact->getTag() != 'CHAN' || !$update_chan) { 1164 $new_gedcom .= "\n" . $fact->getGedcom(); 1165 } 1166 } 1167 } 1168 if ($update_chan) { 1169 $new_gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName(); 1170 } 1171 1172 // Adding a new fact 1173 if (!$fact_id) { 1174 $new_gedcom .= "\n" . $gedcom; 1175 } 1176 1177 if ($new_gedcom != $old_gedcom) { 1178 // Save the changes 1179 Database::prepare( 1180 "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, ?, ?)" 1181 )->execute(array( 1182 $this->tree->getTreeId(), 1183 $this->xref, 1184 $old_gedcom, 1185 $new_gedcom, 1186 Auth::id() 1187 )); 1188 1189 $this->pending = $new_gedcom; 1190 1191 if (Auth::user()->getPreference('auto_accept')) { 1192 accept_all_changes($this->xref, $this->tree->getTreeId()); 1193 $this->gedcom = $new_gedcom; 1194 $this->pending = null; 1195 } 1196 } 1197 $this->parseFacts(); 1198 } 1199 1200 /** 1201 * Create a new record from GEDCOM data. 1202 * 1203 * @param string $gedcom 1204 * @param integer $gedcom_id 1205 * 1206 * @return GedcomRecord 1207 * @throws \Exception 1208 */ 1209 static public function createRecord($gedcom, $gedcom_id) { 1210 if (preg_match('/^0 @(' . WT_REGEX_XREF . ')@ (' . WT_REGEX_TAG . ')/', $gedcom, $match)) { 1211 $xref = $match[1]; 1212 $type = $match[2]; 1213 } else { 1214 throw new \Exception('Invalid argument to GedcomRecord::createRecord(' . $gedcom . ')'); 1215 } 1216 if (strpos("\r", $gedcom) !== false) { 1217 // MSDOS line endings will break things in horrible ways 1218 throw new \Exception('Evil line endings found in GedcomRecord::createRecord(' . $gedcom . ')'); 1219 } 1220 1221 // webtrees creates XREFs containing digits. Anything else (e.g. “new”) is just a placeholder. 1222 if (!preg_match('/\d/', $xref)) { 1223 $xref = get_new_xref($type); 1224 $gedcom = preg_replace('/^0 @(' . WT_REGEX_XREF . ')@/', '0 @' . $xref . '@', $gedcom); 1225 } 1226 1227 // Create a change record, if not already present 1228 if (!preg_match('/\n1 CHAN/', $gedcom)) { 1229 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName(); 1230 } 1231 1232 // Create a pending change 1233 Database::prepare( 1234 "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, '', ?, ?)" 1235 )->execute(array( 1236 $gedcom_id, 1237 $xref, 1238 $gedcom, 1239 Auth::id() 1240 )); 1241 1242 // Accept this pending change 1243 if (Auth::user()->getPreference('auto_accept')) { 1244 accept_all_changes($xref, $gedcom_id); 1245 } 1246 1247 // Clear this record from the cache 1248 self::$pending_record_cache = null; 1249 1250 Log::addEditLog('Create: ' . $type . ' ' . $xref); 1251 1252 // Return the newly created record 1253 return GedcomRecord::getInstance($xref); 1254 } 1255 1256 /** 1257 * Update this record 1258 * 1259 * @param string $gedcom 1260 * @param boolean $update_chan 1261 */ 1262 public function updateRecord($gedcom, $update_chan) { 1263 // MSDOS line endings will break things in horrible ways 1264 $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom); 1265 $gedcom = trim($gedcom); 1266 1267 // Update the CHAN record 1268 if ($update_chan) { 1269 $gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom); 1270 $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->getUserName(); 1271 } 1272 1273 // Create a pending change 1274 Database::prepare( 1275 "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, ?, ?)" 1276 )->execute(array( 1277 $this->tree->getTreeId(), 1278 $this->xref, 1279 $this->getGedcom(), 1280 $gedcom, 1281 Auth::id() 1282 )); 1283 1284 // Clear the cache 1285 $this->pending = $gedcom; 1286 1287 // Accept this pending change 1288 if (Auth::user()->getPreference('auto_accept')) { 1289 accept_all_changes($this->xref, $this->tree->getTreeId()); 1290 $this->gedcom = $gedcom; 1291 $this->pending = null; 1292 } 1293 1294 $this->parseFacts(); 1295 1296 Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref); 1297 } 1298 1299 /** 1300 * Delete this record 1301 */ 1302 public function deleteRecord() { 1303 // Create a pending change 1304 Database::prepare( 1305 "INSERT INTO `##change` (gedcom_id, xref, old_gedcom, new_gedcom, user_id) VALUES (?, ?, ?, '', ?)" 1306 )->execute(array( 1307 $this->tree->getTreeId(), 1308 $this->xref, 1309 $this->getGedcom(), 1310 Auth::id(), 1311 )); 1312 1313 // Accept this pending change 1314 if (Auth::user()->getPreference('auto_accept')) { 1315 accept_all_changes($this->xref, $this->tree->getTreeId()); 1316 } 1317 1318 // Clear the cache 1319 self::$gedcom_record_cache = null; 1320 self::$pending_record_cache = null; 1321 1322 Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref); 1323 } 1324 1325 /** 1326 * Remove all links from this record to $xref 1327 * 1328 * @param string $xref 1329 * @param boolean $update_chan 1330 */ 1331 public function removeLinks($xref, $update_chan) { 1332 $value = '@' . $xref . '@'; 1333 1334 foreach ($this->getFacts() as $fact) { 1335 if ($fact->getValue() == $value) { 1336 $this->deleteFact($fact->getFactId(), $update_chan); 1337 } elseif (preg_match_all('/\n(\d) ' . WT_REGEX_TAG . ' ' . $value . '/', $fact->getGedcom(), $matches, PREG_SET_ORDER)) { 1338 $gedcom = $fact->getGedcom(); 1339 foreach ($matches as $match) { 1340 $next_level = $match[1] + 1; 1341 $next_levels = '[' . $next_level . '-9]'; 1342 $gedcom = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom); 1343 } 1344 $this->updateFact($fact->getFactId(), $gedcom, $update_chan); 1345 } 1346 } 1347 } 1348} 1349