xref: /webtrees/app/Fact.php (revision 62a43f67ae5ca59481845b3fff5258b8751ccba4)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees;
21
22use Closure;
23use Fisharebest\Webtrees\Functions\FunctionsPrint;
24use Fisharebest\Webtrees\Services\GedcomService;
25use Illuminate\Support\Collection;
26use InvalidArgumentException;
27
28use function array_flip;
29use function array_key_exists;
30use function count;
31use function e;
32use function implode;
33use function in_array;
34use function preg_match;
35use function preg_match_all;
36use function preg_replace;
37use function str_contains;
38use function usort;
39
40use const PREG_SET_ORDER;
41
42/**
43 * A GEDCOM fact or event object.
44 */
45class Fact
46{
47    private const FACT_ORDER = [
48        'BIRT',
49        '_HNM',
50        'ALIA',
51        '_AKA',
52        '_AKAN',
53        'ADOP',
54        '_ADPF',
55        '_ADPF',
56        '_BRTM',
57        'CHR',
58        'BAPM',
59        'FCOM',
60        'CONF',
61        'BARM',
62        'BASM',
63        'EDUC',
64        'GRAD',
65        '_DEG',
66        'EMIG',
67        'IMMI',
68        'NATU',
69        '_MILI',
70        '_MILT',
71        'ENGA',
72        'MARB',
73        'MARC',
74        'MARL',
75        '_MARI',
76        '_MBON',
77        'MARR',
78        '_COML',
79        '_STAT',
80        '_SEPR',
81        'DIVF',
82        'MARS',
83        'DIV',
84        'ANUL',
85        'CENS',
86        'OCCU',
87        'RESI',
88        'PROP',
89        'CHRA',
90        'RETI',
91        'FACT',
92        'EVEN',
93        '_NMR',
94        '_NMAR',
95        'NMR',
96        'NCHI',
97        'WILL',
98        '_HOL',
99        '_????_',
100        'DEAT',
101        '_FNRL',
102        'CREM',
103        'BURI',
104        '_INTE',
105        '_YART',
106        '_NLIV',
107        'PROB',
108        'TITL',
109        'COMM',
110        'NATI',
111        'CITN',
112        'CAST',
113        'RELI',
114        'SSN',
115        'IDNO',
116        'TEMP',
117        'SLGC',
118        'BAPL',
119        'CONL',
120        'ENDL',
121        'SLGS',
122        'ADDR',
123        'PHON',
124        'EMAIL',
125        '_EMAIL',
126        'EMAL',
127        'FAX',
128        'WWW',
129        'URL',
130        '_URL',
131        'AFN',
132        'REFN',
133        '_PRMN',
134        'REF',
135        'RIN',
136        '_UID',
137        'OBJE',
138        'NOTE',
139        'SOUR',
140        'CHAN',
141        '_TODO',
142    ];
143
144    /** @var string Unique identifier for this fact (currently implemented as a hash of the raw data). */
145    private $id;
146
147    /** @var GedcomRecord The GEDCOM record from which this fact is taken */
148    private $record;
149
150    /** @var string The raw GEDCOM data for this fact */
151    private $gedcom;
152
153    /** @var string The GEDCOM tag for this record */
154    private $tag;
155
156    /** @var bool Is this a recently deleted fact, pending approval? */
157    private $pending_deletion = false;
158
159    /** @var bool Is this a recently added fact, pending approval? */
160    private $pending_addition = false;
161
162    /** @var Date The date of this fact, from the “2 DATE …” attribute */
163    private $date;
164
165    /** @var Place The place of this fact, from the “2 PLAC …” attribute */
166    private $place;
167
168    /** @var int Used by Functions::sortFacts() */
169    private $sortOrder;
170
171    /**
172     * Create an event object from a gedcom fragment.
173     * We need the parent object (to check privacy) and a (pseudo) fact ID to
174     * identify the fact within the record.
175     *
176     * @param string       $gedcom
177     * @param GedcomRecord $parent
178     * @param string       $id
179     *
180     * @throws InvalidArgumentException
181     */
182    public function __construct(string $gedcom, GedcomRecord $parent, string $id)
183    {
184        if (preg_match('/^1 (' . Gedcom::REGEX_TAG . ')/', $gedcom, $match)) {
185            $this->gedcom = $gedcom;
186            $this->record = $parent;
187            $this->id     = $id;
188            $this->tag    = $match[1];
189        } else {
190            throw new InvalidArgumentException('Invalid GEDCOM data passed to Fact::_construct(' . $gedcom . ',' . $parent->xref() . ')');
191        }
192    }
193
194    /**
195     * Get the value of level 1 data in the fact
196     * Allow for multi-line values
197     *
198     * @return string
199     */
200    public function value(): string
201    {
202        if (preg_match('/^1 (?:' . $this->tag . ') ?(.*(?:(?:\n2 CONT ?.*)*))/', $this->gedcom, $match)) {
203            return preg_replace("/\n2 CONT ?/", "\n", $match[1]);
204        }
205
206        return '';
207    }
208
209    /**
210     * Get the record to which this fact links
211     *
212     * @return Family|GedcomRecord|Individual|Location|Media|Note|Repository|Source|Submission|Submitter|null
213     */
214    public function target()
215    {
216        if (!preg_match('/^@(' . Gedcom::REGEX_XREF . ')@$/', $this->value(), $match)) {
217            return null;
218        }
219
220        $xref = $match[1];
221
222        switch ($this->tag) {
223            case 'FAMC':
224            case 'FAMS':
225                return Registry::familyFactory()->make($xref, $this->record()->tree());
226            case 'HUSB':
227            case 'WIFE':
228            case 'ALIA':
229            case 'CHIL':
230            case '_ASSO':
231                return Registry::individualFactory()->make($xref, $this->record()->tree());
232            case 'ASSO':
233                return
234                    Registry::individualFactory()->make($xref, $this->record()->tree()) ??
235                    Registry::submitterFactory()->make($xref, $this->record()->tree());
236            case 'SOUR':
237                return Registry::sourceFactory()->make($xref, $this->record()->tree());
238            case 'OBJE':
239                return Registry::mediaFactory()->make($xref, $this->record()->tree());
240            case 'REPO':
241                return Registry::repositoryFactory()->make($xref, $this->record()->tree());
242            case 'NOTE':
243                return Registry::noteFactory()->make($xref, $this->record()->tree());
244            case 'ANCI':
245            case 'DESI':
246            case 'SUBM':
247                return Registry::submitterFactory()->make($xref, $this->record()->tree());
248            case 'SUBN':
249                return Registry::submissionFactory()->make($xref, $this->record()->tree());
250            case '_LOC':
251                return Registry::locationFactory()->make($xref, $this->record()->tree());
252            default:
253                return Registry::gedcomRecordFactory()->make($xref, $this->record()->tree());
254        }
255    }
256
257    /**
258     * Get the value of level 2 data in the fact
259     *
260     * @param string $tag
261     *
262     * @return string
263     */
264    public function attribute(string $tag): string
265    {
266        if (preg_match('/\n2 (?:' . $tag . ') ?(.*(?:(?:\n3 CONT ?.*)*)*)/', $this->gedcom, $match)) {
267            return preg_replace("/\n3 CONT ?/", "\n", $match[1]);
268        }
269
270        return '';
271    }
272
273    /**
274     * Get the PLAC:MAP:LATI for the fact.
275     *
276     * @return float|null
277     */
278    public function latitude(): ?float
279    {
280        if (preg_match('/\n4 LATI (.+)/', $this->gedcom, $match)) {
281            $gedcom_service = new GedcomService();
282
283            return $gedcom_service->readLatitude($match[1]);
284        }
285
286        return null;
287    }
288
289    /**
290     * Get the PLAC:MAP:LONG for the fact.
291     *
292     * @return float|null
293     */
294    public function longitude(): ?float
295    {
296        if (preg_match('/\n4 LONG (.+)/', $this->gedcom, $match)) {
297            $gedcom_service = new GedcomService();
298
299            return $gedcom_service->readLongitude($match[1]);
300        }
301
302        return null;
303    }
304
305    /**
306     * Do the privacy rules allow us to display this fact to the current user
307     *
308     * @param int|null $access_level
309     *
310     * @return bool
311     */
312    public function canShow(int $access_level = null): bool
313    {
314        $access_level = $access_level ?? Auth::accessLevel($this->record->tree());
315
316        // Does this record have an explicit RESN?
317        if (str_contains($this->gedcom, "\n2 RESN confidential")) {
318            return Auth::PRIV_NONE >= $access_level;
319        }
320        if (str_contains($this->gedcom, "\n2 RESN privacy")) {
321            return Auth::PRIV_USER >= $access_level;
322        }
323        if (str_contains($this->gedcom, "\n2 RESN none")) {
324            return true;
325        }
326
327        // Does this record have a default RESN?
328        $xref                    = $this->record->xref();
329        $fact_privacy            = $this->record->tree()->getFactPrivacy();
330        $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy();
331        if (isset($individual_fact_privacy[$xref][$this->tag])) {
332            return $individual_fact_privacy[$xref][$this->tag] >= $access_level;
333        }
334        if (isset($fact_privacy[$this->tag])) {
335            return $fact_privacy[$this->tag] >= $access_level;
336        }
337
338        // No restrictions - it must be public
339        return true;
340    }
341
342    /**
343     * Check whether this fact is protected against edit
344     *
345     * @return bool
346     */
347    public function canEdit(): bool
348    {
349        if ($this->isPendingDeletion()) {
350            return false;
351        }
352
353        if (Auth::isManager($this->record->tree())) {
354            return true;
355        }
356
357        // Members cannot edit RESN, CHAN and locked records
358        return Auth::isEditor($this->record->tree()) && !str_contains($this->gedcom, "\n2 RESN locked") && $this->tag !== 'RESN' && $this->tag !== 'CHAN';
359    }
360
361    /**
362     * The place where the event occured.
363     *
364     * @return Place
365     */
366    public function place(): Place
367    {
368        $this->place ??= new Place($this->attribute('PLAC'), $this->record()->tree());
369
370        return $this->place;
371    }
372
373    /**
374     * Get the date for this fact.
375     * We can call this function many times, especially when sorting,
376     * so keep a copy of the date.
377     *
378     * @return Date
379     */
380    public function date(): Date
381    {
382        $this->date ??= new Date($this->attribute('DATE'));
383
384        return $this->date;
385    }
386
387    /**
388     * The raw GEDCOM data for this fact
389     *
390     * @return string
391     */
392    public function gedcom(): string
393    {
394        return $this->gedcom;
395    }
396
397    /**
398     * Get a (pseudo) primary key for this fact.
399     *
400     * @return string
401     */
402    public function id(): string
403    {
404        return $this->id;
405    }
406
407    /**
408     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
409     *
410     * @return string
411     */
412    public function tag(): string
413    {
414        return $this->record->tag() . ':' . $this->tag;
415    }
416
417    /**
418     * The Person/Family record where this Fact came from
419     *
420     * @return Individual|Family|Source|Repository|Media|Note|Submitter|Submission|Location|Header|GedcomRecord
421     */
422    public function record()
423    {
424        return $this->record;
425    }
426
427    /**
428     * Get the name of this fact type, for use as a label.
429     *
430     * @return string
431     */
432    public function label(): string
433    {
434        // Marriages
435        if ($this->tag() === 'FAM:MARR') {
436            $element = Registry::elementFactory()->make('FAM:MARR:TYPE');
437            $type = $this->attribute('TYPE');
438
439            if ($type !== '') {
440                return $element->value($type, $this->record->tree());
441            }
442        }
443
444        // Custom FACT/EVEN - with a TYPE
445        if ($this->tag === 'FACT' || $this->tag === 'EVEN') {
446            $type = $this->attribute('TYPE');
447
448            if ($type !== '') {
449                if (!str_contains($type, '%')) {
450                    // Allow user-translations of custom types.
451                    $translated = I18N::translate($type);
452
453                    if ($translated !== $type) {
454                        return $translated;
455                    }
456                }
457
458                return e($type);
459            }
460        }
461
462        return Registry::elementFactory()->make($this->tag())->label();
463    }
464
465    /**
466     * This is a newly deleted fact, pending approval.
467     *
468     * @return void
469     */
470    public function setPendingDeletion(): void
471    {
472        $this->pending_deletion = true;
473        $this->pending_addition = false;
474    }
475
476    /**
477     * Is this a newly deleted fact, pending approval.
478     *
479     * @return bool
480     */
481    public function isPendingDeletion(): bool
482    {
483        return $this->pending_deletion;
484    }
485
486    /**
487     * This is a newly added fact, pending approval.
488     *
489     * @return void
490     */
491    public function setPendingAddition(): void
492    {
493        $this->pending_addition = true;
494        $this->pending_deletion = false;
495    }
496
497    /**
498     * Is this a newly added fact, pending approval.
499     *
500     * @return bool
501     */
502    public function isPendingAddition(): bool
503    {
504        return $this->pending_addition;
505    }
506
507    /**
508     * Source citations linked to this fact
509     *
510     * @return array<string>
511     */
512    public function getCitations(): array
513    {
514        preg_match_all('/\n(2 SOUR @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->gedcom(), $matches, PREG_SET_ORDER);
515        $citations = [];
516        foreach ($matches as $match) {
517            $source = Registry::sourceFactory()->make($match[2], $this->record()->tree());
518            if ($source && $source->canShow()) {
519                $citations[] = $match[1];
520            }
521        }
522
523        return $citations;
524    }
525
526    /**
527     * Notes (inline and objects) linked to this fact
528     *
529     * @return array<string|Note>
530     */
531    public function getNotes(): array
532    {
533        $notes = [];
534        preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->gedcom(), $matches);
535        foreach ($matches[1] as $match) {
536            $note = preg_replace("/\n3 CONT ?/", "\n", $match);
537            if (preg_match('/@(' . Gedcom::REGEX_XREF . ')@/', $note, $nmatch)) {
538                $note = Registry::noteFactory()->make($nmatch[1], $this->record()->tree());
539                if ($note && $note->canShow()) {
540                    // A note object
541                    $notes[] = $note;
542                }
543            } else {
544                // An inline note
545                $notes[] = $note;
546            }
547        }
548
549        return $notes;
550    }
551
552    /**
553     * Media objects linked to this fact
554     *
555     * @return array<Media>
556     */
557    public function getMedia(): array
558    {
559        $media = [];
560        preg_match_all('/\n2 OBJE @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom(), $matches);
561        foreach ($matches[1] as $match) {
562            $obje = Registry::mediaFactory()->make($match, $this->record()->tree());
563            if ($obje && $obje->canShow()) {
564                $media[] = $obje;
565            }
566        }
567
568        return $media;
569    }
570
571    /**
572     * A one-line summary of the fact - for charts, etc.
573     *
574     * @return string
575     */
576    public function summary(): string
577    {
578        $attributes = [];
579        $target     = $this->target();
580        if ($target instanceof GedcomRecord) {
581            $attributes[] = $target->fullName();
582        } else {
583            // Fact value
584            $value = $this->value();
585            if ($value !== '' && $value !== 'Y') {
586                $attributes[] = '<bdi>' . e($value) . '</bdi>';
587            }
588            // Fact date
589            $date = $this->date();
590            if ($date->isOK()) {
591                if ($this->record() instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) && $this->record()->tree()->getPreference('SHOW_PARENTS_AGE')) {
592                    $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->record(), $date);
593                } else {
594                    $attributes[] = $date->display();
595                }
596            }
597            // Fact place
598            if ($this->place()->gedcomName() !== '') {
599                $attributes[] = $this->place()->shortName();
600            }
601        }
602
603        $class = 'fact_' . $this->tag;
604        if ($this->isPendingAddition()) {
605            $class .= ' wt-new';
606        } elseif ($this->isPendingDeletion()) {
607            $class .= ' wt-old';
608        }
609
610        return
611            '<div class="' . $class . '">' .
612            /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
613            I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->label(), implode(' — ', $attributes)) .
614            '</div>';
615    }
616
617    /**
618     * A one-line summary of the fact - for the clipboard, etc.
619     *
620     * @return string
621     */
622    public function name(): string
623    {
624        $items  = [$this->label()];
625        $target = $this->target();
626
627        if ($target instanceof GedcomRecord) {
628            $items[] = '<bdi>' . $target->fullName() . '</bdi>';
629        } else {
630            // Fact value
631            $value = $this->value();
632            if ($value !== '' && $value !== 'Y') {
633                $items[] = '<bdi>' . e($value) . '</bdi>';
634            }
635
636            // Fact date
637            if ($this->date()->isOK()) {
638                $items[] = $this->date()->minimumDate()->format('%Y');
639            }
640
641            // Fact place
642            if ($this->place()->gedcomName() !== '') {
643                $items[] = $this->place()->shortName();
644            }
645        }
646
647        return implode(' — ', $items);
648    }
649
650    /**
651     * Helper functions to sort facts
652     *
653     * @return Closure
654     */
655    private static function dateComparator(): Closure
656    {
657        return static function (Fact $a, Fact $b): int {
658            if ($a->date()->isOK() && $b->date()->isOK()) {
659                // If both events have dates, compare by date
660                $ret = Date::compare($a->date(), $b->date());
661
662                if ($ret === 0) {
663                    // If dates overlap, compare by fact type
664                    $ret = self::typeComparator()($a, $b);
665
666                    // If the fact type is also the same, retain the initial order
667                    if ($ret === 0) {
668                        $ret = $a->sortOrder <=> $b->sortOrder;
669                    }
670                }
671
672                return $ret;
673            }
674
675            // One or both events have no date - retain the initial order
676            return $a->sortOrder <=> $b->sortOrder;
677        };
678    }
679
680    /**
681     * Helper functions to sort facts.
682     *
683     * @return Closure
684     */
685    public static function typeComparator(): Closure
686    {
687        static $factsort = [];
688
689        if ($factsort === []) {
690            $factsort = array_flip(self::FACT_ORDER);
691        }
692
693        return static function (Fact $a, Fact $b) use ($factsort): int {
694            // Facts from same families stay grouped together
695            // Keep MARR and DIV from the same families from mixing with events from other FAMs
696            // Use the original order in which the facts were added
697            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
698                return $a->sortOrder - $b->sortOrder;
699            }
700
701            $atag = $a->tag;
702            $btag = $b->tag;
703
704            // Events not in the above list get mapped onto one that is.
705            if (!array_key_exists($atag, $factsort)) {
706                $atag = '_????_';
707            }
708
709            if (!array_key_exists($btag, $factsort)) {
710                $btag = '_????_';
711            }
712
713            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
714            // - Treat dated after BURI facts as BURI instead
715            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
716                $atag = 'BURI';
717            }
718
719            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
720                $btag = 'BURI';
721            }
722
723            $ret = $factsort[$atag] - $factsort[$btag];
724
725            // If facts are the same then put dated facts before non-dated facts
726            if ($ret == 0) {
727                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
728                    return -1;
729                }
730
731                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
732                    return 1;
733                }
734
735                // If no sorting preference, then keep original ordering
736                $ret = $a->sortOrder - $b->sortOrder;
737            }
738
739            return $ret;
740        };
741    }
742
743    /**
744     * A multi-key sort
745     * 1. First divide the facts into two arrays one set with dates and one set without dates
746     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
747     * using the compare type function
748     * 3. Then merge the arrays back into the original array using the compare type function
749     *
750     * @param Collection<Fact> $unsorted
751     *
752     * @return Collection<Fact>
753     */
754    public static function sortFacts(Collection $unsorted): Collection
755    {
756        $dated    = [];
757        $nondated = [];
758        $sorted   = [];
759
760        // Split the array into dated and non-dated arrays
761        $order = 0;
762
763        foreach ($unsorted as $fact) {
764            $fact->sortOrder = $order;
765            $order++;
766
767            if ($fact->date()->isOK()) {
768                $dated[] = $fact;
769            } else {
770                $nondated[] = $fact;
771            }
772        }
773
774        usort($dated, self::dateComparator());
775        usort($nondated, self::typeComparator());
776
777        // Merge the arrays
778        $dc = count($dated);
779        $nc = count($nondated);
780        $i  = 0;
781        $j  = 0;
782
783        // while there is anything in the dated array continue merging
784        while ($i < $dc) {
785            // compare each fact by type to merge them in order
786            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
787                $sorted[] = $nondated[$j];
788                $j++;
789            } else {
790                $sorted[] = $dated[$i];
791                $i++;
792            }
793        }
794
795        // get anything that might be left in the nondated array
796        while ($j < $nc) {
797            $sorted[] = $nondated[$j];
798            $j++;
799        }
800
801        return new Collection($sorted);
802    }
803
804    /**
805     * Sort fact/event tags using the same order that we use for facts.
806     *
807     * @param Collection<string> $unsorted
808     *
809     * @return Collection<string>
810     */
811    public static function sortFactTags(Collection $unsorted): Collection
812    {
813        $tag_order = array_flip(self::FACT_ORDER);
814
815        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
816            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
817            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
818
819            return $sort_x - $sort_y;
820        });
821    }
822
823    /**
824     * Allow native PHP functions such as array_unique() to work with objects
825     *
826     * @return string
827     */
828    public function __toString(): string
829    {
830        return $this->id . '@' . $this->record->xref();
831    }
832}
833