105b09c4c5
Replaces the previous "depth in generations along the strict lineal chain" definition (which excluded siblings, aunts, cousins entirely) with the metric the user actually wants: the number of descent-steps separating the target from the recipient's closest direct ancestor or descendant. Examples relative to the recipient: - sibling: 1 (parent → sibling) - great-aunt: 1 (great-grandparent → great-aunt) - nephew: 2 (parent → sibling → nephew) - first cousin: 2 (grandparent → aunt → cousin) - second cousin: 3 - ego, parents, grandparents, ..., children, ..., great-greats: 0 - own spouse, step-parents, brothers-in-law: inherit partner's distance (so spouse-of-distance-1 is also distance 1) Implementation: - Anchor set seeded with R's direct ancestors + R + direct descendants (capped at 25 generations to bound runaway data). - Multi-source BFS expanding by descent only. - Spouse propagation at every level so a person and their spouse always share the same distance. - Memoised per (recipient xref, max distance). Tree preference key and range kept (NEWSLETTER_LINEAL_DEPTH, 0–10, default 3); only the semantics and the user-facing label + help text change, with concrete examples in both English and German.
331 lines
11 KiB
PHP
331 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace EmailNewsletter\Services;
|
|
|
|
use Fisharebest\Webtrees\Auth;
|
|
use Fisharebest\Webtrees\Family;
|
|
use Fisharebest\Webtrees\I18N;
|
|
use Fisharebest\Webtrees\Individual;
|
|
use Fisharebest\Webtrees\Module\ModuleLanguageInterface;
|
|
use Fisharebest\Webtrees\Registry;
|
|
use Fisharebest\Webtrees\Services\ModuleService;
|
|
use Fisharebest\Webtrees\Services\RelationshipService;
|
|
|
|
/**
|
|
* Computes a human-readable relationship label between two individuals
|
|
* (e.g. "your mother", "4th great-grandfather") using webtrees' own
|
|
* relationship matcher.
|
|
*
|
|
* webtrees ships RelationshipService::getCloseRelationshipName which
|
|
* caps the BFS at four hops — enough for nephews and grandparents,
|
|
* not for great-great-grandparents. This class mirrors that algorithm
|
|
* (childFamilies + spouseFamilies expansion, visited-set bookkeeping)
|
|
* but with a configurable depth so we can label distant ancestors.
|
|
*
|
|
* The expensive step is the BFS; nameFromPath() formatting is cheap.
|
|
* Results are memoised per (recipient xref, target xref) for the
|
|
* lifetime of one dispatch run.
|
|
*/
|
|
final class RelationshipPathFinder
|
|
{
|
|
/** Default BFS depth — ~7 generations of ancestry / descent. */
|
|
public const int DEFAULT_MAX_DEPTH = 14;
|
|
|
|
/** @var array<string,array<string,int>> Cache: "rootXref|maxDistance" => {xref => distance} */
|
|
private array $distance_cache = [];
|
|
|
|
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
|
|
private array $label_cache = [];
|
|
|
|
/** @var array<string,ModuleLanguageInterface|null> Cache: languageTag -> module */
|
|
private array $language_cache = [];
|
|
|
|
public function __construct(
|
|
private readonly RelationshipService $relationship_service,
|
|
private readonly ModuleService $module_service,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Returns the relationship label, or null if no path was found
|
|
* within the configured depth.
|
|
*/
|
|
public function label(Individual $from, Individual $to, int $max_depth = self::DEFAULT_MAX_DEPTH): string|null
|
|
{
|
|
$cache_key = $from->xref() . '|' . $to->xref();
|
|
|
|
if (array_key_exists($cache_key, $this->label_cache)) {
|
|
return $this->label_cache[$cache_key];
|
|
}
|
|
|
|
$path = $this->findPath($from, $to, $max_depth);
|
|
|
|
if ($path === []) {
|
|
return $this->label_cache[$cache_key] = null;
|
|
}
|
|
|
|
$language = $this->languageForCurrentLocale();
|
|
|
|
if ($language === null) {
|
|
// Every webtrees install has at least the English language
|
|
// module enabled by default; if not, we can't label.
|
|
return $this->label_cache[$cache_key] = null;
|
|
}
|
|
|
|
return $this->label_cache[$cache_key] = $this->relationship_service->nameFromPath($path, $language);
|
|
}
|
|
|
|
/**
|
|
* Hard cap on how far up / down we'll walk when seeding the direct
|
|
* lineal anchor set. 25 generations is well beyond practical
|
|
* genealogy and prevents pathological data from running away.
|
|
*/
|
|
private const int LINEAL_SEED_DEPTH = 25;
|
|
|
|
/**
|
|
* Distance from `$root` to every reachable kin within `$max_distance`,
|
|
* measured by the "branch length from the closest direct ancestor
|
|
* or descendant of `$root`" metric:
|
|
*
|
|
* 1. Take all of `$root`'s direct ancestors and descendants (and
|
|
* `$root` itself). These are *anchors* at distance 0.
|
|
* 2. Walk downward (child links only) from every anchor. Each
|
|
* hop increases distance by one — so e.g. sibling = 1
|
|
* (anchor = parent, one hop down), great-aunt = 1 (anchor =
|
|
* great-grandparent, one hop down to great-aunt), nephew = 2
|
|
* (anchor = parent → sibling → nephew), first cousin = 2.
|
|
* 3. At every level, a person's spouses inherit that level too —
|
|
* so brothers-in-law share the sibling's distance, and step-
|
|
* grandparents share grandparents' distance.
|
|
*
|
|
* The search stops at `$max_distance` to keep the result bounded.
|
|
*
|
|
* @return array<string,int> xref => smallest distance found
|
|
*/
|
|
public function kinDistances(Individual $root, int $max_distance): array
|
|
{
|
|
$cache_key = $root->xref() . '|' . $max_distance;
|
|
|
|
if (isset($this->distance_cache[$cache_key])) {
|
|
return $this->distance_cache[$cache_key];
|
|
}
|
|
|
|
// Anchors: every direct-line ancestor and descendant, plus root.
|
|
$anchors = [$root->xref() => $root];
|
|
$this->collectAncestors($root, self::LINEAL_SEED_DEPTH, $anchors);
|
|
$this->collectDescendants($root, self::LINEAL_SEED_DEPTH, $anchors);
|
|
|
|
/** @var array<string,int> $distance */
|
|
$distance = [];
|
|
/** @var array<string,Individual> $frontier */
|
|
$frontier = [];
|
|
|
|
foreach ($anchors as $xref => $individual) {
|
|
$distance[$xref] = 0;
|
|
$frontier[$xref] = $individual;
|
|
}
|
|
|
|
// Spouses of anchors inherit distance 0 (so own spouse,
|
|
// step-parents, step-grandparents, ... all qualify).
|
|
$this->propagateSpouses($frontier, 0, $distance, $frontier);
|
|
|
|
for ($d = 1; $d <= $max_distance && $frontier !== []; $d++) {
|
|
$next = [];
|
|
|
|
foreach ($frontier as $individual) {
|
|
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
|
|
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
|
|
$xref = $child->xref();
|
|
if (!isset($distance[$xref])) {
|
|
$distance[$xref] = $d;
|
|
$next[$xref] = $child;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spouses of new arrivals share their distance.
|
|
$this->propagateSpouses($next, $d, $distance, $next);
|
|
|
|
$frontier = $next;
|
|
}
|
|
|
|
return $this->distance_cache[$cache_key] = $distance;
|
|
}
|
|
|
|
/**
|
|
* For every individual in $sources, mark each of their spouses as
|
|
* being at $level (if not already labelled at a smaller distance)
|
|
* and add them to $into so subsequent iterations also descend from
|
|
* them.
|
|
*
|
|
* @param array<string,Individual> $sources
|
|
* @param array<string,int> $distance
|
|
* @param array<string,Individual> $into
|
|
*/
|
|
private function propagateSpouses(array $sources, int $level, array &$distance, array &$into): void
|
|
{
|
|
foreach ($sources as $individual) {
|
|
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
|
|
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
|
|
$xref = $spouse->xref();
|
|
if (!isset($distance[$xref])) {
|
|
$distance[$xref] = $level;
|
|
$into[$xref] = $spouse;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,Individual> $bag
|
|
*/
|
|
private function collectAncestors(Individual $indi, int $depth, array &$bag): void
|
|
{
|
|
if ($depth <= 0) {
|
|
return;
|
|
}
|
|
|
|
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
|
|
foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
|
|
if (!isset($bag[$parent->xref()])) {
|
|
$bag[$parent->xref()] = $parent;
|
|
$this->collectAncestors($parent, $depth - 1, $bag);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,Individual> $bag
|
|
*/
|
|
private function collectDescendants(Individual $indi, int $depth, array &$bag): void
|
|
{
|
|
if ($depth <= 0) {
|
|
return;
|
|
}
|
|
|
|
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
|
|
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
|
|
if (!isset($bag[$child->xref()])) {
|
|
$bag[$child->xref()] = $child;
|
|
$this->collectDescendants($child, $depth - 1, $bag);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* BFS over child- and spouse-families, exactly matching
|
|
* RelationshipService::getCloseRelationship's traversal but with
|
|
* an externally configurable max depth.
|
|
*
|
|
* @return array<Individual|Family>
|
|
*/
|
|
private function findPath(Individual $from, Individual $to, int $max_depth): array
|
|
{
|
|
if ($from === $to || $from->xref() === $to->xref()) {
|
|
return [$from];
|
|
}
|
|
|
|
$visited = [$from->xref() => true];
|
|
$paths = [[$from]];
|
|
|
|
while ($max_depth >= 0) {
|
|
$max_depth--;
|
|
|
|
foreach ($paths as $i => $path) {
|
|
$indi = $path[count($path) - 1];
|
|
|
|
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
|
|
$result = $this->expandFamily($family, $path, $to, $visited, $paths);
|
|
if ($result !== null) {
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
|
|
$result = $this->expandFamily($family, $path, $to, $visited, $paths);
|
|
if ($result !== null) {
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
unset($paths[$i]);
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param array<Individual|Family> $path
|
|
* @param array<string,bool> $visited
|
|
* @param array<int,array<Individual|Family>> $paths
|
|
*
|
|
* @return array<Individual|Family>|null Completed path if $to was reached.
|
|
*/
|
|
private function expandFamily(
|
|
Family $family,
|
|
array $path,
|
|
Individual $to,
|
|
array &$visited,
|
|
array &$paths,
|
|
): array|null {
|
|
$visited[$family->xref()] = true;
|
|
|
|
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
|
|
if (isset($visited[$spouse->xref()])) {
|
|
continue;
|
|
}
|
|
|
|
$new_path = $path;
|
|
$new_path[] = $family;
|
|
$new_path[] = $spouse;
|
|
|
|
if ($spouse->xref() === $to->xref()) {
|
|
return $new_path;
|
|
}
|
|
|
|
$paths[] = $new_path;
|
|
$visited[$spouse->xref()] = true;
|
|
}
|
|
|
|
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
|
|
if (isset($visited[$child->xref()])) {
|
|
continue;
|
|
}
|
|
|
|
$new_path = $path;
|
|
$new_path[] = $family;
|
|
$new_path[] = $child;
|
|
|
|
if ($child->xref() === $to->xref()) {
|
|
return $new_path;
|
|
}
|
|
|
|
$paths[] = $new_path;
|
|
$visited[$child->xref()] = true;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function languageForCurrentLocale(): ModuleLanguageInterface|null
|
|
{
|
|
$tag = I18N::languageTag();
|
|
|
|
if (!array_key_exists($tag, $this->language_cache)) {
|
|
$this->language_cache[$tag] = $this->module_service
|
|
->findByInterface(ModuleLanguageInterface::class, true)
|
|
->first(fn (ModuleLanguageInterface $module): bool => $module->locale()->languageTag() === $tag);
|
|
}
|
|
|
|
return $this->language_cache[$tag];
|
|
}
|
|
|
|
}
|