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