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