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