xref: /webtrees/app/Individual.php (revision 4c621133aafa69fdd64737cca8c1e2754deeee92)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2016 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\ExtCalendar\GregorianCalendar;
19use Fisharebest\Webtrees\GedcomCode\GedcomCodePedi;
20
21/**
22 * A GEDCOM individual (INDI) object.
23 */
24class Individual extends GedcomRecord {
25	const RECORD_TYPE = 'INDI';
26	const URL_PREFIX  = 'individual.php?pid=';
27
28	/** @var int used in some lists to keep track of this individual’s generation in that list */
29	public $generation;
30
31	/** @var Date The estimated date of birth */
32	private $_getEstimatedBirthDate;
33
34	/** @var Date The estimated date of death */
35	private $_getEstimatedDeathDate;
36
37	/**
38	 * Sometimes, we'll know in advance that we need to load a set of records.
39	 * Typically when we load families and their members.
40	 *
41	 * @param Tree  $tree
42	 * @param string[] $xrefs
43	 */
44	public static function load(Tree $tree, array $xrefs) {
45		$args = array(
46			'tree_id' => $tree->getTreeId(),
47		);
48		$placeholders = array();
49
50		foreach (array_unique($xrefs) as $n => $xref) {
51			if (!isset(self::$gedcom_record_cache[$tree->getTreeId()][$xref])) {
52				$placeholders[] = ':x' . $n;
53				$args['x' . $n] = $xref;
54			}
55		}
56
57		if (!empty($placeholders)) {
58			$rows = Database::prepare(
59				"SELECT i_id AS xref, i_gedcom AS gedcom" .
60				" FROM `##individuals`" .
61				" WHERE i_file = :tree_id AND i_id IN (" . implode(',', $placeholders) . ")"
62			)->execute(
63				$args
64			)->fetchAll();
65
66			foreach ($rows as $row) {
67				self::getInstance($row->xref, $tree, $row->gedcom);
68			}
69		}
70	}
71
72	/**
73	 * Can the name of this record be shown?
74	 *
75	 * @param int|null $access_level
76	 *
77	 * @return bool
78	 */
79	public function canShowName($access_level = null) {
80		if ($access_level === null) {
81			$access_level = Auth::accessLevel($this->tree);
82		}
83
84		return $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level || $this->canShow($access_level);
85	}
86
87	/**
88	 * Can this individual be shown?
89	 *
90	 * @param int $access_level
91	 *
92	 * @return bool
93	 */
94	protected function canShowByType($access_level) {
95		global $WT_TREE;
96
97		// Dead people...
98		if ($this->tree->getPreference('SHOW_DEAD_PEOPLE') >= $access_level && $this->isDead()) {
99			$keep_alive             = false;
100			$KEEP_ALIVE_YEARS_BIRTH = $this->tree->getPreference('KEEP_ALIVE_YEARS_BIRTH');
101			if ($KEEP_ALIVE_YEARS_BIRTH) {
102				preg_match_all('/\n1 (?:' . WT_EVENTS_BIRT . ').*(?:\n[2-9].*)*(?:\n2 DATE (.+))/', $this->gedcom, $matches, PREG_SET_ORDER);
103				foreach ($matches as $match) {
104					$date = new Date($match[1]);
105					if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_BIRTH > date('Y')) {
106						$keep_alive = true;
107						break;
108					}
109				}
110			}
111			$KEEP_ALIVE_YEARS_DEATH = $this->tree->getPreference('KEEP_ALIVE_YEARS_DEATH');
112			if ($KEEP_ALIVE_YEARS_DEATH) {
113				preg_match_all('/\n1 (?:' . WT_EVENTS_DEAT . ').*(?:\n[2-9].*)*(?:\n2 DATE (.+))/', $this->gedcom, $matches, PREG_SET_ORDER);
114				foreach ($matches as $match) {
115					$date = new Date($match[1]);
116					if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_DEATH > date('Y')) {
117						$keep_alive = true;
118						break;
119					}
120				}
121			}
122			if (!$keep_alive) {
123				return true;
124			}
125		}
126		// Consider relationship privacy (unless an admin is applying download restrictions)
127		$user_path_length = $this->tree->getUserPreference(Auth::user(), 'RELATIONSHIP_PATH_LENGTH');
128		$gedcomid         = $this->tree->getUserPreference(Auth::user(), 'gedcomid');
129		if ($gedcomid && $user_path_length && $this->tree->getTreeId() == $WT_TREE->getTreeId() && $access_level = Auth::accessLevel($this->tree)) {
130			return self::isRelated($this, $user_path_length);
131		}
132
133		// No restriction found - show living people to members only:
134		return Auth::PRIV_USER >= $access_level;
135	}
136
137	/**
138	 * For relationship privacy calculations - is this individual a close relative?
139	 *
140	 * @param Individual $target
141	 * @param int        $distance
142	 *
143	 * @return bool
144	 */
145	private static function isRelated(Individual $target, $distance) {
146		static $cache = null;
147
148		$user_individual = self::getInstance($target->tree->getUserPreference(Auth::user(), 'gedcomid'), $target->tree);
149		if ($user_individual) {
150			if (!$cache) {
151				$cache = array(
152					0 => array($user_individual),
153					1 => array(),
154				);
155				foreach ($user_individual->getFacts('FAM[CS]', false, Auth::PRIV_HIDE) as $fact) {
156					$family = $fact->getTarget();
157					if ($family) {
158						$cache[1][] = $family;
159					}
160				}
161			}
162		} else {
163			// No individual linked to this account? Cannot use relationship privacy.
164			return true;
165		}
166
167		// Double the distance, as we count the INDI-FAM and FAM-INDI links separately
168		$distance *= 2;
169
170		// Consider each path length in turn
171		for ($n = 0; $n <= $distance; ++$n) {
172			if (array_key_exists($n, $cache)) {
173				// We have already calculated all records with this length
174				if ($n % 2 == 0 && in_array($target, $cache[$n], true)) {
175					return true;
176				}
177			} else {
178				// Need to calculate these paths
179				$cache[$n] = array();
180				if ($n % 2 == 0) {
181					// Add FAM->INDI links
182					foreach ($cache[$n - 1] as $family) {
183						foreach ($family->getFacts('HUSB|WIFE|CHIL', false, Auth::PRIV_HIDE) as $fact) {
184							$individual = $fact->getTarget();
185							// Don’t backtrack
186							if ($individual && !in_array($individual, $cache[$n - 2], true)) {
187								$cache[$n][] = $individual;
188							}
189						}
190					}
191					if (in_array($target, $cache[$n], true)) {
192						return true;
193					}
194				} else {
195					// Add INDI->FAM links
196					foreach ($cache[$n - 1] as $individual) {
197						foreach ($individual->getFacts('FAM[CS]', false, Auth::PRIV_HIDE) as $fact) {
198							$family = $fact->getTarget();
199							// Don’t backtrack
200							if ($family && !in_array($family, $cache[$n - 2], true)) {
201								$cache[$n][] = $family;
202							}
203						}
204					}
205				}
206			}
207		}
208
209		return false;
210	}
211
212	/**
213	 * Generate a private version of this record
214	 *
215	 * @param int $access_level
216	 *
217	 * @return string
218	 */
219	protected function createPrivateGedcomRecord($access_level) {
220		$SHOW_PRIVATE_RELATIONSHIPS = $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
221
222		$rec = '0 @' . $this->xref . '@ INDI';
223		if ($this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level) {
224			// Show all the NAME tags, including subtags
225			foreach ($this->getFacts('NAME') as $fact) {
226				$rec .= "\n" . $fact->getGedcom();
227			}
228		}
229		// Just show the 1 FAMC/FAMS tag, not any subtags, which may contain private data
230		preg_match_all('/\n1 (?:FAMC|FAMS) @(' . WT_REGEX_XREF . ')@/', $this->gedcom, $matches, PREG_SET_ORDER);
231		foreach ($matches as $match) {
232			$rela = Family::getInstance($match[1], $this->tree);
233			if ($rela && ($SHOW_PRIVATE_RELATIONSHIPS || $rela->canShow($access_level))) {
234				$rec .= $match[0];
235			}
236		}
237		// Don’t privatize sex.
238		if (preg_match('/\n1 SEX [MFU]/', $this->gedcom, $match)) {
239			$rec .= $match[0];
240		}
241
242		return $rec;
243	}
244
245	/**
246	 * Fetch data from the database
247	 *
248	 * @param string $xref
249	 * @param int    $tree_id
250	 *
251	 * @return null|string
252	 */
253	protected static function fetchGedcomRecord($xref, $tree_id) {
254		return Database::prepare(
255			"SELECT i_gedcom FROM `##individuals` WHERE i_id = :xref AND i_file = :tree_id"
256		)->execute(array(
257			'xref'    => $xref,
258			'tree_id' => $tree_id,
259		))->fetchOne();
260	}
261
262	/**
263	 * Static helper function to sort an array of people by birth date
264	 *
265	 * @param Individual $x
266	 * @param Individual $y
267	 *
268	 * @return int
269	 */
270	public static function compareBirthDate(Individual $x, Individual $y) {
271		return Date::compare($x->getEstimatedBirthDate(), $y->getEstimatedBirthDate());
272	}
273
274	/**
275	 * Static helper function to sort an array of people by death date
276	 *
277	 * @param Individual $x
278	 * @param Individual $y
279	 *
280	 * @return int
281	 */
282	public static function compareDeathDate(Individual $x, Individual $y) {
283		return Date::compare($x->getEstimatedDeathDate(), $y->getEstimatedDeathDate());
284	}
285
286	/**
287	 * Calculate whether this individual is living or dead.
288	 * If not known to be dead, then assume living.
289	 *
290	 * @return bool
291	 */
292	public function isDead() {
293		$MAX_ALIVE_AGE = $this->tree->getPreference('MAX_ALIVE_AGE');
294
295		// "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC"
296		if (preg_match('/\n1 (?:' . WT_EVENTS_DEAT . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) {
297			return true;
298		}
299
300		// If any event occured more than $MAX_ALIVE_AGE years ago, then assume the individual is dead
301		if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) {
302			foreach ($date_matches[1] as $date_match) {
303				$date = new Date($date_match);
304				if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * $MAX_ALIVE_AGE) {
305					return true;
306				}
307			}
308			// The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago.
309			// If one of these is a birth, the individual must be alive.
310			if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) {
311				return false;
312			}
313		}
314
315		// If we found no conclusive dates then check the dates of close relatives.
316
317		// Check parents (birth and adopted)
318		foreach ($this->getChildFamilies(Auth::PRIV_HIDE) as $family) {
319			foreach ($family->getSpouses(Auth::PRIV_HIDE) as $parent) {
320				// Assume parents are no more than 45 years older than their children
321				preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches);
322				foreach ($date_matches[1] as $date_match) {
323					$date = new Date($date_match);
324					if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE + 45)) {
325						return true;
326					}
327				}
328			}
329		}
330
331		// Check spouses
332		foreach ($this->getSpouseFamilies(Auth::PRIV_HIDE) as $family) {
333			preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches);
334			foreach ($date_matches[1] as $date_match) {
335				$date = new Date($date_match);
336				// Assume marriage occurs after age of 10
337				if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE - 10)) {
338					return true;
339				}
340			}
341			// Check spouse dates
342			$spouse = $family->getSpouse($this);
343			if ($spouse) {
344				preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches);
345				foreach ($date_matches[1] as $date_match) {
346					$date = new Date($date_match);
347					// Assume max age difference between spouses of 40 years
348					if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE + 40)) {
349						return true;
350					}
351				}
352			}
353			// Check child dates
354			foreach ($family->getChildren(Auth::PRIV_HIDE) as $child) {
355				preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches);
356				// Assume children born after age of 15
357				foreach ($date_matches[1] as $date_match) {
358					$date = new Date($date_match);
359					if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE - 15)) {
360						return true;
361					}
362				}
363				// Check grandchildren
364				foreach ($child->getSpouseFamilies(Auth::PRIV_HIDE) as $child_family) {
365					foreach ($child_family->getChildren(Auth::PRIV_HIDE) as $grandchild) {
366						preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches);
367						// Assume grandchildren born after age of 30
368						foreach ($date_matches[1] as $date_match) {
369							$date = new Date($date_match);
370							if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE - 30)) {
371								return true;
372							}
373						}
374					}
375				}
376			}
377		}
378
379		return false;
380	}
381
382	/**
383	 * Find the highlighted media object for an individual
384	 * 1. Ignore all media objects that are not displayable because of Privacy rules
385	 * 2. Ignore all media objects with the Highlight option set to "N"
386	 * 3. Pick the first media object that matches these criteria, in order of preference:
387	 *    (a) Level 1 object with the Highlight option set to "Y"
388	 *    (b) Level 1 object with the Highlight option missing or set to other than "Y" or "N"
389	 *    (c) Level 2 or higher object with the Highlight option set to "Y"
390	 *
391	 * @return null|Media
392	 */
393	public function findHighlightedMedia() {
394		$objectA = null;
395		$objectB = null;
396		$objectC = null;
397
398		// Iterate over all of the media items for the individual
399		preg_match_all('/\n(\d) OBJE @(' . WT_REGEX_XREF . ')@/', $this->getGedcom(), $matches, PREG_SET_ORDER);
400		foreach ($matches as $match) {
401			$media = Media::getInstance($match[2], $this->tree);
402			if (!$media || !$media->canShow() || $media->isExternal()) {
403				continue;
404			}
405			$level = $match[1];
406			$prim  = $media->isPrimary();
407			if ($prim == 'N') {
408				continue;
409			}
410			if ($level == 1) {
411				if ($prim == 'Y') {
412					if (empty($objectA)) {
413						$objectA = $media;
414					}
415				} else {
416					if (empty($objectB)) {
417						$objectB = $media;
418					}
419				}
420			} else {
421				if ($prim == 'Y') {
422					if (empty($objectC)) {
423						$objectC = $media;
424					}
425				}
426			}
427		}
428
429		if ($objectA) {
430			return $objectA;
431		}
432		if ($objectB) {
433			return $objectB;
434		}
435		if ($objectC) {
436			return $objectC;
437		}
438
439		return null;
440	}
441
442	/**
443	 * Display the prefered image for this individual.
444	 * Use an icon if no image is available.
445	 *
446	 * @return string
447	 */
448	public function displayImage() {
449		$media = $this->findHighlightedMedia();
450		if ($media) {
451			// Thumbnail exists - use it.
452			return $media->displayImage();
453		} elseif ($this->tree->getPreference('USE_SILHOUETTE')) {
454			// No thumbnail exists - use an icon
455			return '<i class="icon-silhouette-' . $this->getSex() . '"></i>';
456		} else {
457			return '';
458		}
459	}
460
461	/**
462	 * Get the date of birth
463	 *
464	 * @return Date
465	 */
466	public function getBirthDate() {
467		foreach ($this->getAllBirthDates() as $date) {
468			if ($date->isOK()) {
469				return $date;
470			}
471		}
472
473		return new Date('');
474	}
475
476	/**
477	 * Get the place of birth
478	 *
479	 * @return string
480	 */
481	public function getBirthPlace() {
482		foreach ($this->getAllBirthPlaces() as $place) {
483			if ($place) {
484				return $place;
485			}
486		}
487
488		return '';
489	}
490
491	/**
492	 * Get the year of birth
493	 *
494	 * @return string the year of birth
495	 */
496	public function getBirthYear() {
497		return $this->getBirthDate()->minimumDate()->format('%Y');
498	}
499
500	/**
501	 * Get the date of death
502	 *
503	 * @return Date
504	 */
505	public function getDeathDate() {
506		foreach ($this->getAllDeathDates() as $date) {
507			if ($date->isOK()) {
508				return $date;
509			}
510		}
511
512		return new Date('');
513	}
514
515	/**
516	 * Get the place of death
517	 *
518	 * @return string
519	 */
520	public function getDeathPlace() {
521		foreach ($this->getAllDeathPlaces() as $place) {
522			if ($place) {
523				return $place;
524			}
525		}
526
527		return '';
528	}
529
530	/**
531	 * get the death year
532	 *
533	 * @return string the year of death
534	 */
535	public function getDeathYear() {
536		return $this->getDeathDate()->minimumDate()->format('%Y');
537	}
538
539	/**
540	 * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”.
541	 * Provide the full date using a tooltip.
542	 * For consistent layout in charts, etc., show just a “–” when no dates are known.
543	 * Note that this is a (non-breaking) en-dash, and not a hyphen.
544	 *
545	 * @return string
546	 */
547	public function getLifeSpan() {
548		return
549			/* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */ I18N::translate(
550				'%1$s–%2$s',
551				'<span title="' . strip_tags($this->getBirthDate()->display()) . '">' . $this->getBirthDate()->minimumDate()->format('%Y') . '</span>',
552				'<span title="' . strip_tags($this->getDeathDate()->display()) . '">' . $this->getDeathDate()->minimumDate()->format('%Y') . '</span>'
553			);
554	}
555
556	/**
557	 * Get all the birth dates - for the individual lists.
558	 *
559	 * @return Date[]
560	 */
561	public function getAllBirthDates() {
562		foreach (explode('|', WT_EVENTS_BIRT) as $event) {
563			$tmp = $this->getAllEventDates($event);
564			if ($tmp) {
565				return $tmp;
566			}
567		}
568
569		return array();
570	}
571
572	/**
573	 * Gat all the birth places - for the individual lists.
574	 *
575	 * @return string[]
576	 */
577	public function getAllBirthPlaces() {
578		foreach (explode('|', WT_EVENTS_BIRT) as $event) {
579			$tmp = $this->getAllEventPlaces($event);
580			if ($tmp) {
581				return $tmp;
582			}
583		}
584
585		return array();
586	}
587
588	/**
589	 * Get all the death dates - for the individual lists.
590	 *
591	 * @return Date[]
592	 */
593	public function getAllDeathDates() {
594		foreach (explode('|', WT_EVENTS_DEAT) as $event) {
595			$tmp = $this->getAllEventDates($event);
596			if ($tmp) {
597				return $tmp;
598			}
599		}
600
601		return array();
602	}
603
604	/**
605	 * Get all the death places - for the individual lists.
606	 *
607	 * @return string[]
608	 */
609	public function getAllDeathPlaces() {
610		foreach (explode('|', WT_EVENTS_DEAT) as $event) {
611			$tmp = $this->getAllEventPlaces($event);
612			if ($tmp) {
613				return $tmp;
614			}
615		}
616
617		return array();
618	}
619
620	/**
621	 * Generate an estimate for the date of birth, based on dates of parents/children/spouses
622	 *
623	 * @return Date
624	 */
625	public function getEstimatedBirthDate() {
626		if (is_null($this->_getEstimatedBirthDate)) {
627			foreach ($this->getAllBirthDates() as $date) {
628				if ($date->isOK()) {
629					$this->_getEstimatedBirthDate = $date;
630					break;
631				}
632			}
633			if (is_null($this->_getEstimatedBirthDate)) {
634				$min = array();
635				$max = array();
636				$tmp = $this->getDeathDate();
637				if ($tmp->isOK()) {
638					$min[] = $tmp->minimumJulianDay() - $this->tree->getPreference('MAX_ALIVE_AGE') * 365;
639					$max[] = $tmp->maximumJulianDay();
640				}
641				foreach ($this->getChildFamilies() as $family) {
642					$tmp = $family->getMarriageDate();
643					if ($tmp->isOK()) {
644						$min[] = $tmp->maximumJulianDay() - 365 * 1;
645						$max[] = $tmp->minimumJulianDay() + 365 * 30;
646					}
647					if ($parent = $family->getHusband()) {
648						$tmp = $parent->getBirthDate();
649						if ($tmp->isOK()) {
650							$min[] = $tmp->maximumJulianDay() + 365 * 15;
651							$max[] = $tmp->minimumJulianDay() + 365 * 65;
652						}
653					}
654					if ($parent = $family->getWife()) {
655						$tmp = $parent->getBirthDate();
656						if ($tmp->isOK()) {
657							$min[] = $tmp->maximumJulianDay() + 365 * 15;
658							$max[] = $tmp->minimumJulianDay() + 365 * 45;
659						}
660					}
661					foreach ($family->getChildren() as $child) {
662						$tmp = $child->getBirthDate();
663						if ($tmp->isOK()) {
664							$min[] = $tmp->maximumJulianDay() - 365 * 30;
665							$max[] = $tmp->minimumJulianDay() + 365 * 30;
666						}
667					}
668				}
669				foreach ($this->getSpouseFamilies() as $family) {
670					$tmp = $family->getMarriageDate();
671					if ($tmp->isOK()) {
672						$min[] = $tmp->maximumJulianDay() - 365 * 45;
673						$max[] = $tmp->minimumJulianDay() - 365 * 15;
674					}
675					$spouse = $family->getSpouse($this);
676					if ($spouse) {
677						$tmp = $spouse->getBirthDate();
678						if ($tmp->isOK()) {
679							$min[] = $tmp->maximumJulianDay() - 365 * 25;
680							$max[] = $tmp->minimumJulianDay() + 365 * 25;
681						}
682					}
683					foreach ($family->getChildren() as $child) {
684						$tmp = $child->getBirthDate();
685						if ($tmp->isOK()) {
686							$min[] = $tmp->maximumJulianDay() - 365 * ($this->getSex() == 'F' ? 45 : 65);
687							$max[] = $tmp->minimumJulianDay() - 365 * 15;
688						}
689					}
690				}
691				if ($min && $max) {
692					$gregorian_calendar = new GregorianCalendar;
693
694					list($year)                   = $gregorian_calendar->jdToYmd((int) ((max($min) + min($max)) / 2));
695					$this->_getEstimatedBirthDate = new Date('EST ' . $year);
696				} else {
697					$this->_getEstimatedBirthDate = new Date(''); // always return a date object
698				}
699			}
700		}
701
702		return $this->_getEstimatedBirthDate;
703	}
704
705	/**
706	 * Generate an estimated date of death.
707	 *
708	 * @return Date
709	 */
710	public function getEstimatedDeathDate() {
711		if ($this->_getEstimatedDeathDate === null) {
712			foreach ($this->getAllDeathDates() as $date) {
713				if ($date->isOK()) {
714					$this->_getEstimatedDeathDate = $date;
715					break;
716				}
717			}
718			if ($this->_getEstimatedDeathDate === null) {
719				if ($this->getEstimatedBirthDate()->minimumJulianDay()) {
720					$this->_getEstimatedDeathDate = $this->getEstimatedBirthDate()->addYears($this->tree->getPreference('MAX_ALIVE_AGE'), 'BEF');
721				} else {
722					$this->_getEstimatedDeathDate = new Date(''); // always return a date object
723				}
724			}
725		}
726
727		return $this->_getEstimatedDeathDate;
728	}
729
730	/**
731	 * Get the sex - M F or U
732	 * Use the un-privatised gedcom record. We call this function during
733	 * the privatize-gedcom function, and we are allowed to know this.
734	 *
735	 * @return string
736	 */
737	public function getSex() {
738		if (preg_match('/\n1 SEX ([MF])/', $this->gedcom . $this->pending, $match)) {
739			return $match[1];
740		} else {
741			return 'U';
742		}
743	}
744
745	/**
746	 * Get the individual’s sex image
747	 *
748	 * @param string $size
749	 *
750	 * @return string
751	 */
752	public function getSexImage($size = 'small') {
753		return self::sexImage($this->getSex(), $size);
754	}
755
756	/**
757	 * Generate a sex icon/image
758	 *
759	 * @param string $sex
760	 * @param string $size
761	 *
762	 * @return string
763	 */
764	public static function sexImage($sex, $size = 'small') {
765		return '<i class="icon-sex_' . strtolower($sex) . '_' . ($size == 'small' ? '9x9' : '15x15') . '"></i>';
766	}
767
768	/**
769	 * Generate the CSS class to be used for drawing this individual
770	 *
771	 * @return string
772	 */
773	public function getBoxStyle() {
774		$tmp = array('M' => '', 'F' => 'F', 'U' => 'NN');
775
776		return 'person_box' . $tmp[$this->getSex()];
777	}
778
779	/**
780	 * Get a list of this individual’s spouse families
781	 *
782	 * @param int|null $access_level
783	 *
784	 * @return Family[]
785	 */
786	public function getSpouseFamilies($access_level = null) {
787		if ($access_level === null) {
788			$access_level = Auth::accessLevel($this->tree);
789		}
790
791		$SHOW_PRIVATE_RELATIONSHIPS = $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
792
793		$families = array();
794		foreach ($this->getFacts('FAMS', false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) {
795			$family = $fact->getTarget();
796			if ($family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) {
797				$families[] = $family;
798			}
799		}
800
801		return $families;
802	}
803
804	/**
805	 * Get the current spouse of this individual.
806	 *
807	 * Where an individual has multiple spouses, assume they are stored
808	 * in chronological order, and take the last one found.
809	 *
810	 * @return Individual|null
811	 */
812	public function getCurrentSpouse() {
813		$tmp    = $this->getSpouseFamilies();
814		$family = end($tmp);
815		if ($family) {
816			return $family->getSpouse($this);
817		} else {
818			return null;
819		}
820	}
821
822	/**
823	 * Count the children belonging to this individual.
824	 *
825	 * @return int
826	 */
827	public function getNumberOfChildren() {
828		if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->getGedcom(), $match)) {
829			return $match[1];
830		} else {
831			$children = array();
832			foreach ($this->getSpouseFamilies() as $fam) {
833				foreach ($fam->getChildren() as $child) {
834					$children[$child->getXref()] = true;
835				}
836			}
837
838			return count($children);
839		}
840	}
841
842	/**
843	 * Get a list of this individual’s child families (i.e. their parents).
844	 *
845	 * @param int|null $access_level
846	 *
847	 * @return Family[]
848	 */
849	public function getChildFamilies($access_level = null) {
850		if ($access_level === null) {
851			$access_level = Auth::accessLevel($this->tree);
852		}
853
854		$SHOW_PRIVATE_RELATIONSHIPS = $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
855
856		$families = array();
857		foreach ($this->getFacts('FAMC', false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) {
858			$family = $fact->getTarget();
859			if ($family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) {
860				$families[] = $family;
861			}
862		}
863
864		return $families;
865	}
866
867	/**
868	 * Get the preferred parents for this individual.
869	 *
870	 * An individual may multiple parents (e.g. birth, adopted, disputed).
871	 * The preferred family record is:
872	 * (a) the first one with an explicit tag "_PRIMARY Y"
873	 * (b) the first one with a pedigree of "birth"
874	 * (c) the first one with no pedigree (default is "birth")
875	 * (d) the first one found
876	 *
877	 * @return Family|null
878	 */
879	public function getPrimaryChildFamily() {
880		$families = $this->getChildFamilies();
881		switch (count($families)) {
882		case 0:
883			return null;
884		case 1:
885			return reset($families);
886		default:
887			// If there is more than one FAMC record, choose the preferred parents:
888			// a) records with '2 _PRIMARY'
889			foreach ($families as $famid => $fam) {
890				if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 _PRIMARY Y)/", $this->getGedcom())) {
891					return $fam;
892				}
893			}
894			// b) records with '2 PEDI birt'
895			foreach ($families as $famid => $fam) {
896				if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI birth)/", $this->getGedcom())) {
897					return $fam;
898				}
899			}
900			// c) records with no '2 PEDI'
901			foreach ($families as $famid => $fam) {
902				if (!preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI)/", $this->getGedcom())) {
903					return $fam;
904				}
905			}
906
907			// d) any record
908			return reset($families);
909		}
910	}
911
912	/**
913	 * Get a list of step-parent families.
914	 *
915	 * @return Family[]
916	 */
917	public function getChildStepFamilies() {
918		$step_families = array();
919		$families      = $this->getChildFamilies();
920		foreach ($families as $family) {
921			$father = $family->getHusband();
922			if ($father) {
923				foreach ($father->getSpouseFamilies() as $step_family) {
924					if (!in_array($step_family, $families, true)) {
925						$step_families[] = $step_family;
926					}
927				}
928			}
929			$mother = $family->getWife();
930			if ($mother) {
931				foreach ($mother->getSpouseFamilies() as $step_family) {
932					if (!in_array($step_family, $families, true)) {
933						$step_families[] = $step_family;
934					}
935				}
936			}
937		}
938
939		return $step_families;
940	}
941
942	/**
943	 * Get a list of step-parent families.
944	 *
945	 * @return Family[]
946	 */
947	public function getSpouseStepFamilies() {
948		$step_families = array();
949		$families      = $this->getSpouseFamilies();
950		foreach ($families as $family) {
951			$spouse = $family->getSpouse($this);
952			if ($spouse) {
953				foreach ($family->getSpouse($this)->getSpouseFamilies() as $step_family) {
954					if (!in_array($step_family, $families, true)) {
955						$step_families[] = $step_family;
956					}
957				}
958			}
959		}
960
961		return $step_families;
962	}
963
964	/**
965	 * A label for a parental family group
966	 *
967	 * @param Family $family
968	 *
969	 * @return string
970	 */
971	public function getChildFamilyLabel(Family $family) {
972		if (preg_match('/\n1 FAMC @' . $family->getXref() . '@(?:\n[2-9].*)*\n2 PEDI (.+)/', $this->getGedcom(), $match)) {
973			// A specified pedigree
974			return GedcomCodePedi::getChildFamilyLabel($match[1]);
975		} else {
976			// Default (birth) pedigree
977			return GedcomCodePedi::getChildFamilyLabel('');
978		}
979	}
980
981	/**
982	 * Create a label for a step family
983	 *
984	 * @param Family $step_family
985	 *
986	 * @return string
987	 */
988	public function getStepFamilyLabel(Family $step_family) {
989		foreach ($this->getChildFamilies() as $family) {
990			if ($family !== $step_family) {
991				// Must be a step-family
992				foreach ($family->getSpouses() as $parent) {
993					foreach ($step_family->getSpouses() as $step_parent) {
994						if ($parent === $step_parent) {
995							// One common parent - must be a step family
996							if ($parent->getSex() == 'M') {
997								// Father’s family with someone else
998								if ($step_family->getSpouse($step_parent)) {
999									return
1000										/* I18N: A step-family. %s is an individual’s name */
1001										I18N::translate('Father’s family with %s', $step_family->getSpouse($step_parent)->getFullName());
1002								} else {
1003									return
1004										/* I18N: A step-family. */
1005										I18N::translate('Father’s family with an unknown individual');
1006								}
1007							} else {
1008								// Mother’s family with someone else
1009								if ($step_family->getSpouse($step_parent)) {
1010									return
1011										/* I18N: A step-family. %s is an individual’s name */
1012										I18N::translate('Mother’s family with %s', $step_family->getSpouse($step_parent)->getFullName());
1013								} else {
1014									return
1015										/* I18N: A step-family. */
1016										I18N::translate('Mother’s family with an unknown individual');
1017								}
1018							}
1019						}
1020					}
1021				}
1022			}
1023		}
1024
1025		// Perahps same parents - but a different family record?
1026		return I18N::translate('Family with parents');
1027	}
1028
1029	/**
1030	 * get primary parents names for this individual
1031	 *
1032	 * @param string $classname optional css class
1033	 * @param string $display   optional css style display
1034	 *
1035	 * @return string a div block with father & mother names
1036	 */
1037	public function getPrimaryParentsNames($classname = '', $display = '') {
1038		$fam = $this->getPrimaryChildFamily();
1039		if (!$fam) {
1040			return '';
1041		}
1042		$txt = '<div';
1043		if ($classname) {
1044			$txt .= ' class="' . $classname . '"';
1045		}
1046		if ($display) {
1047			$txt .= ' style="display:' . $display . '"';
1048		}
1049		$txt .= '>';
1050		$husb = $fam->getHusband();
1051		if ($husb) {
1052			// Temporarily reset the 'prefered' display name, as we always
1053			// want the default name, not the one selected for display on the indilist.
1054			$primary = $husb->getPrimaryName();
1055			$husb->setPrimaryName(null);
1056			$txt .=
1057				/* I18N: %s is the name of an individual’s father */
1058				I18N::translate('Father: %s', $husb->getFullName()) . '<br>';
1059			$husb->setPrimaryName($primary);
1060		}
1061		$wife = $fam->getWife();
1062		if ($wife) {
1063			// Temporarily reset the 'prefered' display name, as we always
1064			// want the default name, not the one selected for display on the indilist.
1065			$primary = $wife->getPrimaryName();
1066			$wife->setPrimaryName(null);
1067			$txt .=
1068				/* I18N: %s is the name of an individual’s mother */
1069				I18N::translate('Mother: %s', $wife->getFullName());
1070			$wife->setPrimaryName($primary);
1071		}
1072		$txt .= '</div>';
1073
1074		return $txt;
1075	}
1076
1077	/** {@inheritdoc} */
1078	public function getFallBackName() {
1079		return '@P.N. /@N.N./';
1080	}
1081
1082	/**
1083	 * Convert a name record into ‘full’ and ‘sort’ versions.
1084	 * Use the NAME field to generate the ‘full’ version, as the
1085	 * gedcom spec says that this is the individual’s name, as they would write it.
1086	 * Use the SURN field to generate the sortable names. Note that this field
1087	 * may also be used for the ‘true’ surname, perhaps spelt differently to that
1088	 * recorded in the NAME field. e.g.
1089	 *
1090	 * 1 NAME Robert /de Gliderow/
1091	 * 2 GIVN Robert
1092	 * 2 SPFX de
1093	 * 2 SURN CLITHEROW
1094	 * 2 NICK The Bald
1095	 *
1096	 * full=>'Robert de Gliderow 'The Bald''
1097	 * sort=>'CLITHEROW, ROBERT'
1098	 *
1099	 * Handle multiple surnames, either as;
1100	 *
1101	 * 1 NAME Carlos /Vasquez/ y /Sante/
1102	 * or
1103	 * 1 NAME Carlos /Vasquez y Sante/
1104	 * 2 GIVN Carlos
1105	 * 2 SURN Vasquez,Sante
1106	 *
1107	 * @param string $type
1108	 * @param string $full
1109	 * @param string $gedcom
1110	 */
1111	protected function addName($type, $full, $gedcom) {
1112		////////////////////////////////////////////////////////////////////////////
1113		// Extract the structured name parts - use for "sortable" names and indexes
1114		////////////////////////////////////////////////////////////////////////////
1115
1116		$sublevel = 1 + (int) $gedcom[0];
1117		$NPFX     = preg_match("/\n{$sublevel} NPFX (.+)/", $gedcom, $match) ? $match[1] : '';
1118		$GIVN     = preg_match("/\n{$sublevel} GIVN (.+)/", $gedcom, $match) ? $match[1] : '';
1119		$SURN     = preg_match("/\n{$sublevel} SURN (.+)/", $gedcom, $match) ? $match[1] : '';
1120		$NSFX     = preg_match("/\n{$sublevel} NSFX (.+)/", $gedcom, $match) ? $match[1] : '';
1121		$NICK     = preg_match("/\n{$sublevel} NICK (.+)/", $gedcom, $match) ? $match[1] : '';
1122
1123		// SURN is an comma-separated list of surnames...
1124		if ($SURN) {
1125			$SURNS = preg_split('/ *, */', $SURN);
1126		} else {
1127			$SURNS = array();
1128		}
1129		// ...so is GIVN - but nobody uses it like that
1130		$GIVN = str_replace('/ *, */', ' ', $GIVN);
1131
1132		////////////////////////////////////////////////////////////////////////////
1133		// Extract the components from NAME - use for the "full" names
1134		////////////////////////////////////////////////////////////////////////////
1135
1136		// Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/'
1137		if (substr_count($full, '/') % 2 == 1) {
1138			$full = $full . '/';
1139		}
1140
1141		// GEDCOM uses "//" to indicate an unknown surname
1142		$full = preg_replace('/\/\//', '/@N.N./', $full);
1143
1144		// Extract the surname.
1145		// Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/
1146		if (preg_match('/\/.*\//', $full, $match)) {
1147			$surname = str_replace('/', '', $match[0]);
1148		} else {
1149			$surname = '';
1150		}
1151
1152		// If we don’t have a SURN record, extract it from the NAME
1153		if (!$SURNS) {
1154			if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) {
1155				// There can be many surnames, each wrapped with '/'
1156				$SURNS = $matches[1];
1157				foreach ($SURNS as $n => $SURN) {
1158					// Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only)
1159					$SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN);
1160				}
1161			} else {
1162				// It is valid not to have a surname at all
1163				$SURNS = array('');
1164			}
1165		}
1166
1167		// If we don’t have a GIVN record, extract it from the NAME
1168		if (!$GIVN) {
1169			$GIVN = preg_replace(
1170				array(
1171					'/ ?\/.*\/ ?/', // remove surname
1172					'/ ?".+"/', // remove nickname
1173					'/ {2,}/', // multiple spaces, caused by the above
1174					'/^ | $/', // leading/trailing spaces, caused by the above
1175				),
1176				array(
1177					' ',
1178					' ',
1179					' ',
1180					'',
1181				),
1182				$full
1183			);
1184		}
1185
1186		// Add placeholder for unknown given name
1187		if (!$GIVN) {
1188			$GIVN = '@P.N.';
1189			$pos  = strpos($full, '/');
1190			$full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos);
1191		}
1192
1193		// GEDCOM nicknames should be specificied in a NICK field, or in the
1194		// NAME filed, surrounded by ASCII quotes (or both).
1195		if ($NICK && strpos($full, '"' . $NICK . '"') === false) {
1196			// A NICK field is present, but not included in the NAME.
1197			$pos = strpos($full, '/');
1198			if ($pos === false) {
1199				// No surname - just append it
1200				$full .= ' "' . $NICK . '"';
1201			} else {
1202				// Insert before surname
1203				$full = substr($full, 0, $pos) . '"' . $NICK . '" ' . substr($full, $pos);
1204			}
1205		}
1206
1207		// Remove slashes - they don’t get displayed
1208		// $fullNN keeps the @N.N. placeholders, for the database
1209		// $full is for display on-screen
1210		$fullNN = str_replace('/', '', $full);
1211
1212		// Insert placeholders for any missing/unknown names
1213		$full = str_replace('@N.N.', I18N::translateContext('Unknown surname', '…'), $full);
1214		$full = str_replace('@P.N.', I18N::translateContext('Unknown given name', '…'), $full);
1215		// Format for display
1216		$full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', Filter::escapeHtml($full)) . '</span>';
1217		// Localise quotation marks around the nickname
1218		$full = preg_replace_callback('/&quot;([^&]*)&quot;/', function ($matches) { return I18N::translate('“%s”', $matches[1]); }, $full);
1219
1220		// A suffix of “*” indicates a preferred name
1221		$full = preg_replace('/([^ >]*)\*/', '<span class="starredname">\\1</span>', $full);
1222
1223		// Remove prefered-name indicater - they don’t go in the database
1224		$GIVN   = str_replace('*', '', $GIVN);
1225		$fullNN = str_replace('*', '', $fullNN);
1226
1227		foreach ($SURNS as $SURN) {
1228			// Scottish 'Mc and Mac ' prefixes both sort under 'Mac'
1229			if (strcasecmp(substr($SURN, 0, 2), 'Mc') == 0) {
1230				$SURN = substr_replace($SURN, 'Mac', 0, 2);
1231			} elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') == 0) {
1232				$SURN = substr_replace($SURN, 'Mac', 0, 4);
1233			}
1234
1235			$this->_getAllNames[] = array(
1236				'type'    => $type,
1237				'sort'    => $SURN . ',' . $GIVN,
1238				'full'    => $full, // This is used for display
1239				'fullNN'  => $fullNN, // This goes into the database
1240				'surname' => $surname, // This goes into the database
1241				'givn'    => $GIVN, // This goes into the database
1242				'surn'    => $SURN, // This goes into the database
1243			);
1244		}
1245	}
1246
1247	/**
1248	 * Extract names from the GEDCOM record.
1249	 */
1250	public function extractNames() {
1251		$this->extractNamesFromFacts(1, 'NAME', $this->getFacts('NAME', false, Auth::accessLevel($this->tree), $this->canShowName()));
1252	}
1253
1254	/**
1255	 * Extra info to display when displaying this record in a list of
1256	 * selection items or favorites.
1257	 *
1258	 * @return string
1259	 */
1260	public function formatListDetails() {
1261		return
1262			$this->formatFirstMajorFact(WT_EVENTS_BIRT, 1) .
1263			$this->formatFirstMajorFact(WT_EVENTS_DEAT, 1);
1264	}
1265
1266	/**
1267	 * Create a short name for compact display on charts
1268	 *
1269	 * @return string
1270	 */
1271	public function getShortName() {
1272		global $bwidth;
1273
1274		// Estimate number of characters that can fit in box. Calulates to 28 characters in webtrees theme, or 34 if no thumbnail used.
1275		if ($this->tree->getPreference('SHOW_HIGHLIGHT_IMAGES')) {
1276			$char = intval(($bwidth - 40) / 6.5);
1277		} else {
1278			$char = ($bwidth / 6.5);
1279		}
1280		if ($this->canShowName()) {
1281			$tmp        = $this->getAllNames();
1282			$givn       = $tmp[$this->getPrimaryName()]['givn'];
1283			$surn       = $tmp[$this->getPrimaryName()]['surname'];
1284			$new_givn   = explode(' ', $givn);
1285			$count_givn = count($new_givn);
1286			$len_givn   = mb_strlen($givn);
1287			$len_surn   = mb_strlen($surn);
1288			$len        = $len_givn + $len_surn;
1289			$i          = 1;
1290			while ($len > $char && $i <= $count_givn) {
1291				$new_givn[$count_givn - $i] = mb_substr($new_givn[$count_givn - $i], 0, 1);
1292				$givn                       = implode(' ', $new_givn);
1293				$len_givn                   = mb_strlen($givn);
1294				$len                        = $len_givn + $len_surn;
1295				$i++;
1296			}
1297			$max_surn = $char - $i * 2;
1298			if ($len_surn > $max_surn) {
1299				$surn = substr($surn, 0, $max_surn) . '…';
1300			}
1301			$shortname = str_replace(
1302				array('@P.N.', '@N.N.'),
1303				array(I18N::translateContext('Unknown given name', '…'), I18N::translateContext('Unknown surname', '…')),
1304				$givn . ' ' . $surn
1305			);
1306
1307			return $shortname;
1308		} else {
1309			return I18N::translate('Private');
1310		}
1311	}
1312}
1313