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