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