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