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