xref: /webtrees/app/Fact.php (revision b7059dcc07de88ca9b4a75e1b289a010589eaf4b)
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     * Helper functions to sort facts.
615     *
616     * @return Closure
617     */
618    public static function typeComparator(): Closure
619    {
620        static $factsort = [];
621
622        if (empty($factsort)) {
623            $factsort = array_flip(self::FACT_ORDER);
624        }
625
626        return function (Fact $a, Fact $b) use ($factsort): int {
627            // Facts from same families stay grouped together
628            // Keep MARR and DIV from the same families from mixing with events from other FAMs
629            // Use the original order in which the facts were added
630            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
631                return $a->sortOrder - $b->sortOrder;
632            }
633
634            $atag = $a->getTag();
635            $btag = $b->getTag();
636
637            // Events not in the above list get mapped onto one that is.
638            if (!array_key_exists($atag, $factsort)) {
639                if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $atag, $match)) {
640                    $atag = $match[1];
641                } else {
642                    $atag = '_????_';
643                }
644            }
645
646            if (!array_key_exists($btag, $factsort)) {
647                if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $btag, $match)) {
648                    $btag = $match[1];
649                } else {
650                    $btag = '_????_';
651                }
652            }
653
654            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
655            // - Treat dated after BURI facts as BURI instead
656            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
657                $atag = 'BURI';
658            }
659
660            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
661                $btag = 'BURI';
662            }
663
664            $ret = $factsort[$atag] - $factsort[$btag];
665
666            // If facts are the same then put dated facts before non-dated facts
667            if ($ret == 0) {
668                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
669                    return -1;
670                }
671
672                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
673                    return 1;
674                }
675
676                // If no sorting preference, then keep original ordering
677                $ret = $a->sortOrder - $b->sortOrder;
678            }
679
680            return $ret;
681        };
682    }
683
684    /**
685     * A multi-key sort
686     * 1. First divide the facts into two arrays one set with dates and one set without dates
687     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
688     * using the compare type function
689     * 3. Then merge the arrays back into the original array using the compare type function
690     *
691     * @param Fact[] $unsorted
692     *
693     * @return Collection|Fact[]
694     */
695    public static function sortFacts($unsorted): Collection
696    {
697        $dated    = [];
698        $nondated = [];
699        $sorted   = [];
700
701        // Split the array into dated and non-dated arrays
702        $order = 0;
703
704        foreach ($unsorted as $fact) {
705            $fact->sortOrder = $order;
706            $order++;
707
708            if ($fact->date()->isOK()) {
709                $dated[] = $fact;
710            } else {
711                $nondated[] = $fact;
712            }
713        }
714
715        usort($dated, self::dateComparator());
716        usort($nondated, self::typeComparator());
717
718        // Merge the arrays
719        $dc = count($dated);
720        $nc = count($nondated);
721        $i  = 0;
722        $j  = 0;
723
724        // while there is anything in the dated array continue merging
725        while ($i < $dc) {
726            // compare each fact by type to merge them in order
727            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
728                $sorted[] = $nondated[$j];
729                $j++;
730            } else {
731                $sorted[] = $dated[$i];
732                $i++;
733            }
734        }
735
736        // get anything that might be left in the nondated array
737        while ($j < $nc) {
738            $sorted[] = $nondated[$j];
739            $j++;
740        }
741
742        return new Collection($sorted);
743    }
744
745    /**
746     * Allow native PHP functions such as array_unique() to work with objects
747     *
748     * @return string
749     */
750    public function __toString(): string
751    {
752        return $this->id . '@' . $this->record->xref();
753    }
754}
755