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