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