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