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