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