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