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