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