a07184ab3a
Pull each individual's highlighted media image via webtrees' Individual::findHighlightedMediaFile, attach as Symfony inline parts with stable cid:avatar-<xref> identifiers, and render border-radius:50% on the <img>. Couples on anniversaries show both spouses' circles side-by-side. Fallback when no image is available (privacy-hidden record, no OBJE, external URL, unreadable file): a CSS-only coloured circle with the person's initials. The hue is derived from a hash of the XREF so the same person keeps the same colour across newsletters. Done via a NewsletterMailer subclass of EmailService that adds a sendWithEmbeds() method — the parent's transport() and DKIM config still apply, only the message-construction path differs.
289 lines
9.7 KiB
PHTML
289 lines
9.7 KiB
PHTML
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Fisharebest\Webtrees\Date;
|
|
use Fisharebest\Webtrees\Fact;
|
|
use Fisharebest\Webtrees\Family;
|
|
use Fisharebest\Webtrees\I18N;
|
|
use Fisharebest\Webtrees\Individual;
|
|
use Fisharebest\Webtrees\Tree;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* @var Tree $tree
|
|
* @var Collection<int,Fact> $birthdays
|
|
* @var Collection<int,Fact>|null $anniversaries
|
|
* @var Collection<int,Fact>|null $historical
|
|
* @var bool $include_anniversaries
|
|
* @var bool $include_historical
|
|
* @var int $lookahead_days
|
|
* @var int $historical_lookahead
|
|
* @var int $generated_at
|
|
* @var array<string,string> $avatar_cids xref => CID name
|
|
* @var string $account_url
|
|
*/
|
|
|
|
$avatar_size = 48;
|
|
|
|
/**
|
|
* Inline HTML for a single circular avatar.
|
|
*
|
|
* Renders an <img src="cid:..."> if the dispatch service was able to
|
|
* resolve an image for the individual; otherwise renders a coloured
|
|
* circle with the person's initials. The placeholder is intentionally
|
|
* CSS-only — inline SVG and data: URIs are unreliable in Outlook /
|
|
* some webmail clients.
|
|
*/
|
|
$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size): string {
|
|
if (!$individual instanceof Individual) {
|
|
return '';
|
|
}
|
|
|
|
$alt = e(strip_tags($individual->fullName()));
|
|
|
|
if (isset($avatar_cids[$individual->xref()])) {
|
|
$cid = $avatar_cids[$individual->xref()];
|
|
|
|
return '<img src="cid:' . e($cid) . '" alt="' . $alt . '" width="' . $avatar_size . '" height="' . $avatar_size . '"'
|
|
. ' style="border-radius:50%;object-fit:cover;vertical-align:middle;border:1px solid #ccc;">';
|
|
}
|
|
|
|
// CSS-only fallback: coloured circle with initials. Hash the xref
|
|
// into a stable hue so each person keeps the same colour across
|
|
// newsletters.
|
|
$hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255;
|
|
$first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref());
|
|
$last = strip_tags($individual->getAllNames()[0]['surn'] ?? '');
|
|
$i1 = mb_substr($first, 0, 1);
|
|
$i2 = mb_substr($last, 0, 1);
|
|
$initials = e(mb_strtoupper($i1 . $i2));
|
|
|
|
return '<span aria-label="' . $alt . '"'
|
|
. ' style="display:inline-block;width:' . $avatar_size . 'px;height:' . $avatar_size . 'px;'
|
|
. 'border-radius:50%;background:hsl(' . (int) $hue . ',45%,60%);color:#fff;'
|
|
. 'font:600 18px/' . $avatar_size . 'px Helvetica,Arial,sans-serif;text-align:center;'
|
|
. 'vertical-align:middle;letter-spacing:0.5px;">' . $initials . '</span>';
|
|
};
|
|
|
|
/**
|
|
* HTML for the avatar(s) attached to a Fact's primary record:
|
|
* a single circle for Individual facts, side-by-side circles for
|
|
* Family facts (anniversaries).
|
|
*/
|
|
$record_avatars = static function (Fact $fact) use ($avatar): string {
|
|
$record = $fact->record();
|
|
|
|
if ($record instanceof Individual) {
|
|
return $avatar($record);
|
|
}
|
|
|
|
if ($record instanceof Family) {
|
|
$parts = [];
|
|
|
|
foreach ([$record->husband(), $record->wife()] as $spouse) {
|
|
if ($spouse instanceof Individual) {
|
|
$parts[] = $avatar($spouse);
|
|
}
|
|
}
|
|
|
|
// Slight negative margin so the two circles overlap a touch —
|
|
// visually communicates "couple" without needing extra glue.
|
|
return '<span style="display:inline-block;white-space:nowrap;">'
|
|
. implode('<span style="display:inline-block;width:6px;"></span>', $parts)
|
|
. '</span>';
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
$record_label = static function (Fact $fact): string {
|
|
$record = $fact->record();
|
|
|
|
if ($record instanceof Individual) {
|
|
return strip_tags($record->fullName());
|
|
}
|
|
|
|
if ($record instanceof Family) {
|
|
$husband = $record->husband();
|
|
$wife = $record->wife();
|
|
$names = array_filter([
|
|
$husband !== null ? strip_tags($husband->fullName()) : '',
|
|
$wife !== null ? strip_tags($wife->fullName()) : '',
|
|
]);
|
|
|
|
return implode(' & ', $names);
|
|
}
|
|
|
|
return $record->xref();
|
|
};
|
|
|
|
$event_date = static function (Fact $fact): string {
|
|
$date = $fact->date();
|
|
|
|
if (!$date instanceof Date || !$date->isOK()) {
|
|
return '';
|
|
}
|
|
|
|
return strip_tags($date->display());
|
|
};
|
|
|
|
/**
|
|
* Age the person/couple actually turns on the upcoming anniversary, not
|
|
* their current age. We use the fact's own year (which on an anniversary
|
|
* Fact is the year of the original event — birth or marriage) and the
|
|
* year of the upcoming Julian day stored on the Fact ($fact->jd) so the
|
|
* calculation handles people whose birthday falls before vs. after today
|
|
* uniformly.
|
|
*/
|
|
$upcoming_age = static function (Fact $fact): int {
|
|
static $gregorian = null;
|
|
$gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar();
|
|
|
|
$date = $fact->date();
|
|
|
|
if (!$date->isOK()) {
|
|
return 0;
|
|
}
|
|
|
|
$event_year = $date->gregorianYear();
|
|
$upcoming_jd = $fact->jd ?? 0;
|
|
|
|
if ($upcoming_jd > 0) {
|
|
[$upcoming_year] = $gregorian->jdToYmd($upcoming_jd);
|
|
} else {
|
|
$upcoming_year = (int) date('Y');
|
|
}
|
|
|
|
return max(0, $upcoming_year - $event_year);
|
|
};
|
|
|
|
/**
|
|
* Locale-aware ordinal. English uses st/nd/rd/th suffixes; German (and
|
|
* most other European languages we currently support) just appends a
|
|
* period to the digits.
|
|
*/
|
|
$ordinal = static function (int $n): string {
|
|
if (str_starts_with(I18N::languageTag(), 'de')) {
|
|
return $n . '.';
|
|
}
|
|
|
|
$abs = abs($n);
|
|
$mod100 = $abs % 100;
|
|
|
|
if ($mod100 >= 11 && $mod100 <= 13) {
|
|
return $n . 'th';
|
|
}
|
|
|
|
return $n . match ($abs % 10) {
|
|
1 => 'st',
|
|
2 => 'nd',
|
|
3 => 'rd',
|
|
default => 'th',
|
|
};
|
|
};
|
|
|
|
$birthday_label = static function (int $age) use ($ordinal): string {
|
|
if ($age <= 0) {
|
|
return I18N::translate('Birthday');
|
|
}
|
|
|
|
return I18N::translate('%s birthday', $ordinal($age));
|
|
};
|
|
|
|
$anniversary_label = static function (int $age) use ($ordinal): string {
|
|
if ($age <= 0) {
|
|
return I18N::translate('Wedding anniversary');
|
|
}
|
|
|
|
return I18N::translate('%s wedding anniversary', $ordinal($age));
|
|
};
|
|
|
|
?><!doctype html>
|
|
<html lang="<?= e(I18N::languageTag()) ?>">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title><?= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?></title>
|
|
</head>
|
|
<body style="font-family: Helvetica, Arial, sans-serif; color: #222; max-width: 640px; margin: 0 auto;">
|
|
|
|
<h1 style="border-bottom: 2px solid #888; padding-bottom: 0.3rem;">
|
|
<?= e($tree->title()) ?>
|
|
</h1>
|
|
|
|
<p style="color: #555;">
|
|
<?= e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
|
|
</p>
|
|
|
|
<?php
|
|
// Inline list style: drop bullets, add vertical spacing so the avatars
|
|
// don't crash into each other.
|
|
$list_style = 'list-style:none;padding:0;margin:0.5rem 0 1.5rem;';
|
|
$item_style = 'display:flex;align-items:center;gap:0.75rem;padding:0.4rem 0;border-bottom:1px solid #eee;';
|
|
?>
|
|
|
|
<?php if (!$birthdays->isEmpty()) : ?>
|
|
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
|
<ul style="<?= $list_style ?>">
|
|
<?php foreach ($birthdays as $fact) : ?>
|
|
<?php $age = $upcoming_age($fact); ?>
|
|
<li style="<?= $item_style ?>">
|
|
<?= $record_avatars($fact) ?>
|
|
<span>
|
|
<strong><?= e($record_label($fact)) ?></strong>
|
|
— <?= e($birthday_label($age)) ?>
|
|
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
|
|
</span>
|
|
</li>
|
|
<?php endforeach ?>
|
|
</ul>
|
|
<?php endif ?>
|
|
|
|
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
|
|
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
|
|
<ul style="<?= $list_style ?>">
|
|
<?php foreach ($anniversaries as $fact) : ?>
|
|
<?php $age = $upcoming_age($fact); ?>
|
|
<li style="<?= $item_style ?>">
|
|
<?= $record_avatars($fact) ?>
|
|
<span>
|
|
<strong><?= e($record_label($fact)) ?></strong>
|
|
— <?= e($anniversary_label($age)) ?>
|
|
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
|
|
</span>
|
|
</li>
|
|
<?php endforeach ?>
|
|
</ul>
|
|
<?php endif ?>
|
|
|
|
<?php if ($include_historical && $historical !== null && !$historical->isEmpty()) : ?>
|
|
<h2 style="color: #663;"><?= e(I18N::translate('On this month in history')) ?></h2>
|
|
<p style="color: #666;">
|
|
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
|
|
</p>
|
|
<ul style="<?= $list_style ?>">
|
|
<?php foreach ($historical as $fact) : ?>
|
|
<li style="<?= $item_style ?>">
|
|
<?= $record_avatars($fact) ?>
|
|
<span>
|
|
<strong><?= e($record_label($fact)) ?></strong>
|
|
— <?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
|
|
</span>
|
|
</li>
|
|
<?php endforeach ?>
|
|
</ul>
|
|
<?php endif ?>
|
|
|
|
<hr style="margin-top: 2rem; border: 0; border-top: 1px solid #ccc;">
|
|
<p style="color: #888; font-size: 0.85rem;">
|
|
<?= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
|
|
<br>
|
|
<?= I18N::translate(
|
|
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
|
|
'<a href="' . e($account_url) . '" style="color: #336;">' . e(I18N::translate('My account')) . '</a>',
|
|
) ?>
|
|
</p>
|
|
|
|
</body>
|
|
</html>
|