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];
+ }
+
+}