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