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