xref: /webtrees/app/GedcomRecord.php (revision 89f7189b61a494347591c99bdb92afb7d8b66e1b)
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;
33use Throwable;
34use Transliterator;
35
36use function addcslashes;
37use function app;
38use function array_shift;
39use function assert;
40use function count;
41use function date;
42use function e;
43use function explode;
44use function in_array;
45use function md5;
46use function preg_match;
47use function preg_match_all;
48use function preg_replace;
49use function preg_replace_callback;
50use function preg_split;
51use function route;
52use function str_contains;
53use function str_pad;
54use function strip_tags;
55use function strtoupper;
56use function trim;
57
58use const PREG_SET_ORDER;
59use const STR_PAD_LEFT;
60
61/**
62 * A GEDCOM object.
63 */
64class GedcomRecord
65{
66    public const RECORD_TYPE = 'UNKNOWN';
67
68    protected const ROUTE_NAME = GedcomRecordPage::class;
69
70    /** @var string The record identifier */
71    protected $xref;
72
73    /** @var Tree  The family tree to which this record belongs */
74    protected $tree;
75
76    /** @var string  GEDCOM data (before any pending edits) */
77    protected $gedcom;
78
79    /** @var string|null  GEDCOM data (after any pending edits) */
80    protected $pending;
81
82    /** @var Fact[] facts extracted from $gedcom/$pending */
83    protected $facts;
84
85    /** @var string[][] All the names of this individual */
86    protected $getAllNames;
87
88    /** @var int|null Cached result */
89    protected $getPrimaryName;
90    /** @var int|null Cached result */
91    protected $getSecondaryName;
92
93    /**
94     * Create a GedcomRecord object from raw GEDCOM data.
95     *
96     * @param string      $xref
97     * @param string      $gedcom  an empty string for new/pending records
98     * @param string|null $pending null for a record with no pending edits,
99     *                             empty string for records with pending deletions
100     * @param Tree        $tree
101     */
102    public function __construct(string $xref, string $gedcom, ?string $pending, Tree $tree)
103    {
104        $this->xref    = $xref;
105        $this->gedcom  = $gedcom;
106        $this->pending = $pending;
107        $this->tree    = $tree;
108
109        $this->parseFacts();
110    }
111
112    /**
113     * A closure which will create a record from a database row.
114     *
115     * @deprecated since 2.0.4.  Will be removed in 2.1.0 - Use Factory::gedcomRecord()
116     *
117     * @param Tree $tree
118     *
119     * @return Closure
120     */
121    public static function rowMapper(Tree $tree): Closure
122    {
123        return Registry::gedcomRecordFactory()->mapper($tree);
124    }
125
126    /**
127     * A closure which will filter out private records.
128     *
129     * @return Closure
130     */
131    public static function accessFilter(): Closure
132    {
133        return static function (GedcomRecord $record): bool {
134            return $record->canShow();
135        };
136    }
137
138    /**
139     * A closure which will compare records by name.
140     *
141     * @return Closure
142     */
143    public static function nameComparator(): Closure
144    {
145        return static function (GedcomRecord $x, GedcomRecord $y): int {
146            if ($x->canShowName()) {
147                if ($y->canShowName()) {
148                    return I18N::strcasecmp($x->sortName(), $y->sortName());
149                }
150
151                return -1; // only $y is private
152            }
153
154            if ($y->canShowName()) {
155                return 1; // only $x is private
156            }
157
158            return 0; // both $x and $y private
159        };
160    }
161
162    /**
163     * A closure which will compare records by change time.
164     *
165     * @param int $direction +1 to sort ascending, -1 to sort descending
166     *
167     * @return Closure
168     */
169    public static function lastChangeComparator(int $direction = 1): Closure
170    {
171        return static function (GedcomRecord $x, GedcomRecord $y) use ($direction): int {
172            return $direction * ($x->lastChangeTimestamp() <=> $y->lastChangeTimestamp());
173        };
174    }
175
176    /**
177     * Get an instance of a GedcomRecord object. For single records,
178     * we just receive the XREF. For bulk records (such as lists
179     * and search results) we can receive the GEDCOM data as well.
180     *
181     * @deprecated since 2.0.4.  Will be removed in 2.1.0 - Use Factory::gedcomRecord()
182     *
183     * @param string      $xref
184     * @param Tree        $tree
185     * @param string|null $gedcom
186     *
187     * @return GedcomRecord|Individual|Family|Source|Repository|Media|Note|Submitter|null
188     */
189    public static function getInstance(string $xref, Tree $tree, string $gedcom = null)
190    {
191        return Registry::gedcomRecordFactory()->make($xref, $tree, $gedcom);
192    }
193
194    /**
195     * Get the GEDCOM tag for this record.
196     *
197     * @return string
198     */
199    public function tag(): string
200    {
201        preg_match('/^0 @[^@]*@ (\w+)/', $this->gedcom(), $match);
202
203        return $match[1] ?? static::RECORD_TYPE;
204    }
205
206    /**
207     * Get the XREF for this record
208     *
209     * @return string
210     */
211    public function xref(): string
212    {
213        return $this->xref;
214    }
215
216    /**
217     * Get the tree to which this record belongs
218     *
219     * @return Tree
220     */
221    public function tree(): Tree
222    {
223        return $this->tree;
224    }
225
226    /**
227     * Application code should access data via Fact objects.
228     * This function exists to support old code.
229     *
230     * @return string
231     */
232    public function gedcom(): string
233    {
234        return $this->pending ?? $this->gedcom;
235    }
236
237    /**
238     * Does this record have a pending change?
239     *
240     * @return bool
241     */
242    public function isPendingAddition(): bool
243    {
244        return $this->pending !== null;
245    }
246
247    /**
248     * Does this record have a pending deletion?
249     *
250     * @return bool
251     */
252    public function isPendingDeletion(): bool
253    {
254        return $this->pending === '';
255    }
256
257    /**
258     * Generate a "slug" to use in pretty URLs.
259     *
260     * @return string
261     */
262    public function slug(): string
263    {
264        $slug = strip_tags($this->fullName());
265
266        try {
267            $transliterator = Transliterator::create('Any-Latin;Latin-ASCII');
268            $slug           = $transliterator->transliterate($slug);
269        } catch (Throwable $ex) {
270            // ext-intl not installed?
271            // Transliteration algorithms not present in lib-icu?
272        }
273
274        $slug = preg_replace('/[^A-Za-z0-9]+/', '-', $slug);
275
276        return trim($slug, '-') ?: '-';
277    }
278
279    /**
280     * Generate a URL to this record.
281     *
282     * @return string
283     */
284    public function url(): string
285    {
286        return route(static::ROUTE_NAME, [
287            'xref' => $this->xref(),
288            'tree' => $this->tree->name(),
289            'slug' => $this->slug(),
290        ]);
291    }
292
293    /**
294     * Can the details of this record be shown?
295     *
296     * @param int|null $access_level
297     *
298     * @return bool
299     */
300    public function canShow(int $access_level = null): bool
301    {
302        $access_level = $access_level ?? Auth::accessLevel($this->tree);
303
304        // We use this value to bypass privacy checks. For example,
305        // when downloading data or when calculating privacy itself.
306        if ($access_level === Auth::PRIV_HIDE) {
307            return true;
308        }
309
310        $cache_key = 'show-' . $this->xref . '-' . $this->tree->id() . '-' . $access_level;
311
312        return Registry::cache()->array()->remember($cache_key, function () use ($access_level) {
313            return $this->canShowRecord($access_level);
314        });
315    }
316
317    /**
318     * Can the name of this record be shown?
319     *
320     * @param int|null $access_level
321     *
322     * @return bool
323     */
324    public function canShowName(int $access_level = null): bool
325    {
326        return $this->canShow($access_level);
327    }
328
329    /**
330     * Can we edit this record?
331     *
332     * @return bool
333     */
334    public function canEdit(): bool
335    {
336        if ($this->isPendingDeletion()) {
337            return false;
338        }
339
340        if (Auth::isManager($this->tree)) {
341            return true;
342        }
343
344        return Auth::isEditor($this->tree) && !str_contains($this->gedcom, "\n1 RESN locked");
345    }
346
347    /**
348     * Remove private data from the raw gedcom record.
349     * Return both the visible and invisible data. We need the invisible data when editing.
350     *
351     * @param int $access_level
352     *
353     * @return string
354     */
355    public function privatizeGedcom(int $access_level): string
356    {
357        if ($access_level === Auth::PRIV_HIDE) {
358            // We may need the original record, for example when downloading a GEDCOM or clippings cart
359            return $this->gedcom;
360        }
361
362        if ($this->canShow($access_level)) {
363            // The record is not private, but the individual facts may be.
364
365            // Include the entire first line (for NOTE records)
366            [$gedrec] = explode("\n", $this->gedcom . $this->pending, 2);
367
368            // Check each of the facts for access
369            foreach ($this->facts([], false, $access_level) as $fact) {
370                $gedrec .= "\n" . $fact->gedcom();
371            }
372
373            return $gedrec;
374        }
375
376        // We cannot display the details, but we may be able to display
377        // limited data, such as links to other records.
378        return $this->createPrivateGedcomRecord($access_level);
379    }
380
381    /**
382     * Default for "other" object types
383     *
384     * @return void
385     */
386    public function extractNames(): void
387    {
388        $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
389    }
390
391    /**
392     * Derived classes should redefine this function, otherwise the object will have no name
393     *
394     * @return string[][]
395     */
396    public function getAllNames(): array
397    {
398        if ($this->getAllNames === null) {
399            $this->getAllNames = [];
400            if ($this->canShowName()) {
401                // Ask the record to extract its names
402                $this->extractNames();
403                // No name found? Use a fallback.
404                if ($this->getAllNames === []) {
405                    $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
406                }
407            } else {
408                $this->addName(static::RECORD_TYPE, I18N::translate('Private'), '');
409            }
410        }
411
412        return $this->getAllNames;
413    }
414
415    /**
416     * If this object has no name, what do we call it?
417     *
418     * @return string
419     */
420    public function getFallBackName(): string
421    {
422        return e($this->xref());
423    }
424
425    /**
426     * Which of the (possibly several) names of this record is the primary one.
427     *
428     * @return int
429     */
430    public function getPrimaryName(): int
431    {
432        static $language_script;
433
434        if ($language_script === null) {
435            $language_script = $language_script ?? I18N::locale()->script()->code();
436        }
437
438        if ($this->getPrimaryName === null) {
439            // Generally, the first name is the primary one....
440            $this->getPrimaryName = 0;
441            // ...except when the language/name use different character sets
442            foreach ($this->getAllNames() as $n => $name) {
443                if (I18N::textScript($name['sort']) === $language_script) {
444                    $this->getPrimaryName = $n;
445                    break;
446                }
447            }
448        }
449
450        return $this->getPrimaryName;
451    }
452
453    /**
454     * Which of the (possibly several) names of this record is the secondary one.
455     *
456     * @return int
457     */
458    public function getSecondaryName(): int
459    {
460        if ($this->getSecondaryName === null) {
461            // Generally, the primary and secondary names are the same
462            $this->getSecondaryName = $this->getPrimaryName();
463            // ....except when there are names with different character sets
464            $all_names = $this->getAllNames();
465            if (count($all_names) > 1) {
466                $primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
467                foreach ($all_names as $n => $name) {
468                    if ($n !== $this->getPrimaryName() && $name['type'] !== '_MARNM' && I18N::textScript($name['sort']) !== $primary_script) {
469                        $this->getSecondaryName = $n;
470                        break;
471                    }
472                }
473            }
474        }
475
476        return $this->getSecondaryName;
477    }
478
479    /**
480     * Allow the choice of primary name to be overidden, e.g. in a search result
481     *
482     * @param int|null $n
483     *
484     * @return void
485     */
486    public function setPrimaryName(int $n = null): void
487    {
488        $this->getPrimaryName   = $n;
489        $this->getSecondaryName = null;
490    }
491
492    /**
493     * Allow native PHP functions such as array_unique() to work with objects
494     *
495     * @return string
496     */
497    public function __toString()
498    {
499        return $this->xref . '@' . $this->tree->id();
500    }
501
502    /**
503     * /**
504     * Get variants of the name
505     *
506     * @return string
507     */
508    public function fullName(): string
509    {
510        if ($this->canShowName()) {
511            $tmp = $this->getAllNames();
512
513            return $tmp[$this->getPrimaryName()]['full'];
514        }
515
516        return I18N::translate('Private');
517    }
518
519    /**
520     * Get a sortable version of the name. Do not display this!
521     *
522     * @return string
523     */
524    public function sortName(): string
525    {
526        // The sortable name is never displayed, no need to call canShowName()
527        $tmp = $this->getAllNames();
528
529        return $tmp[$this->getPrimaryName()]['sort'];
530    }
531
532    /**
533     * Get the full name in an alternative character set
534     *
535     * @return string|null
536     */
537    public function alternateName(): ?string
538    {
539        if ($this->canShowName() && $this->getPrimaryName() !== $this->getSecondaryName()) {
540            $all_names = $this->getAllNames();
541
542            return $all_names[$this->getSecondaryName()]['full'];
543        }
544
545        return null;
546    }
547
548    /**
549     * Format this object for display in a list
550     *
551     * @return string
552     */
553    public function formatList(): string
554    {
555        $html = '<a href="' . e($this->url()) . '" class="list_item">';
556        $html .= '<b>' . $this->fullName() . '</b>';
557        $html .= $this->formatListDetails();
558        $html .= '</a>';
559
560        return $html;
561    }
562
563    /**
564     * This function should be redefined in derived classes to show any major
565     * identifying characteristics of this record.
566     *
567     * @return string
568     */
569    public function formatListDetails(): string
570    {
571        return '';
572    }
573
574    /**
575     * Extract/format the first fact from a list of facts.
576     *
577     * @param string[] $facts
578     * @param int      $style
579     *
580     * @return string
581     */
582    public function formatFirstMajorFact(array $facts, int $style): string
583    {
584        foreach ($this->facts($facts, true) as $event) {
585            // Only display if it has a date or place (or both)
586            if ($event->date()->isOK() && $event->place()->gedcomName() !== '') {
587                $joiner = ' — ';
588            } else {
589                $joiner = '';
590            }
591            if ($event->date()->isOK() || $event->place()->gedcomName() !== '') {
592                switch ($style) {
593                    case 1:
594                        return '<br><em>' . $event->label() . ' ' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</em>';
595                    case 2:
596                        return '<dl><dt class="label">' . $event->label() . '</dt><dd class="field">' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</dd></dl>';
597                }
598            }
599        }
600
601        return '';
602    }
603
604    /**
605     * Find individuals linked to this record.
606     *
607     * @param string $link
608     *
609     * @return Collection<Individual>
610     */
611    public function linkedIndividuals(string $link): Collection
612    {
613        return DB::table('individuals')
614            ->join('link', static function (JoinClause $join): void {
615                $join
616                    ->on('l_file', '=', 'i_file')
617                    ->on('l_from', '=', 'i_id');
618            })
619            ->where('i_file', '=', $this->tree->id())
620            ->where('l_type', '=', $link)
621            ->where('l_to', '=', $this->xref)
622            ->select(['individuals.*'])
623            ->get()
624            ->map(Registry::individualFactory()->mapper($this->tree))
625            ->filter(self::accessFilter());
626    }
627
628    /**
629     * Find families linked to this record.
630     *
631     * @param string $link
632     *
633     * @return Collection<Family>
634     */
635    public function linkedFamilies(string $link): Collection
636    {
637        return DB::table('families')
638            ->join('link', static function (JoinClause $join): void {
639                $join
640                    ->on('l_file', '=', 'f_file')
641                    ->on('l_from', '=', 'f_id');
642            })
643            ->where('f_file', '=', $this->tree->id())
644            ->where('l_type', '=', $link)
645            ->where('l_to', '=', $this->xref)
646            ->select(['families.*'])
647            ->get()
648            ->map(Registry::familyFactory()->mapper($this->tree))
649            ->filter(self::accessFilter());
650    }
651
652    /**
653     * Find sources linked to this record.
654     *
655     * @param string $link
656     *
657     * @return Collection<Source>
658     */
659    public function linkedSources(string $link): Collection
660    {
661        return DB::table('sources')
662            ->join('link', static function (JoinClause $join): void {
663                $join
664                    ->on('l_file', '=', 's_file')
665                    ->on('l_from', '=', 's_id');
666            })
667            ->where('s_file', '=', $this->tree->id())
668            ->where('l_type', '=', $link)
669            ->where('l_to', '=', $this->xref)
670            ->select(['sources.*'])
671            ->get()
672            ->map(Registry::sourceFactory()->mapper($this->tree))
673            ->filter(self::accessFilter());
674    }
675
676    /**
677     * Find media objects linked to this record.
678     *
679     * @param string $link
680     *
681     * @return Collection<Media>
682     */
683    public function linkedMedia(string $link): Collection
684    {
685        return DB::table('media')
686            ->join('link', static function (JoinClause $join): void {
687                $join
688                    ->on('l_file', '=', 'm_file')
689                    ->on('l_from', '=', 'm_id');
690            })
691            ->where('m_file', '=', $this->tree->id())
692            ->where('l_type', '=', $link)
693            ->where('l_to', '=', $this->xref)
694            ->select(['media.*'])
695            ->get()
696            ->map(Registry::mediaFactory()->mapper($this->tree))
697            ->filter(self::accessFilter());
698    }
699
700    /**
701     * Find notes linked to this record.
702     *
703     * @param string $link
704     *
705     * @return Collection<Note>
706     */
707    public function linkedNotes(string $link): Collection
708    {
709        return DB::table('other')
710            ->join('link', static function (JoinClause $join): void {
711                $join
712                    ->on('l_file', '=', 'o_file')
713                    ->on('l_from', '=', 'o_id');
714            })
715            ->where('o_file', '=', $this->tree->id())
716            ->where('o_type', '=', Note::RECORD_TYPE)
717            ->where('l_type', '=', $link)
718            ->where('l_to', '=', $this->xref)
719            ->select(['other.*'])
720            ->get()
721            ->map(Registry::noteFactory()->mapper($this->tree))
722            ->filter(self::accessFilter());
723    }
724
725    /**
726     * Find repositories linked to this record.
727     *
728     * @param string $link
729     *
730     * @return Collection<Repository>
731     */
732    public function linkedRepositories(string $link): Collection
733    {
734        return DB::table('other')
735            ->join('link', static function (JoinClause $join): void {
736                $join
737                    ->on('l_file', '=', 'o_file')
738                    ->on('l_from', '=', 'o_id');
739            })
740            ->where('o_file', '=', $this->tree->id())
741            ->where('o_type', '=', Repository::RECORD_TYPE)
742            ->where('l_type', '=', $link)
743            ->where('l_to', '=', $this->xref)
744            ->select(['other.*'])
745            ->get()
746            ->map(Registry::repositoryFactory()->mapper($this->tree))
747            ->filter(self::accessFilter());
748    }
749
750    /**
751     * Find locations linked to this record.
752     *
753     * @param string $link
754     *
755     * @return Collection<Location>
756     */
757    public function linkedLocations(string $link): Collection
758    {
759        return DB::table('other')
760            ->join('link', static function (JoinClause $join): void {
761                $join
762                    ->on('l_file', '=', 'o_file')
763                    ->on('l_from', '=', 'o_id');
764            })
765            ->where('o_file', '=', $this->tree->id())
766            ->where('o_type', '=', Location::RECORD_TYPE)
767            ->where('l_type', '=', $link)
768            ->where('l_to', '=', $this->xref)
769            ->select(['other.*'])
770            ->get()
771            ->map(Registry::locationFactory()->mapper($this->tree))
772            ->filter(self::accessFilter());
773    }
774
775    /**
776     * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
777     * This is used to display multiple events on the individual/family lists.
778     * Multiple events can exist because of uncertainty in dates, dates in different
779     * calendars, place-names in both latin and hebrew character sets, etc.
780     * It also allows us to combine dates/places from different events in the summaries.
781     *
782     * @param string[] $events
783     *
784     * @return Date[]
785     */
786    public function getAllEventDates(array $events): array
787    {
788        $dates = [];
789        foreach ($this->facts($events, false, null, true) as $event) {
790            if ($event->date()->isOK()) {
791                $dates[] = $event->date();
792            }
793        }
794
795        return $dates;
796    }
797
798    /**
799     * Get all the places for a particular type of event
800     *
801     * @param string[] $events
802     *
803     * @return Place[]
804     */
805    public function getAllEventPlaces(array $events): array
806    {
807        $places = [];
808        foreach ($this->facts($events) as $event) {
809            if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->gedcom(), $ged_places)) {
810                foreach ($ged_places[1] as $ged_place) {
811                    $places[] = new Place($ged_place, $this->tree);
812                }
813            }
814        }
815
816        return $places;
817    }
818
819    /**
820     * The facts and events for this record.
821     *
822     * @param string[] $filter
823     * @param bool     $sort
824     * @param int|null $access_level
825     * @param bool     $ignore_deleted
826     *
827     * @return Collection<Fact>
828     */
829    public function facts(
830        array $filter = [],
831        bool $sort = false,
832        int $access_level = null,
833        bool $ignore_deleted = false
834    ): Collection {
835        $access_level = $access_level ?? Auth::accessLevel($this->tree);
836
837        $facts = new Collection();
838        if ($this->canShow($access_level)) {
839            foreach ($this->facts as $fact) {
840                if (($filter === [] || in_array($fact->getTag(), $filter, true)) && $fact->canShow($access_level)) {
841                    $facts->push($fact);
842                }
843            }
844        }
845
846        if ($sort) {
847            $facts = Fact::sortFacts($facts);
848        }
849
850        if ($ignore_deleted) {
851            $facts = $facts->filter(static function (Fact $fact): bool {
852                return !$fact->isPendingDeletion();
853            });
854        }
855
856        return new Collection($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 ($fact->getTag() !== '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 . "\n1 NOTE " . I18N::translate('Private');
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('/([0-9]+)/', 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