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