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