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:
@@ -20,7 +20,8 @@ use Illuminate\Support\Collection;
|
|||||||
* @var int $lookahead_days
|
* @var int $lookahead_days
|
||||||
* @var int $historical_lookahead
|
* @var int $historical_lookahead
|
||||||
* @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 string $account_url
|
* @var string $account_url
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -50,14 +51,24 @@ $avatar_size = 56;
|
|||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$linked_name = static function (Individual $individual) use ($palette): string {
|
$relationships = $relationships ?? [];
|
||||||
|
|
||||||
|
$linked_name = static function (Individual $individual) use ($palette, $relationships): string {
|
||||||
$name = strip_tags($individual->fullName());
|
$name = strip_tags($individual->fullName());
|
||||||
$url = $individual->url();
|
$url = $individual->url();
|
||||||
$style = 'color:' . $palette['ink'] . ';text-decoration:none;'
|
$style = 'color:' . $palette['ink'] . ';text-decoration:none;'
|
||||||
. 'border-bottom:1px solid ' . $palette['border'] . ';'
|
. 'border-bottom:1px solid ' . $palette['border'] . ';'
|
||||||
. 'padding-bottom:1px;';
|
. 'padding-bottom:1px;';
|
||||||
|
|
||||||
return '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
|
$html = '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
|
||||||
|
|
||||||
|
if (isset($relationships[$individual->xref()])) {
|
||||||
|
$html .= ' <span style="color:' . $palette['ink3'] . ';font-style:italic;font-weight:400;font-size:13px;">('
|
||||||
|
. e(strip_tags($relationships[$individual->xref()]))
|
||||||
|
. ')</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
};
|
};
|
||||||
|
|
||||||
$record_label = static function (Fact $fact) use ($linked_name): string {
|
$record_label = static function (Fact $fact) use ($linked_name): string {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Fisharebest\Webtrees\I18N;
|
|||||||
use Fisharebest\Webtrees\Individual;
|
use Fisharebest\Webtrees\Individual;
|
||||||
use Fisharebest\Webtrees\Log;
|
use Fisharebest\Webtrees\Log;
|
||||||
use Fisharebest\Webtrees\Module\ModuleInterface;
|
use Fisharebest\Webtrees\Module\ModuleInterface;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
use Fisharebest\Webtrees\Services\TreeService;
|
use Fisharebest\Webtrees\Services\TreeService;
|
||||||
use Fisharebest\Webtrees\Services\UserService;
|
use Fisharebest\Webtrees\Services\UserService;
|
||||||
use Fisharebest\Webtrees\Tree;
|
use Fisharebest\Webtrees\Tree;
|
||||||
@@ -59,10 +60,11 @@ final class NewsletterDispatchService
|
|||||||
private bool $image_manager_resolved = false;
|
private bool $image_manager_resolved = false;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EventQueryService $event_query_service,
|
private readonly EventQueryService $event_query_service,
|
||||||
private readonly NewsletterMailer $mailer,
|
private readonly NewsletterMailer $mailer,
|
||||||
private readonly TreeService $tree_service,
|
private readonly TreeService $tree_service,
|
||||||
private readonly UserService $user_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());
|
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
|
||||||
}
|
}
|
||||||
|
|
||||||
$from = $this->siteContact($tree);
|
$from = $this->siteContact($tree);
|
||||||
$original_locale = I18N::languageTag();
|
$original_locale = I18N::languageTag();
|
||||||
$groups = $this->groupRecipientsByLanguage($recipients);
|
$groups = $this->groupRecipientsByLanguage($recipients);
|
||||||
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
|
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
|
||||||
$avatar_cids = $this->avatarCids($avatars);
|
$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;
|
$sent = 0;
|
||||||
$failures = 0;
|
$failures = 0;
|
||||||
@@ -161,26 +168,29 @@ final class NewsletterDispatchService
|
|||||||
date('F j, Y', $now),
|
date('F j, Y', $now),
|
||||||
);
|
);
|
||||||
|
|
||||||
$html = view($module->name() . '::email', [
|
// Render the email body per recipient — the relationship
|
||||||
'tree' => $tree,
|
// labels are personalised relative to whichever individual
|
||||||
'birthdays' => $birthdays,
|
// record the recipient is linked to in this tree.
|
||||||
'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);
|
|
||||||
|
|
||||||
foreach ($group as $recipient) {
|
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 {
|
try {
|
||||||
if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $avatars)) {
|
if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $avatars)) {
|
||||||
$sent++;
|
$sent++;
|
||||||
@@ -223,6 +233,81 @@ final class NewsletterDispatchService
|
|||||||
*
|
*
|
||||||
* @return array<string,array{bytes:string,mime:string}>
|
* @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(
|
private function collectAvatars(
|
||||||
Collection $birthdays,
|
Collection $birthdays,
|
||||||
Collection|null $anniversaries,
|
Collection|null $anniversaries,
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter\Services;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Auth;
|
||||||
|
use Fisharebest\Webtrees\Family;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleLanguageInterface;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
|
use Fisharebest\Webtrees\Services\ModuleService;
|
||||||
|
use Fisharebest\Webtrees\Services\RelationshipService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a human-readable relationship label between two individuals
|
||||||
|
* (e.g. "your mother", "4th great-grandfather") using webtrees' own
|
||||||
|
* relationship matcher.
|
||||||
|
*
|
||||||
|
* webtrees ships RelationshipService::getCloseRelationshipName which
|
||||||
|
* caps the BFS at four hops — enough for nephews and grandparents,
|
||||||
|
* not for great-great-grandparents. This class mirrors that algorithm
|
||||||
|
* (childFamilies + spouseFamilies expansion, visited-set bookkeeping)
|
||||||
|
* but with a configurable depth so we can label distant ancestors.
|
||||||
|
*
|
||||||
|
* The expensive step is the BFS; nameFromPath() formatting is cheap.
|
||||||
|
* Results are memoised per (recipient xref, target xref) for the
|
||||||
|
* lifetime of one dispatch run.
|
||||||
|
*/
|
||||||
|
final class RelationshipPathFinder
|
||||||
|
{
|
||||||
|
/** Default BFS depth — ~7 generations of ancestry / descent. */
|
||||||
|
public const int DEFAULT_MAX_DEPTH = 14;
|
||||||
|
|
||||||
|
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
|
||||||
|
private array $label_cache = [];
|
||||||
|
|
||||||
|
/** @var array<string,ModuleLanguageInterface|null> Cache: languageTag -> module */
|
||||||
|
private array $language_cache = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RelationshipService $relationship_service,
|
||||||
|
private readonly ModuleService $module_service,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the relationship label, or null if no path was found
|
||||||
|
* within the configured depth.
|
||||||
|
*/
|
||||||
|
public function label(Individual $from, Individual $to, int $max_depth = self::DEFAULT_MAX_DEPTH): string|null
|
||||||
|
{
|
||||||
|
$cache_key = $from->xref() . '|' . $to->xref();
|
||||||
|
|
||||||
|
if (array_key_exists($cache_key, $this->label_cache)) {
|
||||||
|
return $this->label_cache[$cache_key];
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->findPath($from, $to, $max_depth);
|
||||||
|
|
||||||
|
if ($path === []) {
|
||||||
|
return $this->label_cache[$cache_key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$language = $this->languageForCurrentLocale();
|
||||||
|
|
||||||
|
if ($language === null) {
|
||||||
|
// Every webtrees install has at least the English language
|
||||||
|
// module enabled by default; if not, we can't label.
|
||||||
|
return $this->label_cache[$cache_key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->label_cache[$cache_key] = $this->relationship_service->nameFromPath($path, $language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BFS over child- and spouse-families, exactly matching
|
||||||
|
* RelationshipService::getCloseRelationship's traversal but with
|
||||||
|
* an externally configurable max depth.
|
||||||
|
*
|
||||||
|
* @return array<Individual|Family>
|
||||||
|
*/
|
||||||
|
private function findPath(Individual $from, Individual $to, int $max_depth): array
|
||||||
|
{
|
||||||
|
if ($from === $to || $from->xref() === $to->xref()) {
|
||||||
|
return [$from];
|
||||||
|
}
|
||||||
|
|
||||||
|
$visited = [$from->xref() => true];
|
||||||
|
$paths = [[$from]];
|
||||||
|
|
||||||
|
while ($max_depth >= 0) {
|
||||||
|
$max_depth--;
|
||||||
|
|
||||||
|
foreach ($paths as $i => $path) {
|
||||||
|
$indi = $path[count($path) - 1];
|
||||||
|
|
||||||
|
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
|
||||||
|
$result = $this->expandFamily($family, $path, $to, $visited, $paths);
|
||||||
|
if ($result !== null) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
|
||||||
|
$result = $this->expandFamily($family, $path, $to, $visited, $paths);
|
||||||
|
if ($result !== null) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($paths[$i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<Individual|Family> $path
|
||||||
|
* @param array<string,bool> $visited
|
||||||
|
* @param array<int,array<Individual|Family>> $paths
|
||||||
|
*
|
||||||
|
* @return array<Individual|Family>|null Completed path if $to was reached.
|
||||||
|
*/
|
||||||
|
private function expandFamily(
|
||||||
|
Family $family,
|
||||||
|
array $path,
|
||||||
|
Individual $to,
|
||||||
|
array &$visited,
|
||||||
|
array &$paths,
|
||||||
|
): array|null {
|
||||||
|
$visited[$family->xref()] = true;
|
||||||
|
|
||||||
|
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
|
||||||
|
if (isset($visited[$spouse->xref()])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$new_path = $path;
|
||||||
|
$new_path[] = $family;
|
||||||
|
$new_path[] = $spouse;
|
||||||
|
|
||||||
|
if ($spouse->xref() === $to->xref()) {
|
||||||
|
return $new_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths[] = $new_path;
|
||||||
|
$visited[$spouse->xref()] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
|
||||||
|
if (isset($visited[$child->xref()])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$new_path = $path;
|
||||||
|
$new_path[] = $family;
|
||||||
|
$new_path[] = $child;
|
||||||
|
|
||||||
|
if ($child->xref() === $to->xref()) {
|
||||||
|
return $new_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths[] = $new_path;
|
||||||
|
$visited[$child->xref()] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function languageForCurrentLocale(): ModuleLanguageInterface|null
|
||||||
|
{
|
||||||
|
$tag = I18N::languageTag();
|
||||||
|
|
||||||
|
if (!array_key_exists($tag, $this->language_cache)) {
|
||||||
|
$this->language_cache[$tag] = $this->module_service
|
||||||
|
->findByInterface(ModuleLanguageInterface::class, true)
|
||||||
|
->first(fn (ModuleLanguageInterface $module): bool => $module->locale()->languageTag() === $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->language_cache[$tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user