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