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