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