xref: /webtrees/app/GedcomRecord.php (revision e72c24d6f8af5daa6dc0f4942f8c8f018f99ab41)
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        return strip_tags($this->fullName());
364    }
365
366    /**
367     * Generate a URL to this record.
368     *
369     * @return string
370     */
371    public function url(): string
372    {
373        return route(static::ROUTE_NAME, [
374            'xref' => $this->xref(),
375            'tree' => $this->tree->name(),
376            'slug' => $this->slug(),
377        ]);
378    }
379
380    /**
381     * Can the details of this record be shown?
382     *
383     * @param int|null $access_level
384     *
385     * @return bool
386     */
387    public function canShow(int $access_level = null): bool
388    {
389        $access_level = $access_level ?? Auth::accessLevel($this->tree);
390
391        // We use this value to bypass privacy checks. For example,
392        // when downloading data or when calculating privacy itself.
393        if ($access_level === Auth::PRIV_HIDE) {
394            return true;
395        }
396
397        $cache_key = 'show-' . $this->xref . '-' . $this->tree->id() . '-' . $access_level;
398
399        return app('cache.array')->remember($cache_key, function () use ($access_level) {
400            return $this->canShowRecord($access_level);
401        });
402    }
403
404    /**
405     * Can the name of this record be shown?
406     *
407     * @param int|null $access_level
408     *
409     * @return bool
410     */
411    public function canShowName(int $access_level = null): bool
412    {
413        return $this->canShow($access_level);
414    }
415
416    /**
417     * Can we edit this record?
418     *
419     * @return bool
420     */
421    public function canEdit(): bool
422    {
423        if ($this->isPendingDeletion()) {
424            return false;
425        }
426
427        if (Auth::isManager($this->tree)) {
428            return true;
429        }
430
431        return Auth::isEditor($this->tree) && strpos($this->gedcom, "\n1 RESN locked") === false;
432    }
433
434    /**
435     * Remove private data from the raw gedcom record.
436     * Return both the visible and invisible data. We need the invisible data when editing.
437     *
438     * @param int $access_level
439     *
440     * @return string
441     */
442    public function privatizeGedcom(int $access_level): string
443    {
444        if ($access_level === Auth::PRIV_HIDE) {
445            // We may need the original record, for example when downloading a GEDCOM or clippings cart
446            return $this->gedcom;
447        }
448
449        if ($this->canShow($access_level)) {
450            // The record is not private, but the individual facts may be.
451
452            // Include the entire first line (for NOTE records)
453            [$gedrec] = explode("\n", $this->gedcom, 2);
454
455            // Check each of the facts for access
456            foreach ($this->facts([], false, $access_level) as $fact) {
457                $gedrec .= "\n" . $fact->gedcom();
458            }
459
460            return $gedrec;
461        }
462
463        // We cannot display the details, but we may be able to display
464        // limited data, such as links to other records.
465        return $this->createPrivateGedcomRecord($access_level);
466    }
467
468    /**
469     * Default for "other" object types
470     *
471     * @return void
472     */
473    public function extractNames(): void
474    {
475        $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
476    }
477
478    /**
479     * Derived classes should redefine this function, otherwise the object will have no name
480     *
481     * @return string[][]
482     */
483    public function getAllNames(): array
484    {
485        if ($this->getAllNames === null) {
486            $this->getAllNames = [];
487            if ($this->canShowName()) {
488                // Ask the record to extract its names
489                $this->extractNames();
490                // No name found? Use a fallback.
491                if (!$this->getAllNames) {
492                    $this->addName(static::RECORD_TYPE, $this->getFallBackName(), '');
493                }
494            } else {
495                $this->addName(static::RECORD_TYPE, I18N::translate('Private'), '');
496            }
497        }
498
499        return $this->getAllNames;
500    }
501
502    /**
503     * If this object has no name, what do we call it?
504     *
505     * @return string
506     */
507    public function getFallBackName(): string
508    {
509        return e($this->xref());
510    }
511
512    /**
513     * Which of the (possibly several) names of this record is the primary one.
514     *
515     * @return int
516     */
517    public function getPrimaryName(): int
518    {
519        static $language_script;
520
521        if ($language_script === null) {
522            $language_script = $language_script ?? I18N::locale()->script()->code();
523        }
524
525        if ($this->getPrimaryName === null) {
526            // Generally, the first name is the primary one....
527            $this->getPrimaryName = 0;
528            // ...except when the language/name use different character sets
529            foreach ($this->getAllNames() as $n => $name) {
530                if (I18N::textScript($name['sort']) === $language_script) {
531                    $this->getPrimaryName = $n;
532                    break;
533                }
534            }
535        }
536
537        return $this->getPrimaryName;
538    }
539
540    /**
541     * Which of the (possibly several) names of this record is the secondary one.
542     *
543     * @return int
544     */
545    public function getSecondaryName(): int
546    {
547        if ($this->getSecondaryName === null) {
548            // Generally, the primary and secondary names are the same
549            $this->getSecondaryName = $this->getPrimaryName();
550            // ....except when there are names with different character sets
551            $all_names = $this->getAllNames();
552            if (count($all_names) > 1) {
553                $primary_script = I18N::textScript($all_names[$this->getPrimaryName()]['sort']);
554                foreach ($all_names as $n => $name) {
555                    if ($n !== $this->getPrimaryName() && $name['type'] !== '_MARNM' && I18N::textScript($name['sort']) !== $primary_script) {
556                        $this->getSecondaryName = $n;
557                        break;
558                    }
559                }
560            }
561        }
562
563        return $this->getSecondaryName;
564    }
565
566    /**
567     * Allow the choice of primary name to be overidden, e.g. in a search result
568     *
569     * @param int|null $n
570     *
571     * @return void
572     */
573    public function setPrimaryName(int $n = null): void
574    {
575        $this->getPrimaryName   = $n;
576        $this->getSecondaryName = null;
577    }
578
579    /**
580     * Allow native PHP functions such as array_unique() to work with objects
581     *
582     * @return string
583     */
584    public function __toString()
585    {
586        return $this->xref . '@' . $this->tree->id();
587    }
588
589    /**
590     * /**
591     * Get variants of the name
592     *
593     * @return string
594     */
595    public function fullName(): string
596    {
597        if ($this->canShowName()) {
598            $tmp = $this->getAllNames();
599
600            return $tmp[$this->getPrimaryName()]['full'];
601        }
602
603        return I18N::translate('Private');
604    }
605
606    /**
607     * Get a sortable version of the name. Do not display this!
608     *
609     * @return string
610     */
611    public function sortName(): string
612    {
613        // The sortable name is never displayed, no need to call canShowName()
614        $tmp = $this->getAllNames();
615
616        return $tmp[$this->getPrimaryName()]['sort'];
617    }
618
619    /**
620     * Get the full name in an alternative character set
621     *
622     * @return string|null
623     */
624    public function alternateName(): ?string
625    {
626        if ($this->canShowName() && $this->getPrimaryName() !== $this->getSecondaryName()) {
627            $all_names = $this->getAllNames();
628
629            return $all_names[$this->getSecondaryName()]['full'];
630        }
631
632        return null;
633    }
634
635    /**
636     * Format this object for display in a list
637     *
638     * @return string
639     */
640    public function formatList(): string
641    {
642        $html = '<a href="' . e($this->url()) . '" class="list_item">';
643        $html .= '<b>' . $this->fullName() . '</b>';
644        $html .= $this->formatListDetails();
645        $html .= '</a>';
646
647        return $html;
648    }
649
650    /**
651     * This function should be redefined in derived classes to show any major
652     * identifying characteristics of this record.
653     *
654     * @return string
655     */
656    public function formatListDetails(): string
657    {
658        return '';
659    }
660
661    /**
662     * Extract/format the first fact from a list of facts.
663     *
664     * @param string[] $facts
665     * @param int      $style
666     *
667     * @return string
668     */
669    public function formatFirstMajorFact(array $facts, int $style): string
670    {
671        foreach ($this->facts($facts, true) as $event) {
672            // Only display if it has a date or place (or both)
673            if ($event->date()->isOK() && $event->place()->gedcomName() !== '') {
674                $joiner = ' — ';
675            } else {
676                $joiner = '';
677            }
678            if ($event->date()->isOK() || $event->place()->gedcomName() !== '') {
679                switch ($style) {
680                    case 1:
681                        return '<br><em>' . $event->label() . ' ' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</em>';
682                    case 2:
683                        return '<dl><dt class="label">' . $event->label() . '</dt><dd class="field">' . FunctionsPrint::formatFactDate($event, $this, false, false) . $joiner . FunctionsPrint::formatFactPlace($event) . '</dd></dl>';
684                }
685            }
686        }
687
688        return '';
689    }
690
691    /**
692     * Find individuals linked to this record.
693     *
694     * @param string $link
695     *
696     * @return Collection
697     */
698    public function linkedIndividuals(string $link): Collection
699    {
700        return DB::table('individuals')
701            ->join('link', static function (JoinClause $join): void {
702                $join
703                    ->on('l_file', '=', 'i_file')
704                    ->on('l_from', '=', 'i_id');
705            })
706            ->where('i_file', '=', $this->tree->id())
707            ->where('l_type', '=', $link)
708            ->where('l_to', '=', $this->xref)
709            ->select(['individuals.*'])
710            ->get()
711            ->map(Individual::rowMapper($this->tree))
712            ->filter(self::accessFilter());
713    }
714
715    /**
716     * Find families linked to this record.
717     *
718     * @param string $link
719     *
720     * @return Collection
721     */
722    public function linkedFamilies(string $link): Collection
723    {
724        return DB::table('families')
725            ->join('link', static function (JoinClause $join): void {
726                $join
727                    ->on('l_file', '=', 'f_file')
728                    ->on('l_from', '=', 'f_id');
729            })
730            ->where('f_file', '=', $this->tree->id())
731            ->where('l_type', '=', $link)
732            ->where('l_to', '=', $this->xref)
733            ->select(['families.*'])
734            ->get()
735            ->map(Family::rowMapper($this->tree))
736            ->filter(self::accessFilter());
737    }
738
739    /**
740     * Find sources linked to this record.
741     *
742     * @param string $link
743     *
744     * @return Collection
745     */
746    public function linkedSources(string $link): Collection
747    {
748        return DB::table('sources')
749            ->join('link', static function (JoinClause $join): void {
750                $join
751                    ->on('l_file', '=', 's_file')
752                    ->on('l_from', '=', 's_id');
753            })
754            ->where('s_file', '=', $this->tree->id())
755            ->where('l_type', '=', $link)
756            ->where('l_to', '=', $this->xref)
757            ->select(['sources.*'])
758            ->get()
759            ->map(Source::rowMapper($this->tree))
760            ->filter(self::accessFilter());
761    }
762
763    /**
764     * Find media objects linked to this record.
765     *
766     * @param string $link
767     *
768     * @return Collection
769     */
770    public function linkedMedia(string $link): Collection
771    {
772        return DB::table('media')
773            ->join('link', static function (JoinClause $join): void {
774                $join
775                    ->on('l_file', '=', 'm_file')
776                    ->on('l_from', '=', 'm_id');
777            })
778            ->where('m_file', '=', $this->tree->id())
779            ->where('l_type', '=', $link)
780            ->where('l_to', '=', $this->xref)
781            ->select(['media.*'])
782            ->get()
783            ->map(Media::rowMapper($this->tree))
784            ->filter(self::accessFilter());
785    }
786
787    /**
788     * Find notes linked to this record.
789     *
790     * @param string $link
791     *
792     * @return Collection
793     */
794    public function linkedNotes(string $link): Collection
795    {
796        return DB::table('other')
797            ->join('link', static function (JoinClause $join): void {
798                $join
799                    ->on('l_file', '=', 'o_file')
800                    ->on('l_from', '=', 'o_id');
801            })
802            ->where('o_file', '=', $this->tree->id())
803            ->where('o_type', '=', 'NOTE')
804            ->where('l_type', '=', $link)
805            ->where('l_to', '=', $this->xref)
806            ->select(['other.*'])
807            ->get()
808            ->map(Note::rowMapper($this->tree))
809            ->filter(self::accessFilter());
810    }
811
812    /**
813     * Find repositories linked to this record.
814     *
815     * @param string $link
816     *
817     * @return Collection
818     */
819    public function linkedRepositories(string $link): Collection
820    {
821        return DB::table('other')
822            ->join('link', static function (JoinClause $join): void {
823                $join
824                    ->on('l_file', '=', 'o_file')
825                    ->on('l_from', '=', 'o_id');
826            })
827            ->where('o_file', '=', $this->tree->id())
828            ->where('o_type', '=', 'REPO')
829            ->where('l_type', '=', $link)
830            ->where('l_to', '=', $this->xref)
831            ->select(['other.*'])
832            ->get()
833            ->map(Repository::rowMapper($this->tree))
834            ->filter(self::accessFilter());
835    }
836
837    /**
838     * Get all attributes (e.g. DATE or PLAC) from an event (e.g. BIRT or MARR).
839     * This is used to display multiple events on the individual/family lists.
840     * Multiple events can exist because of uncertainty in dates, dates in different
841     * calendars, place-names in both latin and hebrew character sets, etc.
842     * It also allows us to combine dates/places from different events in the summaries.
843     *
844     * @param string[] $events
845     *
846     * @return Date[]
847     */
848    public function getAllEventDates(array $events): array
849    {
850        $dates = [];
851        foreach ($this->facts($events) as $event) {
852            if ($event->date()->isOK()) {
853                $dates[] = $event->date();
854            }
855        }
856
857        return $dates;
858    }
859
860    /**
861     * Get all the places for a particular type of event
862     *
863     * @param string[] $events
864     *
865     * @return Place[]
866     */
867    public function getAllEventPlaces(array $events): array
868    {
869        $places = [];
870        foreach ($this->facts($events) as $event) {
871            if (preg_match_all('/\n(?:2 PLAC|3 (?:ROMN|FONE|_HEB)) +(.+)/', $event->gedcom(), $ged_places)) {
872                foreach ($ged_places[1] as $ged_place) {
873                    $places[] = new Place($ged_place, $this->tree);
874                }
875            }
876        }
877
878        return $places;
879    }
880
881    /**
882     * The facts and events for this record.
883     *
884     * @param string[] $filter
885     * @param bool     $sort
886     * @param int|null $access_level
887     * @param bool     $override Include private records, to allow us to implement $SHOW_PRIVATE_RELATIONSHIPS and $SHOW_LIVING_NAMES.
888     *
889     * @return Collection
890     */
891    public function facts(array $filter = [], bool $sort = false, int $access_level = null, bool $override = false): Collection
892    {
893        $access_level = $access_level ?? Auth::accessLevel($this->tree);
894
895        $facts = new Collection();
896        if ($this->canShow($access_level) || $override) {
897            foreach ($this->facts as $fact) {
898                if (($filter === [] || in_array($fact->getTag(), $filter, true)) && $fact->canShow($access_level)) {
899                    $facts->push($fact);
900                }
901            }
902        }
903
904        if ($sort) {
905            $facts = Fact::sortFacts($facts);
906        }
907
908        return new Collection($facts);
909    }
910
911    /**
912     * Get the last-change timestamp for this record
913     *
914     * @return Carbon
915     */
916    public function lastChangeTimestamp(): Carbon
917    {
918        /** @var Fact|null $chan */
919        $chan = $this->facts(['CHAN'])->first();
920
921        if ($chan instanceof Fact) {
922            // The record does have a CHAN event
923            $d = $chan->date()->minimumDate();
924
925            if (preg_match('/\n3 TIME (\d\d):(\d\d):(\d\d)/', $chan->gedcom(), $match)) {
926                return Carbon::create($d->year(), $d->month(), $d->day(), (int) $match[1], (int) $match[2], (int) $match[3]);
927            }
928
929            if (preg_match('/\n3 TIME (\d\d):(\d\d)/', $chan->gedcom(), $match)) {
930                return Carbon::create($d->year(), $d->month(), $d->day(), (int) $match[1], (int) $match[2]);
931            }
932
933            return Carbon::create($d->year(), $d->month(), $d->day());
934        }
935
936        // The record does not have a CHAN event
937        return Carbon::createFromTimestamp(0);
938    }
939
940    /**
941     * Get the last-change user for this record
942     *
943     * @return string
944     */
945    public function lastChangeUser(): string
946    {
947        $chan = $this->facts(['CHAN'])->first();
948
949        if ($chan === null) {
950            return I18N::translate('Unknown');
951        }
952
953        $chan_user = $chan->attribute('_WT_USER');
954        if ($chan_user === '') {
955            return I18N::translate('Unknown');
956        }
957
958        return $chan_user;
959    }
960
961    /**
962     * Add a new fact to this record
963     *
964     * @param string $gedcom
965     * @param bool   $update_chan
966     *
967     * @return void
968     */
969    public function createFact(string $gedcom, bool $update_chan): void
970    {
971        $this->updateFact('', $gedcom, $update_chan);
972    }
973
974    /**
975     * Delete a fact from this record
976     *
977     * @param string $fact_id
978     * @param bool   $update_chan
979     *
980     * @return void
981     */
982    public function deleteFact(string $fact_id, bool $update_chan): void
983    {
984        $this->updateFact($fact_id, '', $update_chan);
985    }
986
987    /**
988     * Replace a fact with a new gedcom data.
989     *
990     * @param string $fact_id
991     * @param string $gedcom
992     * @param bool   $update_chan
993     *
994     * @return void
995     * @throws Exception
996     */
997    public function updateFact(string $fact_id, string $gedcom, bool $update_chan): void
998    {
999        // MSDOS line endings will break things in horrible ways
1000        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1001        $gedcom = trim($gedcom);
1002
1003        if ($this->pending === '') {
1004            throw new Exception('Cannot edit a deleted record');
1005        }
1006        if ($gedcom !== '' && !preg_match('/^1 ' . Gedcom::REGEX_TAG . '/', $gedcom)) {
1007            throw new Exception('Invalid GEDCOM data passed to GedcomRecord::updateFact(' . $gedcom . ')');
1008        }
1009
1010        if ($this->pending) {
1011            $old_gedcom = $this->pending;
1012        } else {
1013            $old_gedcom = $this->gedcom;
1014        }
1015
1016        // First line of record may contain data - e.g. NOTE records.
1017        [$new_gedcom] = explode("\n", $old_gedcom, 2);
1018
1019        // Replacing (or deleting) an existing fact
1020        foreach ($this->facts([], false, Auth::PRIV_HIDE) as $fact) {
1021            if (!$fact->isPendingDeletion()) {
1022                if ($fact->id() === $fact_id) {
1023                    if ($gedcom !== '') {
1024                        $new_gedcom .= "\n" . $gedcom;
1025                    }
1026                    $fact_id = 'NOT A VALID FACT ID'; // Only replace/delete one copy of a duplicate fact
1027                } elseif ($fact->getTag() !== 'CHAN' || !$update_chan) {
1028                    $new_gedcom .= "\n" . $fact->gedcom();
1029                }
1030            }
1031        }
1032        if ($update_chan) {
1033            $new_gedcom .= "\n1 CHAN\n2 DATE " . strtoupper(date('d M Y')) . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName();
1034        }
1035
1036        // Adding a new fact
1037        if ($fact_id === '') {
1038            $new_gedcom .= "\n" . $gedcom;
1039        }
1040
1041        if ($new_gedcom !== $old_gedcom) {
1042            // Save the changes
1043            DB::table('change')->insert([
1044                'gedcom_id'  => $this->tree->id(),
1045                'xref'       => $this->xref,
1046                'old_gedcom' => $old_gedcom,
1047                'new_gedcom' => $new_gedcom,
1048                'user_id'    => Auth::id(),
1049            ]);
1050
1051            $this->pending = $new_gedcom;
1052
1053            if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
1054                app(PendingChangesService::class)->acceptRecord($this);
1055                $this->gedcom  = $new_gedcom;
1056                $this->pending = null;
1057            }
1058        }
1059        $this->parseFacts();
1060    }
1061
1062    /**
1063     * Update this record
1064     *
1065     * @param string $gedcom
1066     * @param bool   $update_chan
1067     *
1068     * @return void
1069     */
1070    public function updateRecord(string $gedcom, bool $update_chan): void
1071    {
1072        // MSDOS line endings will break things in horrible ways
1073        $gedcom = preg_replace('/[\r\n]+/', "\n", $gedcom);
1074        $gedcom = trim($gedcom);
1075
1076        // Update the CHAN record
1077        if ($update_chan) {
1078            $gedcom = preg_replace('/\n1 CHAN(\n[2-9].*)*/', '', $gedcom);
1079            $gedcom .= "\n1 CHAN\n2 DATE " . date('d M Y') . "\n3 TIME " . date('H:i:s') . "\n2 _WT_USER " . Auth::user()->userName();
1080        }
1081
1082        // Create a pending change
1083        DB::table('change')->insert([
1084            'gedcom_id'  => $this->tree->id(),
1085            'xref'       => $this->xref,
1086            'old_gedcom' => $this->gedcom(),
1087            'new_gedcom' => $gedcom,
1088            'user_id'    => Auth::id(),
1089        ]);
1090
1091        // Clear the cache
1092        $this->pending = $gedcom;
1093
1094        // Accept this pending change
1095        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
1096            app(PendingChangesService::class)->acceptRecord($this);
1097            $this->gedcom  = $gedcom;
1098            $this->pending = null;
1099        }
1100
1101        $this->parseFacts();
1102
1103        Log::addEditLog('Update: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1104    }
1105
1106    /**
1107     * Delete this record
1108     *
1109     * @return void
1110     */
1111    public function deleteRecord(): void
1112    {
1113        // Create a pending change
1114        if (!$this->isPendingDeletion()) {
1115            DB::table('change')->insert([
1116                'gedcom_id'  => $this->tree->id(),
1117                'xref'       => $this->xref,
1118                'old_gedcom' => $this->gedcom(),
1119                'new_gedcom' => '',
1120                'user_id'    => Auth::id(),
1121            ]);
1122        }
1123
1124        // Auto-accept this pending change
1125        if (Auth::user()->getPreference(User::PREF_AUTO_ACCEPT_EDITS) === '1') {
1126            app(PendingChangesService::class)->acceptRecord($this);
1127        }
1128
1129        // Clear the cache
1130        self::$gedcom_record_cache  = [];
1131        self::$pending_record_cache = [];
1132
1133        Log::addEditLog('Delete: ' . static::RECORD_TYPE . ' ' . $this->xref, $this->tree);
1134    }
1135
1136    /**
1137     * Remove all links from this record to $xref
1138     *
1139     * @param string $xref
1140     * @param bool   $update_chan
1141     *
1142     * @return void
1143     */
1144    public function removeLinks(string $xref, bool $update_chan): void
1145    {
1146        $value = '@' . $xref . '@';
1147
1148        foreach ($this->facts() as $fact) {
1149            if ($fact->value() === $value) {
1150                $this->deleteFact($fact->id(), $update_chan);
1151            } elseif (preg_match_all('/\n(\d) ' . Gedcom::REGEX_TAG . ' ' . $value . '/', $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1152                $gedcom = $fact->gedcom();
1153                foreach ($matches as $match) {
1154                    $next_level  = $match[1] + 1;
1155                    $next_levels = '[' . $next_level . '-9]';
1156                    $gedcom      = preg_replace('/' . $match[0] . '(\n' . $next_levels . '.*)*/', '', $gedcom);
1157                }
1158                $this->updateFact($fact->id(), $gedcom, $update_chan);
1159            }
1160        }
1161    }
1162
1163    /**
1164     * Fetch XREFs of all records linked to a record - when deleting an object, we must
1165     * also delete all links to it.
1166     *
1167     * @return GedcomRecord[]
1168     */
1169    public function linkingRecords(): array
1170    {
1171        $union = DB::table('change')
1172            ->where('gedcom_id', '=', $this->tree()->id())
1173            ->whereContains('new_gedcom', '@' . $this->xref() . '@')
1174            ->where('new_gedcom', 'NOT LIKE', '0 @' . $this->xref() . '@%')
1175            ->whereIn('change_id', function (Builder $query): void {
1176                $query->select(new Expression('MAX(change_id)'))
1177                    ->from('change')
1178                    ->where('gedcom_id', '=', $this->tree->id())
1179                    ->where('status', '=', 'pending')
1180                    ->groupBy(['xref']);
1181            })
1182            ->select(['xref']);
1183
1184        $xrefs = DB::table('link')
1185            ->where('l_file', '=', $this->tree()->id())
1186            ->where('l_to', '=', $this->xref())
1187            ->select(['l_from'])
1188            ->union($union)
1189            ->pluck('l_from');
1190
1191        return $xrefs->map(function (string $xref): GedcomRecord {
1192            $record = GedcomRecord::getInstance($xref, $this->tree);
1193            assert($record instanceof GedcomRecord);
1194
1195            return $record;
1196        })->all();
1197    }
1198
1199    /**
1200     * Each object type may have its own special rules, and re-implement this function.
1201     *
1202     * @param int $access_level
1203     *
1204     * @return bool
1205     */
1206    protected function canShowByType(int $access_level): bool
1207    {
1208        $fact_privacy = $this->tree->getFactPrivacy();
1209
1210        if (isset($fact_privacy[static::RECORD_TYPE])) {
1211            // Restriction found
1212            return $fact_privacy[static::RECORD_TYPE] >= $access_level;
1213        }
1214
1215        // No restriction found - must be public:
1216        return true;
1217    }
1218
1219    /**
1220     * Generate a private version of this record
1221     *
1222     * @param int $access_level
1223     *
1224     * @return string
1225     */
1226    protected function createPrivateGedcomRecord(int $access_level): string
1227    {
1228        return '0 @' . $this->xref . '@ ' . static::RECORD_TYPE . "\n1 NOTE " . I18N::translate('Private');
1229    }
1230
1231    /**
1232     * Convert a name record into sortable and full/display versions. This default
1233     * should be OK for simple record types. INDI/FAM records will need to redefine it.
1234     *
1235     * @param string $type
1236     * @param string $value
1237     * @param string $gedcom
1238     *
1239     * @return void
1240     */
1241    protected function addName(string $type, string $value, string $gedcom): void
1242    {
1243        $this->getAllNames[] = [
1244            'type'   => $type,
1245            'sort'   => preg_replace_callback('/([0-9]+)/', static function (array $matches): string {
1246                return str_pad($matches[0], 10, '0', STR_PAD_LEFT);
1247            }, $value),
1248            'full'   => '<span dir="auto">' . e($value) . '</span>',
1249            // This is used for display
1250            'fullNN' => $value,
1251            // This goes into the database
1252        ];
1253    }
1254
1255    /**
1256     * Get all the names of a record, including ROMN, FONE and _HEB alternatives.
1257     * Records without a name (e.g. FAM) will need to redefine this function.
1258     * Parameters: the level 1 fact containing the name.
1259     * Return value: an array of name structures, each containing
1260     * ['type'] = the gedcom fact, e.g. NAME, TITL, FONE, _HEB, etc.
1261     * ['full'] = the name as specified in the record, e.g. 'Vincent van Gogh' or 'John Unknown'
1262     * ['sort'] = a sortable version of the name (not for display), e.g. 'Gogh, Vincent' or '@N.N., John'
1263     *
1264     * @param int        $level
1265     * @param string     $fact_type
1266     * @param Collection $facts
1267     *
1268     * @return void
1269     */
1270    protected function extractNamesFromFacts(int $level, string $fact_type, Collection $facts): void
1271    {
1272        $sublevel    = $level + 1;
1273        $subsublevel = $sublevel + 1;
1274        foreach ($facts as $fact) {
1275            if (preg_match_all("/^{$level} ({$fact_type}) (.+)((\n[{$sublevel}-9].+)*)/m", $fact->gedcom(), $matches, PREG_SET_ORDER)) {
1276                foreach ($matches as $match) {
1277                    // Treat 1 NAME / 2 TYPE married the same as _MARNM
1278                    if ($match[1] === 'NAME' && strpos($match[3], "\n2 TYPE married") !== false) {
1279                        $this->addName('_MARNM', $match[2], $fact->gedcom());
1280                    } else {
1281                        $this->addName($match[1], $match[2], $fact->gedcom());
1282                    }
1283                    if ($match[3] && preg_match_all("/^{$sublevel} (ROMN|FONE|_\w+) (.+)((\n[{$subsublevel}-9].+)*)/m", $match[3], $submatches, PREG_SET_ORDER)) {
1284                        foreach ($submatches as $submatch) {
1285                            $this->addName($submatch[1], $submatch[2], $match[3]);
1286                        }
1287                    }
1288                }
1289            }
1290        }
1291    }
1292
1293    /**
1294     * Split the record into facts
1295     *
1296     * @return void
1297     */
1298    private function parseFacts(): void
1299    {
1300        // Split the record into facts
1301        if ($this->gedcom) {
1302            $gedcom_facts = preg_split('/\n(?=1)/s', $this->gedcom);
1303            array_shift($gedcom_facts);
1304        } else {
1305            $gedcom_facts = [];
1306        }
1307        if ($this->pending) {
1308            $pending_facts = preg_split('/\n(?=1)/s', $this->pending);
1309            array_shift($pending_facts);
1310        } else {
1311            $pending_facts = [];
1312        }
1313
1314        $this->facts = [];
1315
1316        foreach ($gedcom_facts as $gedcom_fact) {
1317            $fact = new Fact($gedcom_fact, $this, md5($gedcom_fact));
1318            if ($this->pending !== null && !in_array($gedcom_fact, $pending_facts, true)) {
1319                $fact->setPendingDeletion();
1320            }
1321            $this->facts[] = $fact;
1322        }
1323        foreach ($pending_facts as $pending_fact) {
1324            if (!in_array($pending_fact, $gedcom_facts, true)) {
1325                $fact = new Fact($pending_fact, $this, md5($pending_fact));
1326                $fact->setPendingAddition();
1327                $this->facts[] = $fact;
1328            }
1329        }
1330    }
1331
1332    /**
1333     * Work out whether this record can be shown to a user with a given access level
1334     *
1335     * @param int $access_level
1336     *
1337     * @return bool
1338     */
1339    private function canShowRecord(int $access_level): bool
1340    {
1341        // This setting would better be called "$ENABLE_PRIVACY"
1342        if (!$this->tree->getPreference('HIDE_LIVE_PEOPLE')) {
1343            return true;
1344        }
1345
1346        // We should always be able to see our own record (unless an admin is applying download restrictions)
1347        if ($this->xref() === $this->tree->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF) && $access_level === Auth::accessLevel($this->tree)) {
1348            return true;
1349        }
1350
1351        // Does this record have a RESN?
1352        if (strpos($this->gedcom, "\n1 RESN confidential") !== false) {
1353            return Auth::PRIV_NONE >= $access_level;
1354        }
1355        if (strpos($this->gedcom, "\n1 RESN privacy") !== false) {
1356            return Auth::PRIV_USER >= $access_level;
1357        }
1358        if (strpos($this->gedcom, "\n1 RESN none") !== false) {
1359            return true;
1360        }
1361
1362        // Does this record have a default RESN?
1363        $individual_privacy = $this->tree->getIndividualPrivacy();
1364        if (isset($individual_privacy[$this->xref()])) {
1365            return $individual_privacy[$this->xref()] >= $access_level;
1366        }
1367
1368        // Privacy rules do not apply to admins
1369        if (Auth::PRIV_NONE >= $access_level) {
1370            return true;
1371        }
1372
1373        // Different types of record have different privacy rules
1374        return $this->canShowByType($access_level);
1375    }
1376}
1377