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