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