. */ declare(strict_types=1); namespace Fisharebest\Webtrees; use Closure; use Exception; use Fisharebest\ExtCalendar\GregorianCalendar; use Fisharebest\Webtrees\GedcomCode\GedcomCodePedi; use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Support\Collection; use stdClass; /** * A GEDCOM individual (INDI) object. */ class Individual extends GedcomRecord { public const RECORD_TYPE = 'INDI'; protected const ROUTE_NAME = IndividualPage::class; /** @var int used in some lists to keep track of this individual’s generation in that list */ public $generation; /** @var Date The estimated date of birth */ private $estimated_birth_date; /** @var Date The estimated date of death */ private $estimated_death_date; /** * A closure which will create a record from a database row. * * @param Tree $tree * * @return Closure */ public static function rowMapper(Tree $tree): Closure { return static function (stdClass $row) use ($tree): Individual { $individual = Individual::getInstance($row->i_id, $tree, $row->i_gedcom); assert($individual instanceof Individual); // Some queries include the names table. // For these we must select the specified name. if (($row->n_num ?? null) !== null) { $individual = clone $individual; $individual->setPrimaryName($row->n_num); return $individual; } return $individual; }; } /** * A closure which will compare individuals by birth date. * * @return Closure */ public static function birthDateComparator(): Closure { return static function (Individual $x, Individual $y): int { return Date::compare($x->getEstimatedBirthDate(), $y->getEstimatedBirthDate()); }; } /** * A closure which will compare individuals by death date. * * @return Closure */ public static function deathDateComparator(): Closure { return static function (Individual $x, Individual $y): int { return Date::compare($x->getEstimatedBirthDate(), $y->getEstimatedBirthDate()); }; } /** * Get an instance of an individual object. For single records, * we just receive the XREF. For bulk records (such as lists * and search results) we can receive the GEDCOM data as well. * * @param string $xref * @param Tree $tree * @param string|null $gedcom * * @throws Exception * @return Individual|null */ public static function getInstance(string $xref, Tree $tree, string $gedcom = null): ?Individual { $record = parent::getInstance($xref, $tree, $gedcom); if ($record instanceof self) { return $record; } return null; } /** * Sometimes, we'll know in advance that we need to load a set of records. * Typically when we load families and their members. * * @param Tree $tree * @param string[] $xrefs * * @return void */ public static function load(Tree $tree, array $xrefs): void { $rows = DB::table('individuals') ->where('i_file', '=', $tree->id()) ->whereIn('i_id', array_unique($xrefs)) ->select(['i_id AS xref', 'i_gedcom AS gedcom']) ->get(); foreach ($rows as $row) { self::getInstance($row->xref, $tree, $row->gedcom); } } /** * Can the name of this record be shown? * * @param int|null $access_level * * @return bool */ public function canShowName(int $access_level = null): bool { $access_level = $access_level ?? Auth::accessLevel($this->tree); return $this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level || $this->canShow($access_level); } /** * Can this individual be shown? * * @param int $access_level * * @return bool */ protected function canShowByType(int $access_level): bool { // Dead people... if ($this->tree->getPreference('SHOW_DEAD_PEOPLE') >= $access_level && $this->isDead()) { $keep_alive = false; $KEEP_ALIVE_YEARS_BIRTH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_BIRTH'); if ($KEEP_ALIVE_YEARS_BIRTH) { preg_match_all('/\n1 (?:' . implode('|', Gedcom::BIRTH_EVENTS) . ').*(?:\n[2-9].*)*(?:\n2 DATE (.+))/', $this->gedcom, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $date = new Date($match[1]); if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_BIRTH > date('Y')) { $keep_alive = true; break; } } } $KEEP_ALIVE_YEARS_DEATH = (int) $this->tree->getPreference('KEEP_ALIVE_YEARS_DEATH'); if ($KEEP_ALIVE_YEARS_DEATH) { preg_match_all('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ').*(?:\n[2-9].*)*(?:\n2 DATE (.+))/', $this->gedcom, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $date = new Date($match[1]); if ($date->isOK() && $date->gregorianYear() + $KEEP_ALIVE_YEARS_DEATH > date('Y')) { $keep_alive = true; break; } } } if (!$keep_alive) { return true; } } // Consider relationship privacy (unless an admin is applying download restrictions) $user_path_length = (int) $this->tree->getUserPreference(Auth::user(), User::PREF_TREE_PATH_LENGTH); $gedcomid = $this->tree->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF); if ($gedcomid !== '' && $user_path_length > 0) { return self::isRelated($this, $user_path_length); } // No restriction found - show living people to members only: return Auth::PRIV_USER >= $access_level; } /** * For relationship privacy calculations - is this individual a close relative? * * @param Individual $target * @param int $distance * * @return bool */ private static function isRelated(Individual $target, $distance): bool { static $cache = null; $user_individual = self::getInstance($target->tree->getUserPreference(Auth::user(), User::PREF_TREE_ACCOUNT_XREF), $target->tree); if ($user_individual) { if (!$cache) { $cache = [ 0 => [$user_individual], 1 => [], ]; foreach ($user_individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) { $family = $fact->target(); if ($family instanceof Family) { $cache[1][] = $family; } } } } else { // No individual linked to this account? Cannot use relationship privacy. return true; } // Double the distance, as we count the INDI-FAM and FAM-INDI links separately $distance *= 2; // Consider each path length in turn for ($n = 0; $n <= $distance; ++$n) { if (array_key_exists($n, $cache)) { // We have already calculated all records with this length if ($n % 2 === 0 && in_array($target, $cache[$n], true)) { return true; } } else { // Need to calculate these paths $cache[$n] = []; if ($n % 2 === 0) { // Add FAM->INDI links foreach ($cache[$n - 1] as $family) { foreach ($family->facts(['HUSB', 'WIFE', 'CHIL'], false, Auth::PRIV_HIDE) as $fact) { $individual = $fact->target(); // Don’t backtrack if ($individual instanceof self && !in_array($individual, $cache[$n - 2], true)) { $cache[$n][] = $individual; } } } if (in_array($target, $cache[$n], true)) { return true; } } else { // Add INDI->FAM links foreach ($cache[$n - 1] as $individual) { foreach ($individual->facts(['FAMC', 'FAMS'], false, Auth::PRIV_HIDE) as $fact) { $family = $fact->target(); // Don’t backtrack if ($family instanceof Family && !in_array($family, $cache[$n - 2], true)) { $cache[$n][] = $family; } } } } } } return false; } /** * Generate a private version of this record * * @param int $access_level * * @return string */ protected function createPrivateGedcomRecord(int $access_level): string { $SHOW_PRIVATE_RELATIONSHIPS = (bool) $this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS'); $rec = '0 @' . $this->xref . '@ INDI'; if ($this->tree->getPreference('SHOW_LIVING_NAMES') >= $access_level) { // Show all the NAME tags, including subtags foreach ($this->facts(['NAME']) as $fact) { $rec .= "\n" . $fact->gedcom(); } } // Just show the 1 FAMC/FAMS tag, not any subtags, which may contain private data preg_match_all('/\n1 (?:FAMC|FAMS) @(' . Gedcom::REGEX_XREF . ')@/', $this->gedcom, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $rela = Family::getInstance($match[1], $this->tree); if ($rela && ($SHOW_PRIVATE_RELATIONSHIPS || $rela->canShow($access_level))) { $rec .= $match[0]; } } // Don’t privatize sex. if (preg_match('/\n1 SEX [MFU]/', $this->gedcom, $match)) { $rec .= $match[0]; } return $rec; } /** * Fetch data from the database * * @param string $xref * @param int $tree_id * * @return string|null */ protected static function fetchGedcomRecord(string $xref, int $tree_id): ?string { return DB::table('individuals') ->where('i_id', '=', $xref) ->where('i_file', '=', $tree_id) ->value('i_gedcom'); } /** * Calculate whether this individual is living or dead. * If not known to be dead, then assume living. * * @return bool */ public function isDead(): bool { $MAX_ALIVE_AGE = (int) $this->tree->getPreference('MAX_ALIVE_AGE'); $today_jd = Carbon::now()->julianDay(); // "1 DEAT Y" or "1 DEAT/2 DATE" or "1 DEAT/2 PLAC" if (preg_match('/\n1 (?:' . implode('|', Gedcom::DEATH_EVENTS) . ')(?: Y|(?:\n[2-9].+)*\n2 (DATE|PLAC) )/', $this->gedcom)) { return true; } // If any event occured more than $MAX_ALIVE_AGE years ago, then assume the individual is dead if (preg_match_all('/\n2 DATE (.+)/', $this->gedcom, $date_matches)) { foreach ($date_matches[1] as $date_match) { $date = new Date($date_match); if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * $MAX_ALIVE_AGE) { return true; } } // The individual has one or more dated events. All are less than $MAX_ALIVE_AGE years ago. // If one of these is a birth, the individual must be alive. if (preg_match('/\n1 BIRT(?:\n[2-9].+)*\n2 DATE /', $this->gedcom)) { return false; } } // If we found no conclusive dates then check the dates of close relatives. // Check parents (birth and adopted) foreach ($this->childFamilies(Auth::PRIV_HIDE) as $family) { foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) { // Assume parents are no more than 45 years older than their children preg_match_all('/\n2 DATE (.+)/', $parent->gedcom, $date_matches); foreach ($date_matches[1] as $date_match) { $date = new Date($date_match); if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 45)) { return true; } } } } // Check spouses foreach ($this->spouseFamilies(Auth::PRIV_HIDE) as $family) { preg_match_all('/\n2 DATE (.+)/', $family->gedcom, $date_matches); foreach ($date_matches[1] as $date_match) { $date = new Date($date_match); // Assume marriage occurs after age of 10 if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 10)) { return true; } } // Check spouse dates $spouse = $family->spouse($this, Auth::PRIV_HIDE); if ($spouse) { preg_match_all('/\n2 DATE (.+)/', $spouse->gedcom, $date_matches); foreach ($date_matches[1] as $date_match) { $date = new Date($date_match); // Assume max age difference between spouses of 40 years if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE + 40)) { return true; } } } // Check child dates foreach ($family->children(Auth::PRIV_HIDE) as $child) { preg_match_all('/\n2 DATE (.+)/', $child->gedcom, $date_matches); // Assume children born after age of 15 foreach ($date_matches[1] as $date_match) { $date = new Date($date_match); if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 15)) { return true; } } // Check grandchildren foreach ($child->spouseFamilies(Auth::PRIV_HIDE) as $child_family) { foreach ($child_family->children(Auth::PRIV_HIDE) as $grandchild) { preg_match_all('/\n2 DATE (.+)/', $grandchild->gedcom, $date_matches); // Assume grandchildren born after age of 30 foreach ($date_matches[1] as $date_match) { $date = new Date($date_match); if ($date->isOK() && $date->maximumJulianDay() <= $today_jd - 365 * ($MAX_ALIVE_AGE - 30)) { return true; } } } } } } return false; } /** * Find the highlighted media object for an individual * * @return MediaFile|null */ public function findHighlightedMediaFile(): ?MediaFile { foreach ($this->facts(['OBJE']) as $fact) { $media = $fact->target(); if ($media instanceof Media) { foreach ($media->mediaFiles() as $media_file) { if ($media_file->isImage() && !$media_file->isExternal()) { return $media_file; } } } } return null; } /** * Display the prefered image for this individual. * Use an icon if no image is available. * * @param int $width Pixels * @param int $height Pixels * @param string $fit "crop" or "contain" * @param string[] $attributes Additional HTML attributes * * @return string */ public function displayImage($width, $height, $fit, $attributes): string { $media_file = $this->findHighlightedMediaFile(); if ($media_file !== null) { return $media_file->displayImage($width, $height, $fit, $attributes); } if ($this->tree->getPreference('USE_SILHOUETTE')) { return ''; } return ''; } /** * Get the date of birth * * @return Date */ public function getBirthDate(): Date { foreach ($this->getAllBirthDates() as $date) { if ($date->isOK()) { return $date; } } return new Date(''); } /** * Get the place of birth * * @return Place */ public function getBirthPlace(): Place { foreach ($this->getAllBirthPlaces() as $place) { return $place; } return new Place('', $this->tree); } /** * Get the year of birth * * @return string the year of birth */ public function getBirthYear(): string { return $this->getBirthDate()->minimumDate()->format('%Y'); } /** * Get the date of death * * @return Date */ public function getDeathDate(): Date { foreach ($this->getAllDeathDates() as $date) { if ($date->isOK()) { return $date; } } return new Date(''); } /** * Get the place of death * * @return Place */ public function getDeathPlace(): Place { foreach ($this->getAllDeathPlaces() as $place) { return $place; } return new Place('', $this->tree); } /** * get the death year * * @return string the year of death */ public function getDeathYear(): string { return $this->getDeathDate()->minimumDate()->format('%Y'); } /** * Get the range of years in which a individual lived. e.g. “1870–”, “1870–1920”, “–1920”. * Provide the place and full date using a tooltip. * For consistent layout in charts, etc., show just a “–” when no dates are known. * Note that this is a (non-breaking) en-dash, and not a hyphen. * * @return string */ public function lifespan(): string { // Just the first part of the place name $birth_place = strip_tags($this->getBirthPlace()->shortName()); $death_place = strip_tags($this->getDeathPlace()->shortName()); // Remove markup from dates $birth_date = strip_tags($this->getBirthDate()->display()); $death_date = strip_tags($this->getDeathDate()->display()); /* I18N: A range of years, e.g. “1870–”, “1870–1920”, “–1920” */ return I18N::translate( '%1$s–%2$s', '' . $this->getBirthYear() . '', '' . $this->getDeathYear() . '' ); } /** * Get all the birth dates - for the individual lists. * * @return Date[] */ public function getAllBirthDates(): array { foreach (Gedcom::BIRTH_EVENTS as $event) { $tmp = $this->getAllEventDates([$event]); if ($tmp) { return $tmp; } } return []; } /** * Gat all the birth places - for the individual lists. * * @return Place[] */ public function getAllBirthPlaces(): array { foreach (Gedcom::BIRTH_EVENTS as $event) { $places = $this->getAllEventPlaces([$event]); if ($places !== []) { return $places; } } return []; } /** * Get all the death dates - for the individual lists. * * @return Date[] */ public function getAllDeathDates(): array { foreach (Gedcom::DEATH_EVENTS as $event) { $tmp = $this->getAllEventDates([$event]); if ($tmp) { return $tmp; } } return []; } /** * Get all the death places - for the individual lists. * * @return Place[] */ public function getAllDeathPlaces(): array { foreach (Gedcom::DEATH_EVENTS as $event) { $places = $this->getAllEventPlaces([$event]); if ($places !== []) { return $places; } } return []; } /** * Generate an estimate for the date of birth, based on dates of parents/children/spouses * * @return Date */ public function getEstimatedBirthDate(): Date { if ($this->estimated_birth_date === null) { foreach ($this->getAllBirthDates() as $date) { if ($date->isOK()) { $this->estimated_birth_date = $date; break; } } if ($this->estimated_birth_date === null) { $min = []; $max = []; $tmp = $this->getDeathDate(); if ($tmp->isOK()) { $min[] = $tmp->minimumJulianDay() - $this->tree->getPreference('MAX_ALIVE_AGE') * 365; $max[] = $tmp->maximumJulianDay(); } foreach ($this->childFamilies() as $family) { $tmp = $family->getMarriageDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() - 365 * 1; $max[] = $tmp->minimumJulianDay() + 365 * 30; } $husband = $family->husband(); if ($husband instanceof self) { $tmp = $husband->getBirthDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() + 365 * 15; $max[] = $tmp->minimumJulianDay() + 365 * 65; } } $wife = $family->wife(); if ($wife instanceof self) { $tmp = $wife->getBirthDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() + 365 * 15; $max[] = $tmp->minimumJulianDay() + 365 * 45; } } foreach ($family->children() as $child) { $tmp = $child->getBirthDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() - 365 * 30; $max[] = $tmp->minimumJulianDay() + 365 * 30; } } } foreach ($this->spouseFamilies() as $family) { $tmp = $family->getMarriageDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() - 365 * 45; $max[] = $tmp->minimumJulianDay() - 365 * 15; } $spouse = $family->spouse($this); if ($spouse) { $tmp = $spouse->getBirthDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() - 365 * 25; $max[] = $tmp->minimumJulianDay() + 365 * 25; } } foreach ($family->children() as $child) { $tmp = $child->getBirthDate(); if ($tmp->isOK()) { $min[] = $tmp->maximumJulianDay() - 365 * ($this->sex() === 'F' ? 45 : 65); $max[] = $tmp->minimumJulianDay() - 365 * 15; } } } if ($min && $max) { $gregorian_calendar = new GregorianCalendar(); [$year] = $gregorian_calendar->jdToYmd(intdiv(max($min) + min($max), 2)); $this->estimated_birth_date = new Date('EST ' . $year); } else { $this->estimated_birth_date = new Date(''); // always return a date object } } } return $this->estimated_birth_date; } /** * Generate an estimated date of death. * * @return Date */ public function getEstimatedDeathDate(): Date { if ($this->estimated_death_date === null) { foreach ($this->getAllDeathDates() as $date) { if ($date->isOK()) { $this->estimated_death_date = $date; break; } } if ($this->estimated_death_date === null) { if ($this->getEstimatedBirthDate()->minimumJulianDay()) { $max_alive_age = (int) $this->tree->getPreference('MAX_ALIVE_AGE'); $this->estimated_death_date = $this->getEstimatedBirthDate()->addYears($max_alive_age, 'BEF'); } else { $this->estimated_death_date = new Date(''); // always return a date object } } } return $this->estimated_death_date; } /** * Get the sex - M F or U * Use the un-privatised gedcom record. We call this function during * the privatize-gedcom function, and we are allowed to know this. * * @return string */ public function sex(): string { if (preg_match('/\n1 SEX ([MF])/', $this->gedcom . $this->pending, $match)) { return $match[1]; } return 'U'; } /** * Generate the CSS class to be used for drawing this individual * * @return string */ public function getBoxStyle(): string { $tmp = [ 'M' => '', 'F' => 'F', 'U' => 'NN', ]; return 'person_box' . $tmp[$this->sex()]; } /** * Get a list of this individual’s spouse families * * @param int|null $access_level * * @return Collection */ public function spouseFamilies($access_level = null): Collection { $access_level = $access_level ?? Auth::accessLevel($this->tree); if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') { $access_level = Auth::PRIV_HIDE; } $families = new Collection(); foreach ($this->facts(['FAMS'], false, $access_level) as $fact) { $family = $fact->target(); if ($family instanceof Family && $family->canShow($access_level)) { $families->push($family); } } return new Collection($families); } /** * Get the current spouse of this individual. * * Where an individual has multiple spouses, assume they are stored * in chronological order, and take the last one found. * * @return Individual|null */ public function getCurrentSpouse(): ?Individual { $family = $this->spouseFamilies()->last(); if ($family instanceof Family) { return $family->spouse($this); } return null; } /** * Count the children belonging to this individual. * * @return int */ public function numberOfChildren(): int { if (preg_match('/\n1 NCHI (\d+)(?:\n|$)/', $this->gedcom(), $match)) { return (int) $match[1]; } $children = []; foreach ($this->spouseFamilies() as $fam) { foreach ($fam->children() as $child) { $children[$child->xref()] = true; } } return count($children); } /** * Get a list of this individual’s child families (i.e. their parents). * * @param int|null $access_level * * @return Collection */ public function childFamilies($access_level = null): Collection { $access_level = $access_level ?? Auth::accessLevel($this->tree); if ($this->tree->getPreference('SHOW_PRIVATE_RELATIONSHIPS') === '1') { $access_level = Auth::PRIV_HIDE; } $families = new Collection(); foreach ($this->facts(['FAMC'], false, $access_level) as $fact) { $family = $fact->target(); if ($family instanceof Family && $family->canShow($access_level)) { $families->push($family); } } return $families; } /** * Get a list of step-parent families. * * @return Collection */ public function childStepFamilies(): Collection { $step_families = new Collection(); $families = $this->childFamilies(); foreach ($families as $family) { foreach ($family->spouses() as $parent) { foreach ($parent->spouseFamilies() as $step_family) { if (!$families->containsStrict($step_family)) { $step_families->add($step_family); } } } } return $step_families->uniqueStrict(static function (Family $family): string { return $family->xref(); }); } /** * Get a list of step-parent families. * * @return Collection */ public function spouseStepFamilies(): Collection { $step_families = []; $families = $this->spouseFamilies(); foreach ($families as $family) { $spouse = $family->spouse($this); if ($spouse instanceof Individual) { foreach ($family->spouse($this)->spouseFamilies() as $step_family) { if (!$families->containsStrict($step_family)) { $step_families[] = $step_family; } } } } return new Collection($step_families); } /** * A label for a parental family group * * @param Family $family * * @return string */ public function getChildFamilyLabel(Family $family): string { if (preg_match('/\n1 FAMC @' . $family->xref() . '@(?:\n[2-9].*)*\n2 PEDI (.+)/', $this->gedcom(), $match)) { // A specified pedigree return GedcomCodePedi::getChildFamilyLabel($match[1]); } // Default (birth) pedigree return GedcomCodePedi::getChildFamilyLabel(''); } /** * Create a label for a step family * * @param Family $step_family * * @return string */ public function getStepFamilyLabel(Family $step_family): string { foreach ($this->childFamilies() as $family) { if ($family !== $step_family) { // Must be a step-family foreach ($family->spouses() as $parent) { foreach ($step_family->spouses() as $step_parent) { if ($parent === $step_parent) { // One common parent - must be a step family if ($parent->sex() === 'M') { // Father’s family with someone else if ($step_family->spouse($step_parent)) { /* I18N: A step-family. %s is an individual’s name */ return I18N::translate('Father’s family with %s', $step_family->spouse($step_parent)->fullName()); } /* I18N: A step-family. */ return I18N::translate('Father’s family with an unknown individual'); } // Mother’s family with someone else if ($step_family->spouse($step_parent)) { /* I18N: A step-family. %s is an individual’s name */ return I18N::translate('Mother’s family with %s', $step_family->spouse($step_parent)->fullName()); } /* I18N: A step-family. */ return I18N::translate('Mother’s family with an unknown individual'); } } } } } // Perahps same parents - but a different family record? return I18N::translate('Family with parents'); } /** * Get the description for the family. * * For example, "XXX's family with new wife". * * @param Family $family * * @return string */ public function getSpouseFamilyLabel(Family $family): string { $spouse = $family->spouse($this); if ($spouse) { /* I18N: %s is the spouse name */ return I18N::translate('Family with %s', $spouse->fullName()); } return $family->fullName(); } /** * If this object has no name, what do we call it? * * @return string */ public function getFallBackName(): string { return '@P.N. /@N.N./'; } /** * Convert a name record into ‘full’ and ‘sort’ versions. * Use the NAME field to generate the ‘full’ version, as the * gedcom spec says that this is the individual’s name, as they would write it. * Use the SURN field to generate the sortable names. Note that this field * may also be used for the ‘true’ surname, perhaps spelt differently to that * recorded in the NAME field. e.g. * * 1 NAME Robert /de Gliderow/ * 2 GIVN Robert * 2 SPFX de * 2 SURN CLITHEROW * 2 NICK The Bald * * full=>'Robert de Gliderow 'The Bald'' * sort=>'CLITHEROW, ROBERT' * * Handle multiple surnames, either as; * * 1 NAME Carlos /Vasquez/ y /Sante/ * or * 1 NAME Carlos /Vasquez y Sante/ * 2 GIVN Carlos * 2 SURN Vasquez,Sante * * @param string $type * @param string $full * @param string $gedcom * * @return void */ protected function addName(string $type, string $full, string $gedcom): void { //////////////////////////////////////////////////////////////////////////// // Extract the structured name parts - use for "sortable" names and indexes //////////////////////////////////////////////////////////////////////////// $sublevel = 1 + (int) substr($gedcom, 0, 1); $GIVN = preg_match("/\n{$sublevel} GIVN (.+)/", $gedcom, $match) ? $match[1] : ''; $SURN = preg_match("/\n{$sublevel} SURN (.+)/", $gedcom, $match) ? $match[1] : ''; $NICK = preg_match("/\n{$sublevel} NICK (.+)/", $gedcom, $match) ? $match[1] : ''; // SURN is an comma-separated list of surnames... if ($SURN !== '') { $SURNS = preg_split('/ *, */', $SURN); } else { $SURNS = []; } // ...so is GIVN - but nobody uses it like that $GIVN = str_replace('/ *, */', ' ', $GIVN); //////////////////////////////////////////////////////////////////////////// // Extract the components from NAME - use for the "full" names //////////////////////////////////////////////////////////////////////////// // Fix bad slashes. e.g. 'John/Smith' => 'John/Smith/' if (substr_count($full, '/') % 2 === 1) { $full .= '/'; } // GEDCOM uses "//" to indicate an unknown surname $full = preg_replace('/\/\//', '/@N.N./', $full); // Extract the surname. // Note, there may be multiple surnames, e.g. Jean /Vasquez/ y /Cortes/ if (preg_match('/\/.*\//', $full, $match)) { $surname = str_replace('/', '', $match[0]); } else { $surname = ''; } // If we don’t have a SURN record, extract it from the NAME if (!$SURNS) { if (preg_match_all('/\/([^\/]*)\//', $full, $matches)) { // There can be many surnames, each wrapped with '/' $SURNS = $matches[1]; foreach ($SURNS as $n => $SURN) { // Remove surname prefixes, such as "van de ", "d'" and "'t " (lower case only) $SURNS[$n] = preg_replace('/^(?:[a-z]+ |[a-z]+\' ?|\'[a-z]+ )+/', '', $SURN); } } else { // It is valid not to have a surname at all $SURNS = ['']; } } // If we don’t have a GIVN record, extract it from the NAME if (!$GIVN) { $GIVN = preg_replace( [ '/ ?\/.*\/ ?/', // remove surname '/ ?".+"/', // remove nickname '/ {2,}/', // multiple spaces, caused by the above '/^ | $/', // leading/trailing spaces, caused by the above ], [ ' ', ' ', ' ', '', ], $full ); } // Add placeholder for unknown given name if (!$GIVN) { $GIVN = '@P.N.'; $pos = (int) strpos($full, '/'); $full = substr($full, 0, $pos) . '@P.N. ' . substr($full, $pos); } // GEDCOM 5.5.1 nicknames should be specificied in a NICK field // GEDCOM 5.5 nicknames should be specified in the NAME field, surrounded by quotes if ($NICK && strpos($full, '"' . $NICK . '"') === false) { // A NICK field is present, but not included in the NAME. Show it at the end. $full .= ' "' . $NICK . '"'; } // Remove slashes - they don’t get displayed // $fullNN keeps the @N.N. placeholders, for the database // $full is for display on-screen $fullNN = str_replace('/', '', $full); // Insert placeholders for any missing/unknown names $full = str_replace('@N.N.', I18N::translateContext('Unknown surname', '…'), $full); $full = str_replace('@P.N.', I18N::translateContext('Unknown given name', '…'), $full); // Format for display $full = '' . preg_replace('/\/([^\/]*)\//', '$1', e($full)) . ''; // Localise quotation marks around the nickname $full = preg_replace_callback('/"([^&]*)"/', static function (array $matches): string { return '' . $matches[1] . ''; }, $full); // A suffix of “*” indicates a preferred name $full = preg_replace('/([^ >]*)\*/', '\\1', $full); // Remove prefered-name indicater - they don’t go in the database $GIVN = str_replace('*', '', $GIVN); $fullNN = str_replace('*', '', $fullNN); foreach ($SURNS as $SURN) { // Scottish 'Mc and Mac ' prefixes both sort under 'Mac' if (strcasecmp(substr($SURN, 0, 2), 'Mc') === 0) { $SURN = substr_replace($SURN, 'Mac', 0, 2); } elseif (strcasecmp(substr($SURN, 0, 4), 'Mac ') === 0) { $SURN = substr_replace($SURN, 'Mac', 0, 4); } $this->getAllNames[] = [ 'type' => $type, 'sort' => $SURN . ',' . $GIVN, 'full' => $full, // This is used for display 'fullNN' => $fullNN, // This goes into the database 'surname' => $surname, // This goes into the database 'givn' => $GIVN, // This goes into the database 'surn' => $SURN, // This goes into the database ]; } } /** * Extract names from the GEDCOM record. * * @return void */ public function extractNames(): void { $access_level = $this->canShowName() ? Auth::PRIV_HIDE : Auth::accessLevel($this->tree); $this->extractNamesFromFacts( 1, 'NAME', $this->facts(['NAME'], false, $access_level) ); } /** * Extra info to display when displaying this record in a list of * selection items or favorites. * * @return string */ public function formatListDetails(): string { return $this->formatFirstMajorFact(Gedcom::BIRTH_EVENTS, 1) . $this->formatFirstMajorFact(Gedcom::DEATH_EVENTS, 1); } }