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