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