Passed
Push — master ( 1afbbc...ed5b62 )
by Greg
06:15
created

Individual::extractNames()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 0
dl 0
loc 10
rs 10
c 0
b 0
f 0
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
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees;
21
22
use Closure;
23
use Exception;
24
use Fisharebest\ExtCalendar\GregorianCalendar;
25
use Fisharebest\Webtrees\GedcomCode\GedcomCodePedi;
26
use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage;
27
use Illuminate\Database\Capsule\Manager as DB;
28
use Illuminate\Support\Collection;
29
use stdClass;
30
31
/**
32
 * A GEDCOM individual (INDI) object.
33
 */
34
class 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) {
0 ignored issues
show
introduced by
The condition $media_file !== null is always false.
Loading history...
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