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