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