xref: /webtrees/app/Individual.php (revision fc29f963d0888db6b4dac3d09015d703c8b2742c)
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\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 = [
46			'tree_id' => $tree->getTreeId(),
47		];
48		$placeholders = [];
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 = [
152					0 => [$user_individual],
153					1 => [],
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] = [];
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([
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, Auth::PRIV_HIDE);
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	 * @param int      $width      Pixels
447	 * @param int      $height     Pixels
448	 * @param string   $fit        "crop" or "contain"
449	 * @param string[] $attributes Additional HTML attributes
450	 *
451	 * @return string
452	 */
453	public function displayImage($width, $height, $fit, $attributes) {
454		$media = $this->findHighlightedMedia();
455		if ($media !== null) {
456			// Image exists - use it.
457			return $media->displayImage($width, $height, $fit, $attributes);
458		} elseif ($this->tree->getPreference('USE_SILHOUETTE')) {
459			// No image exists - use an icon
460			return '<i class="icon-silhouette-' . $this->getSex() . '"></i>';
461		} else {
462			return '';
463		}
464	}
465
466	/**
467	 * Get the date of birth
468	 *
469	 * @return Date
470	 */
471	public function getBirthDate() {
472		foreach ($this->getAllBirthDates() as $date) {
473			if ($date->isOK()) {
474				return $date;
475			}
476		}
477
478		return new Date('');
479	}
480
481	/**
482	 * Get the place of birth
483	 *
484	 * @return string
485	 */
486	public function getBirthPlace() {
487		foreach ($this->getAllBirthPlaces() as $place) {
488			if ($place) {
489				return $place;
490			}
491		}
492
493		return '';
494	}
495
496	/**
497	 * Get the year of birth
498	 *
499	 * @return string the year of birth
500	 */
501	public function getBirthYear() {
502		return $this->getBirthDate()->minimumDate()->format('%Y');
503	}
504
505	/**
506	 * Get the date of death
507	 *
508	 * @return Date
509	 */
510	public function getDeathDate() {
511		foreach ($this->getAllDeathDates() as $date) {
512			if ($date->isOK()) {
513				return $date;
514			}
515		}
516
517		return new Date('');
518	}
519
520	/**
521	 * Get the place of death
522	 *
523	 * @return string
524	 */
525	public function getDeathPlace() {
526		foreach ($this->getAllDeathPlaces() as $place) {
527			if ($place) {
528				return $place;
529			}
530		}
531
532		return '';
533	}
534
535	/**
536	 * get the death year
537	 *
538	 * @return string the year of death
539	 */
540	public function getDeathYear() {
541		return $this->getDeathDate()->minimumDate()->format('%Y');
542	}
543
544	/**
545	 * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”.
546	 * Provide the place and full date using a tooltip.
547	 * For consistent layout in charts, etc., show just a “–” when no dates are known.
548	 * Note that this is a (non-breaking) en-dash, and not a hyphen.
549	 *
550	 * @return string
551	 */
552	public function getLifeSpan() {
553		// Just the first part of the place name
554		$birth_place = preg_replace('/,.*/', '', $this->getBirthPlace());
555		$death_place = preg_replace('/,.*/', '', $this->getDeathPlace());
556		// Remove markup from dates
557		$birth_date = strip_tags($this->getBirthDate()->display());
558		$death_date = strip_tags($this->getDeathDate()->display());
559
560		return
561			/* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */ I18N::translate(
562				'%1$s–%2$s',
563				'<span title="' . Html::escape($birth_place) . ' ' . $birth_date . '">' . $this->getBirthYear() . '</span>',
564				'<span title="' . Html::escape($death_place) . ' ' . $death_date . '">' . $this->getDeathYear() . '</span>'
565			);
566	}
567
568	/**
569	 * Get all the birth dates - for the individual lists.
570	 *
571	 * @return Date[]
572	 */
573	public function getAllBirthDates() {
574		foreach (explode('|', WT_EVENTS_BIRT) as $event) {
575			$tmp = $this->getAllEventDates($event);
576			if ($tmp) {
577				return $tmp;
578			}
579		}
580
581		return [];
582	}
583
584	/**
585	 * Gat all the birth places - for the individual lists.
586	 *
587	 * @return string[]
588	 */
589	public function getAllBirthPlaces() {
590		foreach (explode('|', WT_EVENTS_BIRT) as $event) {
591			$tmp = $this->getAllEventPlaces($event);
592			if ($tmp) {
593				return $tmp;
594			}
595		}
596
597		return [];
598	}
599
600	/**
601	 * Get all the death dates - for the individual lists.
602	 *
603	 * @return Date[]
604	 */
605	public function getAllDeathDates() {
606		foreach (explode('|', WT_EVENTS_DEAT) as $event) {
607			$tmp = $this->getAllEventDates($event);
608			if ($tmp) {
609				return $tmp;
610			}
611		}
612
613		return [];
614	}
615
616	/**
617	 * Get all the death places - for the individual lists.
618	 *
619	 * @return string[]
620	 */
621	public function getAllDeathPlaces() {
622		foreach (explode('|', WT_EVENTS_DEAT) as $event) {
623			$tmp = $this->getAllEventPlaces($event);
624			if ($tmp) {
625				return $tmp;
626			}
627		}
628
629		return [];
630	}
631
632	/**
633	 * Generate an estimate for the date of birth, based on dates of parents/children/spouses
634	 *
635	 * @return Date
636	 */
637	public function getEstimatedBirthDate() {
638		if (is_null($this->_getEstimatedBirthDate)) {
639			foreach ($this->getAllBirthDates() as $date) {
640				if ($date->isOK()) {
641					$this->_getEstimatedBirthDate = $date;
642					break;
643				}
644			}
645			if (is_null($this->_getEstimatedBirthDate)) {
646				$min = [];
647				$max = [];
648				$tmp = $this->getDeathDate();
649				if ($tmp->isOK()) {
650					$min[] = $tmp->minimumJulianDay() - $this->tree->getPreference('MAX_ALIVE_AGE') * 365;
651					$max[] = $tmp->maximumJulianDay();
652				}
653				foreach ($this->getChildFamilies() as $family) {
654					$tmp = $family->getMarriageDate();
655					if ($tmp->isOK()) {
656						$min[] = $tmp->maximumJulianDay() - 365 * 1;
657						$max[] = $tmp->minimumJulianDay() + 365 * 30;
658					}
659					if ($parent = $family->getHusband()) {
660						$tmp = $parent->getBirthDate();
661						if ($tmp->isOK()) {
662							$min[] = $tmp->maximumJulianDay() + 365 * 15;
663							$max[] = $tmp->minimumJulianDay() + 365 * 65;
664						}
665					}
666					if ($parent = $family->getWife()) {
667						$tmp = $parent->getBirthDate();
668						if ($tmp->isOK()) {
669							$min[] = $tmp->maximumJulianDay() + 365 * 15;
670							$max[] = $tmp->minimumJulianDay() + 365 * 45;
671						}
672					}
673					foreach ($family->getChildren() as $child) {
674						$tmp = $child->getBirthDate();
675						if ($tmp->isOK()) {
676							$min[] = $tmp->maximumJulianDay() - 365 * 30;
677							$max[] = $tmp->minimumJulianDay() + 365 * 30;
678						}
679					}
680				}
681				foreach ($this->getSpouseFamilies() as $family) {
682					$tmp = $family->getMarriageDate();
683					if ($tmp->isOK()) {
684						$min[] = $tmp->maximumJulianDay() - 365 * 45;
685						$max[] = $tmp->minimumJulianDay() - 365 * 15;
686					}
687					$spouse = $family->getSpouse($this);
688					if ($spouse) {
689						$tmp = $spouse->getBirthDate();
690						if ($tmp->isOK()) {
691							$min[] = $tmp->maximumJulianDay() - 365 * 25;
692							$max[] = $tmp->minimumJulianDay() + 365 * 25;
693						}
694					}
695					foreach ($family->getChildren() as $child) {
696						$tmp = $child->getBirthDate();
697						if ($tmp->isOK()) {
698							$min[] = $tmp->maximumJulianDay() - 365 * ($this->getSex() == 'F' ? 45 : 65);
699							$max[] = $tmp->minimumJulianDay() - 365 * 15;
700						}
701					}
702				}
703				if ($min && $max) {
704					$gregorian_calendar = new GregorianCalendar;
705
706					list($year)                   = $gregorian_calendar->jdToYmd((int) ((max($min) + min($max)) / 2));
707					$this->_getEstimatedBirthDate = new Date('EST ' . $year);
708				} else {
709					$this->_getEstimatedBirthDate = new Date(''); // always return a date object
710				}
711			}
712		}
713
714		return $this->_getEstimatedBirthDate;
715	}
716
717	/**
718	 * Generate an estimated date of death.
719	 *
720	 * @return Date
721	 */
722	public function getEstimatedDeathDate() {
723		if ($this->_getEstimatedDeathDate === null) {
724			foreach ($this->getAllDeathDates() as $date) {
725				if ($date->isOK()) {
726					$this->_getEstimatedDeathDate = $date;
727					break;
728				}
729			}
730			if ($this->_getEstimatedDeathDate === null) {
731				if ($this->getEstimatedBirthDate()->minimumJulianDay()) {
732					$this->_getEstimatedDeathDate = $this->getEstimatedBirthDate()->addYears($this->tree->getPreference('MAX_ALIVE_AGE'), 'BEF');
733				} else {
734					$this->_getEstimatedDeathDate = new Date(''); // always return a date object
735				}
736			}
737		}
738
739		return $this->_getEstimatedDeathDate;
740	}
741
742	/**
743	 * Get the sex - M F or U
744	 * Use the un-privatised gedcom record. We call this function during
745	 * the privatize-gedcom function, and we are allowed to know this.
746	 *
747	 * @return string
748	 */
749	public function getSex() {
750		if (preg_match('/\n1 SEX ([MF])/', $this->gedcom . $this->pending, $match)) {
751			return $match[1];
752		} else {
753			return 'U';
754		}
755	}
756
757	/**
758	 * Get the individual’s sex image
759	 *
760	 * @param string $size
761	 *
762	 * @return string
763	 */
764	public function getSexImage($size = 'small') {
765		return self::sexImage($this->getSex(), $size);
766	}
767
768	/**
769	 * Generate a sex icon/image
770	 *
771	 * @param string $sex
772	 * @param string $size
773	 *
774	 * @return string
775	 */
776	public static function sexImage($sex, $size = 'small') {
777		return '<i class="icon-sex_' . strtolower($sex) . '_' . ($size == 'small' ? '9x9' : '15x15') . '"></i>';
778	}
779
780	/**
781	 * Generate the CSS class to be used for drawing this individual
782	 *
783	 * @return string
784	 */
785	public function getBoxStyle() {
786		$tmp = ['M' => '', 'F' => 'F', 'U' => 'NN'];
787
788		return 'person_box' . $tmp[$this->getSex()];
789	}
790
791	/**
792	 * Get a list of this individual’s spouse families
793	 *
794	 * @param int|null $access_level
795	 *
796	 * @return Family[]
797	 */
798	public function getSpouseFamilies($access_level = null) {
799		if ($access_level === null) {
800			$access_level = Auth::accessLevel($this->tree);
801		}
802
803		$SHOW_PRIVATE_RELATIONSHIPS = $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
804
805		$families = [];
806		foreach ($this->getFacts('FAMS', false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) {
807			$family = $fact->getTarget();
808			if ($family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) {
809				$families[] = $family;
810			}
811		}
812
813		return $families;
814	}
815
816	/**
817	 * Get the current spouse of this individual.
818	 *
819	 * Where an individual has multiple spouses, assume they are stored
820	 * in chronological order, and take the last one found.
821	 *
822	 * @return Individual|null
823	 */
824	public function getCurrentSpouse() {
825		$tmp    = $this->getSpouseFamilies();
826		$family = end($tmp);
827		if ($family) {
828			return $family->getSpouse($this);
829		} else {
830			return null;
831		}
832	}
833
834	/**
835	 * Count the children belonging to this individual.
836	 *
837	 * @return int
838	 */
839	public function getNumberOfChildren() {
840		if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->getGedcom(), $match)) {
841			return $match[1];
842		} else {
843			$children = [];
844			foreach ($this->getSpouseFamilies() as $fam) {
845				foreach ($fam->getChildren() as $child) {
846					$children[$child->getXref()] = true;
847				}
848			}
849
850			return count($children);
851		}
852	}
853
854	/**
855	 * Get a list of this individual’s child families (i.e. their parents).
856	 *
857	 * @param int|null $access_level
858	 *
859	 * @return Family[]
860	 */
861	public function getChildFamilies($access_level = null) {
862		if ($access_level === null) {
863			$access_level = Auth::accessLevel($this->tree);
864		}
865
866		$SHOW_PRIVATE_RELATIONSHIPS = $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS');
867
868		$families = [];
869		foreach ($this->getFacts('FAMC', false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) {
870			$family = $fact->getTarget();
871			if ($family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) {
872				$families[] = $family;
873			}
874		}
875
876		return $families;
877	}
878
879	/**
880	 * Get the preferred parents for this individual.
881	 *
882	 * An individual may multiple parents (e.g. birth, adopted, disputed).
883	 * The preferred family record is:
884	 * (a) the first one with an explicit tag "_PRIMARY Y"
885	 * (b) the first one with a pedigree of "birth"
886	 * (c) the first one with no pedigree (default is "birth")
887	 * (d) the first one found
888	 *
889	 * @return Family|null
890	 */
891	public function getPrimaryChildFamily() {
892		$families = $this->getChildFamilies();
893		switch (count($families)) {
894		case 0:
895			return null;
896		case 1:
897			return $families[0];
898		default:
899			// If there is more than one FAMC record, choose the preferred parents:
900			// a) records with '2 _PRIMARY'
901			foreach ($families as $famid => $fam) {
902				if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 _PRIMARY Y)/", $this->getGedcom())) {
903					return $fam;
904				}
905			}
906			// b) records with '2 PEDI birt'
907			foreach ($families as $famid => $fam) {
908				if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI birth)/", $this->getGedcom())) {
909					return $fam;
910				}
911			}
912			// c) records with no '2 PEDI'
913			foreach ($families as $famid => $fam) {
914				if (!preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI)/", $this->getGedcom())) {
915					return $fam;
916				}
917			}
918
919			// d) any record
920			return $families[0];
921		}
922	}
923
924	/**
925	 * Get a list of step-parent families.
926	 *
927	 * @return Family[]
928	 */
929	public function getChildStepFamilies() {
930		$step_families = [];
931		$families      = $this->getChildFamilies();
932		foreach ($families as $family) {
933			$father = $family->getHusband();
934			if ($father) {
935				foreach ($father->getSpouseFamilies() as $step_family) {
936					if (!in_array($step_family, $families, true)) {
937						$step_families[] = $step_family;
938					}
939				}
940			}
941			$mother = $family->getWife();
942			if ($mother) {
943				foreach ($mother->getSpouseFamilies() as $step_family) {
944					if (!in_array($step_family, $families, true)) {
945						$step_families[] = $step_family;
946					}
947				}
948			}
949		}
950
951		return $step_families;
952	}
953
954	/**
955	 * Get a list of step-parent families.
956	 *
957	 * @return Family[]
958	 */
959	public function getSpouseStepFamilies() {
960		$step_families = [];
961		$families      = $this->getSpouseFamilies();
962		foreach ($families as $family) {
963			$spouse = $family->getSpouse($this);
964			if ($spouse) {
965				foreach ($family->getSpouse($this)->getSpouseFamilies() as $step_family) {
966					if (!in_array($step_family, $families, true)) {
967						$step_families[] = $step_family;
968					}
969				}
970			}
971		}
972
973		return $step_families;
974	}
975
976	/**
977	 * A label for a parental family group
978	 *
979	 * @param Family $family
980	 *
981	 * @return string
982	 */
983	public function getChildFamilyLabel(Family $family) {
984		if (preg_match('/\n1 FAMC @' . $family->getXref() . '@(?:\n[2-9].*)*\n2 PEDI (.+)/', $this->getGedcom(), $match)) {
985			// A specified pedigree
986			return GedcomCodePedi::getChildFamilyLabel($match[1]);
987		} else {
988			// Default (birth) pedigree
989			return GedcomCodePedi::getChildFamilyLabel('');
990		}
991	}
992
993	/**
994	 * Create a label for a step family
995	 *
996	 * @param Family $step_family
997	 *
998	 * @return string
999	 */
1000	public function getStepFamilyLabel(Family $step_family) {
1001		foreach ($this->getChildFamilies() as $family) {
1002			if ($family !== $step_family) {
1003				// Must be a step-family
1004				foreach ($family->getSpouses() as $parent) {
1005					foreach ($step_family->getSpouses() as $step_parent) {
1006						if ($parent === $step_parent) {
1007							// One common parent - must be a step family
1008							if ($parent->getSex() == 'M') {
1009								// Father’s family with someone else
1010								if ($step_family->getSpouse($step_parent)) {
1011									return
1012										/* I18N: A step-family. %s is an individual’s name */
1013										I18N::translate('Father’s family with %s', $step_family->getSpouse($step_parent)->getFullName());
1014								} else {
1015									return
1016										/* I18N: A step-family. */
1017										I18N::translate('Father’s family with an unknown individual');
1018								}
1019							} else {
1020								// Mother’s family with someone else
1021								if ($step_family->getSpouse($step_parent)) {
1022									return
1023										/* I18N: A step-family. %s is an individual’s name */
1024										I18N::translate('Mother’s family with %s', $step_family->getSpouse($step_parent)->getFullName());
1025								} else {
1026									return
1027										/* I18N: A step-family. */
1028										I18N::translate('Mother’s family with an unknown individual');
1029								}
1030							}
1031						}
1032					}
1033				}
1034			}
1035		}
1036
1037		// Perahps same parents - but a different family record?
1038		return I18N::translate('Family with parents');
1039	}
1040
1041	/**
1042	 * get primary parents names for this individual
1043	 *
1044	 * @param string $classname optional css class
1045	 * @param string $display   optional css style display
1046	 *
1047	 * @return string a div block with father & mother names
1048	 */
1049	public function getPrimaryParentsNames($classname = '', $display = '') {
1050		$fam = $this->getPrimaryChildFamily();
1051		if (!$fam) {
1052			return '';
1053		}
1054		$txt = '<div';
1055		if ($classname) {
1056			$txt .= ' class="' . $classname . '"';
1057		}
1058		if ($display) {
1059			$txt .= ' style="display:' . $display . '"';
1060		}
1061		$txt .= '>';
1062		$husb = $fam->getHusband();
1063		if ($husb) {
1064			// Temporarily reset the 'prefered' display name, as we always
1065			// want the default name, not the one selected for display on the indilist.
1066			$primary = $husb->getPrimaryName();
1067			$husb->setPrimaryName(null);
1068			$txt .=
1069				/* I18N: %s is the name of an individual’s father */
1070				I18N::translate('Father: %s', $husb->getFullName()) . '<br>';
1071			$husb->setPrimaryName($primary);
1072		}
1073		$wife = $fam->getWife();
1074		if ($wife) {
1075			// Temporarily reset the 'prefered' display name, as we always
1076			// want the default name, not the one selected for display on the indilist.
1077			$primary = $wife->getPrimaryName();
1078			$wife->setPrimaryName(null);
1079			$txt .=
1080				/* I18N: %s is the name of an individual’s mother */
1081				I18N::translate('Mother: %s', $wife->getFullName());
1082			$wife->setPrimaryName($primary);
1083		}
1084		$txt .= '</div>';
1085
1086		return $txt;
1087	}
1088
1089	/** {@inheritdoc} */
1090	public function getFallBackName() {
1091		return '@P.N. /@N.N./';
1092	}
1093
1094	/**
1095	 * Convert a name record into ‘full’ and ‘sort’ versions.
1096	 * Use the NAME field to generate the ‘full’ version, as the
1097	 * gedcom spec says that this is the individual’s name, as they would write it.
1098	 * Use the SURN field to generate the sortable names. Note that this field
1099	 * may also be used for the ‘true’ surname, perhaps spelt differently to that
1100	 * recorded in the NAME field. e.g.
1101	 *
1102	 * 1 NAME Robert /de Gliderow/
1103	 * 2 GIVN Robert
1104	 * 2 SPFX de
1105	 * 2 SURN CLITHEROW
1106	 * 2 NICK The Bald
1107	 *
1108	 * full=>'Robert de Gliderow 'The Bald''
1109	 * sort=>'CLITHEROW, ROBERT'
1110	 *
1111	 * Handle multiple surnames, either as;
1112	 *
1113	 * 1 NAME Carlos /Vasquez/ y /Sante/
1114	 * or
1115	 * 1 NAME Carlos /Vasquez y Sante/
1116	 * 2 GIVN Carlos
1117	 * 2 SURN Vasquez,Sante
1118	 *
1119	 * @param string $type
1120	 * @param string $full
1121	 * @param string $gedcom
1122	 */
1123	protected function addName($type, $full, $gedcom) {
1124		////////////////////////////////////////////////////////////////////////////
1125		// Extract the structured name parts - use for "sortable" names and indexes
1126		////////////////////////////////////////////////////////////////////////////
1127
1128		$sublevel = 1 + (int) $gedcom[0];
1129		$NPFX     = preg_match("/\n{$sublevel} NPFX (.+)/", $gedcom, $match) ? $match[1] : '';
1130		$GIVN     = preg_match("/\n{$sublevel} GIVN (.+)/", $gedcom, $match) ? $match[1] : '';
1131		$SURN     = preg_match("/\n{$sublevel} SURN (.+)/", $gedcom, $match) ? $match[1] : '';
1132		$NSFX     = preg_match("/\n{$sublevel} NSFX (.+)/", $gedcom, $match) ? $match[1] : '';
1133		$NICK     = preg_match("/\n{$sublevel} NICK (.+)/", $gedcom, $match) ? $match[1] : '';
1134
1135		// SURN is an comma-separated list of surnames...
1136		if ($SURN) {
1137			$SURNS = preg_split('/ *, */', $SURN);
1138		} else {
1139			$SURNS = [];
1140		}
1141		// ...so is GIVN - but nobody uses it like that
1142		$GIVN = str_replace('/ *, */', ' ', $GIVN);
1143
1144		////////////////////////////////////////////////////////////////////////////
1145		// Extract the components from NAME - use for the "full" names
1146		////////////////////////////////////////////////////////////////////////////
1147
1148		// Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/'
1149		if (substr_count($full, '/') % 2 == 1) {
1150			$full = $full . '/';
1151		}
1152
1153		// GEDCOM uses "//" to indicate an unknown surname
1154		$full = preg_replace('/\/\//', '/@N.N./', $full);
1155
1156		// Extract the surname.
1157		// Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/
1158		if (preg_match('/\/.*\//', $full, $match)) {
1159			$surname = str_replace('/', '', $match[0]);
1160		} else {
1161			$surname = '';
1162		}
1163
1164		// If we don’t have a SURN record, extract it from the NAME
1165		if (!$SURNS) {
1166			if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) {
1167				// There can be many surnames, each wrapped with '/'
1168				$SURNS = $matches[1];
1169				foreach ($SURNS as $n => $SURN) {
1170					// Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only)
1171					$SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN);
1172				}
1173			} else {
1174				// It is valid not to have a surname at all
1175				$SURNS = [''];
1176			}
1177		}
1178
1179		// If we don’t have a GIVN record, extract it from the NAME
1180		if (!$GIVN) {
1181			$GIVN = preg_replace(
1182				[
1183					'/ ?\/.*\/ ?/', // remove surname
1184					'/ ?".+"/', // remove nickname
1185					'/ {2,}/', // multiple spaces, caused by the above
1186					'/^ | $/', // leading/trailing spaces, caused by the above
1187				],
1188				[
1189					' ',
1190					' ',
1191					' ',
1192					'',
1193				],
1194				$full
1195			);
1196		}
1197
1198		// Add placeholder for unknown given name
1199		if (!$GIVN) {
1200			$GIVN = '@P.N.';
1201			$pos  = strpos($full, '/');
1202			$full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos);
1203		}
1204
1205		// GEDCOM 5.5.1 nicknames should be specificied in a NICK field
1206		// GEDCOM 5.5   nicknames should be specified in the NAME field, surrounded by quotes
1207		if ($NICK && strpos($full, '"' . $NICK . '"') === false) {
1208			// A NICK field is present, but not included in the NAME.  Show it at the end.
1209			$full .= ' "' . $NICK . '"';
1210		}
1211
1212		// Remove slashes - they don’t get displayed
1213		// $fullNN keeps the @N.N. placeholders, for the database
1214		// $full is for display on-screen
1215		$fullNN = str_replace('/', '', $full);
1216
1217		// Insert placeholders for any missing/unknown names
1218		$full = str_replace('@N.N.', I18N::translateContext('Unknown surname', '…'), $full);
1219		$full = str_replace('@P.N.', I18N::translateContext('Unknown given name', '…'), $full);
1220		// Format for display
1221		$full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', Html::escape($full)) . '</span>';
1222		// Localise quotation marks around the nickname
1223		$full = preg_replace_callback('/&quot;([^&]*)&quot;/', function ($matches) { return I18N::translate('“%s”', $matches[1]); }, $full);
1224
1225		// A suffix of “*” indicates a preferred name
1226		$full = preg_replace('/([^ >]*)\*/', '<span class="starredname">\\1</span>', $full);
1227
1228		// Remove prefered-name indicater - they don’t go in the database
1229		$GIVN   = str_replace('*', '', $GIVN);
1230		$fullNN = str_replace('*', '', $fullNN);
1231
1232		foreach ($SURNS as $SURN) {
1233			// Scottish 'Mc and Mac ' prefixes both sort under 'Mac'
1234			if (strcasecmp(substr($SURN, 0, 2), 'Mc') == 0) {
1235				$SURN = substr_replace($SURN, 'Mac', 0, 2);
1236			} elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') == 0) {
1237				$SURN = substr_replace($SURN, 'Mac', 0, 4);
1238			}
1239
1240			$this->_getAllNames[] = [
1241				'type'    => $type,
1242				'sort'    => $SURN . ',' . $GIVN,
1243				'full'    => $full, // This is used for display
1244				'fullNN'  => $fullNN, // This goes into the database
1245				'surname' => $surname, // This goes into the database
1246				'givn'    => $GIVN, // This goes into the database
1247				'surn'    => $SURN, // This goes into the database
1248			];
1249		}
1250	}
1251
1252	/**
1253	 * Extract names from the GEDCOM record.
1254	 */
1255	public function extractNames() {
1256		$this->extractNamesFromFacts(1, 'NAME', $this->getFacts('NAME', false, Auth::accessLevel($this->tree), $this->canShowName()));
1257	}
1258
1259	/**
1260	 * Extra info to display when displaying this record in a list of
1261	 * selection items or favorites.
1262	 *
1263	 * @return string
1264	 */
1265	public function formatListDetails() {
1266		return
1267			$this->formatFirstMajorFact(WT_EVENTS_BIRT, 1) .
1268			$this->formatFirstMajorFact(WT_EVENTS_DEAT, 1);
1269	}
1270
1271	/**
1272	 * Create a short name for compact display on charts
1273	 *
1274	 * @return string
1275	 */
1276	public function getShortName() {
1277		global $bwidth;
1278
1279		// Estimate number of characters that can fit in box. Calulates to 28 characters in webtrees theme, or 34 if no thumbnail used.
1280		if ($this->tree->getPreference('SHOW_HIGHLIGHT_IMAGES')) {
1281			$char = intval(($bwidth - 40) / 6.5);
1282		} else {
1283			$char = ($bwidth / 6.5);
1284		}
1285		if ($this->canShowName()) {
1286			$tmp        = $this->getAllNames();
1287			$givn       = $tmp[$this->getPrimaryName()]['givn'];
1288			$surn       = $tmp[$this->getPrimaryName()]['surname'];
1289			$new_givn   = explode(' ', $givn);
1290			$count_givn = count($new_givn);
1291			$len_givn   = mb_strlen($givn);
1292			$len_surn   = mb_strlen($surn);
1293			$len        = $len_givn + $len_surn;
1294			$i          = 1;
1295			while ($len > $char && $i <= $count_givn) {
1296				$new_givn[$count_givn - $i] = mb_substr($new_givn[$count_givn - $i], 0, 1);
1297				$givn                       = implode(' ', $new_givn);
1298				$len_givn                   = mb_strlen($givn);
1299				$len                        = $len_givn + $len_surn;
1300				$i++;
1301			}
1302			$max_surn = $char - $i * 2;
1303			if ($len_surn > $max_surn) {
1304				$surn = substr($surn, 0, $max_surn) . '…';
1305			}
1306			$shortname = str_replace(
1307				['@P.N.', '@N.N.'],
1308				[I18N::translateContext('Unknown given name', '…'), I18N::translateContext('Unknown surname', '…')],
1309				$givn . ' ' . $surn
1310			);
1311
1312			return $shortname;
1313		} else {
1314			return I18N::translate('Private');
1315		}
1316	}
1317}
1318