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 stdClass; 25 26/** 27 * A GEDCOM individual (INDI) object. 28 */ 29class Individual extends GedcomRecord 30{ 31 public const RECORD_TYPE = 'INDI'; 32 33 protected const ROUTE_NAME = 'individual'; 34 35 /** @var int used in some lists to keep track of this individual’s generation in that list */ 36 public $generation; 37 38 /** @var Date The estimated date of birth */ 39 private $estimated_birth_date; 40 41 /** @var Date The estimated date of death */ 42 private $estimated_death_date; 43 44 /** 45 * A closure which will create a record from a database row. 46 * 47 * @param Tree $tree 48 * 49 * @return Closure 50 */ 51 public static function rowMapper(Tree $tree): Closure 52 { 53 return function (stdClass $row) use ($tree): Individual { 54 $individual = Individual::getInstance($row->i_id, $tree, $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 334 // "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC" 335 if (preg_match('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) { 336 return true; 337 } 338 339 // If any event occured more than $MAX_ALIVE_AGE years ago, then assume the individual is dead 340 if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) { 341 foreach ($date_matches[1] as $date_match) { 342 $date = new Date($date_match); 343 if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * $MAX_ALIVE_AGE) { 344 return true; 345 } 346 } 347 // The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago. 348 // If one of these is a birth, the individual must be alive. 349 if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) { 350 return false; 351 } 352 } 353 354 // If we found no conclusive dates then check the dates of close relatives. 355 356 // Check parents (birth and adopted) 357 foreach ($this->getChildFamilies(Auth::PRIV_HIDE) as $family) { 358 foreach ($family->getSpouses(Auth::PRIV_HIDE) as $parent) { 359 // Assume parents are no more than 45 years older than their children 360 preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches); 361 foreach ($date_matches[1] as $date_match) { 362 $date = new Date($date_match); 363 if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE + 45)) { 364 return true; 365 } 366 } 367 } 368 } 369 370 // Check spouses 371 foreach ($this->getSpouseFamilies(Auth::PRIV_HIDE) as $family) { 372 preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches); 373 foreach ($date_matches[1] as $date_match) { 374 $date = new Date($date_match); 375 // Assume marriage occurs after age of 10 376 if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE - 10)) { 377 return true; 378 } 379 } 380 // Check spouse dates 381 $spouse = $family->getSpouse($this, Auth::PRIV_HIDE); 382 if ($spouse) { 383 preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches); 384 foreach ($date_matches[1] as $date_match) { 385 $date = new Date($date_match); 386 // Assume max age difference between spouses of 40 years 387 if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE + 40)) { 388 return true; 389 } 390 } 391 } 392 // Check child dates 393 foreach ($family->getChildren(Auth::PRIV_HIDE) as $child) { 394 preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches); 395 // Assume children born after age of 15 396 foreach ($date_matches[1] as $date_match) { 397 $date = new Date($date_match); 398 if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE - 15)) { 399 return true; 400 } 401 } 402 // Check grandchildren 403 foreach ($child->getSpouseFamilies(Auth::PRIV_HIDE) as $child_family) { 404 foreach ($child_family->getChildren(Auth::PRIV_HIDE) as $grandchild) { 405 preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches); 406 // Assume grandchildren born after age of 30 407 foreach ($date_matches[1] as $date_match) { 408 $date = new Date($date_match); 409 if ($date->isOK() && $date->maximumJulianDay() <= WT_CLIENT_JD - 365 * ($MAX_ALIVE_AGE - 30)) { 410 return true; 411 } 412 } 413 } 414 } 415 } 416 } 417 418 return false; 419 } 420 421 /** 422 * Find the highlighted media object for an individual 423 * 424 * @return null|MediaFile 425 */ 426 public function findHighlightedMediaFile() 427 { 428 foreach ($this->facts(['OBJE']) as $fact) { 429 $media = $fact->target(); 430 if ($media instanceof Media) { 431 foreach ($media->mediaFiles() as $media_file) { 432 if ($media_file->isImage() && !$media_file->isExternal()) { 433 return $media_file; 434 } 435 } 436 } 437 } 438 439 return null; 440 } 441 442 /** 443 * Display the prefered image for this individual. 444 * Use an icon if no image is available. 445 * 446 * @param int $width Pixels 447 * @param int $height Pixels 448 * @param string $fit "crop" or "contain" 449 * @param string[] $attributes Additional HTML attributes 450 * 451 * @return string 452 */ 453 public function displayImage($width, $height, $fit, $attributes): string 454 { 455 $media_file = $this->findHighlightedMediaFile(); 456 457 if ($media_file !== null) { 458 return $media_file->displayImage($width, $height, $fit, $attributes); 459 } 460 461 if ($this->tree->getPreference('USE_SILHOUETTE')) { 462 return '<i class="icon-silhouette-' . $this->getSex() . '"></i>'; 463 } 464 465 return ''; 466 } 467 468 /** 469 * Get the date of birth 470 * 471 * @return Date 472 */ 473 public function getBirthDate(): Date 474 { 475 foreach ($this->getAllBirthDates() as $date) { 476 if ($date->isOK()) { 477 return $date; 478 } 479 } 480 481 return new Date(''); 482 } 483 484 /** 485 * Get the place of birth 486 * 487 * @return Place 488 */ 489 public function getBirthPlace(): Place 490 { 491 foreach ($this->getAllBirthPlaces() as $place) { 492 return $place; 493 } 494 495 return new Place('', $this->tree); 496 } 497 498 /** 499 * Get the year of birth 500 * 501 * @return string the year of birth 502 */ 503 public function getBirthYear(): string 504 { 505 return $this->getBirthDate()->minimumDate()->format('%Y'); 506 } 507 508 /** 509 * Get the date of death 510 * 511 * @return Date 512 */ 513 public function getDeathDate(): Date 514 { 515 foreach ($this->getAllDeathDates() as $date) { 516 if ($date->isOK()) { 517 return $date; 518 } 519 } 520 521 return new Date(''); 522 } 523 524 /** 525 * Get the place of death 526 * 527 * @return Place 528 */ 529 public function getDeathPlace(): Place 530 { 531 foreach ($this->getAllDeathPlaces() as $place) { 532 return $place; 533 } 534 535 return new Place('', $this->tree); 536 } 537 538 /** 539 * get the death year 540 * 541 * @return string the year of death 542 */ 543 public function getDeathYear(): string 544 { 545 return $this->getDeathDate()->minimumDate()->format('%Y'); 546 } 547 548 /** 549 * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”. 550 * Provide the place and full date using a tooltip. 551 * For consistent layout in charts, etc., show just a “–” when no dates are known. 552 * Note that this is a (non-breaking) en-dash, and not a hyphen. 553 * 554 * @return string 555 */ 556 public function getLifeSpan(): string 557 { 558 // Just the first part of the place name 559 $birth_place = strip_tags($this->getBirthPlace()->getShortName()); 560 $death_place = strip_tags($this->getDeathPlace()->getShortName()); 561 // Remove markup from dates 562 $birth_date = strip_tags($this->getBirthDate()->display()); 563 $death_date = strip_tags($this->getDeathDate()->display()); 564 565 /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */ 566 return 567 I18N::translate( 568 '%1$s–%2$s', 569 '<span title="' . $birth_place . ' ' . $birth_date . '">' . $this->getBirthYear() . '</span>', 570 '<span title="' . $death_place . ' ' . $death_date . '">' . $this->getDeathYear() . '</span>' 571 ); 572 } 573 574 /** 575 * Get all the birth dates - for the individual lists. 576 * 577 * @return Date[] 578 */ 579 public function getAllBirthDates(): array 580 { 581 foreach (Gedcom::BIRTH_EVENTS as $event) { 582 $tmp = $this->getAllEventDates([$event]); 583 if ($tmp) { 584 return $tmp; 585 } 586 } 587 588 return []; 589 } 590 591 /** 592 * Gat all the birth places - for the individual lists. 593 * 594 * @return Place[] 595 */ 596 public function getAllBirthPlaces(): array 597 { 598 foreach (Gedcom::BIRTH_EVENTS as $event) { 599 $places = $this->getAllEventPlaces([$event]); 600 if (!empty($places)) { 601 return $places; 602 } 603 } 604 605 return []; 606 } 607 608 /** 609 * Get all the death dates - for the individual lists. 610 * 611 * @return Date[] 612 */ 613 public function getAllDeathDates(): array 614 { 615 foreach (Gedcom::DEATH_EVENTS as $event) { 616 $tmp = $this->getAllEventDates([$event]); 617 if ($tmp) { 618 return $tmp; 619 } 620 } 621 622 return []; 623 } 624 625 /** 626 * Get all the death places - for the individual lists. 627 * 628 * @return Place[] 629 */ 630 public function getAllDeathPlaces(): array 631 { 632 foreach (Gedcom::DEATH_EVENTS as $event) { 633 $places = $this->getAllEventPlaces([$event]); 634 if (!empty($places)) { 635 return $places; 636 } 637 } 638 639 return []; 640 } 641 642 /** 643 * Generate an estimate for the date of birth, based on dates of parents/children/spouses 644 * 645 * @return Date 646 */ 647 public function getEstimatedBirthDate(): Date 648 { 649 if ($this->estimated_birth_date === null) { 650 foreach ($this->getAllBirthDates() as $date) { 651 if ($date->isOK()) { 652 $this->estimated_birth_date = $date; 653 break; 654 } 655 } 656 if ($this->estimated_birth_date === null) { 657 $min = []; 658 $max = []; 659 $tmp = $this->getDeathDate(); 660 if ($tmp->isOK()) { 661 $min[] = $tmp->minimumJulianDay() - $this->tree->getPreference('MAX_ALIVE_AGE') * 365; 662 $max[] = $tmp->maximumJulianDay(); 663 } 664 foreach ($this->getChildFamilies() as $family) { 665 $tmp = $family->getMarriageDate(); 666 if ($tmp->isOK()) { 667 $min[] = $tmp->maximumJulianDay() - 365 * 1; 668 $max[] = $tmp->minimumJulianDay() + 365 * 30; 669 } 670 $husband = $family->getHusband(); 671 if ($husband instanceof Individual) { 672 $tmp = $husband->getBirthDate(); 673 if ($tmp->isOK()) { 674 $min[] = $tmp->maximumJulianDay() + 365 * 15; 675 $max[] = $tmp->minimumJulianDay() + 365 * 65; 676 } 677 } 678 $wife = $family->getWife(); 679 if ($wife instanceof Individual) { 680 $tmp = $wife->getBirthDate(); 681 if ($tmp->isOK()) { 682 $min[] = $tmp->maximumJulianDay() + 365 * 15; 683 $max[] = $tmp->minimumJulianDay() + 365 * 45; 684 } 685 } 686 foreach ($family->getChildren() as $child) { 687 $tmp = $child->getBirthDate(); 688 if ($tmp->isOK()) { 689 $min[] = $tmp->maximumJulianDay() - 365 * 30; 690 $max[] = $tmp->minimumJulianDay() + 365 * 30; 691 } 692 } 693 } 694 foreach ($this->getSpouseFamilies() as $family) { 695 $tmp = $family->getMarriageDate(); 696 if ($tmp->isOK()) { 697 $min[] = $tmp->maximumJulianDay() - 365 * 45; 698 $max[] = $tmp->minimumJulianDay() - 365 * 15; 699 } 700 $spouse = $family->getSpouse($this); 701 if ($spouse) { 702 $tmp = $spouse->getBirthDate(); 703 if ($tmp->isOK()) { 704 $min[] = $tmp->maximumJulianDay() - 365 * 25; 705 $max[] = $tmp->minimumJulianDay() + 365 * 25; 706 } 707 } 708 foreach ($family->getChildren() as $child) { 709 $tmp = $child->getBirthDate(); 710 if ($tmp->isOK()) { 711 $min[] = $tmp->maximumJulianDay() - 365 * ($this->getSex() == 'F' ? 45 : 65); 712 $max[] = $tmp->minimumJulianDay() - 365 * 15; 713 } 714 } 715 } 716 if ($min && $max) { 717 $gregorian_calendar = new GregorianCalendar(); 718 719 [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2)); 720 $this->estimated_birth_date = new Date('EST ' . $year); 721 } else { 722 $this->estimated_birth_date = new Date(''); // always return a date object 723 } 724 } 725 } 726 727 return $this->estimated_birth_date; 728 } 729 730 /** 731 * Generate an estimated date of death. 732 * 733 * @return Date 734 */ 735 public function getEstimatedDeathDate(): Date 736 { 737 if ($this->estimated_death_date === null) { 738 foreach ($this->getAllDeathDates() as $date) { 739 if ($date->isOK()) { 740 $this->estimated_death_date = $date; 741 break; 742 } 743 } 744 if ($this->estimated_death_date === null) { 745 if ($this->getEstimatedBirthDate()->minimumJulianDay()) { 746 $max_alive_age = (int) $this->tree->getPreference('MAX_ALIVE_AGE'); 747 $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF'); 748 } else { 749 $this->estimated_death_date = new Date(''); // always return a date object 750 } 751 } 752 } 753 754 return $this->estimated_death_date; 755 } 756 757 /** 758 * Get the sex - M F or U 759 * Use the un-privatised gedcom record. We call this function during 760 * the privatize-gedcom function, and we are allowed to know this. 761 * 762 * @return string 763 */ 764 public function getSex() 765 { 766 if (preg_match('/\n1 SEX ([MF])/', $this->gedcom . $this->pending, $match)) { 767 return $match[1]; 768 } 769 770 return 'U'; 771 } 772 773 /** 774 * Get the individual’s sex image 775 * 776 * @param string $size 777 * 778 * @return string 779 */ 780 public function getSexImage($size = 'small'): string 781 { 782 return self::sexImage($this->getSex(), $size); 783 } 784 785 /** 786 * Generate a sex icon/image 787 * 788 * @param string $sex 789 * @param string $size 790 * 791 * @return string 792 */ 793 public static function sexImage($sex, $size = 'small'): string 794 { 795 return '<i class="icon-sex_' . strtolower($sex) . '_' . ($size == 'small' ? '9x9' : '15x15') . '"></i>'; 796 } 797 798 /** 799 * Generate the CSS class to be used for drawing this individual 800 * 801 * @return string 802 */ 803 public function getBoxStyle(): string 804 { 805 $tmp = [ 806 'M' => '', 807 'F' => 'F', 808 'U' => 'NN', 809 ]; 810 811 return 'person_box' . $tmp[$this->getSex()]; 812 } 813 814 /** 815 * Get a list of this individual’s spouse families 816 * 817 * @param int|null $access_level 818 * 819 * @return Family[] 820 */ 821 public function getSpouseFamilies($access_level = null): array 822 { 823 if ($access_level === null) { 824 $access_level = Auth::accessLevel($this->tree); 825 } 826 827 $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS'); 828 829 $families = []; 830 foreach ($this->facts(['FAMS'], false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) { 831 $family = $fact->target(); 832 if ($family instanceof Family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) { 833 $families[] = $family; 834 } 835 } 836 837 return $families; 838 } 839 840 /** 841 * Get the current spouse of this individual. 842 * 843 * Where an individual has multiple spouses, assume they are stored 844 * in chronological order, and take the last one found. 845 * 846 * @return Individual|null 847 */ 848 public function getCurrentSpouse() 849 { 850 $tmp = $this->getSpouseFamilies(); 851 $family = end($tmp); 852 if ($family) { 853 return $family->getSpouse($this); 854 } 855 856 return null; 857 } 858 859 /** 860 * Count the children belonging to this individual. 861 * 862 * @return int 863 */ 864 public function getNumberOfChildren() 865 { 866 if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) { 867 return (int) $match[1]; 868 } 869 870 $children = []; 871 foreach ($this->getSpouseFamilies() as $fam) { 872 foreach ($fam->getChildren() as $child) { 873 $children[$child->xref()] = true; 874 } 875 } 876 877 return count($children); 878 } 879 880 /** 881 * Get a list of this individual’s child families (i.e. their parents). 882 * 883 * @param int|null $access_level 884 * 885 * @return Family[] 886 */ 887 public function getChildFamilies($access_level = null): array 888 { 889 if ($access_level === null) { 890 $access_level = Auth::accessLevel($this->tree); 891 } 892 893 $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS'); 894 895 $families = []; 896 foreach ($this->facts(['FAMC'], false, $access_level, $SHOW_PRIVATE_RELATIONSHIPS) as $fact) { 897 $family = $fact->target(); 898 if ($family instanceof Family && ($SHOW_PRIVATE_RELATIONSHIPS || $family->canShow($access_level))) { 899 $families[] = $family; 900 } 901 } 902 903 return $families; 904 } 905 906 /** 907 * Get the preferred parents for this individual. 908 * 909 * An individual may multiple parents (e.g. birth, adopted, disputed). 910 * The preferred family record is: 911 * (a) the first one with an explicit tag "_PRIMARY Y" 912 * (b) the first one with a pedigree of "birth" 913 * (c) the first one with no pedigree (default is "birth") 914 * (d) the first one found 915 * 916 * @return Family|null 917 */ 918 public function getPrimaryChildFamily() 919 { 920 $families = $this->getChildFamilies(); 921 switch (count($families)) { 922 case 0: 923 return null; 924 case 1: 925 return $families[0]; 926 default: 927 // If there is more than one FAMC record, choose the preferred parents: 928 // a) records with '2 _PRIMARY' 929 foreach ($families as $fam) { 930 $famid = $fam->xref(); 931 if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 _PRIMARY Y)/", $this->gedcom())) { 932 return $fam; 933 } 934 } 935 // b) records with '2 PEDI birt' 936 foreach ($families as $fam) { 937 $famid = $fam->xref(); 938 if (preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI birth)/", $this->gedcom())) { 939 return $fam; 940 } 941 } 942 // c) records with no '2 PEDI' 943 foreach ($families as $fam) { 944 $famid = $fam->xref(); 945 if (!preg_match("/\n1 FAMC @{$famid}@\n(?:[2-9].*\n)*(?:2 PEDI)/", $this->gedcom())) { 946 return $fam; 947 } 948 } 949 950 // d) any record 951 return $families[0]; 952 } 953 } 954 955 /** 956 * Get a list of step-parent families. 957 * 958 * @return Family[] 959 */ 960 public function getChildStepFamilies(): array 961 { 962 $step_families = []; 963 $families = $this->getChildFamilies(); 964 foreach ($families as $family) { 965 $father = $family->getHusband(); 966 if ($father) { 967 foreach ($father->getSpouseFamilies() as $step_family) { 968 if (!in_array($step_family, $families, true)) { 969 $step_families[] = $step_family; 970 } 971 } 972 } 973 $mother = $family->getWife(); 974 if ($mother) { 975 foreach ($mother->getSpouseFamilies() as $step_family) { 976 if (!in_array($step_family, $families, true)) { 977 $step_families[] = $step_family; 978 } 979 } 980 } 981 } 982 983 return $step_families; 984 } 985 986 /** 987 * Get a list of step-parent families. 988 * 989 * @return Family[] 990 */ 991 public function getSpouseStepFamilies(): array 992 { 993 $step_families = []; 994 $families = $this->getSpouseFamilies(); 995 foreach ($families as $family) { 996 $spouse = $family->getSpouse($this); 997 if ($spouse) { 998 foreach ($family->getSpouse($this)->getSpouseFamilies() as $step_family) { 999 if (!in_array($step_family, $families, true)) { 1000 $step_families[] = $step_family; 1001 } 1002 } 1003 } 1004 } 1005 1006 return $step_families; 1007 } 1008 1009 /** 1010 * A label for a parental family group 1011 * 1012 * @param Family $family 1013 * 1014 * @return string 1015 */ 1016 public function getChildFamilyLabel(Family $family) 1017 { 1018 if (preg_match('/\n1 FAMC @' . $family->xref() . '@(?:\n[2-9].*)*\n2 PEDI (.+)/', $this->gedcom(), $match)) { 1019 // A specified pedigree 1020 return GedcomCodePedi::getChildFamilyLabel($match[1]); 1021 } 1022 1023 // Default (birth) pedigree 1024 return GedcomCodePedi::getChildFamilyLabel(''); 1025 } 1026 1027 /** 1028 * Create a label for a step family 1029 * 1030 * @param Family $step_family 1031 * 1032 * @return string 1033 */ 1034 public function getStepFamilyLabel(Family $step_family): string 1035 { 1036 foreach ($this->getChildFamilies() as $family) { 1037 if ($family !== $step_family) { 1038 // Must be a step-family 1039 foreach ($family->getSpouses() as $parent) { 1040 foreach ($step_family->getSpouses() as $step_parent) { 1041 if ($parent === $step_parent) { 1042 // One common parent - must be a step family 1043 if ($parent->getSex() == 'M') { 1044 // Father’s family with someone else 1045 if ($step_family->getSpouse($step_parent)) { 1046 /* I18N: A step-family. %s is an individual’s name */ 1047 return I18N::translate('Father’s family with %s', $step_family->getSpouse($step_parent)->getFullName()); 1048 } 1049 1050 /* I18N: A step-family. */ 1051 return I18N::translate('Father’s family with an unknown individual'); 1052 } 1053 1054 // Mother’s family with someone else 1055 if ($step_family->getSpouse($step_parent)) { 1056 /* I18N: A step-family. %s is an individual’s name */ 1057 return I18N::translate('Mother’s family with %s', $step_family->getSpouse($step_parent)->getFullName()); 1058 } 1059 1060 /* I18N: A step-family. */ 1061 return I18N::translate('Mother’s family with an unknown individual'); 1062 } 1063 } 1064 } 1065 } 1066 } 1067 1068 // Perahps same parents - but a different family record? 1069 return I18N::translate('Family with parents'); 1070 } 1071 1072 /** 1073 * Get the description for the family. 1074 * 1075 * For example, "XXX's family with new wife". 1076 * 1077 * @param Family $family 1078 * 1079 * @return string 1080 */ 1081 public function getSpouseFamilyLabel(Family $family) 1082 { 1083 $spouse = $family->getSpouse($this); 1084 if ($spouse) { 1085 /* I18N: %s is the spouse name */ 1086 return I18N::translate('Family with %s', $spouse->getFullName()); 1087 } 1088 1089 return $family->getFullName(); 1090 } 1091 1092 /** 1093 * get primary parents names for this individual 1094 * 1095 * @param string $classname optional css class 1096 * @param string $display optional css style display 1097 * 1098 * @return string a div block with father & mother names 1099 */ 1100 public function getPrimaryParentsNames($classname = '', $display = ''): string 1101 { 1102 $fam = $this->getPrimaryChildFamily(); 1103 if (!$fam) { 1104 return ''; 1105 } 1106 $txt = '<div'; 1107 if ($classname) { 1108 $txt .= ' class="' . $classname . '"'; 1109 } 1110 if ($display) { 1111 $txt .= ' style="display:' . $display . '"'; 1112 } 1113 $txt .= '>'; 1114 $husb = $fam->getHusband(); 1115 if ($husb) { 1116 // Temporarily reset the 'prefered' display name, as we always 1117 // want the default name, not the one selected for display on the indilist. 1118 $primary = $husb->getPrimaryName(); 1119 $husb->setPrimaryName(null); 1120 /* I18N: %s is the name of an individual’s father */ 1121 $txt .= I18N::translate('Father: %s', $husb->getFullName()) . '<br>'; 1122 $husb->setPrimaryName($primary); 1123 } 1124 $wife = $fam->getWife(); 1125 if ($wife) { 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 = $wife->getPrimaryName(); 1129 $wife->setPrimaryName(null); 1130 /* I18N: %s is the name of an individual’s mother */ 1131 $txt .= I18N::translate('Mother: %s', $wife->getFullName()); 1132 $wife->setPrimaryName($primary); 1133 } 1134 $txt .= '</div>'; 1135 1136 return $txt; 1137 } 1138 1139 /** {@inheritdoc} */ 1140 public function getFallBackName(): string 1141 { 1142 return '@P.N. /@N.N./'; 1143 } 1144 1145 /** 1146 * Convert a name record into ‘full’ and ‘sort’ versions. 1147 * Use the NAME field to generate the ‘full’ version, as the 1148 * gedcom spec says that this is the individual’s name, as they would write it. 1149 * Use the SURN field to generate the sortable names. Note that this field 1150 * may also be used for the ‘true’ surname, perhaps spelt differently to that 1151 * recorded in the NAME field. e.g. 1152 * 1153 * 1 NAME Robert /de Gliderow/ 1154 * 2 GIVN Robert 1155 * 2 SPFX de 1156 * 2 SURN CLITHEROW 1157 * 2 NICK The Bald 1158 * 1159 * full=>'Robert de Gliderow 'The Bald'' 1160 * sort=>'CLITHEROW, ROBERT' 1161 * 1162 * Handle multiple surnames, either as; 1163 * 1164 * 1 NAME Carlos /Vasquez/ y /Sante/ 1165 * or 1166 * 1 NAME Carlos /Vasquez y Sante/ 1167 * 2 GIVN Carlos 1168 * 2 SURN Vasquez,Sante 1169 * 1170 * @param string $type 1171 * @param string $full 1172 * @param string $gedcom 1173 */ 1174 protected function addName(string $type, string $full, string $gedcom) 1175 { 1176 //////////////////////////////////////////////////////////////////////////// 1177 // Extract the structured name parts - use for "sortable" names and indexes 1178 //////////////////////////////////////////////////////////////////////////// 1179 1180 $sublevel = 1 + (int) substr($gedcom, 0, 1); 1181 $GIVN = preg_match("/\n{$sublevel} GIVN (.+)/", $gedcom, $match) ? $match[1] : ''; 1182 $SURN = preg_match("/\n{$sublevel} SURN (.+)/", $gedcom, $match) ? $match[1] : ''; 1183 $NICK = preg_match("/\n{$sublevel} NICK (.+)/", $gedcom, $match) ? $match[1] : ''; 1184 1185 // SURN is an comma-separated list of surnames... 1186 if ($SURN !== '') { 1187 $SURNS = preg_split('/ *, */', $SURN); 1188 } else { 1189 $SURNS = []; 1190 } 1191 1192 // ...so is GIVN - but nobody uses it like that 1193 $GIVN = str_replace('/ *, */', ' ', $GIVN); 1194 1195 //////////////////////////////////////////////////////////////////////////// 1196 // Extract the components from NAME - use for the "full" names 1197 //////////////////////////////////////////////////////////////////////////// 1198 1199 // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/' 1200 if (substr_count($full, '/') % 2 === 1) { 1201 $full = $full . '/'; 1202 } 1203 1204 // GEDCOM uses "//" to indicate an unknown surname 1205 $full = preg_replace('/\/\//', '/@N.N./', $full); 1206 1207 // Extract the surname. 1208 // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/ 1209 if (preg_match('/\/.*\//', $full, $match)) { 1210 $surname = str_replace('/', '', $match[0]); 1211 } else { 1212 $surname = ''; 1213 } 1214 1215 // If we don’t have a SURN record, extract it from the NAME 1216 if (!$SURNS) { 1217 if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) { 1218 // There can be many surnames, each wrapped with '/' 1219 $SURNS = $matches[1]; 1220 foreach ($SURNS as $n => $SURN) { 1221 // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only) 1222 $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN); 1223 } 1224 } else { 1225 // It is valid not to have a surname at all 1226 $SURNS = ['']; 1227 } 1228 } 1229 1230 // If we don’t have a GIVN record, extract it from the NAME 1231 if (!$GIVN) { 1232 $GIVN = preg_replace( 1233 [ 1234 '/ ?\/.*\/ ?/', 1235 // remove surname 1236 '/ ?".+"/', 1237 // remove nickname 1238 '/ {2,}/', 1239 // multiple spaces, caused by the above 1240 '/^ | $/', 1241 // leading/trailing spaces, caused by the above 1242 ], 1243 [ 1244 ' ', 1245 ' ', 1246 ' ', 1247 '', 1248 ], 1249 $full 1250 ); 1251 } 1252 1253 // Add placeholder for unknown given name 1254 if (!$GIVN) { 1255 $GIVN = '@P.N.'; 1256 $pos = (int) strpos($full, '/'); 1257 $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos); 1258 } 1259 1260 // GEDCOM 5.5.1 nicknames should be specificied in a NICK field 1261 // GEDCOM 5.5 nicknames should be specified in the NAME field, surrounded by quotes 1262 if ($NICK && strpos($full, '"' . $NICK . '"') === false) { 1263 // A NICK field is present, but not included in the NAME. Show it at the end. 1264 $full .= ' "' . $NICK . '"'; 1265 } 1266 1267 // Remove slashes - they don’t get displayed 1268 // $fullNN keeps the @N.N. placeholders, for the database 1269 // $full is for display on-screen 1270 $fullNN = str_replace('/', '', $full); 1271 1272 // Insert placeholders for any missing/unknown names 1273 $full = str_replace('@N.N.', I18N::translateContext('Unknown surname', '…'), $full); 1274 $full = str_replace('@P.N.', I18N::translateContext('Unknown given name', '…'), $full); 1275 // Format for display 1276 $full = '<span class="NAME" dir="auto" translate="no">' . preg_replace('/\/([^\/]*)\//', '<span class="SURN">$1</span>', e($full)) . '</span>'; 1277 // Localise quotation marks around the nickname 1278 $full = preg_replace_callback('/"([^&]*)"/', function (array $matches): string { 1279 return I18N::translate('“%s”', $matches[1]); 1280 }, $full); 1281 1282 // A suffix of “*” indicates a preferred name 1283 $full = preg_replace('/([^ >]*)\*/', '<span class="starredname">\\1</span>', $full); 1284 1285 // Remove prefered-name indicater - they don’t go in the database 1286 $GIVN = str_replace('*', '', $GIVN); 1287 $fullNN = str_replace('*', '', $fullNN); 1288 1289 foreach ($SURNS as $SURN) { 1290 // Scottish 'Mc and Mac ' prefixes both sort under 'Mac' 1291 if (strcasecmp(substr($SURN, 0, 2), 'Mc') == 0) { 1292 $SURN = substr_replace($SURN, 'Mac', 0, 2); 1293 } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') == 0) { 1294 $SURN = substr_replace($SURN, 'Mac', 0, 4); 1295 } 1296 1297 $this->getAllNames[] = [ 1298 'type' => $type, 1299 'sort' => $SURN . ',' . $GIVN, 1300 'full' => $full, 1301 // This is used for display 1302 'fullNN' => $fullNN, 1303 // This goes into the database 1304 'surname' => $surname, 1305 // This goes into the database 1306 'givn' => $GIVN, 1307 // This goes into the database 1308 'surn' => $SURN, 1309 // This goes into the database 1310 ]; 1311 } 1312 } 1313 1314 /** 1315 * Extract names from the GEDCOM record. 1316 * 1317 * @return void 1318 */ 1319 public function extractNames() 1320 { 1321 $this->extractNamesFromFacts( 1322 1, 1323 'NAME', 1324 $this->facts( 1325 ['NAME'], 1326 false, 1327 Auth::accessLevel($this->tree), 1328 $this->canShowName() 1329 ) 1330 ); 1331 } 1332 1333 /** 1334 * Extra info to display when displaying this record in a list of 1335 * selection items or favorites. 1336 * 1337 * @return string 1338 */ 1339 public function formatListDetails(): string 1340 { 1341 return 1342 $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) . 1343 $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1); 1344 } 1345} 1346