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