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