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