diff --git a/resources/views/admin.phtml b/resources/views/admin.phtml
index 0ccd005..44d6ea9 100644
--- a/resources/views/admin.phtml
+++ b/resources/views/admin.phtml
@@ -47,6 +47,7 @@ use Illuminate\Support\Collection;
$annivs = Configuration::includeAnniversaries($tree);
$subject = Configuration::subjectPrefix($tree);
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
+ $lineal = Configuration::linealDepth($tree);
$last_sent = Configuration::lastSentAt($tree);
?>
@@ -129,6 +130,22 @@ use Illuminate\Support\Collection;
+
+
+ = I18N::translate('Detailed view depth (generations)') ?>
+
+
+
+
+ = I18N::translate('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.') ?>
+
+
+
+
= I18N::translate('Subject prefix') ?>
diff --git a/resources/views/email.phtml b/resources/views/email.phtml
index f327038..c9c70ee 100644
--- a/resources/views/email.phtml
+++ b/resources/views/email.phtml
@@ -20,8 +20,9 @@ use Illuminate\Support\Collection;
* @var int $lookahead_days
* @var int $historical_lookahead
* @var int $generated_at
- * @var array $avatar_cids xref => CID name
- * @var array $relationships xref => "your mother" etc. (per-recipient)
+ * @var array $avatar_cids xref => CID name
+ * @var array $relationships xref => "your mother" etc. (per-recipient)
+ * @var array $detailed_xrefs xref-set — render in detail; others as summary bullet
* @var string $account_url
*/
@@ -51,7 +52,34 @@ $avatar_size = 56;
// ─── Helpers ────────────────────────────────────────────────────────────
-$relationships = $relationships ?? [];
+$relationships = $relationships ?? [];
+$detailed_xrefs = $detailed_xrefs ?? [];
+
+/**
+ * Returns true if this fact's primary record should render as a full
+ * detailed row (avatar + icon + timeline), or false for the compact
+ * text-only summary bullet at the bottom of the section.
+ *
+ * A Family fact (anniversary) counts as detailed if either spouse is
+ * in the recipient's lineal set.
+ */
+$is_detailed = static function (Fact $fact) use ($detailed_xrefs): bool {
+ $record = $fact->record();
+
+ if ($record instanceof Individual) {
+ return isset($detailed_xrefs[$record->xref()]);
+ }
+
+ if ($record instanceof Family) {
+ foreach ([$record->husband(), $record->wife()] as $spouse) {
+ if ($spouse instanceof Individual && isset($detailed_xrefs[$spouse->xref()])) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+};
$linked_name = static function (Individual $individual) use ($palette, $relationships): string {
$name = strip_tags($individual->fullName());
@@ -309,6 +337,18 @@ $timeline_cell_style = 'width:170px;vertical-align:middle;'
// Renders one event row in the consistent 3-column layout shared by
// every section (avatar | content | date on the timeline rail).
+$summary_kicker_style = 'margin:18px 0 8px;'
+ . 'font-family:' . $font_stack . ';'
+ . 'font-weight:500;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;'
+ . 'color:' . $palette['ink3'] . ';'
+ . 'padding-top:10px;border-top:1px dashed ' . $palette['border'] . ';';
+
+$summary_list_style = 'list-style:none;margin:0;padding:0;'
+ . 'font-family:' . $font_stack . ';font-size:13px;line-height:1.7;font-weight:300;'
+ . 'color:' . $palette['ink2'] . ';';
+
+$summary_item_style = 'padding:3px 0;';
+
$event_row = static function (Fact $fact, string $body_html, string $dot_color)
use (
$record_avatars,
@@ -382,68 +422,139 @@ $card_close = '';
isEmpty()) : ?>
+
= e(I18N::translate('Upcoming birthdays')) ?>
= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
- = $card_open ?>
-
- ' . $event_icon('BIRT') . ''
- . ''
- . '' . $record_label($fact) . ' '
- . ' — ' . e($birthday_label($age)) . ' '
- . ' ';
- echo $event_row($fact, $body, $palette['birth']);
- ?>
-
- = $card_close ?>
+
+ = $card_open ?>
+
+ ' . $event_icon('BIRT') . ''
+ . ''
+ . '' . $record_label($fact) . ' '
+ . ' — ' . e($birthday_label($age)) . ' '
+ . ' ';
+ echo $event_row($fact, $body, $palette['birth']);
+ ?>
+
+ = $card_close ?>
+
+
+
+ = e(I18N::translate('Other birthdays')) ?>
+
+
+
+
+
+ = $record_label($fact) ?>
+ — = e($birthday_label($age)) ?>
+ · = e($event_date_display($fact)) ?>
+
+
+
+
isEmpty()) : ?>
+
= e(I18N::translate('Upcoming marriage anniversaries')) ?>
= e(I18N::translate('Marriages still intact.')) ?>
- = $card_open ?>
-
- ' . $event_icon('MARR') . ''
- . ''
- . '' . $record_label($fact) . ' '
- . ' — ' . e($anniversary_label($age)) . ' '
- . ' ';
- echo $event_row($fact, $body, $palette['marr']);
- ?>
-
- = $card_close ?>
+
+ = $card_open ?>
+
+ ' . $event_icon('MARR') . ''
+ . ''
+ . '' . $record_label($fact) . ' '
+ . ' — ' . e($anniversary_label($age)) . ' '
+ . ' ';
+ echo $event_row($fact, $body, $palette['marr']);
+ ?>
+
+ = $card_close ?>
+
+
+
+ = e(I18N::translate('Other anniversaries')) ?>
+
+
+
+
+
+ = $record_label($fact) ?>
+ — = e($anniversary_label($age)) ?>
+ · = e($event_date_display($fact)) ?>
+
+
+
+
isEmpty()) : ?>
+
= e(I18N::translate('On this month in history')) ?>
= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
- = $card_open ?>
-
- ' . $event_icon($kind) . ''
- . ''
- . '' . $record_label($fact) . ' '
- . ' — ' . e($fact->label()) . ' '
- . ' ';
- echo $event_row($fact, $body, $event_color($kind));
- ?>
-
- = $card_close ?>
+
+ = $card_open ?>
+
+ ' . $event_icon($kind) . ''
+ . ''
+ . '' . $record_label($fact) . ' '
+ . ' — ' . e($fact->label()) . ' '
+ . ' ';
+ echo $event_row($fact, $body, $event_color($kind));
+ ?>
+
+ = $card_close ?>
+
+
+
+ = e(I18N::translate('Other historical events')) ?>
+
+
+
+
+ = $record_label($fact) ?>
+ — = e($fact->label()) ?>
+ · = e($event_date_display($fact)) ?>
+
+
+
+
diff --git a/src/Configuration.php b/src/Configuration.php
index 1a06f1d..1bc4eb5 100644
--- a/src/Configuration.php
+++ b/src/Configuration.php
@@ -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(
diff --git a/src/Module.php b/src/Module.php
index 28930dd..f897c00 100644
--- a/src/Module.php
+++ b/src/Module.php
@@ -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);
diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php
index f17dc5d..e8b4618 100644
--- a/src/Services/NewsletterDispatchService.php
+++ b/src/Services/NewsletterDispatchService.php
@@ -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 $featured
+ *
+ * @return array 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-" => true). Lets us
+ * array_intersect_key the embeds map cheaply.
+ *
+ * @param array $xrefs
+ * @return array
+ */
+ 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.
*
diff --git a/src/Services/RelationshipPathFinder.php b/src/Services/RelationshipPathFinder.php
index d5d8cb1..1549036 100644
--- a/src/Services/RelationshipPathFinder.php
+++ b/src/Services/RelationshipPathFinder.php
@@ -33,6 +33,9 @@ final class RelationshipPathFinder
/** Default BFS depth — ~7 generations of ancestry / descent. */
public const int DEFAULT_MAX_DEPTH = 14;
+ /** @var array> Cache: xref => {ancestor/descendant xref set} */
+ private array $lineal_cache = [];
+
/** @var array 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
+ */
+ 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 $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 $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