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