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