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