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