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