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