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