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