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