Add per-recipient relationship labels in newsletter
Each featured person now carries a parenthetical label relative to the recipient: "Jane Doe (your mother) — 45th birthday", "Karl Müller (your 4th great-grandfather) — death". Labels are italic, muted, and only appear when a path can be computed. - New RelationshipPathFinder service mirrors webtrees' RelationshipService::getCloseRelationship BFS but with a configurable depth (default 14 hops ≈ 7 generations) so it reaches great-great-grandparents and beyond. Results are memoised per (recipient xref, target xref) within one dispatch run. - nameFromPath() formatting is delegated to webtrees so the label honours the configured UI language (German, English, etc.) and gendered/inflected forms. - The recipient's tree-bound Individual is looked up via Tree::getUserPreference(user, PREF_TREE_ACCOUNT_XREF). External admin-added recipients (no webtrees account, no linked record) silently get no labels — names render plain. - Trade-off: the view now renders once per recipient (instead of once per language group), because the relationship map is personalised. For typical subscriber counts the extra string- concat cost is negligible compared to the SMTP send itself.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<?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,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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user