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