xref: /webtrees/app/Fact.php (revision 873953697c930fadbf3243d2b8c0029fd684da0e)
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    public function setTag($tag)
363    {
364        $this->tag = $tag;
365    }
366
367    /**
368     * The Person/Family record where this Fact came from
369     *
370     * @return Individual|Family|Source|Repository|Media|Note|GedcomRecord
371     */
372    public function getParent()
373    {
374        return $this->parent;
375    }
376
377    /**
378     * Get the name of this fact type, for use as a label.
379     *
380     * @return string
381     */
382    public function getLabel(): string
383    {
384        // Custom FACT/EVEN - with a TYPE
385        if (($this->tag === 'FACT' || $this->tag === 'EVEN') && $this->getAttribute('TYPE') !== '') {
386            return I18N::translate(e($this->getAttribute('TYPE')));
387        }
388
389        return GedcomTag::getLabel($this->tag, $this->parent);
390    }
391
392    /**
393     * This is a newly deleted fact, pending approval.
394     */
395    public function setPendingDeletion()
396    {
397        $this->pending_deletion = true;
398        $this->pending_addition = false;
399    }
400
401    /**
402     * Is this a newly deleted fact, pending approval.
403     *
404     * @return bool
405     */
406    public function isPendingDeletion(): bool
407    {
408        return $this->pending_deletion;
409    }
410
411    /**
412     * This is a newly added fact, pending approval.
413     */
414    public function setPendingAddition()
415    {
416        $this->pending_addition = true;
417        $this->pending_deletion = false;
418    }
419
420    /**
421     * Is this a newly added fact, pending approval.
422     *
423     * @return bool
424     */
425    public function isPendingAddition(): bool
426    {
427        return $this->pending_addition;
428    }
429
430    /**
431     * Source citations linked to this fact
432     *
433     * @return string[]
434     */
435    public function getCitations(): array
436    {
437        preg_match_all('/\n(2 SOUR @(' . WT_REGEX_XREF . ')@(?:\n[3-9] .*)*)/', $this->getGedcom(), $matches, PREG_SET_ORDER);
438        $citations = [];
439        foreach ($matches as $match) {
440            $source = Source::getInstance($match[2], $this->getParent()->getTree());
441            if ($source->canShow()) {
442                $citations[] = $match[1];
443            }
444        }
445
446        return $citations;
447    }
448
449    /**
450     * Notes (inline and objects) linked to this fact
451     *
452     * @return string[]|Note[]
453     */
454    public function getNotes(): array
455    {
456        $notes = [];
457        preg_match_all('/\n2 NOTE ?(.*(?:\n3.*)*)/', $this->getGedcom(), $matches);
458        foreach ($matches[1] as $match) {
459            $note = preg_replace("/\n3 CONT ?/", "\n", $match);
460            if (preg_match('/@(' . WT_REGEX_XREF . ')@/', $note, $nmatch)) {
461                $note = Note::getInstance($nmatch[1], $this->getParent()->getTree());
462                if ($note && $note->canShow()) {
463                    // A note object
464                    $notes[] = $note;
465                }
466            } else {
467                // An inline note
468                $notes[] = $note;
469            }
470        }
471
472        return $notes;
473    }
474
475    /**
476     * Media objects linked to this fact
477     *
478     * @return Media[]
479     */
480    public function getMedia(): array
481    {
482        $media = [];
483        preg_match_all('/\n2 OBJE @(' . WT_REGEX_XREF . ')@/', $this->getGedcom(), $matches);
484        foreach ($matches[1] as $match) {
485            $obje = Media::getInstance($match, $this->getParent()->getTree());
486            if ($obje->canShow()) {
487                $media[] = $obje;
488            }
489        }
490
491        return $media;
492    }
493
494    /**
495     * A one-line summary of the fact - for charts, etc.
496     *
497     * @return string
498     */
499    public function summary(): string
500    {
501        $attributes = [];
502        $target     = $this->getTarget();
503        if ($target) {
504            $attributes[] = $target->getFullName();
505        } else {
506            // Fact value
507            $value = $this->getValue();
508            if ($value !== '' && $value !== 'Y') {
509                $attributes[] = '<span dir="auto">' . e($value) . '</span>';
510            }
511            // Fact date
512            $date = $this->getDate();
513            if ($date->isOK()) {
514                if (in_array($this->getTag(), explode('|', WT_EVENTS_BIRT)) && $this->getParent() instanceof Individual && $this->getParent()->getTree()->getPreference('SHOW_PARENTS_AGE')) {
515                    $attributes[] = $date->display() . FunctionsPrint::formatParentsAges($this->getParent(), $date);
516                } else {
517                    $attributes[] = $date->display();
518                }
519            }
520            // Fact place
521            if (!$this->getPlace()->isEmpty()) {
522                $attributes[] = $this->getPlace()->getShortName();
523            }
524        }
525
526        $class = 'fact_' . $this->getTag();
527        if ($this->isPendingAddition()) {
528            $class .= ' new';
529        } elseif ($this->isPendingDeletion()) {
530            $class .= ' old';
531        }
532
533        return
534            '<div class="' . $class . '">' .
535            /* I18N: a label/value pair, such as “Occupation: Farmer”. Some languages may need to change the punctuation. */
536            I18N::translate('<span class="label">%1$s:</span> <span class="field" dir="auto">%2$s</span>', $this->getLabel(), implode(' — ', $attributes)) .
537            '</div>';
538    }
539
540    /**
541     * Static Helper functions to sort events
542     *
543     * @param Fact $a Fact one
544     * @param Fact $b Fact two
545     *
546     * @return int
547     */
548    public static function compareDate(Fact $a, Fact $b)
549    {
550        if ($a->getDate()->isOK() && $b->getDate()->isOK()) {
551            // If both events have dates, compare by date
552            $ret = Date::compare($a->getDate(), $b->getDate());
553
554            if ($ret == 0) {
555                // If dates are the same, compare by fact type
556                $ret = self::compareType($a, $b);
557
558                // If the fact type is also the same, retain the initial order
559                if ($ret == 0) {
560                    $ret = $a->sortOrder - $b->sortOrder;
561                }
562            }
563
564            return $ret;
565        } else {
566            // One or both events have no date - retain the initial order
567            return $a->sortOrder - $b->sortOrder;
568        }
569    }
570
571    /**
572     * Static method to compare two events by their type.
573     *
574     * @param Fact $a Fact one
575     * @param Fact $b Fact two
576     *
577     * @return int
578     */
579    public static function compareType(Fact $a, Fact $b): int
580    {
581        static $factsort = [];
582
583        if (empty($factsort)) {
584            $factsort = array_flip(self::FACT_ORDER);
585        }
586
587        // Facts from same families stay grouped together
588        // Keep MARR and DIV from the same families from mixing with events from other FAMs
589        // Use the original order in which the facts were added
590        if ($a->parent instanceof Family && $b->parent instanceof Family && $a->parent !== $b->parent) {
591            return $a->sortOrder - $b->sortOrder;
592        }
593
594        $atag = $a->getTag();
595        $btag = $b->getTag();
596
597        // Events not in the above list get mapped onto one that is.
598        if (!array_key_exists($atag, $factsort)) {
599            if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $atag, $match)) {
600                $atag = $match[1];
601            } else {
602                $atag = '_????_';
603            }
604        }
605
606        if (!array_key_exists($btag, $factsort)) {
607            if (preg_match('/^(_(BIRT|MARR|DEAT|BURI)_)/', $btag, $match)) {
608                $btag = $match[1];
609            } else {
610                $btag = '_????_';
611            }
612        }
613
614        // - Don't let dated after DEAT/BURI facts sort non-dated facts before DEAT/BURI
615        // - Treat dated after BURI facts as BURI instead
616        if ($a->getAttribute('DATE') !== '' && $factsort[$atag] > $factsort['BURI'] && $factsort[$atag] < $factsort['CHAN']) {
617            $atag = 'BURI';
618        }
619
620        if ($b->getAttribute('DATE') !== '' && $factsort[$btag] > $factsort['BURI'] && $factsort[$btag] < $factsort['CHAN']) {
621            $btag = 'BURI';
622        }
623
624        $ret = $factsort[$atag] - $factsort[$btag];
625
626        // If facts are the same then put dated facts before non-dated facts
627        if ($ret == 0) {
628            if ($a->getAttribute('DATE') !== '' && $b->getAttribute('DATE') === '') {
629                return -1;
630            }
631
632            if ($b->getAttribute('DATE') !== '' && $a->getAttribute('DATE') === '') {
633                return 1;
634            }
635
636            // If no sorting preference, then keep original ordering
637            $ret = $a->sortOrder - $b->sortOrder;
638        }
639
640        return $ret;
641    }
642
643    /**
644     * Allow native PHP functions such as array_unique() to work with objects
645     *
646     * @return string
647     */
648    public function __toString()
649    {
650        return $this->fact_id . '@' . $this->parent->getXref();
651    }
652}
653