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