Issues (2511)

app/Individual.php (1 issue)

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