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
+11
View File
@@ -23,6 +23,7 @@ final class Configuration
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS';
public const string PREF_LINEAL_DEPTH = 'NEWSLETTER_LINEAL_DEPTH';
public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT';
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
@@ -36,10 +37,13 @@ final class Configuration
public const int DEFAULT_FREQUENCY_DAYS = 14;
public const int DEFAULT_LOOKAHEAD_DAYS = 14;
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
public const int DEFAULT_LINEAL_DEPTH = 3;
public const int MIN_FREQUENCY_DAYS = 1;
public const int MAX_FREQUENCY_DAYS = 90;
public const int MIN_LOOKAHEAD_DAYS = 1;
public const int MAX_LOOKAHEAD_DAYS = 60;
public const int MIN_LINEAL_DEPTH = 0;
public const int MAX_LINEAL_DEPTH = 10;
public static function isEnabled(Tree $tree): bool
{
@@ -65,6 +69,13 @@ final class Configuration
return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1';
}
public static function linealDepth(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_LINEAL_DEPTH, (string) self::DEFAULT_LINEAL_DEPTH);
return max(self::MIN_LINEAL_DEPTH, min(self::MAX_LINEAL_DEPTH, $value));
}
public static function historicalLookaheadDays(Tree $tree): int
{
$value = (int) $tree->getPreference(
+10
View File
@@ -122,6 +122,12 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Living kin who will celebrate this fortnight.'
=> 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.',
'Marriages still intact.' => 'Noch bestehende Ehen.',
'Other birthdays' => 'Weitere Geburtstage',
'Other anniversaries' => 'Weitere Hochzeitstage',
'Other historical events' => 'Weitere historische Ereignisse',
'Detailed view depth (generations)' => 'Detailansicht-Tiefe (Generationen)',
'Recipients see profile pictures, icons and the timeline only for their own direct ancestors and descendants within this many generations. Everyone else appears as a compact text list at the bottom of each section. Set to 0 to render the whole newsletter as text. Recipients with no linked tree record always see the full detailed view.'
=> 'Empfänger sehen Profilbilder, Symbole und Zeitachse nur für ihre eigenen direkten Vorfahren und Nachkommen innerhalb dieser Generationenzahl. Alle anderen erscheinen als kompakte Textliste am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
'%s birthday' => '%s Geburtstag',
'%s wedding anniversary' => '%s Hochzeitstag',
'Birthday' => 'Geburtstag',
@@ -231,6 +237,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
$histLook = Validator::parsedBody($request)
->isBetween(7, 60)
->integer('historical-' . $id, Configuration::DEFAULT_HISTORICAL_LOOKAHEAD);
$lineal = Validator::parsedBody($request)
->isBetween(Configuration::MIN_LINEAL_DEPTH, Configuration::MAX_LINEAL_DEPTH)
->integer('lineal-' . $id, Configuration::DEFAULT_LINEAL_DEPTH);
$annivs = Validator::parsedBody($request)->string('anniversaries-' . $id, '0') === '1';
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
$subject = Validator::parsedBody($request)->string('subject-' . $id, '');
@@ -239,6 +248,7 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
$tree->setPreference(Configuration::PREF_FREQUENCY_DAYS, (string) $frequency);
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
$tree->setPreference(Configuration::PREF_HISTORICAL_LOOKAHEAD, (string) $histLook);
$tree->setPreference(Configuration::PREF_LINEAL_DEPTH, (string) $lineal);
$tree->setPreference(Configuration::PREF_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0');
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
+74 -1
View File
@@ -173,6 +173,15 @@ final class NewsletterDispatchService
// record the recipient is linked to in this tree.
foreach ($group as $recipient) {
$relationships = $this->relationshipMap($tree, $recipient, $featured);
$detailed_set = $this->detailedXrefs($tree, $recipient, $featured);
// Trim the embedded image set to only the avatars
// we'll actually reference (detailed rows). Summary
// bullets render without pictures.
$recipient_avatars = array_intersect_key(
$avatars,
$this->avatarKeysForXrefs(array_keys($detailed_set)),
);
$html = view($module->name() . '::email', [
'tree' => $tree,
@@ -186,13 +195,14 @@ final class NewsletterDispatchService
'generated_at' => $now,
'avatar_cids' => $avatar_cids,
'relationships' => $relationships,
'detailed_xrefs' => $detailed_set,
'account_url' => $account_url,
]);
$text = $this->htmlToText($html);
try {
if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $avatars)) {
if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $recipient_avatars)) {
$sent++;
} else {
$failures++;
@@ -269,6 +279,69 @@ final class NewsletterDispatchService
return $individuals;
}
/**
* Which featured xrefs deserve the full detailed row (avatar +
* timeline) for this recipient?
*
* If the recipient is unmapped to the tree (external address or a
* user with no PREF_TREE_ACCOUNT_XREF), every featured xref counts
* as "detailed" — they have no lineal context to filter against.
*
* Otherwise, only the recipient's direct ancestors and descendants
* within Configuration::linealDepth() generations qualify. For
* Family records (anniversaries), either spouse being lineal
* promotes the row.
*
* @param array<string,Individual> $featured
*
* @return array<string,true> Set of featured xrefs to render in detail.
*/
private function detailedXrefs(Tree $tree, UserInterface $recipient, array $featured): array
{
$self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($self_xref === '') {
// Unmapped recipient — no lineal anchor, show everything.
return array_fill_keys(array_keys($featured), true);
}
$self = Registry::individualFactory()->make($self_xref, $tree);
if (!$self instanceof Individual) {
return array_fill_keys(array_keys($featured), true);
}
$depth = Configuration::linealDepth($tree);
$lineal_set = $this->relationship_finder->linealKin($self, $depth);
$detailed = [];
foreach ($featured as $xref => $_individual) {
if (isset($lineal_set[$xref])) {
$detailed[$xref] = true;
}
}
return $detailed;
}
/**
* Translates a list of xrefs into the cid-name keys used by the
* avatar embed map ("avatar-<xref>" => true). Lets us
* array_intersect_key the embeds map cheaply.
*
* @param array<int,string> $xrefs
* @return array<string,true>
*/
private function avatarKeysForXrefs(array $xrefs): array
{
$keys = [];
foreach ($xrefs as $xref) {
$keys['avatar-' . $xref] = true;
}
return $keys;
}
/**
* Build a "xref => relationship label" map for one recipient.
*
+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