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