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