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