1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 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 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees; 21 22use Closure; 23use Fisharebest\ExtCalendar\GregorianCalendar; 24use Fisharebest\Webtrees\Contracts\UserInterface; 25use Fisharebest\Webtrees\Elements\PedigreeLinkageType; 26use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage; 27use Illuminate\Support\Collection; 28 29use function array_key_exists; 30use function count; 31use function in_array; 32use function preg_match; 33 34/** 35 * A GEDCOM individual (INDI) object. 36 */ 37class Individual extends GedcomRecord 38{ 39 public const RECORD_TYPE = 'INDI'; 40 41 // Placeholders to indicate unknown names 42 public const NOMEN_NESCIO = '@N.N.'; 43 public const PRAENOMEN_NESCIO = '@P.N.'; 44 45 protected const 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('/"([^&]*)"/', 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