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