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