xref: /webtrees/app/Fact.php (revision d11be7027e34e3121be11cc025421873364403f9)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 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_replace;
36use function str_contains;
37use function str_ends_with;
38use function usort;
39
40/**
41 * A GEDCOM fact or event object.
42 */
43class Fact
44{
45    private const FACT_ORDER = [
46        'BIRT',
47        '_HNM',
48        'ALIA',
49        '_AKA',
50        '_AKAN',
51        'ADOP',
52        '_ADPF',
53        '_ADPF',
54        '_BRTM',
55        'CHR',
56        'BAPM',
57        'FCOM',
58        'CONF',
59        'BARM',
60        'BASM',
61        'EDUC',
62        'GRAD',
63        '_DEG',
64        'EMIG',
65        'IMMI',
66        'NATU',
67        '_MILI',
68        '_MILT',
69        'ENGA',
70        'MARB',
71        'MARC',
72        'MARL',
73        '_MARI',
74        '_MBON',
75        'MARR',
76        '_COML',
77        '_STAT',
78        '_SEPR',
79        'DIVF',
80        'MARS',
81        'DIV',
82        'ANUL',
83        'CENS',
84        'OCCU',
85        'RESI',
86        'PROP',
87        'CHRA',
88        'RETI',
89        'FACT',
90        'EVEN',
91        '_NMR',
92        '_NMAR',
93        'NMR',
94        'NCHI',
95        'WILL',
96        '_HOL',
97        '_????_',
98        'DEAT',
99        '_FNRL',
100        'CREM',
101        'BURI',
102        '_INTE',
103        '_YART',
104        '_NLIV',
105        'PROB',
106        'TITL',
107        'COMM',
108        'NATI',
109        'CITN',
110        'CAST',
111        'RELI',
112        'SSN',
113        'IDNO',
114        'TEMP',
115        'SLGC',
116        'BAPL',
117        'CONL',
118        'ENDL',
119        'SLGS',
120        'NO',
121        'ADDR',
122        'PHON',
123        'EMAIL',
124        '_EMAIL',
125        'EMAL',
126        'FAX',
127        'WWW',
128        'URL',
129        '_URL',
130        '_FSFTID',
131        'AFN',
132        'REFN',
133        '_PRMN',
134        'REF',
135        'RIN',
136        '_UID',
137        'OBJE',
138        'NOTE',
139        'SOUR',
140        'CREA',
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 . '\b ?(.*(?:(?:\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 ??= Auth::accessLevel($this->record->tree());
318
319        // Does this record have an explicit restriction notice?
320        $element     = new RestrictionNotice('');
321        $restriction = $element->canonical($this->attribute('RESN'));
322
323        if (str_ends_with($restriction, RestrictionNotice::VALUE_CONFIDENTIAL)) {
324            return Auth::PRIV_NONE >= $access_level;
325        }
326
327        if (str_ends_with($restriction, RestrictionNotice::VALUE_PRIVACY)) {
328            return Auth::PRIV_USER >= $access_level;
329        }
330        if (str_ends_with($restriction, RestrictionNotice::VALUE_NONE)) {
331            return true;
332        }
333
334        // A link to a record of the same type: NOTE=>NOTE, OBJE=>OBJE, SOUR=>SOUR, etc.
335        // Use the privacy of the target record.
336        $target = $this->target();
337
338        if ($target instanceof GedcomRecord && $target->tag() === $this->tag) {
339            return $target->canShow($access_level);
340        }
341
342        // Does this record have a default RESN?
343        $xref                    = $this->record->xref();
344        $fact_privacy            = $this->record->tree()->getFactPrivacy();
345        $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy();
346        if (isset($individual_fact_privacy[$xref][$this->tag])) {
347            return $individual_fact_privacy[$xref][$this->tag] >= $access_level;
348        }
349        if (isset($fact_privacy[$this->tag])) {
350            return $fact_privacy[$this->tag] >= $access_level;
351        }
352
353        // No restrictions - it must be public
354        return true;
355    }
356
357    /**
358     * Check whether this fact is protected against edit
359     *
360     * @return bool
361     */
362    public function canEdit(): bool
363    {
364        if ($this->isPendingDeletion()) {
365            return false;
366        }
367
368        if (Auth::isManager($this->record->tree())) {
369            return true;
370        }
371
372        // Members cannot edit RESN, CHAN and locked records
373        return Auth::isEditor($this->record->tree()) && !str_ends_with($this->attribute('RESN'), RestrictionNotice::VALUE_LOCKED) && $this->tag !== 'RESN' && $this->tag !== 'CHAN';
374    }
375
376    /**
377     * The place where the event occurred.
378     *
379     * @return Place
380     */
381    public function place(): Place
382    {
383        $this->place ??= new Place($this->attribute('PLAC'), $this->record->tree());
384
385        return $this->place;
386    }
387
388    /**
389     * Get the date for this fact.
390     * We can call this function many times, especially when sorting,
391     * so keep a copy of the date.
392     *
393     * @return Date
394     */
395    public function date(): Date
396    {
397        $this->date ??= new Date($this->attribute('DATE'));
398
399        return $this->date;
400    }
401
402    /**
403     * The raw GEDCOM data for this fact
404     *
405     * @return string
406     */
407    public function gedcom(): string
408    {
409        return $this->gedcom;
410    }
411
412    /**
413     * Get a (pseudo) primary key for this fact.
414     *
415     * @return string
416     */
417    public function id(): string
418    {
419        return $this->id;
420    }
421
422    /**
423     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
424     *
425     * @return string
426     */
427    public function tag(): string
428    {
429        return $this->record->tag() . ':' . $this->tag;
430    }
431
432    /**
433     * The GEDCOM record where this Fact came from
434     *
435     * @return GedcomRecord
436     */
437    public function record(): GedcomRecord
438    {
439        return $this->record;
440    }
441
442    /**
443     * Get the name of this fact type, for use as a label.
444     *
445     * @return string
446     */
447    public function label(): string
448    {
449        if (str_ends_with($this->tag(), ':NOTE') && preg_match('/^@' . Gedcom::REGEX_XREF . '@$/', $this->value())) {
450            return I18N::translate('Shared note');
451        }
452
453        // Marriages
454        if ($this->tag() === 'FAM:MARR') {
455            $element = Registry::elementFactory()->make('FAM:MARR:TYPE');
456            $type = $this->attribute('TYPE');
457
458            if ($type !== '') {
459                return $element->value($type, $this->record->tree());
460            }
461        }
462
463        // Custom FACT/EVEN - with a TYPE
464        if ($this->tag === 'FACT' || $this->tag === 'EVEN') {
465            $type = $this->attribute('TYPE');
466
467            if ($type !== '') {
468                if (!str_contains($type, '%')) {
469                    // Allow user-translations of custom types.
470                    $translated = I18N::translate($type);
471
472                    if ($translated !== $type) {
473                        return $translated;
474                    }
475                }
476
477                return e($type);
478            }
479        }
480
481        return Registry::elementFactory()->make($this->tag())->label();
482    }
483
484    /**
485     * This is a newly deleted fact, pending approval.
486     *
487     * @return void
488     */
489    public function setPendingDeletion(): void
490    {
491        $this->pending_deletion = true;
492        $this->pending_addition = false;
493    }
494
495    /**
496     * Is this a newly deleted fact, pending approval.
497     *
498     * @return bool
499     */
500    public function isPendingDeletion(): bool
501    {
502        return $this->pending_deletion;
503    }
504
505    /**
506     * This is a newly added fact, pending approval.
507     *
508     * @return void
509     */
510    public function setPendingAddition(): void
511    {
512        $this->pending_addition = true;
513        $this->pending_deletion = false;
514    }
515
516    /**
517     * Is this a newly added fact, pending approval.
518     *
519     * @return bool
520     */
521    public function isPendingAddition(): bool
522    {
523        return $this->pending_addition;
524    }
525
526    /**
527     * A one-line summary of the fact - for charts, etc.
528     *
529     * @return string
530     */
531    public function summary(): string
532    {
533        $attributes = [];
534        $target     = $this->target();
535        if ($target instanceof GedcomRecord) {
536            $attributes[] = $target->fullName();
537        } else {
538            // Fact value
539            $value = $this->value();
540            if ($value !== '' && $value !== 'Y') {
541                $attributes[] = '<bdi>' . e($value) . '</bdi>';
542            }
543            // Fact date
544            $date = $this->date();
545            if ($date->isOK()) {
546                if ($this->record instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) && $this->record->tree()->getPreference('SHOW_PARENTS_AGE')) {
547                    $attributes[] = $date->display() . view('fact-parents-age', ['individual' => $this->record, 'birth_date' => $date]);
548                } else {
549                    $attributes[] = $date->display();
550                }
551            }
552            // Fact place
553            if ($this->place()->gedcomName() !== '') {
554                $attributes[] = $this->place()->shortName();
555            }
556        }
557
558        $class = 'fact_' . $this->tag;
559        if ($this->isPendingAddition()) {
560            $class .= ' wt-new';
561        } elseif ($this->isPendingDeletion()) {
562            $class .= ' wt-old';
563        }
564
565        $label = '<span class="label">' . $this->label() . '</span>';
566        $value = '<span class="field" dir="auto">' . implode(' — ', $attributes) . '</span>';
567
568        /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
569        return '<div class="' . $class . '">' . I18N::translate('%1$s: %2$s', $label, $value) . '</div>';
570    }
571
572    /**
573     * A one-line summary of the fact - for the clipboard, etc.
574     *
575     * @return string
576     */
577    public function name(): string
578    {
579        $items  = [$this->label()];
580        $target = $this->target();
581
582        if ($target instanceof GedcomRecord) {
583            $items[] = '<bdi>' . $target->fullName() . '</bdi>';
584        } else {
585            // Fact value
586            $value = $this->value();
587            if ($value !== '' && $value !== 'Y') {
588                $items[] = '<bdi>' . e($value) . '</bdi>';
589            }
590
591            // Fact date
592            if ($this->date()->isOK()) {
593                $items[] = $this->date()->minimumDate()->format('%Y');
594            }
595
596            // Fact place
597            if ($this->place()->gedcomName() !== '') {
598                $items[] = $this->place()->shortName();
599            }
600        }
601
602        return implode(' — ', $items);
603    }
604
605    /**
606     * Helper functions to sort facts
607     *
608     * @return Closure
609     */
610    private static function dateComparator(): Closure
611    {
612        return static function (Fact $a, Fact $b): int {
613            if ($a->date()->isOK() && $b->date()->isOK()) {
614                // If both events have dates, compare by date
615                $ret = Date::compare($a->date(), $b->date());
616
617                if ($ret === 0) {
618                    // If dates overlap, compare by fact type
619                    $ret = self::typeComparator()($a, $b);
620
621                    // If the fact type is also the same, retain the initial order
622                    if ($ret === 0) {
623                        $ret = $a->sortOrder <=> $b->sortOrder;
624                    }
625                }
626
627                return $ret;
628            }
629
630            // One or both events have no date - retain the initial order
631            return $a->sortOrder <=> $b->sortOrder;
632        };
633    }
634
635    /**
636     * Helper functions to sort facts.
637     *
638     * @return Closure
639     */
640    public static function typeComparator(): Closure
641    {
642        static $factsort = [];
643
644        if ($factsort === []) {
645            $factsort = array_flip(self::FACT_ORDER);
646        }
647
648        return static function (Fact $a, Fact $b) use ($factsort): int {
649            // Facts from same families stay grouped together
650            // Keep MARR and DIV from the same families from mixing with events from other FAMs
651            // Use the original order in which the facts were added
652            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
653                return $a->sortOrder <=> $b->sortOrder;
654            }
655
656            // NO events sort as the non-event itself.
657            $atag = $a->tag === 'NO' ? $a->value() : $a->tag;
658            $btag = $b->tag === 'NO' ? $b->value() : $b->tag;
659
660            // Events not in the above list get mapped onto one that is.
661            if (!array_key_exists($atag, $factsort)) {
662                $atag = '_????_';
663            }
664
665            if (!array_key_exists($btag, $factsort)) {
666                $btag = '_????_';
667            }
668
669            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
670            // - Treat dated after BURI facts as BURI instead
671            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
672                $atag = 'BURI';
673            }
674
675            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
676                $btag = 'BURI';
677            }
678
679            // If facts are the same then put dated facts before non-dated facts
680            if ($atag === $btag) {
681                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
682                    return -1;
683                }
684
685                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
686                    return 1;
687                }
688
689                // If no sorting preference, then keep original ordering
690                return $a->sortOrder <=> $b->sortOrder;
691            }
692
693            return $factsort[$atag] <=> $factsort[$btag];
694        };
695    }
696
697    /**
698     * A multi-key sort
699     * 1. First divide the facts into two arrays one set with dates and one set without dates
700     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
701     * using the compare type function
702     * 3. Then merge the arrays back into the original array using the compare type function
703     *
704     * @param Collection<int,Fact> $unsorted
705     *
706     * @return Collection<int,Fact>
707     */
708    public static function sortFacts(Collection $unsorted): Collection
709    {
710        $dated    = [];
711        $nondated = [];
712        $sorted   = [];
713
714        // Split the array into dated and non-dated arrays
715        $order = 0;
716
717        foreach ($unsorted as $fact) {
718            $fact->sortOrder = $order;
719            $order++;
720
721            if ($fact->date()->isOK()) {
722                $dated[] = $fact;
723            } else {
724                $nondated[] = $fact;
725            }
726        }
727
728        usort($dated, self::dateComparator());
729        usort($nondated, self::typeComparator());
730
731        // Merge the arrays
732        $dc = count($dated);
733        $nc = count($nondated);
734        $i  = 0;
735        $j  = 0;
736
737        // while there is anything in the dated array continue merging
738        while ($i < $dc) {
739            // compare each fact by type to merge them in order
740            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
741                $sorted[] = $nondated[$j];
742                $j++;
743            } else {
744                $sorted[] = $dated[$i];
745                $i++;
746            }
747        }
748
749        // get anything that might be left in the nondated array
750        while ($j < $nc) {
751            $sorted[] = $nondated[$j];
752            $j++;
753        }
754
755        return new Collection($sorted);
756    }
757
758    /**
759     * Sort fact/event tags using the same order that we use for facts.
760     *
761     * @param Collection<int,string> $unsorted
762     *
763     * @return Collection<int,string>
764     */
765    public static function sortFactTags(Collection $unsorted): Collection
766    {
767        $tag_order = array_flip(self::FACT_ORDER);
768
769        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
770            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
771            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
772
773            return $sort_x - $sort_y;
774        });
775    }
776
777    /**
778     * Allow native PHP functions such as array_unique() to work with objects
779     *
780     * @return string
781     */
782    public function __toString(): string
783    {
784        return $this->id . '@' . $this->record->xref();
785    }
786}
787