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