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