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