Passed
Push — master ( c9a927...3e5f5a )
by Greg
05:29
created

Individual   F

Complexity

Total Complexity 202

Size/Duplication

Total Lines 1165
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 411
c 1
b 0
f 0
dl 0
loc 1165
rs 2
wmc 202

40 Methods

Rating   Name   Duplication   Size   Complexity  
A spouseStepFamilies() 0 18 5
A findHighlightedMediaFile() 0 14 4
A getAllBirthDates() 0 10 3
A childStepFamilies() 0 16 5
F isDead() 0 90 29
A childFamilies() 0 18 5
A formatListDetails() 0 5 1
D isRelated() 0 66 19
A getBoxStyle() 0 9 1
A getFallBackName() 0 3 1
A spouseFamilies() 0 17 5
A getChildFamilyLabel() 0 9 2
A getEstimatedDeathDate() 0 20 6
A birthDateComparator() 0 4 1
A getInstance() 0 3 1
A numberOfChildren() 0 14 4
B getStepFamilyLabel() 0 36 9
A getDeathPlace() 0 7 2
D getEstimatedBirthDate() 0 81 23
A lifespan() 0 14 1
A getDeathYear() 0 3 1
A getSpouseFamilyLabel() 0 9 2
A getAllDeathDates() 0 10 3
A load() 0 10 2
F addName() 0 135 17
A displayImage() 0 13 3
A rowMapper() 0 3 1
A deathDateComparator() 0 4 1
A getBirthPlace() 0 7 2
C canShowByType() 0 41 14
A getBirthYear() 0 3 1
A getCurrentSpouse() 0 9 2
A canShowName() 0 5 2
A getAllDeathPlaces() 0 10 3
B createPrivateGedcomRecord() 0 25 8
A getDeathDate() 0 9 3
A extractNames() 0 8 2
A sex() 0 7 2
A getBirthDate() 0 9 3
A getAllBirthPlaces() 0 10 3

How to fix   Complexity   

Complex Class

Complex classes like Individual often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Individual, and based on these observations, apply Extract Interface, too.

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