From 3bc25a2bdb6ae6aab4d2cd09506207da3ee37c64 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 12:53:22 +0200 Subject: [PATCH] Add per-recipient relationship labels in newsletter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- resources/views/email.phtml | 17 +- src/Services/NewsletterDispatchService.php | 141 ++++++++++++---- src/Services/RelationshipPathFinder.php | 186 +++++++++++++++++++++ 3 files changed, 313 insertions(+), 31 deletions(-) create mode 100644 src/Services/RelationshipPathFinder.php diff --git a/resources/views/email.phtml b/resources/views/email.phtml index 05aed11..f327038 100644 --- a/resources/views/email.phtml +++ b/resources/views/email.phtml @@ -20,7 +20,8 @@ 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 $avatar_cids xref => CID name + * @var array $relationships xref => "your mother" etc. (per-recipient) * @var string $account_url */ @@ -50,14 +51,24 @@ $avatar_size = 56; // ─── 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()); $url = $individual->url(); $style = 'color:' . $palette['ink'] . ';text-decoration:none;' . 'border-bottom:1px solid ' . $palette['border'] . ';' . 'padding-bottom:1px;'; - return '' . e($name) . ''; + $html = '' . e($name) . ''; + + if (isset($relationships[$individual->xref()])) { + $html .= ' (' + . e(strip_tags($relationships[$individual->xref()])) + . ')'; + } + + return $html; }; $record_label = static function (Fact $fact) use ($linked_name): string { diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index 7ccd924..f17dc5d 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -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 */ + /** + * 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|null $anniversaries + * @param Collection|null $historical + * + * @return array 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 $featured + * + * @return array + */ + 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, diff --git a/src/Services/RelationshipPathFinder.php b/src/Services/RelationshipPathFinder.php new file mode 100644 index 0000000..d5d8cb1 --- /dev/null +++ b/src/Services/RelationshipPathFinder.php @@ -0,0 +1,186 @@ + Cache: "fromXref|toXref" -> label */ + private array $label_cache = []; + + /** @var array 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 + */ + 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 $path + * @param array $visited + * @param array> $paths + * + * @return array|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]; + } + +}