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