xref: /webtrees/app/Fact.php (revision cf7c85896a54223772c76826381a9151d4a21e10)
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->getTag() !== 'RESN' && $this->getTag() !== '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 getTag(): string
426    {
427        return $this->tag;
428    }
429
430    /**
431     * Used to convert a real fact (e.g. BIRT) into a close-relative’s fact (e.g. _BIRT_CHIL)
432     *
433     * @param string $tag
434     *
435     * @return void
436     */
437    public function setTag($tag): void
438    {
439        $this->tag = $tag;
440    }
441
442    /**
443     * The Person/Family record where this Fact came from
444     *
445     * @return Individual|Family|Source|Repository|Media|Note|GedcomRecord
446     */
447    public function record()
448    {
449        return $this->record;
450    }
451
452    /**
453     * Get the name of this fact type, for use as a label.
454     *
455     * @return string
456     */
457    public function label(): string
458    {
459        // Custom FACT/EVEN - with a TYPE
460        if (($this->tag === 'FACT' || $this->tag === 'EVEN') && $this->attribute('TYPE') !== '') {
461            return I18N::translate(e($this->attribute('TYPE')));
462        }
463
464        return GedcomTag::getLabel($this->tag, $this->record);
465    }
466
467    /**
468     * This is a newly deleted fact, pending approval.
469     *
470     * @return void
471     */
472    public function setPendingDeletion(): void
473    {
474        $this->pending_deletion = true;
475        $this->pending_addition = false;
476    }
477
478    /**
479     * Is this a newly deleted fact, pending approval.
480     *
481     * @return bool
482     */
483    public function isPendingDeletion(): bool
484    {
485        return $this->pending_deletion;
486    }
487
488    /**
489     * This is a newly added fact, pending approval.
490     *
491     * @return void
492     */
493    public function setPendingAddition(): void
494    {
495        $this->pending_addition = true;
496        $this->pending_deletion = false;
497    }
498
499    /**
500     * Is this a newly added fact, pending approval.
501     *
502     * @return bool
503     */
504    public function isPendingAddition(): bool
505    {
506        return $this->pending_addition;
507    }
508
509    /**
510     * Source citations linked to this fact
511     *
512     * @return string[]
513     */
514    public function getCitations(): array
515    {
516        preg_match_all('/\n(2 SOUR @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->gedcom(), $matches, PREG_SET_ORDER);
517        $citations = [];
518        foreach ($matches as $match) {
519            $source = Factory::source()->make($match[2], $this->record()->tree());
520            if ($source && $source->canShow()) {
521                $citations[] = $match[1];
522            }
523        }
524
525        return $citations;
526    }
527
528    /**
529     * Notes (inline and objects) linked to this fact
530     *
531     * @return string[]|Note[]
532     */
533    public function getNotes(): array
534    {
535        $notes = [];
536        preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->gedcom(), $matches);
537        foreach ($matches[1] as $match) {
538            $note = preg_replace("/\n3 CONT ?/", "\n", $match);
539            if (preg_match('/@(' . Gedcom::REGEX_XREF . ')@/', $note, $nmatch)) {
540                $note = Factory::note()->make($nmatch[1], $this->record()->tree());
541                if ($note && $note->canShow()) {
542                    // A note object
543                    $notes[] = $note;
544                }
545            } else {
546                // An inline note
547                $notes[] = $note;
548            }
549        }
550
551        return $notes;
552    }
553
554    /**
555     * Media objects linked to this fact
556     *
557     * @return Media[]
558     */
559    public function getMedia(): array
560    {
561        $media = [];
562        preg_match_all('/\n2 OBJE @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom(), $matches);
563        foreach ($matches[1] as $match) {
564            $obje = Factory::media()->make($match, $this->record()->tree());
565            if ($obje && $obje->canShow()) {
566                $media[] = $obje;
567            }
568        }
569
570        return $media;
571    }
572
573    /**
574     * A one-line summary of the fact - for charts, etc.
575     *
576     * @return string
577     */
578    public function summary(): string
579    {
580        $attributes = [];
581        $target     = $this->target();
582        if ($target instanceof GedcomRecord) {
583            $attributes[] = $target->fullName();
584        } else {
585            // Fact value
586            $value = $this->value();
587            if ($value !== '' && $value !== 'Y') {
588                $attributes[] = '<span dir="auto">' . e($value) . '</span>';
589            }
590            // Fact date
591            $date = $this->date();
592            if ($date->isOK()) {
593                if ($this->record() instanceof Individual && in_array($this->getTag(), Gedcom::BIRTH_EVENTS, true) && $this->record()->tree()->getPreference('SHOW_PARENTS_AGE')) {
594                    $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->record(), $date);
595                } else {
596                    $attributes[] = $date->display();
597                }
598            }
599            // Fact place
600            if ($this->place()->gedcomName() !== '') {
601                $attributes[] = $this->place()->shortName();
602            }
603        }
604
605        $class = 'fact_' . $this->getTag();
606        if ($this->isPendingAddition()) {
607            $class .= ' wt-new';
608        } elseif ($this->isPendingDeletion()) {
609            $class .= ' wt-old';
610        }
611
612        return
613            '<div class="' . $class . '">' .
614            /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
615            I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->label(), implode(' — ', $attributes)) .
616            '</div>';
617    }
618
619    /**
620     * Helper functions to sort facts
621     *
622     * @return Closure
623     */
624    private static function dateComparator(): Closure
625    {
626        return static function (Fact $a, Fact $b): int {
627            if ($a->date()->isOK() && $b->date()->isOK()) {
628                // If both events have dates, compare by date
629                $ret = Date::compare($a->date(), $b->date());
630
631                if ($ret === 0) {
632                    // If dates overlap, compare by fact type
633                    $ret = self::typeComparator()($a, $b);
634
635                    // If the fact type is also the same, retain the initial order
636                    if ($ret === 0) {
637                        $ret = $a->sortOrder <=> $b->sortOrder;
638                    }
639                }
640
641                return $ret;
642            }
643
644            // One or both events have no date - retain the initial order
645            return $a->sortOrder <=> $b->sortOrder;
646        };
647    }
648
649    /**
650     * Helper functions to sort facts.
651     *
652     * @return Closure
653     */
654    public static function typeComparator(): Closure
655    {
656        static $factsort = [];
657
658        if ($factsort === []) {
659            $factsort = array_flip(self::FACT_ORDER);
660        }
661
662        return static function (Fact $a, Fact $b) use ($factsort): int {
663            // Facts from same families stay grouped together
664            // Keep MARR and DIV from the same families from mixing with events from other FAMs
665            // Use the original order in which the facts were added
666            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
667                return $a->sortOrder - $b->sortOrder;
668            }
669
670            $atag = $a->getTag();
671            $btag = $b->getTag();
672
673            // Events not in the above list get mapped onto one that is.
674            if (!array_key_exists($atag, $factsort)) {
675                if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $atag, $match)) {
676                    $atag = $match[1];
677                } else {
678                    $atag = '_????_';
679                }
680            }
681
682            if (!array_key_exists($btag, $factsort)) {
683                if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $btag, $match)) {
684                    $btag = $match[1];
685                } else {
686                    $btag = '_????_';
687                }
688            }
689
690            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
691            // - Treat dated after BURI facts as BURI instead
692            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
693                $atag = 'BURI';
694            }
695
696            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
697                $btag = 'BURI';
698            }
699
700            $ret = $factsort[$atag] - $factsort[$btag];
701
702            // If facts are the same then put dated facts before non-dated facts
703            if ($ret == 0) {
704                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
705                    return -1;
706                }
707
708                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
709                    return 1;
710                }
711
712                // If no sorting preference, then keep original ordering
713                $ret = $a->sortOrder - $b->sortOrder;
714            }
715
716            return $ret;
717        };
718    }
719
720    /**
721     * A multi-key sort
722     * 1. First divide the facts into two arrays one set with dates and one set without dates
723     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
724     * using the compare type function
725     * 3. Then merge the arrays back into the original array using the compare type function
726     *
727     * @param Collection<Fact> $unsorted
728     *
729     * @return Collection<Fact>
730     */
731    public static function sortFacts(Collection $unsorted): Collection
732    {
733        $dated    = [];
734        $nondated = [];
735        $sorted   = [];
736
737        // Split the array into dated and non-dated arrays
738        $order = 0;
739
740        foreach ($unsorted as $fact) {
741            $fact->sortOrder = $order;
742            $order++;
743
744            if ($fact->date()->isOK()) {
745                $dated[] = $fact;
746            } else {
747                $nondated[] = $fact;
748            }
749        }
750
751        usort($dated, self::dateComparator());
752        usort($nondated, self::typeComparator());
753
754        // Merge the arrays
755        $dc = count($dated);
756        $nc = count($nondated);
757        $i  = 0;
758        $j  = 0;
759
760        // while there is anything in the dated array continue merging
761        while ($i < $dc) {
762            // compare each fact by type to merge them in order
763            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
764                $sorted[] = $nondated[$j];
765                $j++;
766            } else {
767                $sorted[] = $dated[$i];
768                $i++;
769            }
770        }
771
772        // get anything that might be left in the nondated array
773        while ($j < $nc) {
774            $sorted[] = $nondated[$j];
775            $j++;
776        }
777
778        return new Collection($sorted);
779    }
780
781    /**
782     * Sort fact/event tags using the same order that we use for facts.
783     *
784     * @param Collection<string> $unsorted
785     *
786     * @return Collection<string>
787     */
788    public static function sortFactTags(Collection $unsorted): Collection
789    {
790        $tag_order = array_flip(self::FACT_ORDER);
791
792        return $unsorted->sort(static function (string $x, string $y) use ($tag_order): int {
793            $sort_x = $tag_order[$x] ?? $tag_order['_????_'];
794            $sort_y = $tag_order[$y] ?? $tag_order['_????_'];
795
796            return $sort_x - $sort_y;
797        });
798    }
799
800    /**
801     * Allow native PHP functions such as array_unique() to work with objects
802     *
803     * @return string
804     */
805    public function __toString(): string
806    {
807        return $this->id . '@' . $this->record->xref();
808    }
809}
810