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