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:
@@ -47,6 +47,7 @@ use Illuminate\Support\Collection;
|
|||||||
$annivs = Configuration::includeAnniversaries($tree);
|
$annivs = Configuration::includeAnniversaries($tree);
|
||||||
$subject = Configuration::subjectPrefix($tree);
|
$subject = Configuration::subjectPrefix($tree);
|
||||||
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
|
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
|
||||||
|
$lineal = Configuration::linealDepth($tree);
|
||||||
$last_sent = Configuration::lastSentAt($tree);
|
$last_sent = Configuration::lastSentAt($tree);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -129,6 +130,22 @@ use Illuminate\Support\Collection;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Detailed view depth (generations)') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<input class="form-control" type="number" style="max-width: 18rem;"
|
||||||
|
id="lineal-<?= $id ?>" name="lineal-<?= $id ?>"
|
||||||
|
value="<?= e((string) $lineal) ?>"
|
||||||
|
min="<?= Configuration::MIN_LINEAL_DEPTH ?>"
|
||||||
|
max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
<?= 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.') ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
|
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
|
||||||
<?= I18N::translate('Subject prefix') ?>
|
<?= I18N::translate('Subject prefix') ?>
|
||||||
|
|||||||
+114
-3
@@ -22,6 +22,7 @@ use Illuminate\Support\Collection;
|
|||||||
* @var int $generated_at
|
* @var int $generated_at
|
||||||
* @var array<string,string> $avatar_cids xref => CID name
|
* @var array<string,string> $avatar_cids xref => CID name
|
||||||
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
|
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
|
||||||
|
* @var array<string,true> $detailed_xrefs xref-set — render in detail; others as summary bullet
|
||||||
* @var string $account_url
|
* @var string $account_url
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -52,6 +53,33 @@ $avatar_size = 56;
|
|||||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
// ─── 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 {
|
$linked_name = static function (Individual $individual) use ($palette, $relationships): string {
|
||||||
$name = strip_tags($individual->fullName());
|
$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
|
// Renders one event row in the consistent 3-column layout shared by
|
||||||
// every section (avatar | content | date on the timeline rail).
|
// 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)
|
$event_row = static function (Fact $fact, string $body_html, string $dot_color)
|
||||||
use (
|
use (
|
||||||
$record_avatars,
|
$record_avatars,
|
||||||
@@ -382,13 +422,21 @@ $card_close = '</table>';
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<?php if (!$birthdays->isEmpty()) : ?>
|
<?php if (!$birthdays->isEmpty()) : ?>
|
||||||
|
<?php
|
||||||
|
$detailed = [];
|
||||||
|
$summary = [];
|
||||||
|
foreach ($birthdays as $fact) {
|
||||||
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
||||||
|
}
|
||||||
|
?>
|
||||||
<tr><td style="padding:8px 0 0;">
|
<tr><td style="padding:8px 0 0;">
|
||||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
||||||
<p style="<?= $section_kicker_style ?>">
|
<p style="<?= $section_kicker_style ?>">
|
||||||
<?= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
|
<?= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php if ($detailed !== []) : ?>
|
||||||
<?= $card_open ?>
|
<?= $card_open ?>
|
||||||
<?php foreach ($birthdays as $fact) : ?>
|
<?php foreach ($detailed as $fact) : ?>
|
||||||
<?php
|
<?php
|
||||||
$age = $upcoming_age($fact);
|
$age = $upcoming_age($fact);
|
||||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
|
||||||
@@ -400,17 +448,41 @@ $card_close = '</table>';
|
|||||||
?>
|
?>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?= $card_close ?>
|
<?= $card_close ?>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php if ($summary !== []) : ?>
|
||||||
|
<div style="<?= $summary_kicker_style ?>">
|
||||||
|
<?= e(I18N::translate('Other birthdays')) ?>
|
||||||
|
</div>
|
||||||
|
<ul style="<?= $summary_list_style ?>">
|
||||||
|
<?php foreach ($summary as $fact) : ?>
|
||||||
|
<?php $age = $upcoming_age($fact); ?>
|
||||||
|
<li style="<?= $summary_item_style ?>">
|
||||||
|
<?= $record_label($fact) ?>
|
||||||
|
<span style="color:<?= $palette['ink3'] ?>;">— <?= e($birthday_label($age)) ?></span>
|
||||||
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif ?>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
|
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
|
||||||
|
<?php
|
||||||
|
$detailed = [];
|
||||||
|
$summary = [];
|
||||||
|
foreach ($anniversaries as $fact) {
|
||||||
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
||||||
|
}
|
||||||
|
?>
|
||||||
<tr><td style="padding:32px 0 0;">
|
<tr><td style="padding:32px 0 0;">
|
||||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
|
||||||
<p style="<?= $section_kicker_style ?>">
|
<p style="<?= $section_kicker_style ?>">
|
||||||
<?= e(I18N::translate('Marriages still intact.')) ?>
|
<?= e(I18N::translate('Marriages still intact.')) ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php if ($detailed !== []) : ?>
|
||||||
<?= $card_open ?>
|
<?= $card_open ?>
|
||||||
<?php foreach ($anniversaries as $fact) : ?>
|
<?php foreach ($detailed as $fact) : ?>
|
||||||
<?php
|
<?php
|
||||||
$age = $upcoming_age($fact);
|
$age = $upcoming_age($fact);
|
||||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
|
||||||
@@ -422,17 +494,41 @@ $card_close = '</table>';
|
|||||||
?>
|
?>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?= $card_close ?>
|
<?= $card_close ?>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php if ($summary !== []) : ?>
|
||||||
|
<div style="<?= $summary_kicker_style ?>">
|
||||||
|
<?= e(I18N::translate('Other anniversaries')) ?>
|
||||||
|
</div>
|
||||||
|
<ul style="<?= $summary_list_style ?>">
|
||||||
|
<?php foreach ($summary as $fact) : ?>
|
||||||
|
<?php $age = $upcoming_age($fact); ?>
|
||||||
|
<li style="<?= $summary_item_style ?>">
|
||||||
|
<?= $record_label($fact) ?>
|
||||||
|
<span style="color:<?= $palette['ink3'] ?>;">— <?= e($anniversary_label($age)) ?></span>
|
||||||
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif ?>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<?php if ($include_historical && $historical !== null && !$historical->isEmpty()) : ?>
|
<?php if ($include_historical && $historical !== null && !$historical->isEmpty()) : ?>
|
||||||
|
<?php
|
||||||
|
$detailed = [];
|
||||||
|
$summary = [];
|
||||||
|
foreach ($historical as $fact) {
|
||||||
|
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
||||||
|
}
|
||||||
|
?>
|
||||||
<tr><td style="padding:32px 0 0;">
|
<tr><td style="padding:32px 0 0;">
|
||||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('On this month in history')) ?></h2>
|
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('On this month in history')) ?></h2>
|
||||||
<p style="<?= $section_kicker_style ?>">
|
<p style="<?= $section_kicker_style ?>">
|
||||||
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
|
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php if ($detailed !== []) : ?>
|
||||||
<?= $card_open ?>
|
<?= $card_open ?>
|
||||||
<?php foreach ($historical as $fact) : ?>
|
<?php foreach ($detailed as $fact) : ?>
|
||||||
<?php
|
<?php
|
||||||
$kind = $event_kind($fact);
|
$kind = $event_kind($fact);
|
||||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
||||||
@@ -444,6 +540,21 @@ $card_close = '</table>';
|
|||||||
?>
|
?>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?= $card_close ?>
|
<?= $card_close ?>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php if ($summary !== []) : ?>
|
||||||
|
<div style="<?= $summary_kicker_style ?>">
|
||||||
|
<?= e(I18N::translate('Other historical events')) ?>
|
||||||
|
</div>
|
||||||
|
<ul style="<?= $summary_list_style ?>">
|
||||||
|
<?php foreach ($summary as $fact) : ?>
|
||||||
|
<li style="<?= $summary_item_style ?>">
|
||||||
|
<?= $record_label($fact) ?>
|
||||||
|
<span style="color:<?= $palette['ink3'] ?>;">— <?= e($fact->label()) ?></span>
|
||||||
|
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif ?>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ final class Configuration
|
|||||||
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
|
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
|
||||||
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
|
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
|
||||||
public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS';
|
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_SENT_AT = 'NEWSLETTER_LAST_SENT';
|
||||||
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
|
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
|
||||||
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
|
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_FREQUENCY_DAYS = 14;
|
||||||
public const int DEFAULT_LOOKAHEAD_DAYS = 14;
|
public const int DEFAULT_LOOKAHEAD_DAYS = 14;
|
||||||
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
|
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
|
||||||
|
public const int DEFAULT_LINEAL_DEPTH = 3;
|
||||||
public const int MIN_FREQUENCY_DAYS = 1;
|
public const int MIN_FREQUENCY_DAYS = 1;
|
||||||
public const int MAX_FREQUENCY_DAYS = 90;
|
public const int MAX_FREQUENCY_DAYS = 90;
|
||||||
public const int MIN_LOOKAHEAD_DAYS = 1;
|
public const int MIN_LOOKAHEAD_DAYS = 1;
|
||||||
public const int MAX_LOOKAHEAD_DAYS = 60;
|
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
|
public static function isEnabled(Tree $tree): bool
|
||||||
{
|
{
|
||||||
@@ -65,6 +69,13 @@ final class Configuration
|
|||||||
return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1';
|
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
|
public static function historicalLookaheadDays(Tree $tree): int
|
||||||
{
|
{
|
||||||
$value = (int) $tree->getPreference(
|
$value = (int) $tree->getPreference(
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
'Living kin who will celebrate this fortnight.'
|
'Living kin who will celebrate this fortnight.'
|
||||||
=> 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.',
|
=> 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.',
|
||||||
'Marriages still intact.' => 'Noch bestehende Ehen.',
|
'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 birthday' => '%s Geburtstag',
|
||||||
'%s wedding anniversary' => '%s Hochzeitstag',
|
'%s wedding anniversary' => '%s Hochzeitstag',
|
||||||
'Birthday' => 'Geburtstag',
|
'Birthday' => 'Geburtstag',
|
||||||
@@ -231,6 +237,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
$histLook = Validator::parsedBody($request)
|
$histLook = Validator::parsedBody($request)
|
||||||
->isBetween(7, 60)
|
->isBetween(7, 60)
|
||||||
->integer('historical-' . $id, Configuration::DEFAULT_HISTORICAL_LOOKAHEAD);
|
->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';
|
$annivs = Validator::parsedBody($request)->string('anniversaries-' . $id, '0') === '1';
|
||||||
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
|
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
|
||||||
$subject = Validator::parsedBody($request)->string('subject-' . $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_FREQUENCY_DAYS, (string) $frequency);
|
||||||
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
|
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
|
||||||
$tree->setPreference(Configuration::PREF_HISTORICAL_LOOKAHEAD, (string) $histLook);
|
$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_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0');
|
||||||
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
|
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ final class NewsletterDispatchService
|
|||||||
// record the recipient is linked to in this tree.
|
// record the recipient is linked to in this tree.
|
||||||
foreach ($group as $recipient) {
|
foreach ($group as $recipient) {
|
||||||
$relationships = $this->relationshipMap($tree, $recipient, $featured);
|
$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', [
|
$html = view($module->name() . '::email', [
|
||||||
'tree' => $tree,
|
'tree' => $tree,
|
||||||
@@ -186,13 +195,14 @@ final class NewsletterDispatchService
|
|||||||
'generated_at' => $now,
|
'generated_at' => $now,
|
||||||
'avatar_cids' => $avatar_cids,
|
'avatar_cids' => $avatar_cids,
|
||||||
'relationships' => $relationships,
|
'relationships' => $relationships,
|
||||||
|
'detailed_xrefs' => $detailed_set,
|
||||||
'account_url' => $account_url,
|
'account_url' => $account_url,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$text = $this->htmlToText($html);
|
$text = $this->htmlToText($html);
|
||||||
|
|
||||||
try {
|
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++;
|
$sent++;
|
||||||
} else {
|
} else {
|
||||||
$failures++;
|
$failures++;
|
||||||
@@ -269,6 +279,69 @@ final class NewsletterDispatchService
|
|||||||
return $individuals;
|
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.
|
* Build a "xref => relationship label" map for one recipient.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ final class RelationshipPathFinder
|
|||||||
/** Default BFS depth — ~7 generations of ancestry / descent. */
|
/** Default BFS depth — ~7 generations of ancestry / descent. */
|
||||||
public const int DEFAULT_MAX_DEPTH = 14;
|
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 */
|
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
|
||||||
private array $label_cache = [];
|
private array $label_cache = [];
|
||||||
|
|
||||||
@@ -74,6 +77,77 @@ final class RelationshipPathFinder
|
|||||||
return $this->label_cache[$cache_key] = $this->relationship_service->nameFromPath($path, $language);
|
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
|
* BFS over child- and spouse-families, exactly matching
|
||||||
* RelationshipService::getCloseRelationship's traversal but with
|
* RelationshipService::getCloseRelationship's traversal but with
|
||||||
|
|||||||
Reference in New Issue
Block a user