Limit detailed view to lineal kin; rest as summary bullets

Per-recipient: only direct ancestors and direct descendants
within a configurable number of generations (default 3) get the
full row treatment (avatar, icon, timeline). Everyone else falls
through to a compact text-only bullet list at the bottom of the
same section.

- New tree preference NEWSLETTER_LINEAL_DEPTH (range 0–10,
  default 3) with a clearly-explained admin input.
- RelationshipPathFinder::linealKin() does two cheap recursive
  expansions (ancestors and descendants only — no spouse or
  sibling traversal) and returns the xref set. Memoised per
  recipient within a dispatch run.
- Avatar attachments are filtered per recipient to only the
  embeds actually referenced in their HTML, so summary-only rows
  no longer inflate per-email size with unused images.
- Recipients with no PREF_TREE_ACCOUNT_XREF (external admin
  addresses, users not linked to a record) see the entire
  newsletter in detail — no lineal anchor to filter against.
- German translations for the three new section kickers ("Other
  birthdays", etc.) and the admin input help text.
This commit is contained in:
2026-05-15 13:01:41 +02:00
parent 3bc25a2bdb
commit ff743e484f
6 changed files with 339 additions and 43 deletions
+74
View File
@@ -33,6 +33,9 @@ final class RelationshipPathFinder
/** Default BFS depth — ~7 generations of ancestry / descent. */
public const int DEFAULT_MAX_DEPTH = 14;
/** @var array<string,array<string,true>> Cache: xref => {ancestor/descendant xref set} */
private array $lineal_cache = [];
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
private array $label_cache = [];
@@ -74,6 +77,77 @@ final class RelationshipPathFinder
return $this->label_cache[$cache_key] = $this->relationship_service->nameFromPath($path, $language);
}
/**
* Set of xrefs that are direct ancestors or direct descendants of
* `$root` within `$depth` generations.
*
* Only lineal hops are followed (parent ↔ child) — spouses,
* siblings, and step-relations are deliberately excluded. The
* recipient's own xref is included in the set.
*
* The result is memoised: callers may invoke this once per
* recipient and reuse it across all featured xrefs.
*
* @return array<string,true>
*/
public function linealKin(Individual $root, int $depth): array
{
if ($depth < 0) {
return [$root->xref() => true];
}
$cache_key = $root->xref() . '|' . $depth;
if (isset($this->lineal_cache[$cache_key])) {
return $this->lineal_cache[$cache_key];
}
$set = [$root->xref() => true];
$this->expandAncestors($root, $depth, $set);
$this->expandDescendants($root, $depth, $set);
return $this->lineal_cache[$cache_key] = $set;
}
/**
* @param array<string,true> $set
*/
private function expandAncestors(Individual $indi, int $depth, array &$set): void
{
if ($depth <= 0) {
return;
}
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
if (!isset($set[$parent->xref()])) {
$set[$parent->xref()] = true;
$this->expandAncestors($parent, $depth - 1, $set);
}
}
}
}
/**
* @param array<string,true> $set
*/
private function expandDescendants(Individual $indi, int $depth, array &$set): void
{
if ($depth <= 0) {
return;
}
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
if (!isset($set[$child->xref()])) {
$set[$child->xref()] = true;
$this->expandDescendants($child, $depth - 1, $set);
}
}
}
}
/**
* BFS over child- and spouse-families, exactly matching
* RelationshipService::getCloseRelationship's traversal but with