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