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