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