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