xref: /webtrees/app/Fact.php (revision e873f434551745f888937263ff89e80db3b0f785)
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 array 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            $value = preg_replace("/\n2 CONT ?/", "\n", $match[1]);
206
207            return Registry::elementFactory()->make($this->tag())->canonical($value);
208        }
209
210        return '';
211    }
212
213    /**
214     * Get the record to which this fact links
215     *
216     * @return Family|GedcomRecord|Individual|Location|Media|Note|Repository|Source|Submission|Submitter|null
217     */
218    public function target()
219    {
220        if (!preg_match('/^@(' . Gedcom::REGEX_XREF . ')@$/', $this->value(), $match)) {
221            return null;
222        }
223
224        $xref = $match[1];
225
226        switch ($this->tag) {
227            case 'FAMC':
228            case 'FAMS':
229                return Registry::familyFactory()->make($xref, $this->record->tree());
230            case 'HUSB':
231            case 'WIFE':
232            case 'ALIA':
233            case 'CHIL':
234            case '_ASSO':
235                return Registry::individualFactory()->make($xref, $this->record->tree());
236            case 'ASSO':
237                return
238                    Registry::individualFactory()->make($xref, $this->record->tree()) ??
239                    Registry::submitterFactory()->make($xref, $this->record->tree());
240            case 'SOUR':
241                return Registry::sourceFactory()->make($xref, $this->record->tree());
242            case 'OBJE':
243                return Registry::mediaFactory()->make($xref, $this->record->tree());
244            case 'REPO':
245                return Registry::repositoryFactory()->make($xref, $this->record->tree());
246            case 'NOTE':
247                return Registry::noteFactory()->make($xref, $this->record->tree());
248            case 'ANCI':
249            case 'DESI':
250            case 'SUBM':
251                return Registry::submitterFactory()->make($xref, $this->record->tree());
252            case 'SUBN':
253                return Registry::submissionFactory()->make($xref, $this->record->tree());
254            case '_LOC':
255                return Registry::locationFactory()->make($xref, $this->record->tree());
256            default:
257                return Registry::gedcomRecordFactory()->make($xref, $this->record->tree());
258        }
259    }
260
261    /**
262     * Get the value of level 2 data in the fact
263     *
264     * @param string $tag
265     *
266     * @return string
267     */
268    public function attribute(string $tag): string
269    {
270        if (preg_match('/\n2 ' . $tag . '\b ?(.*(?:(?:\n3 CONT ?.*)*)*)/', $this->gedcom, $match)) {
271            $value = preg_replace("/\n3 CONT ?/", "\n", $match[1]);
272
273            return Registry::elementFactory()->make($this->tag() . ':' . $tag)->canonical($value);
274        }
275
276        return '';
277    }
278
279    /**
280     * Get the PLAC:MAP:LATI for the fact.
281     */
282    public function latitude(): float|null
283    {
284        if (preg_match('/\n4 LATI (.+)/', $this->gedcom, $match)) {
285            $gedcom_service = new GedcomService();
286
287            return $gedcom_service->readLatitude($match[1]);
288        }
289
290        return null;
291    }
292
293    /**
294     * Get the PLAC:MAP:LONG for the fact.
295     */
296    public function longitude(): float|null
297    {
298        if (preg_match('/\n4 LONG (.+)/', $this->gedcom, $match)) {
299            $gedcom_service = new GedcomService();
300
301            return $gedcom_service->readLongitude($match[1]);
302        }
303
304        return null;
305    }
306
307    /**
308     * Do the privacy rules allow us to display this fact to the current user
309     *
310     * @param int|null $access_level
311     *
312     * @return bool
313     */
314    public function canShow(int|null $access_level = null): bool
315    {
316        $access_level ??= Auth::accessLevel($this->record->tree());
317
318        // Does this record have an explicit restriction notice?
319        $element     = new RestrictionNotice('');
320        $restriction = $element->canonical($this->attribute('RESN'));
321
322        if (str_starts_with($restriction, RestrictionNotice::VALUE_CONFIDENTIAL)) {
323            return Auth::PRIV_NONE >= $access_level;
324        }
325
326        if (str_starts_with($restriction, RestrictionNotice::VALUE_PRIVACY)) {
327            return Auth::PRIV_USER >= $access_level;
328        }
329        if (str_starts_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 occurred.
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        if (str_ends_with($this->tag(), ':NOTE') && preg_match('/^@' . Gedcom::REGEX_XREF . '@$/', $this->value())) {
449            return I18N::translate('Shared note');
450        }
451
452        // Marriages
453        if ($this->tag() === 'FAM:MARR') {
454            $element = Registry::elementFactory()->make('FAM:MARR:TYPE');
455            $type = $this->attribute('TYPE');
456
457            if ($type !== '') {
458                return $element->value($type, $this->record->tree());
459            }
460        }
461
462        // Custom FACT/EVEN - with a TYPE
463        if ($this->tag === 'FACT' || $this->tag === 'EVEN') {
464            $type = $this->attribute('TYPE');
465
466            if ($type !== '') {
467                if (!str_contains($type, '%')) {
468                    // Allow user-translations of custom types.
469                    $translated = I18N::translate($type);
470
471                    if ($translated !== $type) {
472                        return $translated;
473                    }
474                }
475
476                return e($type);
477            }
478        }
479
480        return Registry::elementFactory()->make($this->tag())->label();
481    }
482
483    /**
484     * This is a newly deleted fact, pending approval.
485     *
486     * @return void
487     */
488    public function setPendingDeletion(): void
489    {
490        $this->pending_deletion = true;
491        $this->pending_addition = false;
492    }
493
494    /**
495     * Is this a newly deleted fact, pending approval.
496     *
497     * @return bool
498     */
499    public function isPendingDeletion(): bool
500    {
501        return $this->pending_deletion;
502    }
503
504    /**
505     * This is a newly added fact, pending approval.
506     *
507     * @return void
508     */
509    public function setPendingAddition(): void
510    {
511        $this->pending_addition = true;
512        $this->pending_deletion = false;
513    }
514
515    /**
516     * Is this a newly added fact, pending approval.
517     *
518     * @return bool
519     */
520    public function isPendingAddition(): bool
521    {
522        return $this->pending_addition;
523    }
524
525    /**
526     * A one-line summary of the fact - for charts, etc.
527     *
528     * @return string
529     */
530    public function summary(): string
531    {
532        $attributes = [];
533        $target     = $this->target();
534        if ($target instanceof GedcomRecord) {
535            $attributes[] = $target->fullName();
536        } else {
537            // Fact value
538            $value = $this->value();
539            if ($value !== '' && $value !== 'Y') {
540                $attributes[] = '<bdi>' . e($value) . '</bdi>';
541            }
542            // Fact date
543            $date = $this->date();
544            if ($date->isOK()) {
545                if ($this->record instanceof Individual && in_array($this->tag, Gedcom::BIRTH_EVENTS, true) && $this->record->tree()->getPreference('SHOW_PARENTS_AGE')) {
546                    $attributes[] = $date->display() . view('fact-parents-age', ['individual' => $this->record, 'birth_date' => $date]);
547                } else {
548                    $attributes[] = $date->display();
549                }
550            }
551            // Fact place
552            if ($this->place()->gedcomName() !== '') {
553                $attributes[] = $this->place()->shortName();
554            }
555        }
556
557        $class = 'fact_' . $this->tag;
558        if ($this->isPendingAddition()) {
559            $class .= ' wt-new';
560        } elseif ($this->isPendingDeletion()) {
561            $class .= ' wt-old';
562        }
563
564        $label = '<span class="label">' . $this->label() . '</span>';
565        $value = '<span class="field" dir="auto">' . implode(' — ', $attributes) . '</span>';
566
567        /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
568        return '<div class="' . $class . '">' . I18N::translate('%1$s: %2$s', $label, $value) . '</div>';
569    }
570
571    /**
572     * A one-line summary of the fact - for the clipboard, etc.
573     *
574     * @return string
575     */
576    public function name(): string
577    {
578        $items  = [$this->label()];
579        $target = $this->target();
580
581        if ($target instanceof GedcomRecord) {
582            $items[] = '<bdi>' . $target->fullName() . '</bdi>';
583        } else {
584            // Fact value
585            $value = $this->value();
586            if ($value !== '' && $value !== 'Y') {
587                $items[] = '<bdi>' . e($value) . '</bdi>';
588            }
589
590            // Fact date
591            if ($this->date()->isOK()) {
592                $items[] = $this->date()->minimumDate()->format('%Y');
593            }
594
595            // Fact place
596            if ($this->place()->gedcomName() !== '') {
597                $items[] = $this->place()->shortName();
598            }
599        }
600
601        return implode(' — ', $items);
602    }
603
604    /**
605     * Helper functions to sort facts
606     *
607     * @return Closure(Fact,Fact):int
608     */
609    private static function dateComparator(): Closure
610    {
611        return static function (Fact $a, Fact $b): int {
612            if ($a->date()->isOK() && $b->date()->isOK()) {
613                // If both events have dates, compare by date
614                $ret = Date::compare($a->date(), $b->date());
615
616                if ($ret === 0) {
617                    // If dates overlap, compare by fact type
618                    $ret = self::typeComparator()($a, $b);
619
620                    // If the fact type is also the same, retain the initial order
621                    if ($ret === 0) {
622                        $ret = $a->sortOrder <=> $b->sortOrder;
623                    }
624                }
625
626                return $ret;
627            }
628
629            // One or both events have no date - retain the initial order
630            return $a->sortOrder <=> $b->sortOrder;
631        };
632    }
633
634    /**
635     * Helper functions to sort facts.
636     *
637     * @return Closure(Fact,Fact):int
638     */
639    public static function typeComparator(): Closure
640    {
641        static $factsort = [];
642
643        if ($factsort === []) {
644            $factsort = array_flip(self::FACT_ORDER);
645        }
646
647        return static function (Fact $a, Fact $b) use ($factsort): int {
648            // Facts from same families stay grouped together
649            // Keep MARR and DIV from the same families from mixing with events from other FAMs
650            // Use the original order in which the facts were added
651            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
652                return $a->sortOrder <=> $b->sortOrder;
653            }
654
655            // NO events sort as the non-event itself.
656            $atag = $a->tag === 'NO' ? $a->value() : $a->tag;
657            $btag = $b->tag === 'NO' ? $b->value() : $b->tag;
658
659            // Events not in the above list get mapped onto one that is.
660            if (!array_key_exists($atag, $factsort)) {
661                $atag = '_????_';
662            }
663
664            if (!array_key_exists($btag, $factsort)) {
665                $btag = '_????_';
666            }
667
668            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
669            // - Treat dated after BURI facts as BURI instead
670            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
671                $atag = 'BURI';
672            }
673
674            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
675                $btag = 'BURI';
676            }
677
678            // If facts are the same then put dated facts before non-dated facts
679            if ($atag === $btag) {
680                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
681                    return -1;
682                }
683
684                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
685                    return 1;
686                }
687
688                // If no sorting preference, then keep original ordering
689                return $a->sortOrder <=> $b->sortOrder;
690            }
691
692            return $factsort[$atag] <=> $factsort[$btag];
693        };
694    }
695
696    /**
697     * A multi-key sort
698     * 1. First divide the facts into two arrays one set with dates and one set without dates
699     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
700     * using the compare type function
701     * 3. Then merge the arrays back into the original array using the compare type function
702     *
703     * @param Collection<int,Fact> $unsorted
704     *
705     * @return Collection<int,Fact>
706     */
707    public static function sortFacts(Collection $unsorted): Collection
708    {
709        $dated    = [];
710        $nondated = [];
711        $sorted   = [];
712
713        // Split the array into dated and non-dated arrays
714        $order = 0;
715
716        foreach ($unsorted as $fact) {
717            $fact->sortOrder = $order;
718            $order++;
719
720            if ($fact->date()->isOK()) {
721                $dated[] = $fact;
722            } else {
723                $nondated[] = $fact;
724            }
725        }
726
727        usort($dated, self::dateComparator());
728        usort($nondated, self::typeComparator());
729
730        // Merge the arrays
731        $dc = count($dated);
732        $nc = count($nondated);
733        $i  = 0;
734        $j  = 0;
735
736        // while there is anything in the dated array continue merging
737        while ($i < $dc) {
738            // compare each fact by type to merge them in order
739            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
740                $sorted[] = $nondated[$j];
741                $j++;
742            } else {
743                $sorted[] = $dated[$i];
744                $i++;
745            }
746        }
747
748        // get anything that might be left in the nondated array
749        while ($j < $nc) {
750            $sorted[] = $nondated[$j];
751            $j++;
752        }
753
754        return new Collection($sorted);
755    }
756
757    /**
758     * Sort fact/event tags using the same order that we use for facts.
759     *
760     * @param Collection<int,string> $unsorted
761     *
762     * @return Collection<int,string>
763     */
764    public static function sortFactTags(Collection $unsorted): Collection
765    {
766        $tag_order = array_flip(self::FACT_ORDER);
767
768        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
769            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
770            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
771
772            return $sort_x - $sort_y;
773        });
774    }
775
776    /**
777     * Allow native PHP functions such as array_unique() to work with objects
778     *
779     * @return string
780     */
781    public function __toString(): string
782    {
783        return $this->id . '@' . $this->record->xref();
784    }
785}
786