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