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