xref: /webtrees/app/Fact.php (revision b0fcccb03b84c12d54b4ae85014c3af52863d08c)
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        $access_level = $access_level ?? Auth::accessLevel($this->record->tree());
293
294        // Does this record have an explicit RESN?
295        if (strpos($this->gedcom, "\n2 RESN confidential") !== false) {
296            return Auth::PRIV_NONE >= $access_level;
297        }
298        if (strpos($this->gedcom, "\n2 RESN privacy") !== false) {
299            return Auth::PRIV_USER >= $access_level;
300        }
301        if (strpos($this->gedcom, "\n2 RESN none") !== false) {
302            return true;
303        }
304
305        // Does this record have a default RESN?
306        $xref                    = $this->record->xref();
307        $fact_privacy            = $this->record->tree()->getFactPrivacy();
308        $individual_fact_privacy = $this->record->tree()->getIndividualFactPrivacy();
309        if (isset($individual_fact_privacy[$xref][$this->tag])) {
310            return $individual_fact_privacy[$xref][$this->tag] >= $access_level;
311        }
312        if (isset($fact_privacy[$this->tag])) {
313            return $fact_privacy[$this->tag] >= $access_level;
314        }
315
316        // No restrictions - it must be public
317        return true;
318    }
319
320    /**
321     * Check whether this fact is protected against edit
322     *
323     * @return bool
324     */
325    public function canEdit(): bool
326    {
327        if ($this->isPendingDeletion()) {
328            return false;
329        }
330
331        if (Auth::isManager($this->record->tree())) {
332            return true;
333        }
334
335        // Members cannot edit RESN, CHAN and locked records
336        return Auth::isEditor($this->record->tree()) && strpos($this->gedcom, "\n2 RESN locked") === false && $this->getTag() !== 'RESN' && $this->getTag() !== 'CHAN';
337    }
338
339    /**
340     * The place where the event occured.
341     *
342     * @return Place
343     */
344    public function place(): Place
345    {
346        if ($this->place === null) {
347            $this->place = new Place($this->attribute('PLAC'), $this->record()->tree());
348        }
349
350        return $this->place;
351    }
352
353    /**
354     * Get the date for this fact.
355     * We can call this function many times, especially when sorting,
356     * so keep a copy of the date.
357     *
358     * @return Date
359     */
360    public function date(): Date
361    {
362        if ($this->date === null) {
363            $this->date = new Date($this->attribute('DATE'));
364        }
365
366        return $this->date;
367    }
368
369    /**
370     * The raw GEDCOM data for this fact
371     *
372     * @return string
373     */
374    public function gedcom(): string
375    {
376        return $this->gedcom;
377    }
378
379    /**
380     * Get a (pseudo) primary key for this fact.
381     *
382     * @return string
383     */
384    public function id(): string
385    {
386        return $this->id;
387    }
388
389    /**
390     * What is the tag (type) of this fact, such as BIRT, MARR or DEAT.
391     *
392     * @return string
393     */
394    public function getTag(): string
395    {
396        return $this->tag;
397    }
398
399    /**
400     * Used to convert a real fact (e.g. BIRT) into a close-relative’s fact (e.g. _BIRT_CHIL)
401     *
402     * @param string $tag
403     *
404     * @return void
405     */
406    public function setTag($tag): void
407    {
408        $this->tag = $tag;
409    }
410
411    /**
412     * The Person/Family record where this Fact came from
413     *
414     * @return Individual|Family|Source|Repository|Media|Note|GedcomRecord
415     */
416    public function record()
417    {
418        return $this->record;
419    }
420
421    /**
422     * Get the name of this fact type, for use as a label.
423     *
424     * @return string
425     */
426    public function label(): string
427    {
428        // Custom FACT/EVEN - with a TYPE
429        if (($this->tag === 'FACT' || $this->tag === 'EVEN') && $this->attribute('TYPE') !== '') {
430            return I18N::translate(e($this->attribute('TYPE')));
431        }
432
433        return GedcomTag::getLabel($this->tag, $this->record);
434    }
435
436    /**
437     * This is a newly deleted fact, pending approval.
438     *
439     * @return void
440     */
441    public function setPendingDeletion(): void
442    {
443        $this->pending_deletion = true;
444        $this->pending_addition = false;
445    }
446
447    /**
448     * Is this a newly deleted fact, pending approval.
449     *
450     * @return bool
451     */
452    public function isPendingDeletion(): bool
453    {
454        return $this->pending_deletion;
455    }
456
457    /**
458     * This is a newly added fact, pending approval.
459     *
460     * @return void
461     */
462    public function setPendingAddition(): void
463    {
464        $this->pending_addition = true;
465        $this->pending_deletion = false;
466    }
467
468    /**
469     * Is this a newly added fact, pending approval.
470     *
471     * @return bool
472     */
473    public function isPendingAddition(): bool
474    {
475        return $this->pending_addition;
476    }
477
478    /**
479     * Source citations linked to this fact
480     *
481     * @return string[]
482     */
483    public function getCitations(): array
484    {
485        preg_match_all('/\n(2 SOUR @(' . Gedcom::REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->gedcom(), $matches, PREG_SET_ORDER);
486        $citations = [];
487        foreach ($matches as $match) {
488            $source = Source::getInstance($match[2], $this->record()->tree());
489            if ($source && $source->canShow()) {
490                $citations[] = $match[1];
491            }
492        }
493
494        return $citations;
495    }
496
497    /**
498     * Notes (inline and objects) linked to this fact
499     *
500     * @return string[]|Note[]
501     */
502    public function getNotes(): array
503    {
504        $notes = [];
505        preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->gedcom(), $matches);
506        foreach ($matches[1] as $match) {
507            $note = preg_replace("/\n3 CONT ?/", "\n", $match);
508            if (preg_match('/@(' . Gedcom::REGEX_XREF . ')@/', $note, $nmatch)) {
509                $note = Note::getInstance($nmatch[1], $this->record()->tree());
510                if ($note && $note->canShow()) {
511                    // A note object
512                    $notes[] = $note;
513                }
514            } else {
515                // An inline note
516                $notes[] = $note;
517            }
518        }
519
520        return $notes;
521    }
522
523    /**
524     * Media objects linked to this fact
525     *
526     * @return Media[]
527     */
528    public function getMedia(): array
529    {
530        $media = [];
531        preg_match_all('/\n2 OBJE @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom(), $matches);
532        foreach ($matches[1] as $match) {
533            $obje = Media::getInstance($match, $this->record()->tree());
534            if ($obje && $obje->canShow()) {
535                $media[] = $obje;
536            }
537        }
538
539        return $media;
540    }
541
542    /**
543     * A one-line summary of the fact - for charts, etc.
544     *
545     * @return string
546     */
547    public function summary(): string
548    {
549        $attributes = [];
550        $target     = $this->target();
551        if ($target instanceof GedcomRecord) {
552            $attributes[] = $target->fullName();
553        } else {
554            // Fact value
555            $value = $this->value();
556            if ($value !== '' && $value !== 'Y') {
557                $attributes[] = '<span dir="auto">' . e($value) . '</span>';
558            }
559            // Fact date
560            $date = $this->date();
561            if ($date->isOK()) {
562                if ($this->record() instanceof Individual && in_array($this->getTag(), Gedcom::BIRTH_EVENTS, true) && $this->record()->tree()->getPreference('SHOW_PARENTS_AGE')) {
563                    $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->record(), $date);
564                } else {
565                    $attributes[] = $date->display();
566                }
567            }
568            // Fact place
569            if ($this->place()->gedcomName() !== '') {
570                $attributes[] = $this->place()->shortName();
571            }
572        }
573
574        $class = 'fact_' . $this->getTag();
575        if ($this->isPendingAddition()) {
576            $class .= ' new';
577        } elseif ($this->isPendingDeletion()) {
578            $class .= ' old';
579        }
580
581        return
582            '<div class="' . $class . '">' .
583            /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
584            I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->label(), implode(' — ', $attributes)) .
585            '</div>';
586    }
587
588    /**
589     * Helper functions to sort facts
590     *
591     * @return Closure
592     */
593    private static function dateComparator(): Closure
594    {
595        return static function (Fact $a, Fact $b): int {
596            if ($a->date()->isOK() && $b->date()->isOK()) {
597                // If both events have dates, compare by date
598                $ret = Date::compare($a->date(), $b->date());
599
600                if ($ret === 0) {
601                    // If dates overlap, compare by fact type
602                    $ret = self::typeComparator()($a, $b);
603
604                    // If the fact type is also the same, retain the initial order
605                    if ($ret === 0) {
606                        $ret = $a->sortOrder <=> $b->sortOrder;
607                    }
608                }
609
610                return $ret;
611            }
612
613            // One or both events have no date - retain the initial order
614            return $a->sortOrder <=> $b->sortOrder;
615        };
616    }
617
618    /**
619     * Helper functions to sort facts.
620     *
621     * @return Closure
622     */
623    public static function typeComparator(): Closure
624    {
625        static $factsort = [];
626
627        if ($factsort === []) {
628            $factsort = array_flip(self::FACT_ORDER);
629        }
630
631        return static function (Fact $a, Fact $b) use ($factsort): int {
632            // Facts from same families stay grouped together
633            // Keep MARR and DIV from the same families from mixing with events from other FAMs
634            // Use the original order in which the facts were added
635            if ($a->record instanceof Family && $b->record instanceof Family && $a->record !== $b->record) {
636                return $a->sortOrder - $b->sortOrder;
637            }
638
639            $atag = $a->getTag();
640            $btag = $b->getTag();
641
642            // Events not in the above list get mapped onto one that is.
643            if (!array_key_exists($atag, $factsort)) {
644                if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $atag, $match)) {
645                    $atag = $match[1];
646                } else {
647                    $atag = '_????_';
648                }
649            }
650
651            if (!array_key_exists($btag, $factsort)) {
652                if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $btag, $match)) {
653                    $btag = $match[1];
654                } else {
655                    $btag = '_????_';
656                }
657            }
658
659            // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
660            // - Treat dated after BURI facts as BURI instead
661            if ($a->attribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
662                $atag = 'BURI';
663            }
664
665            if ($b->attribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
666                $btag = 'BURI';
667            }
668
669            $ret = $factsort[$atag] - $factsort[$btag];
670
671            // If facts are the same then put dated facts before non-dated facts
672            if ($ret == 0) {
673                if ($a->attribute('DATE') !== '' && $b->attribute('DATE') === '') {
674                    return -1;
675                }
676
677                if ($b->attribute('DATE') !== '' && $a->attribute('DATE') === '') {
678                    return 1;
679                }
680
681                // If no sorting preference, then keep original ordering
682                $ret = $a->sortOrder - $b->sortOrder;
683            }
684
685            return $ret;
686        };
687    }
688
689    /**
690     * A multi-key sort
691     * 1. First divide the facts into two arrays one set with dates and one set without dates
692     * 2. Sort each of the two new arrays, the date using the compare date function, the non-dated
693     * using the compare type function
694     * 3. Then merge the arrays back into the original array using the compare type function
695     *
696     * @param Collection $unsorted
697     *
698     * @return Collection
699     */
700    public static function sortFacts(Collection $unsorted): Collection
701    {
702        $dated    = [];
703        $nondated = [];
704        $sorted   = [];
705
706        // Split the array into dated and non-dated arrays
707        $order = 0;
708
709        foreach ($unsorted as $fact) {
710            $fact->sortOrder = $order;
711            $order++;
712
713            if ($fact->date()->isOK()) {
714                $dated[] = $fact;
715            } else {
716                $nondated[] = $fact;
717            }
718        }
719
720        usort($dated, self::dateComparator());
721        usort($nondated, self::typeComparator());
722
723        // Merge the arrays
724        $dc = count($dated);
725        $nc = count($nondated);
726        $i  = 0;
727        $j  = 0;
728
729        // while there is anything in the dated array continue merging
730        while ($i < $dc) {
731            // compare each fact by type to merge them in order
732            if ($j < $nc && self::typeComparator()($dated[$i], $nondated[$j]) > 0) {
733                $sorted[] = $nondated[$j];
734                $j++;
735            } else {
736                $sorted[] = $dated[$i];
737                $i++;
738            }
739        }
740
741        // get anything that might be left in the nondated array
742        while ($j < $nc) {
743            $sorted[] = $nondated[$j];
744            $j++;
745        }
746
747        return new Collection($sorted);
748    }
749
750    /**
751     * Allow native PHP functions such as array_unique() to work with objects
752     *
753     * @return string
754     */
755    public function __toString(): string
756    {
757        return $this->id . '@' . $this->record->xref();
758    }
759}
760