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:
@@ -12,6 +12,7 @@ use Fisharebest\Webtrees\I18N;
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use Fisharebest\Webtrees\Log;
|
||||
use Fisharebest\Webtrees\Module\ModuleInterface;
|
||||
use Fisharebest\Webtrees\Registry;
|
||||
use Fisharebest\Webtrees\Services\TreeService;
|
||||
use Fisharebest\Webtrees\Services\UserService;
|
||||
use Fisharebest\Webtrees\Tree;
|
||||
@@ -59,10 +60,11 @@ final class NewsletterDispatchService
|
||||
private bool $image_manager_resolved = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventQueryService $event_query_service,
|
||||
private readonly NewsletterMailer $mailer,
|
||||
private readonly TreeService $tree_service,
|
||||
private readonly UserService $user_service,
|
||||
private readonly EventQueryService $event_query_service,
|
||||
private readonly NewsletterMailer $mailer,
|
||||
private readonly TreeService $tree_service,
|
||||
private readonly UserService $user_service,
|
||||
private readonly RelationshipPathFinder $relationship_finder,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -143,11 +145,16 @@ final class NewsletterDispatchService
|
||||
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
|
||||
}
|
||||
|
||||
$from = $this->siteContact($tree);
|
||||
$original_locale = I18N::languageTag();
|
||||
$groups = $this->groupRecipientsByLanguage($recipients);
|
||||
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
|
||||
$avatar_cids = $this->avatarCids($avatars);
|
||||
$from = $this->siteContact($tree);
|
||||
$original_locale = I18N::languageTag();
|
||||
$groups = $this->groupRecipientsByLanguage($recipients);
|
||||
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
|
||||
$avatar_cids = $this->avatarCids($avatars);
|
||||
$featured = $this->collectFeaturedIndividuals($birthdays, $anniversaries, $historical);
|
||||
$account_url = route(
|
||||
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
|
||||
['tree' => $tree->name()],
|
||||
);
|
||||
|
||||
$sent = 0;
|
||||
$failures = 0;
|
||||
@@ -161,26 +168,29 @@ final class NewsletterDispatchService
|
||||
date('F j, Y', $now),
|
||||
);
|
||||
|
||||
$html = view($module->name() . '::email', [
|
||||
'tree' => $tree,
|
||||
'birthdays' => $birthdays,
|
||||
'anniversaries' => $anniversaries,
|
||||
'historical' => $historical,
|
||||
'include_anniversaries' => $include_anniversaries,
|
||||
'include_historical' => $include_historical,
|
||||
'lookahead_days' => $lookahead,
|
||||
'historical_lookahead' => $historical_lookahead,
|
||||
'generated_at' => $now,
|
||||
'avatar_cids' => $avatar_cids,
|
||||
'account_url' => route(
|
||||
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
|
||||
['tree' => $tree->name()],
|
||||
),
|
||||
]);
|
||||
|
||||
$text = $this->htmlToText($html);
|
||||
|
||||
// Render the email body per recipient — the relationship
|
||||
// labels are personalised relative to whichever individual
|
||||
// record the recipient is linked to in this tree.
|
||||
foreach ($group as $recipient) {
|
||||
$relationships = $this->relationshipMap($tree, $recipient, $featured);
|
||||
|
||||
$html = view($module->name() . '::email', [
|
||||
'tree' => $tree,
|
||||
'birthdays' => $birthdays,
|
||||
'anniversaries' => $anniversaries,
|
||||
'historical' => $historical,
|
||||
'include_anniversaries' => $include_anniversaries,
|
||||
'include_historical' => $include_historical,
|
||||
'lookahead_days' => $lookahead,
|
||||
'historical_lookahead' => $historical_lookahead,
|
||||
'generated_at' => $now,
|
||||
'avatar_cids' => $avatar_cids,
|
||||
'relationships' => $relationships,
|
||||
'account_url' => $account_url,
|
||||
]);
|
||||
|
||||
$text = $this->htmlToText($html);
|
||||
|
||||
try {
|
||||
if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $avatars)) {
|
||||
$sent++;
|
||||
@@ -223,6 +233,81 @@ final class NewsletterDispatchService
|
||||
*
|
||||
* @return array<string,array{bytes:string,mime:string}>
|
||||
*/
|
||||
/**
|
||||
* Set of Individual records that appear in any of the newsletter
|
||||
* sections. Used by the relationship-map builder so each row can
|
||||
* be labelled "your mother", "3rd cousin once removed", etc.
|
||||
*
|
||||
* @param Collection<int,Fact>|null $anniversaries
|
||||
* @param Collection<int,Fact>|null $historical
|
||||
*
|
||||
* @return array<string,Individual> xref => Individual
|
||||
*/
|
||||
private function collectFeaturedIndividuals(
|
||||
Collection $birthdays,
|
||||
Collection|null $anniversaries,
|
||||
Collection|null $historical,
|
||||
): array {
|
||||
$individuals = [];
|
||||
|
||||
foreach ($birthdays as $fact) {
|
||||
$this->indexIndividualsFromFact($fact, $individuals);
|
||||
}
|
||||
|
||||
if ($anniversaries !== null) {
|
||||
foreach ($anniversaries as $fact) {
|
||||
$this->indexIndividualsFromFact($fact, $individuals);
|
||||
}
|
||||
}
|
||||
|
||||
if ($historical !== null) {
|
||||
foreach ($historical as $fact) {
|
||||
$this->indexIndividualsFromFact($fact, $individuals);
|
||||
}
|
||||
}
|
||||
|
||||
return $individuals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a "xref => relationship label" map for one recipient.
|
||||
*
|
||||
* Returns an empty map for recipients we cannot label: external
|
||||
* addresses (no webtrees account), users with no linked Individual
|
||||
* record on this tree, or users whose linked record can't be
|
||||
* resolved (privacy-hidden, broken xref).
|
||||
*
|
||||
* @param array<string,Individual> $featured
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function relationshipMap(Tree $tree, UserInterface $recipient, array $featured): array
|
||||
{
|
||||
$self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF);
|
||||
|
||||
if ($self_xref === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$self = Registry::individualFactory()->make($self_xref, $tree);
|
||||
|
||||
if (!$self instanceof Individual) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($featured as $xref => $individual) {
|
||||
$label = $this->relationship_finder->label($self, $individual);
|
||||
|
||||
if ($label !== null && $label !== '') {
|
||||
$map[$xref] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function collectAvatars(
|
||||
Collection $birthdays,
|
||||
Collection|null $anniversaries,
|
||||
|
||||
Reference in New Issue
Block a user