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