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