Compare commits
5 Commits
9458867d4d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
f9698cf7bc
|
|||
|
68b347a61f
|
|||
|
2f174bb229
|
|||
|
7402843d07
|
|||
|
90ad060421
|
@@ -3,35 +3,73 @@
|
||||
A [webtrees](https://www.webtrees.net/) 2.2+ custom module that sends recurring
|
||||
email newsletters with:
|
||||
|
||||
- **Upcoming birthdays** of still-living individuals.
|
||||
- **Upcoming marriage anniversaries** of intact couples (optional — admin
|
||||
toggle, per tree). Marriages with a divorce or annulment fact are excluded
|
||||
- **Upcoming birthdays** of still-living individuals (formatted as ordinal
|
||||
age — *"45th birthday"* / *"45. Geburtstag"*).
|
||||
- **Upcoming marriage anniversaries** of intact couples (admin toggle, per
|
||||
tree). Marriages with a divorce or annulment fact are excluded
|
||||
automatically.
|
||||
- **Once-per-month historical section**: births and deaths of deceased
|
||||
individuals whose anniversary falls in the upcoming window.
|
||||
- **Historical events** — births and deaths of deceased individuals whose
|
||||
anniversary falls in the upcoming window.
|
||||
|
||||
The decision to actually send is made by comparing a stored "last sent"
|
||||
timestamp to the configured frequency, so the dispatch run is idempotent —
|
||||
calling the trigger more often than the frequency simply does nothing
|
||||
extra.
|
||||
The look-ahead window and the send cadence are the same number: one
|
||||
"every N days" setting (default 14) drives both the cron interval and how
|
||||
far each issue looks ahead. Issues with nothing to report are silently
|
||||
skipped.
|
||||
|
||||
Each recipient gets a per-recipient render — language, relationship
|
||||
labels, detail filter, cadence, and personalisation tokens are all
|
||||
resolved against *their* webtrees account.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Editorial layout** with embedded circular avatars, a left-side
|
||||
timeline rail, and event-type icons (birth / death / marriage).
|
||||
- **BockenTheme light-mode skin** — Open Sans, cream background, Nord
|
||||
accent palette. The newsletter and the website read as one product.
|
||||
- **Per-recipient localisation** — German for users whose webtrees
|
||||
language starts with `de`, English otherwise. Subject line, body,
|
||||
date strings, and (optionally) a custom subject prefix are all
|
||||
localised. Subject dates use `IntlDateFormatter` for the recipient's
|
||||
locale.
|
||||
- **Per-recipient relationship labels** — *"your mother"*, *"4th great-
|
||||
grandfather"*, *"first cousin twice removed"*. Uses webtrees' own
|
||||
`RelationshipService` so the labels match the site.
|
||||
- **Kin-distance detail filter** — close family get the full card
|
||||
(avatar + timeline + icon); distant kin appear as a single-line
|
||||
bullet at the foot of each section. The "distance" radius is an
|
||||
admin setting (default 3); spouses inherit their partner's distance;
|
||||
recipients with no linked tree record always see the full detailed
|
||||
view.
|
||||
- **Per-recipient cadence** — each subscriber can pick weekly,
|
||||
biweekly, monthly, every-two-months, quarterly, or "use site
|
||||
default" on their `/my-account/{tree}` page.
|
||||
- **One-shot intro paragraph** — admins can attach a Markdown intro
|
||||
(bilingual, EN/DE) to the next issue. Supports
|
||||
`{{first_name}}`, `{{last_name}}`, `{{username}}`, `{{email}}`
|
||||
personalisation tokens. Rendered alongside the tree-contact user's
|
||||
avatar as an editorial column. Cleared automatically after a
|
||||
successful send.
|
||||
- **Cron-only dispatch** — the "is it due?" decision is made server-
|
||||
side against stored timestamps. Calling the trigger more often than
|
||||
the cadence is harmless and idempotent.
|
||||
|
||||
## Requirements
|
||||
|
||||
- webtrees ≥ 2.2.0
|
||||
- PHP ≥ 8.2
|
||||
- A working SMTP / sendmail configuration in *webtrees → Control panel →
|
||||
Sending email* (this module reuses webtrees' standard mailer).
|
||||
- An external scheduler on the host: system `cron`, a `systemd` timer,
|
||||
a Kubernetes `CronJob`, or anything else that can fire an HTTP request
|
||||
at a fixed interval. **Newsletter dispatch never runs on visitor page
|
||||
loads — it only runs when the scheduler triggers it.**
|
||||
- PHP ≥ 8.2 with `ext-intl` (for locale-aware subject dates) and
|
||||
either `ext-imagick` or `ext-gd` (for avatar resizing — falls back
|
||||
to original-size embeds if neither is present).
|
||||
- A working SMTP / sendmail configuration in *Control panel → Sending
|
||||
email*. This module reuses webtrees' standard mailer and signs with
|
||||
the site's DKIM keys if configured.
|
||||
- An external scheduler on the host (system `cron`, `systemd` timer,
|
||||
Kubernetes `CronJob`, …) that can fire an HTTP request at a fixed
|
||||
interval. **Newsletter dispatch never runs on visitor page loads.**
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy this directory into the webtrees `modules_v4/` folder, renaming
|
||||
it to `email_newsletter` (the folder name determines the internal
|
||||
module identifier — the registered name will be
|
||||
`_email_newsletter_`).
|
||||
it to `email_newsletter`:
|
||||
|
||||
```sh
|
||||
cp -r webtrees_email_newsletter /var/www/webtrees/modules_v4/email_newsletter
|
||||
@@ -40,20 +78,33 @@ extra.
|
||||
2. In the webtrees control panel, go to *Modules → All modules* and
|
||||
enable **Email Newsletter**.
|
||||
|
||||
3. Open *Control panel → Modules → Email Newsletter → Preferences* and:
|
||||
- Enable newsletter dispatch per tree.
|
||||
- Pick a frequency (default: 14 days).
|
||||
- Optionally toggle marriage anniversaries and add any extra
|
||||
external email addresses.
|
||||
- Copy the **Cron URL** at the bottom — this is the secret-token
|
||||
URL your scheduler must hit.
|
||||
3. Open *Control panel → Modules → Email Newsletter → Preferences*
|
||||
and, for each tree:
|
||||
|
||||
- Tick *Enable newsletter for this tree*.
|
||||
- Set the send-cadence (default 14 days). This same number is the
|
||||
look-ahead window for the next issue.
|
||||
- Toggle *Include marriage anniversaries* if desired.
|
||||
- Set *Detailed view distance* (default 3). Lower values produce a
|
||||
terser email focused tightly on close kin.
|
||||
- Optional: set per-locale *Subject prefix* and a *Generic*
|
||||
fallback (e.g. `[Bocken family] `).
|
||||
- Optional: tick existing webtrees users in *Subscribed users* to
|
||||
subscribe them. Users can still adjust their own subscription
|
||||
and cadence on `/my-account/{tree}`.
|
||||
- Optional: add external (non-user) addresses in *Extra recipient
|
||||
email addresses*.
|
||||
|
||||
4. Copy the **Cron URL** at the bottom and wire it into your scheduler
|
||||
(see below).
|
||||
|
||||
## Setting up the scheduler
|
||||
|
||||
> **Why no built-in scheduler?** PHP has no daemon, and frameworks like
|
||||
> Laravel rely on a once-per-minute system cron to fire their internal
|
||||
> scheduler. This module follows the same convention: the host OS owns
|
||||
> the timer, the module owns the "is it actually due?" decision.
|
||||
> **Why no built-in scheduler?** PHP has no daemon, and frameworks
|
||||
> like Laravel rely on a once-per-minute system cron to fire their
|
||||
> internal scheduler. This module follows the same convention: the
|
||||
> host OS owns the timer, the module owns the "is it actually due?"
|
||||
> decision.
|
||||
|
||||
### System cron
|
||||
|
||||
@@ -94,29 +145,47 @@ Then `systemctl enable --now webtrees-newsletter.timer`.
|
||||
### Forcing a one-off send
|
||||
|
||||
The admin **Preferences** page has a *Send now* button for testing.
|
||||
For an unattended one-off send, append `&force=1` to the cron URL —
|
||||
that bypasses the "is it due?" check.
|
||||
For an unattended forced send (bypassing the per-recipient "is it
|
||||
due?" check), append `&force=1` to the cron URL.
|
||||
|
||||
## Subscribers
|
||||
|
||||
Two sources, combined:
|
||||
Three sources, combined and de-duplicated by email:
|
||||
|
||||
1. **Logged-in webtrees users** who opt in via the per-tree
|
||||
*Newsletter subscription* menu entry (visible only to logged-in
|
||||
users on trees where the module is enabled). Only **approved and
|
||||
email-verified** accounts will receive the newsletter.
|
||||
2. **External addresses** the tree administrator lists in the
|
||||
*Extra recipient email addresses* textarea (one per line).
|
||||
1. **Webtrees users** who opt in themselves via the per-tree
|
||||
*Newsletter subscription* menu entry on `/my-account/{tree}` —
|
||||
visible only to logged-in users on trees where the module is
|
||||
enabled.
|
||||
2. **Webtrees users** an admin subscribes from the preferences page.
|
||||
3. **External addresses** the admin lists in *Extra recipient email
|
||||
addresses*.
|
||||
|
||||
Only **approved and email-verified** webtrees accounts will receive
|
||||
the newsletter. External addresses always receive on every run
|
||||
(they have no per-user cadence timer).
|
||||
|
||||
## Sender identity
|
||||
|
||||
To match webtrees' own convention for system-generated email
|
||||
(registration, password resets, "new version available"), the
|
||||
`From:` header is **SiteUser** — *Control panel → Sending email →
|
||||
Sender name / Sender email* (`SMTP_FROM_NAME` / `SMTP_DISP_NAME`).
|
||||
The tree's contact user becomes the `Reply-To:`, so replies still
|
||||
reach a human admin.
|
||||
|
||||
If `SMTP_FROM_NAME` isn't set the dispatcher falls back to the tree
|
||||
contact for `From:` as well, so the message always has a valid
|
||||
sender envelope.
|
||||
|
||||
## Privacy
|
||||
|
||||
The dispatch service does not impersonate a webtrees user, so it sees
|
||||
the tree from the **visitor** access level. Records and facts that
|
||||
your tree settings hide from visitors will be omitted from the
|
||||
newsletter even if a recipient has higher in-app access. This is the
|
||||
safest default for an outbound email — if you need to expose more
|
||||
information, relax the tree's visitor-access settings or hand-curate
|
||||
the *Extra recipient* list.
|
||||
The dispatch service does not impersonate a webtrees user, so it
|
||||
sees the tree from the **visitor** access level. Records and facts
|
||||
that your tree settings hide from visitors will be omitted from the
|
||||
newsletter even if a recipient has higher in-app access. This is
|
||||
the safest default for an outbound email — if you need to expose
|
||||
more information, relax the tree's visitor-access settings or
|
||||
hand-curate the *Extra recipient* list.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ use Illuminate\Support\Collection;
|
||||
</legend>
|
||||
<div class="col-sm-9">
|
||||
<small class="form-text text-muted d-block mb-2">
|
||||
<?= I18N::translate('Shown once, above the upcoming events. Cleared automatically after the next successful send.') ?>
|
||||
<?= I18N::translate('Delivered once to every subscriber on their own cadence. Edit and save the text to send a new intro to everyone.') ?>
|
||||
<br>
|
||||
<?= I18N::translate('Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.', '<code>**bold**</code>', '<code>[label](https://example.org)</code>') ?>
|
||||
<br>
|
||||
@@ -183,17 +183,92 @@ use Illuminate\Support\Collection;
|
||||
<code>{{username}}</code>,
|
||||
<code>{{email}}</code>
|
||||
</small>
|
||||
<?php
|
||||
// Subscribers for this tree — used by the
|
||||
// per-locale "who has seen it?" block
|
||||
// below. Same approved/verified gate that
|
||||
// the dispatcher applies, so the admin's
|
||||
// numbers match the actual run.
|
||||
$tree_subscribers = $all_users->filter(static function (User $user) use ($tree): bool {
|
||||
if ($user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
|
||||
return false;
|
||||
}
|
||||
if ($user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
|
||||
return false;
|
||||
}
|
||||
return $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1';
|
||||
});
|
||||
$external_addresses = Configuration::extraRecipients($tree);
|
||||
?>
|
||||
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
|
||||
<?php
|
||||
$field = 'intro-' . $id . '-' . $code;
|
||||
$val = Configuration::introForLocale($module, $tree, $code);
|
||||
$current_v = Configuration::introVersion($module, $tree, $code);
|
||||
$external_v = Configuration::externalIntroVersion($module, $tree, $code);
|
||||
$locale_subs = $tree_subscribers->filter(static function (User $user) use ($code): bool {
|
||||
$pref = $user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_LANGUAGE, '');
|
||||
return Configuration::canonicalSubjectLocale($pref) === $code;
|
||||
});
|
||||
// Status counts — only meaningful once
|
||||
// the intro has been bumped to v ≥ 1.
|
||||
$seen_users = 0;
|
||||
$pending = [];
|
||||
foreach ($locale_subs as $user) {
|
||||
if (Configuration::userIntroVersion($tree, $user, $code) >= $current_v) {
|
||||
$seen_users++;
|
||||
} else {
|
||||
$pending[] = $user;
|
||||
}
|
||||
}
|
||||
$externals_seen = $current_v > 0 && $external_v >= $current_v;
|
||||
$externals_pending = $current_v > 0 && !$externals_seen && $external_addresses !== [];
|
||||
$total = $locale_subs->count() + ($external_addresses === [] ? 0 : 1);
|
||||
$done = $seen_users + ($externals_seen ? 1 : 0);
|
||||
?>
|
||||
<div class="mb-2">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1" for="<?= e($field) ?>">
|
||||
<?= e($label) ?>
|
||||
</label>
|
||||
<textarea class="form-control" rows="6"
|
||||
id="<?= e($field) ?>" name="<?= e($field) ?>"><?= e($val) ?></textarea>
|
||||
|
||||
<?php if ($val !== '' && $current_v > 0) : ?>
|
||||
<div class="small text-muted mt-1">
|
||||
<?php if ($done === $total) : ?>
|
||||
<span class="text-success">✓</span>
|
||||
<?= I18N::translate('Delivered to all %d subscriber(s).', $total) ?>
|
||||
<?php else : ?>
|
||||
<?= I18N::translate('Delivered to %1$d of %2$d subscriber(s).', $done, $total) ?>
|
||||
<?php if ($pending !== [] || $externals_pending) : ?>
|
||||
<details class="mt-1">
|
||||
<summary class="text-muted" style="cursor:pointer;">
|
||||
<?= I18N::translate('Pending') ?>
|
||||
</summary>
|
||||
<ul class="list-unstyled small mb-0 mt-1 ps-2">
|
||||
<?php foreach ($pending as $user) : ?>
|
||||
<li>
|
||||
<span class="text-warning">⏳</span>
|
||||
<?= e($user->realName()) ?>
|
||||
<span class="text-muted"><<?= e($user->email()) ?>></span>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
<?php if ($externals_pending) : ?>
|
||||
<li>
|
||||
<span class="text-warning">⏳</span>
|
||||
<?= I18N::translate('External recipients (%d)', count($external_addresses)) ?>
|
||||
</li>
|
||||
<?php endif ?>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif ?>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php elseif ($val !== '' && $current_v === 0) : ?>
|
||||
<div class="small text-muted mt-1">
|
||||
<?= I18N::translate('Save to schedule delivery.') ?>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Upcoming-events block for the tree home page.
|
||||
*
|
||||
* Renders the same editorial card-and-timeline visualisation as the
|
||||
* newsletter email, with two adaptations for web context:
|
||||
* - avatars resolve to https URLs (`$avatar_srcs`) rather than the
|
||||
* email's `cid:` MIME-part references;
|
||||
* - everyone is "detailed" — there's no per-recipient kin-distance
|
||||
* filter, since the block is rendered once for the whole tree.
|
||||
*
|
||||
* Keep this file structurally close to email.phtml so the two stay
|
||||
* visually identical when one is tweaked.
|
||||
*
|
||||
* @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 int $window_days
|
||||
* @var array<string,string> $avatar_srcs xref => https URL of the highlight image
|
||||
* @var array<string,string> $relationships xref => "your mother" (per-viewer when signed-in + linked)
|
||||
*/
|
||||
|
||||
// ─── Palette ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Bind to BockenTheme CSS custom properties instead of fixed hex values
|
||||
// so the block automatically tracks the user's light/dark preference
|
||||
// (and any future theme tweak) without us re-implementing dark-mode
|
||||
// overrides. Where the theme has no semantic token for a particular
|
||||
// role — the rail or the dot, for instance — we lean on `--color-border`
|
||||
// (subtle in both modes) and `--color-link` (the Nord blue swap-pair).
|
||||
$palette = [
|
||||
'bg' => 'var(--color-bg-primary)',
|
||||
'surface' => 'var(--color-bg-secondary)',
|
||||
'border' => 'var(--color-border)',
|
||||
'ink' => 'var(--color-text-primary)',
|
||||
'ink2' => 'var(--color-text-secondary)',
|
||||
'ink3' => 'var(--color-text-tertiary)',
|
||||
'mute' => 'var(--color-text-muted)',
|
||||
'link' => 'var(--color-link)',
|
||||
'link_hov' => 'var(--color-link-hover)',
|
||||
'accent' => 'var(--color-accent)',
|
||||
'birth' => 'var(--color-link)',
|
||||
'death' => 'var(--color-text-secondary)',
|
||||
'marr' => 'var(--nord15)',
|
||||
'rail' => 'var(--color-border)',
|
||||
'dot' => 'var(--color-link)',
|
||||
];
|
||||
|
||||
$font_stack = "inherit";
|
||||
$avatar_size = 56;
|
||||
|
||||
$relationships = $relationships ?? [];
|
||||
$avatar_srcs = $avatar_srcs ?? [];
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
$linked_name = static function (Individual $individual) use ($palette): string {
|
||||
$name = strip_tags($individual->fullName());
|
||||
$url = $individual->url();
|
||||
// No bottom border — link affordance comes from the color token
|
||||
// and the theme's :hover underline, keeping the card surface
|
||||
// visually quiet.
|
||||
$style = 'color:' . $palette['ink'] . ';text-decoration:none;';
|
||||
|
||||
return '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
|
||||
};
|
||||
|
||||
$record_label = static function (Fact $fact) use ($linked_name, $relationships, $palette): string {
|
||||
$record = $fact->record();
|
||||
|
||||
$names = [];
|
||||
$rels = [];
|
||||
|
||||
if ($record instanceof Individual) {
|
||||
$names[] = $linked_name($record);
|
||||
if (isset($relationships[$record->xref()])) {
|
||||
$rels[] = strip_tags($relationships[$record->xref()]);
|
||||
}
|
||||
} elseif ($record instanceof Family) {
|
||||
foreach ([$record->husband(), $record->wife()] as $spouse) {
|
||||
if ($spouse instanceof Individual) {
|
||||
$names[] = $linked_name($spouse);
|
||||
if (isset($relationships[$spouse->xref()])) {
|
||||
$rels[] = strip_tags($relationships[$spouse->xref()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return e($record->xref());
|
||||
}
|
||||
|
||||
$html = implode(' & ', $names);
|
||||
|
||||
if ($rels !== []) {
|
||||
$rels = array_values(array_unique(array_map(static fn ($r) => e($r), $rels)));
|
||||
$html .= '<div style="margin-top:2px;color:' . $palette['ink3']
|
||||
. ';font-style:italic;font-weight:400;font-size:13px;">'
|
||||
. implode(' & ', $rels)
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
};
|
||||
|
||||
$event_date_display = static function (Fact $fact): string {
|
||||
$date = $fact->date();
|
||||
|
||||
if (!$date instanceof Date || !$date->isOK()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return strip_tags($date->display());
|
||||
};
|
||||
|
||||
$date_parts = static function (Fact $fact): array {
|
||||
static $gregorian = null;
|
||||
static $months_de = [
|
||||
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
static $months_en = [
|
||||
1 => 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
|
||||
$gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar();
|
||||
|
||||
$date = $fact->date();
|
||||
|
||||
if (!$date->isOK()) {
|
||||
return ['day_month' => '', 'year' => ''];
|
||||
}
|
||||
|
||||
[$year, $month, $day] = $gregorian->jdToYmd($date->minimumJulianDay());
|
||||
|
||||
if (str_starts_with(I18N::languageTag(), 'de')) {
|
||||
$day_month = $day . '. ' . mb_strtoupper($months_de[$month] ?? '');
|
||||
} else {
|
||||
$day_month = mb_strtoupper(($months_en[$month] ?? '') . ' ' . $day);
|
||||
}
|
||||
|
||||
return ['day_month' => $day_month, 'year' => (string) $year];
|
||||
};
|
||||
|
||||
$event_kind = static function (Fact $fact): string {
|
||||
$parts = explode(':', $fact->tag());
|
||||
|
||||
return end($parts);
|
||||
};
|
||||
|
||||
// SVG attribute values can't reference CSS custom properties directly
|
||||
// — only the `style` attribute does — so each glyph carries its
|
||||
// fill/stroke as inline style and inherits the right Nord shade from
|
||||
// BockenTheme automatically.
|
||||
$event_icon = static function (string $kind) use ($palette): string {
|
||||
$svg_open = '<svg xmlns="http://www.w3.org/2000/svg" style="vertical-align:-3px;flex:none;" ';
|
||||
|
||||
return match ($kind) {
|
||||
'BIRT' => $svg_open . 'viewBox="0 0 24 24" width="18" height="18">'
|
||||
. '<path style="fill:' . $palette['birth'] . ';" d="M12 1 L13.4 9 L20.5 7.2 L15.4 12.5 L20.5 17.8 L13.4 16 L12 24 L10.6 16 L3.5 17.8 L8.6 12.5 L3.5 7.2 L10.6 9 Z"/>'
|
||||
. '</svg>',
|
||||
|
||||
'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">'
|
||||
. '<rect x="10.5" y="2" width="3" height="20" style="fill:' . $palette['death'] . ';"/>'
|
||||
. '<rect x="5.5" y="7" width="13" height="3" style="fill:' . $palette['death'] . ';"/>'
|
||||
. '</svg>',
|
||||
|
||||
'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">'
|
||||
. '<circle cx="13" cy="11" r="8" fill="none" style="stroke:' . $palette['marr'] . ';" stroke-width="1.8"/>'
|
||||
. '<circle cx="23" cy="11" r="8" fill="none" style="stroke:' . $palette['marr'] . ';" stroke-width="1.8"/>'
|
||||
. '</svg>',
|
||||
|
||||
default => '',
|
||||
};
|
||||
};
|
||||
|
||||
$avatar = static function (Individual|null $individual) use ($avatar_srcs, $avatar_size, $palette, $font_stack): string {
|
||||
if (!$individual instanceof Individual) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$alt = e(strip_tags($individual->fullName()));
|
||||
|
||||
if (isset($avatar_srcs[$individual->xref()])) {
|
||||
$src = $avatar_srcs[$individual->xref()];
|
||||
$inner = '<img src="' . e($src) . '" alt="' . $alt . '"'
|
||||
. ' width="' . $avatar_size . '" height="' . $avatar_size . '"'
|
||||
. ' style="border-radius:50%;object-fit:cover;display:block;">';
|
||||
} else {
|
||||
// Gendered silhouette placeholder — same artwork as the
|
||||
// full-diagram plugin's `.photo-placeholder` + `.silhouette`
|
||||
// shapes (see resources/css/full-diagram.css). Sex token
|
||||
// gives the bg pastel; the silhouette stays the same shape
|
||||
// regardless.
|
||||
// Reuse the BockenTheme `.person-card .photo-placeholder` +
|
||||
// `.silhouette` rules verbatim so the avatar shading matches
|
||||
// the full-diagram plugin exactly (including dark mode).
|
||||
$sex = strtolower($individual->sex());
|
||||
$wrap_cls = 'person-card' . ($sex === 'm' ? ' sex-m' : ($sex === 'f' ? ' sex-f' : ''));
|
||||
$inner = '<svg class="' . $wrap_cls . '" xmlns="http://www.w3.org/2000/svg"'
|
||||
. ' viewBox="0 0 56 56"'
|
||||
. ' width="' . $avatar_size . '" height="' . $avatar_size . '"'
|
||||
. ' aria-label="' . $alt . '"'
|
||||
. ' style="display:block;border-radius:50%;">'
|
||||
. '<circle class="photo-placeholder" cx="28" cy="28" r="28"/>'
|
||||
. '<circle class="silhouette" cx="28" cy="22" r="10"/>'
|
||||
. '<ellipse class="silhouette" cx="28" cy="48" rx="16" ry="12"/>'
|
||||
. '</svg>';
|
||||
}
|
||||
|
||||
return '<a href="' . e($individual->url()) . '" style="text-decoration:none;">' . $inner . '</a>';
|
||||
};
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
return '<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
||||
. '<td>' . ($parts[0] ?? '') . '</td>'
|
||||
. (isset($parts[1]) ? '<td style="padding-left:8px;">' . $parts[1] . '</td>' : '')
|
||||
. '</tr></table>';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$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',
|
||||
};
|
||||
};
|
||||
|
||||
$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);
|
||||
};
|
||||
|
||||
$birthday_label = static function (int $age) use ($ordinal): string {
|
||||
return $age > 0
|
||||
? I18N::translate('%s birthday', $ordinal($age))
|
||||
: I18N::translate('Birthday');
|
||||
};
|
||||
|
||||
$anniversary_label = static function (int $age) use ($ordinal): string {
|
||||
return $age > 0
|
||||
? I18N::translate('%s wedding anniversary', $ordinal($age))
|
||||
: I18N::translate('Wedding anniversary');
|
||||
};
|
||||
|
||||
// ─── Row styles ─────────────────────────────────────────────────────────
|
||||
|
||||
$section_title_style = 'margin:0 0 4px;'
|
||||
. 'font-family:' . $font_stack . ';'
|
||||
. 'font-weight:400;font-size:22px;line-height:1.2;'
|
||||
. 'color:' . $palette['ink'] . ';'
|
||||
. 'letter-spacing:-0.005em;';
|
||||
|
||||
$section_kicker_style = 'margin:0 0 18px;'
|
||||
. 'font-family:' . $font_stack . ';'
|
||||
. 'font-style:italic;font-weight:300;font-size:14px;'
|
||||
. 'color:' . $palette['ink3'] . ';';
|
||||
|
||||
$card_padding_y = 14;
|
||||
$row_gap = 6;
|
||||
|
||||
// Card surface follows the IndividualPage facts-table treatment in
|
||||
// BockenTheme: surface tone + 8 px radius + a soft drop shadow for
|
||||
// the visual lift. The class hook `nl-card` carries everything so
|
||||
// it survives Bootstrap's `.table-bordered` overrides — see the
|
||||
// <style> block at the top of this view.
|
||||
$card_outer_style = 'width:100%;border-radius:0.5rem;overflow:hidden;';
|
||||
|
||||
$avatar_inner_td = 'width:72px;vertical-align:middle;'
|
||||
. 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;';
|
||||
|
||||
$content_inner_td = 'vertical-align:middle;'
|
||||
. 'padding:' . $card_padding_y . 'px 18px;'
|
||||
. 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;'
|
||||
. 'font-weight:300;color:' . $palette['ink'] . ';';
|
||||
|
||||
$outer_card_td = 'vertical-align:middle;padding-bottom:' . $row_gap . 'px;';
|
||||
$outer_gutter_td = 'width:16px;padding-bottom:' . $row_gap . 'px;';
|
||||
// border-left lives on the `.nl-rail` class (see <style> block at the
|
||||
// top of this view) so it survives the global Bootstrap-borders reset.
|
||||
$outer_rail_td = 'width:1%;vertical-align:middle;'
|
||||
. 'padding:0 4px ' . $row_gap . 'px 24px;'
|
||||
. 'font-family:' . $font_stack . ';'
|
||||
. 'color:' . $palette['ink3'] . ';white-space:nowrap;';
|
||||
|
||||
$summary_kicker_style = 'margin:24px 0 8px;'
|
||||
. 'font-family:' . $font_stack . ';'
|
||||
. 'font-weight:500;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;'
|
||||
. 'color:' . $palette['ink3'] . ';';
|
||||
|
||||
$summary_list_style = 'list-style:none;margin:0;padding:0;'
|
||||
. 'font-family:' . $font_stack . ';font-size:13px;line-height:1.7;font-weight:300;'
|
||||
. 'color:' . $palette['ink2'] . ';';
|
||||
|
||||
$summary_item_style = 'padding:3px 0;';
|
||||
|
||||
$event_row = static function (
|
||||
Fact $fact,
|
||||
string $body_html,
|
||||
bool $show_dot,
|
||||
)
|
||||
use (
|
||||
$record_avatars,
|
||||
$date_parts,
|
||||
$card_outer_style,
|
||||
$avatar_inner_td,
|
||||
$content_inner_td,
|
||||
$outer_card_td,
|
||||
$outer_gutter_td,
|
||||
$outer_rail_td,
|
||||
$palette,
|
||||
): string {
|
||||
$card_html =
|
||||
'<table class="nl-card" role="presentation" cellpadding="0" cellspacing="0" border="0" '
|
||||
. 'style="' . $card_outer_style . '"><tr>'
|
||||
. '<td style="' . $avatar_inner_td . '">' . $record_avatars($fact) . '</td>'
|
||||
. '<td style="' . $content_inner_td . '">' . $body_html . '</td>'
|
||||
. '</tr></table>';
|
||||
|
||||
$parts = $date_parts($fact);
|
||||
|
||||
$dot_html = $show_dot
|
||||
? '<span style="display:inline-block;width:14px;height:14px;background:'
|
||||
. $palette['dot']
|
||||
. ';border-radius:50%;margin-left:-33px;margin-right:16px;'
|
||||
. 'vertical-align:middle;box-shadow:0 0 0 4px ' . $palette['bg'] . ';"></span>'
|
||||
: '<span style="display:inline-block;width:14px;margin-left:-33px;'
|
||||
. 'margin-right:16px;vertical-align:middle;"></span>';
|
||||
|
||||
if ($show_dot) {
|
||||
$date_html =
|
||||
'<span style="display:inline-block;vertical-align:middle;">'
|
||||
. '<div style="font-weight:600;font-size:13px;letter-spacing:0.12em;color:'
|
||||
. $palette['ink'] . ';">' . e($parts['day_month']) . '</div>'
|
||||
. '<div style="font-weight:400;font-size:13px;color:'
|
||||
. $palette['ink3'] . ';margin-top:2px;">' . e($parts['year']) . '</div>'
|
||||
. '</span>';
|
||||
} else {
|
||||
$date_html =
|
||||
'<span style="display:inline-block;vertical-align:middle;'
|
||||
. 'font-weight:400;font-size:14px;color:' . $palette['ink2'] . ';">'
|
||||
. e($parts['year'])
|
||||
. '</span>';
|
||||
}
|
||||
|
||||
return '<tr>'
|
||||
. '<td style="' . $outer_card_td . '">' . $card_html . '</td>'
|
||||
. '<td style="' . $outer_gutter_td . '"></td>'
|
||||
. '<td class="nl-rail" style="' . $outer_rail_td . '">' . $dot_html . $date_html . '</td>'
|
||||
. '</tr>';
|
||||
};
|
||||
|
||||
$card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
|
||||
. 'style="width:100%;border-collapse:collapse;">';
|
||||
$card_close = '</table>';
|
||||
|
||||
$timeline_top_cap = '<tr>'
|
||||
. '<td></td><td></td>'
|
||||
. '<td class="nl-rail" style="height:4px;padding:0;'
|
||||
. 'border-top-left-radius:4px;border-top-right-radius:4px;'
|
||||
. 'font-size:0;line-height:0;"></td>'
|
||||
. '</tr>';
|
||||
|
||||
$timeline_bottom_cap = '<tr>'
|
||||
. '<td></td><td></td>'
|
||||
. '<td class="nl-rail" style="height:18px;padding:0;'
|
||||
. 'border-bottom-left-radius:4px;border-bottom-right-radius:4px;'
|
||||
. 'font-size:0;line-height:0;"></td>'
|
||||
. '</tr>';
|
||||
|
||||
// Stroke as inline style so the CSS variable resolves; the attribute
|
||||
// form can only take a literal colour.
|
||||
$arrow_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="22"'
|
||||
. ' viewBox="0 0 26 22" fill="none" style="stroke:' . $palette['rail'] . ';"'
|
||||
. ' stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'
|
||||
. '<path d="M5 6 L13 16 L21 6"/>'
|
||||
. '</svg>';
|
||||
|
||||
$timeline_arrow_row = '<tr>'
|
||||
. '<td></td><td></td>'
|
||||
. '<td style="height:32px;padding:0 0 0 24px;'
|
||||
. 'font-size:0;line-height:0;vertical-align:top;text-align:left;">'
|
||||
. '<span style="display:inline-block;margin-left:-37px;vertical-align:top;margin-top:-14px;">'
|
||||
. $arrow_svg
|
||||
. '</span>'
|
||||
. '</td></tr>';
|
||||
|
||||
$nothing_to_show = $birthdays->isEmpty()
|
||||
&& ($anniversaries === null || $anniversaries->isEmpty())
|
||||
&& ($historical === null || $historical->isEmpty());
|
||||
?>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* BockenTheme applies `@extend .table; @extend .table-bordered;`
|
||||
* to every <table> on the page, which (a) paints 1px hairline
|
||||
* borders on every cell and (b) sets `--bs-table-bg` to the body
|
||||
* bg so cells get repainted with the page colour — wiping out
|
||||
* any inline card background. Reset both, scoped to this block.
|
||||
* The timeline rail is then re-added via the `.nl-rail` class on
|
||||
* the relevant TDs.
|
||||
*/
|
||||
.email-newsletter-block table {
|
||||
--bs-table-bg: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
.email-newsletter-block table,
|
||||
.email-newsletter-block tr,
|
||||
.email-newsletter-block td,
|
||||
.email-newsletter-block th {
|
||||
border: 0 !important;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
/* Card surface — matches the IndividualPage `.wt-facts-table > tr`
|
||||
treatment in BockenTheme: a quiet surface tone lifted by a soft
|
||||
drop shadow. Cells inside are transparent so the table-level
|
||||
bg shows through (and so Bootstrap's `--bs-table-bg` can't
|
||||
repaint them with the page color). */
|
||||
.email-newsletter-block .nl-card {
|
||||
background: var(--color-surface, #efecea);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.email-newsletter-block .nl-card > tbody > tr > td {
|
||||
background-color: transparent;
|
||||
}
|
||||
/* Timeline rail — re-add the 4 px left border the global reset
|
||||
killed. !important is needed to beat the reset above. */
|
||||
.email-newsletter-block .nl-rail {
|
||||
border-left: 4px solid var(--color-border) !important;
|
||||
}
|
||||
/* Avatar fallback piggybacks on BockenTheme's `.person-card`
|
||||
placeholder rules (see theme.scss "FULL DIAGRAM PLUGIN"
|
||||
section), so light/dark shading stays in sync with the
|
||||
full-diagram plugin automatically — no styles needed here. */
|
||||
</style>
|
||||
|
||||
<div class="email-newsletter-block"
|
||||
style="max-width:760px;">
|
||||
<?php if ($nothing_to_show) : ?>
|
||||
<p style="margin:8px 0;color:<?= $palette['ink3'] ?>;font-style:italic;">
|
||||
<?= e(I18N::translate('No upcoming family events in the next %d days.', $window_days)) ?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width:100%;font-family:<?= $font_stack ?>;color:<?= $palette['ink'] ?>;">
|
||||
|
||||
<?php
|
||||
// Merge living-kin events (birthdays + intact-couple
|
||||
// anniversaries) into one date-sorted timeline. Icon
|
||||
// and label key off the fact's tag so a mixed row of
|
||||
// BIRT and MARR shares a single rail.
|
||||
$living = collect($birthdays);
|
||||
if ($include_anniversaries && $anniversaries !== null) {
|
||||
$living = $living->merge($anniversaries);
|
||||
}
|
||||
$living = $living->sortBy(static fn (Fact $f): int => $f->jd ?? 0)->values();
|
||||
?>
|
||||
<?php if (!$living->isEmpty()) : ?>
|
||||
<tr><td style="padding:8px 0 0;">
|
||||
<h3 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming events')) ?></h3>
|
||||
<p style="<?= $section_kicker_style ?>">
|
||||
<?= e(I18N::translate('Birthdays of living kin and anniversaries of intact couples in the next %d days.', $window_days)) ?>
|
||||
</p>
|
||||
<?= $card_open ?>
|
||||
<?= $timeline_top_cap ?>
|
||||
<?php $prev_jd = null; ?>
|
||||
<?php foreach ($living as $fact) : ?>
|
||||
<?php
|
||||
$kind = $event_kind($fact);
|
||||
$age = $upcoming_age($fact);
|
||||
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
|
||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
||||
. '<span style="vertical-align:middle;">'
|
||||
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
||||
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($label) . '</div>'
|
||||
. '</span>';
|
||||
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
||||
$prev_jd = $fact->jd ?? 0;
|
||||
echo $event_row($fact, $body, $show_dot);
|
||||
?>
|
||||
<?php endforeach ?>
|
||||
<?= $timeline_bottom_cap ?>
|
||||
<?= $timeline_arrow_row ?>
|
||||
<?= $card_close ?>
|
||||
</td></tr>
|
||||
<?php endif ?>
|
||||
|
||||
<?php if ($historical !== null && !$historical->isEmpty()) : ?>
|
||||
<tr><td style="padding:32px 0 0;">
|
||||
<h3 style="<?= $section_title_style ?>"><?= e(I18N::translate('On this month in history')) ?></h3>
|
||||
<p style="<?= $section_kicker_style ?>">
|
||||
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $window_days)) ?>
|
||||
</p>
|
||||
<?= $card_open ?>
|
||||
<?= $timeline_top_cap ?>
|
||||
<?php $prev_jd = null; ?>
|
||||
<?php foreach ($historical as $fact) : ?>
|
||||
<?php
|
||||
$kind = $event_kind($fact);
|
||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
||||
. '<span style="vertical-align:middle;">'
|
||||
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
||||
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($fact->label()) . '</div>'
|
||||
. '</span>';
|
||||
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
||||
$prev_jd = $fact->jd ?? 0;
|
||||
echo $event_row($fact, $body, $show_dot);
|
||||
?>
|
||||
<?php endforeach ?>
|
||||
<?= $timeline_bottom_cap ?>
|
||||
<?= $timeline_arrow_row ?>
|
||||
<?= $card_close ?>
|
||||
</td></tr>
|
||||
<?php endif ?>
|
||||
|
||||
</table>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
+105
-67
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use Fisharebest\Webtrees\Date;
|
||||
use Fisharebest\Webtrees\Fact;
|
||||
use Fisharebest\Webtrees\Family;
|
||||
use Fisharebest\Webtrees\Http\RequestHandlers\HomePage;
|
||||
use Fisharebest\Webtrees\Http\RequestHandlers\TreePage;
|
||||
use Fisharebest\Webtrees\I18N;
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
@@ -569,12 +570,70 @@ $timeline_arrow_row = '<tr>'
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title><?= e(I18N::translate('Family newsletter — %s', $masthead_date($generated_at))) ?></title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&display=swap');
|
||||
body { margin: 0; padding: 0; background: <?= $palette['bg'] ?>; }
|
||||
a:hover { color: <?= $palette['link_hov'] ?> !important; }
|
||||
.nl-tr:last-child { border-bottom: 0 !important; }
|
||||
|
||||
/*
|
||||
* Dark-mode skin — mirrors the BockenTheme dark palette so the
|
||||
* newsletter reads as one product with the website on every
|
||||
* device that honours prefers-color-scheme (Apple Mail / iOS
|
||||
* Mail / Outlook for Mac and iOS, Gmail web with a Gmail account).
|
||||
*
|
||||
* The inline styles in the template all reference fixed hex
|
||||
* values from the light palette, so we lean on case-insensitive
|
||||
* attribute-substring selectors to re-tint them without having
|
||||
* to thread classes through every span and td. !important is
|
||||
* required to beat the inline declarations.
|
||||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #0d0d0d !important; color: #e5e5e5 !important; }
|
||||
a:hover { color: #8FBCBB !important; }
|
||||
|
||||
/* Surfaces */
|
||||
[style*="background:#f8f6f1" i] { background-color: #0d0d0d !important; }
|
||||
[style*="background:#efecea" i] { background-color: #1a1a1a !important; }
|
||||
[style*="background:#dfdcd8" i] { background-color: #222 !important; }
|
||||
|
||||
/* Hairline + accent borders */
|
||||
[style*="border:1px solid #ddd" i],
|
||||
[style*="border-top:1px solid #ddd" i],
|
||||
[style*="border-bottom:1px solid #ddd" i] { border-color: #2a2a2a !important; }
|
||||
[style*="border-top:1px dashed #ddd" i] { border-top-color: #2a2a2a !important; }
|
||||
|
||||
/* Timeline rail — muted grey on dark */
|
||||
[style*="border-left:4px solid #cdc7be" i] { border-left-color: #444 !important; }
|
||||
/* The chevron arrow SVG carries stroke="#cdc7be" attribute; the
|
||||
CSS attribute-substring trick on the surrounding span styles
|
||||
doesn't reach into the SVG, so we restyle via stroke. */
|
||||
svg[stroke="#cdc7be"] { stroke: #444 !important; }
|
||||
|
||||
/* Text colours — ink, ink2, ink3, mute */
|
||||
[style*="color:#2a2a2a" i] { color: #e5e5e5 !important; }
|
||||
[style*="color:#555" i] { color: #b6b6b6 !important; }
|
||||
[style*="color:#777" i] { color: #8a8a8a !important; }
|
||||
[style*="color:#aaa" i] { color: #666 !important; }
|
||||
|
||||
/* Links and the matching birth/dot accent (same hex) */
|
||||
[style*="color:#5E81AC" i] { color: #88C0D0 !important; }
|
||||
[style*="background:#5E81AC" i] { background-color: #88C0D0 !important; }
|
||||
[style*="border-bottom:1px solid #5E81AC" i],
|
||||
[style*="border-bottom:1px solid #5E81AC33" i] { border-bottom-color: #88C0D0 !important; }
|
||||
|
||||
/* Event-type icons — death (graphite) lifts; marriage (lavender)
|
||||
stays since it already reads on dark. */
|
||||
[style*="color:#4C566A" i] { color: #B0B6BF !important; }
|
||||
|
||||
/* The intro red rule keeps its accent — Nord red reads on
|
||||
dark just as it does on cream — but we re-declare so the
|
||||
attribute selector wins explicitly over inheritance. */
|
||||
[style*="border-left:3px solid #BF616A" i] { border-left-color: #BF616A !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:<?= $palette['bg'] ?>;color:<?= $palette['ink'] ?>;">
|
||||
@@ -593,20 +652,24 @@ $timeline_arrow_row = '<tr>'
|
||||
<div style="font-size:11px;font-weight:600;letter-spacing:0.22em;text-transform:uppercase;color:<?= $palette['link'] ?>;">
|
||||
<?= e(I18N::translate('Family Chronicle')) ?>
|
||||
</div>
|
||||
<h1 style="margin:10px 0 6px;font-weight:300;font-size:38px;line-height:1.1;letter-spacing:-0.015em;color:<?= $palette['ink'] ?>;">
|
||||
<?= e($tree->title()) ?>
|
||||
</h1>
|
||||
<?php
|
||||
// Strip the leading scheme so the link
|
||||
// reads as a clean hostname/path — the
|
||||
// anchor still points at the absolute URL.
|
||||
// Header H1 → tree home page (familie tree).
|
||||
// Site link below → site root, so recipients
|
||||
// see a clean hostname they can paste / share.
|
||||
$tree_url = route(TreePage::class, ['tree' => $tree->name()]);
|
||||
$tree_url_lbl = preg_replace('~^https?://~i', '', rtrim($tree_url, '/'));
|
||||
$site_url = route(HomePage::class);
|
||||
$site_lbl = preg_replace('~^https?://~i', '', rtrim($site_url, '/'));
|
||||
?>
|
||||
<div style="margin-top:4px;font-size:13px;font-weight:400;letter-spacing:0.01em;">
|
||||
<h1 style="margin:10px 0 6px;font-weight:300;font-size:38px;line-height:1.1;letter-spacing:-0.015em;color:<?= $palette['ink'] ?>;">
|
||||
<a href="<?= e($tree_url) ?>"
|
||||
style="color:<?= $palette['ink'] ?>;text-decoration:none;">
|
||||
<?= e($tree->title()) ?>
|
||||
</a>
|
||||
</h1>
|
||||
<div style="margin-top:4px;font-size:13px;font-weight:400;letter-spacing:0.01em;">
|
||||
<a href="<?= e($site_url) ?>"
|
||||
style="color:<?= $palette['link'] ?>;text-decoration:none;border-bottom:1px solid <?= $palette['link'] ?>33;">
|
||||
<?= e($tree_url_lbl) ?>
|
||||
<?= e($site_lbl) ?>
|
||||
</a>
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:13px;font-weight:300;color:<?= $palette['ink3'] ?>;">
|
||||
@@ -626,6 +689,16 @@ $timeline_arrow_row = '<tr>'
|
||||
// by the factory's HtmlFilter::ESCAPE setting, so
|
||||
// a stray "<" can't break the email layout.
|
||||
$intro_html = Registry::markdownFactory()->markdown(trim($intro), $tree);
|
||||
// Force every Markdown-rendered <img> to fit
|
||||
// inside the intro container — many email
|
||||
// clients honour neither <style> blocks nor
|
||||
// CSS class hooks reliably, so inline width
|
||||
// constraints are the only portable fix.
|
||||
$intro_html = preg_replace(
|
||||
'/<img\b/i',
|
||||
'<img style="max-width:100%;height:auto;display:block;border-radius:6px;margin:8px 0;"',
|
||||
$intro_html,
|
||||
) ?? $intro_html;
|
||||
$intro_inner = '<div style="border-left:3px solid ' . $palette['accent'] . ';padding:6px 0 6px 16px;'
|
||||
. 'font-size:15px;line-height:1.55;font-weight:300;color:' . $palette['ink'] . ';'
|
||||
. 'font-style:italic;">' . $intro_html . '</div>';
|
||||
@@ -651,18 +724,29 @@ $timeline_arrow_row = '<tr>'
|
||||
</tr>
|
||||
<?php endif ?>
|
||||
|
||||
<?php if (!$birthdays->isEmpty()) : ?>
|
||||
<?php
|
||||
// Merge living-kin events (birthdays + intact-couple
|
||||
// anniversaries) into a single date-sorted timeline.
|
||||
// The icon and label come from the fact's tag, so a
|
||||
// mixed row of BIRT and MARR facts shares one rail.
|
||||
$living = collect($birthdays);
|
||||
if ($include_anniversaries && $anniversaries !== null) {
|
||||
$living = $living->merge($anniversaries);
|
||||
}
|
||||
$living = $living->sortBy(static fn (Fact $f): int => $f->jd ?? 0)->values();
|
||||
?>
|
||||
<?php if (!$living->isEmpty()) : ?>
|
||||
<?php
|
||||
$detailed = [];
|
||||
$summary = [];
|
||||
foreach ($birthdays as $fact) {
|
||||
foreach ($living as $fact) {
|
||||
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
||||
}
|
||||
?>
|
||||
<tr><td style="padding:8px 0 0;">
|
||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming events')) ?></h2>
|
||||
<p style="<?= $section_kicker_style ?>">
|
||||
<?= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
|
||||
<?= e(I18N::translate('Birthdays of living kin and anniversaries of intact couples in the next %d days.', $window_days)) ?>
|
||||
</p>
|
||||
<?php if ($detailed !== []) : ?>
|
||||
<?= $card_open ?>
|
||||
@@ -670,11 +754,13 @@ $timeline_arrow_row = '<tr>'
|
||||
<?php $prev_jd = null; ?>
|
||||
<?php foreach ($detailed as $fact) : ?>
|
||||
<?php
|
||||
$kind = $event_kind($fact);
|
||||
$age = $upcoming_age($fact);
|
||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
|
||||
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
|
||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
|
||||
. '<span style="vertical-align:middle;">'
|
||||
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
||||
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($birthday_label($age)) . '</div>'
|
||||
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($label) . '</div>'
|
||||
. '</span>';
|
||||
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
||||
$prev_jd = $fact->jd ?? 0;
|
||||
@@ -687,66 +773,18 @@ $timeline_arrow_row = '<tr>'
|
||||
<?php endif ?>
|
||||
<?php if ($summary !== []) : ?>
|
||||
<div style="<?= $summary_kicker_style ?>">
|
||||
<?= e(I18N::translate('Other birthdays')) ?>
|
||||
<?= e(I18N::translate('Other upcoming events')) ?>
|
||||
</div>
|
||||
<ul style="<?= $summary_list_style ?>">
|
||||
<?php foreach ($summary as $fact) : ?>
|
||||
<?php $age = $upcoming_age($fact); ?>
|
||||
<li style="<?= $summary_item_style ?>">
|
||||
<?= $record_label($fact) ?>
|
||||
<span style="color:<?= $palette['ink3'] ?>;"><?= e($birthday_label($age)) ?></span>
|
||||
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
</ul>
|
||||
<?php endif ?>
|
||||
</td></tr>
|
||||
<?php endif ?>
|
||||
|
||||
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
|
||||
<?php
|
||||
$detailed = [];
|
||||
$summary = [];
|
||||
foreach ($anniversaries as $fact) {
|
||||
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
|
||||
}
|
||||
?>
|
||||
<tr><td style="padding:32px 0 0;">
|
||||
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
|
||||
<p style="<?= $section_kicker_style ?>">
|
||||
<?= e(I18N::translate('Marriages still intact.')) ?>
|
||||
</p>
|
||||
<?php if ($detailed !== []) : ?>
|
||||
<?= $card_open ?>
|
||||
<?= $timeline_top_cap ?>
|
||||
<?php $prev_jd = null; ?>
|
||||
<?php foreach ($detailed as $fact) : ?>
|
||||
<?php
|
||||
$kind = $event_kind($fact);
|
||||
$age = $upcoming_age($fact);
|
||||
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
|
||||
. '<span style="vertical-align:middle;">'
|
||||
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
|
||||
. '<div style="margin-top:2px;color:' . $palette['ink2'] . ';font-weight:300;">' . e($anniversary_label($age)) . '</div>'
|
||||
. '</span>';
|
||||
$show_dot = ($fact->jd ?? 0) !== $prev_jd;
|
||||
$prev_jd = $fact->jd ?? 0;
|
||||
echo $event_row($fact, $body, $show_dot);
|
||||
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
|
||||
?>
|
||||
<?php endforeach ?>
|
||||
<?= $timeline_bottom_cap ?>
|
||||
<?= $timeline_arrow_row ?>
|
||||
<?= $card_close ?>
|
||||
<?php endif ?>
|
||||
<?php if ($summary !== []) : ?>
|
||||
<div style="<?= $summary_kicker_style ?>">
|
||||
<?= e(I18N::translate('Other anniversaries')) ?>
|
||||
</div>
|
||||
<ul style="<?= $summary_list_style ?>">
|
||||
<?php foreach ($summary as $fact) : ?>
|
||||
<?php $age = $upcoming_age($fact); ?>
|
||||
<li style="<?= $summary_item_style ?>">
|
||||
<?= $record_label($fact) ?>
|
||||
<span style="color:<?= $palette['ink3'] ?>;"><?= e($anniversary_label($age)) ?></span>
|
||||
<span style="color:<?= $palette['ink3'] ?>;"><?= e($label) ?></span>
|
||||
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
|
||||
+71
-5
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace EmailNewsletter;
|
||||
|
||||
use Fisharebest\Webtrees\Contracts\UserInterface;
|
||||
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||
use Fisharebest\Webtrees\Tree;
|
||||
use Fisharebest\Webtrees\User;
|
||||
|
||||
/**
|
||||
* Per-tree configuration for the Email Newsletter module.
|
||||
@@ -68,6 +70,7 @@ final class Configuration
|
||||
public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed';
|
||||
public const string USER_PREF_FREQUENCY_DAYS = 'NEWSLETTER_USER_FREQ_DAYS';
|
||||
public const string USER_PREF_LAST_SENT_AT = 'NEWSLETTER_USER_LAST_SENT';
|
||||
public const string USER_PREF_INTRO_SEEN_PREFIX = 'intro_seen_';
|
||||
|
||||
public const int DEFAULT_FREQUENCY_DAYS = 14;
|
||||
public const int DEFAULT_LINEAL_DEPTH = 3;
|
||||
@@ -217,14 +220,67 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe every locale's intro paragraph. Called by the dispatcher
|
||||
* after a successful send so the next issue starts clean.
|
||||
* Monotonic version counter for the per-locale intro paragraph.
|
||||
* Bumped by `bumpIntroVersion()` when an admin saves a new
|
||||
* non-empty intro that differs from the previous value. Each
|
||||
* recipient has their own last-seen version (per-user for
|
||||
* registered users, tree-level for external addresses) — the
|
||||
* dispatcher compares the two and only includes the intro for
|
||||
* recipients who are behind, so a single intro reaches every
|
||||
* subscriber exactly once even when their cadences differ.
|
||||
*/
|
||||
public static function clearIntros(AbstractModule $module, Tree $tree): void
|
||||
public static function introVersion(AbstractModule $module, Tree $tree, string $language): int
|
||||
{
|
||||
foreach (array_keys(self::supportedSubjectLocales()) as $code) {
|
||||
$module->setPreference(self::introKey($tree, $code), '');
|
||||
$code = self::canonicalSubjectLocale($language);
|
||||
|
||||
return (int) $module->getPreference(self::introVersionKey($tree, $code), '0');
|
||||
}
|
||||
|
||||
public static function bumpIntroVersion(AbstractModule $module, Tree $tree, string $language): int
|
||||
{
|
||||
$code = self::canonicalSubjectLocale($language);
|
||||
$next = self::introVersion($module, $tree, $language) + 1;
|
||||
$module->setPreference(self::introVersionKey($tree, $code), (string) $next);
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last intro version a webtrees user has received, per tree per
|
||||
* locale. Stored as a per-tree-per-user gedcom_setting — the
|
||||
* setting name needs no tree suffix because
|
||||
* `$tree->setUserPreference()` is already tree-scoped.
|
||||
*/
|
||||
public static function userIntroVersion(Tree $tree, UserInterface $user, string $language): int
|
||||
{
|
||||
$code = self::canonicalSubjectLocale($language);
|
||||
|
||||
return (int) $tree->getUserPreference($user, self::USER_PREF_INTRO_SEEN_PREFIX . $code, '0');
|
||||
}
|
||||
|
||||
public static function setUserIntroVersion(Tree $tree, UserInterface $user, string $language, int $version): void
|
||||
{
|
||||
$code = self::canonicalSubjectLocale($language);
|
||||
$tree->setUserPreference($user, self::USER_PREF_INTRO_SEEN_PREFIX . $code, (string) $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Last intro version delivered to any external (non-User) address
|
||||
* for this tree + locale. Webtrees has nowhere to hang per-user
|
||||
* state for these recipients, so we track a single tree-level
|
||||
* watermark instead — externals see each intro at most once.
|
||||
*/
|
||||
public static function externalIntroVersion(AbstractModule $module, Tree $tree, string $language): int
|
||||
{
|
||||
$code = self::canonicalSubjectLocale($language);
|
||||
|
||||
return (int) $module->getPreference(self::externalSeenKey($tree, $code), '0');
|
||||
}
|
||||
|
||||
public static function setExternalIntroVersion(AbstractModule $module, Tree $tree, string $language, int $version): void
|
||||
{
|
||||
$code = self::canonicalSubjectLocale($language);
|
||||
$module->setPreference(self::externalSeenKey($tree, $code), (string) $version);
|
||||
}
|
||||
|
||||
private static function introKey(Tree $tree, string $locale_code): string
|
||||
@@ -232,6 +288,16 @@ final class Configuration
|
||||
return self::MODULE_PREF_INTRO_PREFIX . $tree->id() . '_' . $locale_code;
|
||||
}
|
||||
|
||||
private static function introVersionKey(Tree $tree, string $locale_code): string
|
||||
{
|
||||
return 'intro_v_' . $tree->id() . '_' . $locale_code;
|
||||
}
|
||||
|
||||
private static function externalSeenKey(Tree $tree, string $locale_code): string
|
||||
{
|
||||
return 'intro_ext_' . $tree->id() . '_' . $locale_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra recipient email addresses configured by the admin (one per line).
|
||||
*
|
||||
|
||||
+241
-31
@@ -11,43 +11,55 @@ declare(strict_types=1);
|
||||
namespace EmailNewsletter;
|
||||
|
||||
use EmailNewsletter\Http\AccountUpdateDecorator;
|
||||
use EmailNewsletter\Services\EventQueryService;
|
||||
use EmailNewsletter\Services\NewsletterDispatchService;
|
||||
use EmailNewsletter\Services\RelationshipPathFinder;
|
||||
use Fisharebest\Webtrees\Auth;
|
||||
use Fisharebest\Webtrees\Contracts\UserInterface;
|
||||
use Fisharebest\Webtrees\FlashMessages;
|
||||
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
|
||||
use Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit;
|
||||
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
|
||||
use Fisharebest\Webtrees\I18N;
|
||||
use Fisharebest\Webtrees\Menu;
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use Fisharebest\Webtrees\Registry;
|
||||
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||
use Fisharebest\Webtrees\Module\ModuleBlockInterface;
|
||||
use Fisharebest\Webtrees\Module\ModuleBlockTrait;
|
||||
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
|
||||
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
|
||||
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
|
||||
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
|
||||
use Fisharebest\Webtrees\Module\ModuleMenuInterface;
|
||||
use Fisharebest\Webtrees\Module\ModuleMenuTrait;
|
||||
use Fisharebest\Webtrees\Services\TreeService;
|
||||
use Fisharebest\Webtrees\Services\UserService;
|
||||
use Fisharebest\Webtrees\User;
|
||||
use Fisharebest\Webtrees\Tree;
|
||||
use Fisharebest\Webtrees\User;
|
||||
use Fisharebest\Webtrees\Validator;
|
||||
use Fisharebest\Webtrees\View;
|
||||
use Illuminate\Support\Str;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleMenuInterface
|
||||
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleBlockInterface
|
||||
{
|
||||
use ModuleCustomTrait;
|
||||
use ModuleConfigTrait;
|
||||
use ModuleMenuTrait;
|
||||
use ModuleBlockTrait;
|
||||
|
||||
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
|
||||
|
||||
/**
|
||||
* Default look-ahead window for the tree-home block. Distinct from
|
||||
* the per-tree newsletter cadence — the block always shows the
|
||||
* next 30 days regardless of how often the email is sent.
|
||||
*/
|
||||
private const int BLOCK_DEFAULT_WINDOW_DAYS = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly NewsletterDispatchService $dispatch_service,
|
||||
private readonly TreeService $tree_service,
|
||||
private readonly UserService $user_service,
|
||||
private readonly EventQueryService $event_query_service,
|
||||
private readonly RelationshipPathFinder $relationship_finder,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -128,6 +140,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
||||
'Other birthdays' => 'Weitere Geburtstage',
|
||||
'Other anniversaries' => 'Weitere Hochzeitstage',
|
||||
'Other historical events' => 'Weitere historische Ereignisse',
|
||||
'Upcoming events' => 'Anstehende Ereignisse',
|
||||
'Birthdays of living kin and anniversaries of intact couples in the next %d days.'
|
||||
=> 'Geburtstage lebender Verwandter und Hochzeitstage bestehender Paare in den nächsten %d Tagen.',
|
||||
'Other upcoming events' => 'Weitere anstehende Ereignisse',
|
||||
'Detailed view distance' => 'Detailansicht-Abstand',
|
||||
'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.'
|
||||
=> 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
|
||||
@@ -173,9 +189,20 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
||||
'Intro paragraph for the next email' => 'Einleitungsabsatz für die nächste E-Mail',
|
||||
'Shown once, above the upcoming events. Cleared automatically after the next successful send.'
|
||||
=> 'Wird einmalig über den anstehenden Ereignissen angezeigt. Wird nach dem nächsten erfolgreichen Versand automatisch geleert.',
|
||||
'Delivered once to every subscriber on their own cadence. Edit and save the text to send a new intro to everyone.'
|
||||
=> 'Wird jedem Abonnenten einmalig in seinem eigenen Versandrhythmus zugestellt. Text bearbeiten und speichern, um eine neue Einleitung an alle zu versenden.',
|
||||
'Personalisation tokens:' => 'Personalisierungs-Platzhalter:',
|
||||
'Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.'
|
||||
=> 'Formatiert als Markdown — z. B. %1$s für Hervorhebung, %2$s für einen Link.',
|
||||
'Delivered to all %d subscriber(s).' => 'An alle %d Abonnenten zugestellt.',
|
||||
'Delivered to %1$d of %2$d subscriber(s).' => 'An %1$d von %2$d Abonnenten zugestellt.',
|
||||
'Pending' => 'Ausstehend',
|
||||
'External recipients (%d)' => 'Externe Empfänger (%d)',
|
||||
'Save to schedule delivery.' => 'Speichern, um die Zustellung zu starten.',
|
||||
'Upcoming family events' => 'Anstehende Familienereignisse',
|
||||
'No upcoming family events in the next %d days.'
|
||||
=> 'Keine anstehenden Familienereignisse in den nächsten %d Tagen.',
|
||||
'Living kin celebrating in the next %d days.' => 'Lebende Verwandte, die in den nächsten %d Tagen feiern.',
|
||||
],
|
||||
'nl' => [
|
||||
'Email Newsletter' => 'E-mailnieuwsbrief',
|
||||
@@ -205,30 +232,6 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
||||
return $translations[$language] ?? [];
|
||||
}
|
||||
|
||||
// ─── Menu ────────────────────────────────────────────────────────
|
||||
|
||||
public function defaultMenuOrder(): int
|
||||
{
|
||||
return 99;
|
||||
}
|
||||
|
||||
public function getMenu(Tree $tree): Menu|null
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Configuration::isEnabled($tree)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Menu(
|
||||
I18N::translate('Newsletter subscription'),
|
||||
route(AccountEdit::class, ['tree' => $tree->name()]),
|
||||
'menu-newsletter-subscription',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Admin config page ──────────────────────────────────────────
|
||||
|
||||
public function getAdminAction(): ResponseInterface
|
||||
@@ -293,7 +296,17 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
||||
$intro = Validator::parsedBody($request)
|
||||
->string('intro-' . $id . '-' . $code, '');
|
||||
|
||||
// Bump the version only when the saved text actually
|
||||
// changed AND is non-empty. That makes "save the same
|
||||
// intro again" a no-op (no resends), while saving a
|
||||
// new non-empty paragraph re-delivers it to every
|
||||
// subscriber on their own cadence.
|
||||
$previous = Configuration::introForLocale($this, $tree, $code);
|
||||
Configuration::setIntroForLocale($this, $tree, $code, $intro);
|
||||
|
||||
if ($intro !== '' && $intro !== $previous) {
|
||||
Configuration::bumpIntroVersion($this, $tree, $code);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-user subscription toggles. A users-roster marker is
|
||||
@@ -345,6 +358,203 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
||||
return redirect($this->getConfigLink());
|
||||
}
|
||||
|
||||
// ─── Tree-home block ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render an "Upcoming family events" block for the tree home page.
|
||||
* Reuses the same visualisation as the newsletter email (cards,
|
||||
* circular avatars, timeline rail, event icons) but adapted for
|
||||
* web context: avatars resolve to media-file URLs instead of CID
|
||||
* attachments, and relationship labels are computed against the
|
||||
* viewer's tree-linked Individual when available.
|
||||
*
|
||||
* Default look-ahead window is 30 days; admins can override per
|
||||
* block placement via the standard "configure" UI.
|
||||
*
|
||||
* @param array<string,string> $config
|
||||
*/
|
||||
public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string
|
||||
{
|
||||
$window = (int) ($config['window_days'] ?? self::BLOCK_DEFAULT_WINDOW_DAYS);
|
||||
$window = max(1, min(365, $window));
|
||||
|
||||
// Cache the rendered block — relationship labels and avatar
|
||||
// URL lookups are per-viewer, so the cache key includes the
|
||||
// signed-in user id (0 for guests). 5-minute TTL is short
|
||||
// enough that admin edits propagate within one refresh.
|
||||
$viewer_id = Auth::user() instanceof User ? Auth::user()->id() : 0;
|
||||
$cache_key = sprintf(
|
||||
'email_newsletter_block_%d_%d_%d_%s',
|
||||
$tree->id(),
|
||||
$window,
|
||||
$viewer_id,
|
||||
I18N::languageTag(),
|
||||
);
|
||||
|
||||
$content = Registry::cache()->file()->remember(
|
||||
$cache_key,
|
||||
fn (): string => $this->renderBlockContent($tree, $window),
|
||||
300,
|
||||
);
|
||||
|
||||
if ($context !== self::CONTEXT_EMBED) {
|
||||
return view('modules/block-template', [
|
||||
'block' => Str::kebab($this->name()),
|
||||
'id' => $block_id,
|
||||
'config_url' => '',
|
||||
'title' => I18N::translate('Upcoming family events'),
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather upcoming-event data and render the inner block HTML. Kept
|
||||
* separate from getBlock() so the result can be wrapped in a
|
||||
* file-cache without re-querying the database on every page load.
|
||||
*/
|
||||
private function renderBlockContent(Tree $tree, int $window): string
|
||||
{
|
||||
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $window);
|
||||
$anniversaries = Configuration::includeAnniversaries($tree)
|
||||
? $this->event_query_service->upcomingAnniversaries($tree, $window)
|
||||
: null;
|
||||
$historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window);
|
||||
|
||||
// Featured individuals — every Individual referenced by any
|
||||
// fact in the block. Used to scope relationship labels and
|
||||
// avatar URLs.
|
||||
$featured = [];
|
||||
foreach ([$birthdays, $anniversaries, $historical] as $facts) {
|
||||
if ($facts === null) {
|
||||
continue;
|
||||
}
|
||||
foreach ($facts as $fact) {
|
||||
$record = $fact->record();
|
||||
if ($record instanceof Individual) {
|
||||
$featured[$record->xref()] = $record;
|
||||
} elseif ($record instanceof \Fisharebest\Webtrees\Family) {
|
||||
foreach ([$record->husband(), $record->wife()] as $spouse) {
|
||||
if ($spouse instanceof Individual) {
|
||||
$featured[$spouse->xref()] = $spouse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relationships = $this->viewerRelationships($tree, $featured);
|
||||
$avatar_srcs = $this->collectBlockAvatarSrcs($featured);
|
||||
|
||||
return view($this->name() . '::block', [
|
||||
'tree' => $tree,
|
||||
'birthdays' => $birthdays,
|
||||
'anniversaries' => $anniversaries,
|
||||
'historical' => $historical,
|
||||
'include_anniversaries' => Configuration::includeAnniversaries($tree),
|
||||
'window_days' => $window,
|
||||
'avatar_srcs' => $avatar_srcs,
|
||||
'relationships' => $relationships,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loadAjax(): bool
|
||||
{
|
||||
// Defer the block to an async fetch so the rest of the tree
|
||||
// home page paints before our (cached) HTML arrives. Same
|
||||
// pattern webtrees uses for heavy stats blocks.
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isUserBlock(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isTreeBlock(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map xref => avatar src URL. Only entries for individuals with a
|
||||
* resolvable highlighted media file are present — the view treats
|
||||
* absence as "render an initials disc".
|
||||
*
|
||||
* @param array<string,Individual> $featured
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function collectBlockAvatarSrcs(array $featured): array
|
||||
{
|
||||
$srcs = [];
|
||||
|
||||
foreach ($featured as $xref => $individual) {
|
||||
try {
|
||||
$media_file = $individual->findHighlightedMediaFile();
|
||||
} catch (\Throwable $ex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($media_file === null || !$media_file->isImage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 192 px source so the 56-px-rendered avatar stays crisp
|
||||
// on retina displays — matches the email-side resize.
|
||||
try {
|
||||
$srcs[$xref] = $media_file->imageUrl(192, 192, 'crop');
|
||||
} catch (\Throwable $ex) {
|
||||
// imageUrl can throw on broken file paths; just skip.
|
||||
}
|
||||
}
|
||||
|
||||
return $srcs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build xref => "your mother" labels for the current viewer if
|
||||
* they're signed in and linked to an Individual on this tree.
|
||||
*
|
||||
* @param array<string,Individual> $featured
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function viewerRelationships(Tree $tree, array $featured): array
|
||||
{
|
||||
$viewer = Auth::user();
|
||||
|
||||
if (!$viewer instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$self_xref = $tree->getUserPreference($viewer, 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;
|
||||
}
|
||||
|
||||
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,9 +152,20 @@ final class NewsletterDispatchService
|
||||
// The intro paragraph is attributed to the tree contact user
|
||||
// (the same person we use as Reply-To). If they're linked to an
|
||||
// Individual record we fold their avatar into the embed set so
|
||||
// the editorial block can render with their face on the left,
|
||||
// matching the styling of the event cards below.
|
||||
$intro_author = $this->resolveIntroAuthor($tree, $reply_to);
|
||||
// the editorial block can render with their face on the left.
|
||||
// We only bother when at least one locale actually has an intro
|
||||
// to send — otherwise loading/encoding the portrait is wasted
|
||||
// work and the bytes would be attached to every recipient's
|
||||
// email without ever being referenced.
|
||||
$has_any_intro = false;
|
||||
foreach (array_keys(Configuration::supportedSubjectLocales()) as $code) {
|
||||
if (trim(Configuration::introForLocale($module, $tree, $code)) !== '') {
|
||||
$has_any_intro = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$intro_author = $has_any_intro ? $this->resolveIntroAuthor($tree, $reply_to) : null;
|
||||
|
||||
if ($intro_author instanceof Individual && !isset($avatars[$this->avatarCidName($intro_author->xref())])) {
|
||||
$author_avatar = $this->resolveAvatar($intro_author);
|
||||
@@ -185,9 +196,14 @@ final class NewsletterDispatchService
|
||||
|
||||
// One-shot intro paragraph (admin-supplied). Empty
|
||||
// string is "no intro" — the view simply omits the
|
||||
// block. Cleared from preferences after the run if
|
||||
// at least one recipient was successfully reached.
|
||||
// block. We do NOT clear it after sending; instead
|
||||
// we version-stamp it and track each recipient's
|
||||
// last-seen version, so subscribers on slower
|
||||
// cadences still get the message exactly once.
|
||||
$intro = Configuration::introForLocale($module, $tree, $lang);
|
||||
$intro_version = Configuration::introVersion($module, $tree, $lang);
|
||||
$external_seen = Configuration::externalIntroVersion($module, $tree, $lang);
|
||||
$external_served_this_run = false;
|
||||
|
||||
// Render the email body per recipient — the relationship
|
||||
// labels are personalised relative to whichever individual
|
||||
@@ -208,12 +224,21 @@ final class NewsletterDispatchService
|
||||
$this->avatarKeysForXrefs(array_keys($detailed_set)),
|
||||
);
|
||||
|
||||
// The intro author sits outside the detailed set —
|
||||
// make sure their avatar bytes still ride along so
|
||||
// the editorial portrait renders inline instead of
|
||||
// showing a broken image (which we'd otherwise hide
|
||||
// behind a tree-page login link).
|
||||
if ($intro_author instanceof Individual) {
|
||||
// Decide whether to attach the intro for *this*
|
||||
// recipient: only if it's non-empty AND this
|
||||
// recipient hasn't yet received the current
|
||||
// version. Webtrees users have their own
|
||||
// watermark; external addresses share a single
|
||||
// tree-level one.
|
||||
$recipient_seen = $recipient instanceof User
|
||||
? Configuration::userIntroVersion($tree, $recipient, $lang)
|
||||
: $external_seen;
|
||||
$show_intro = $intro !== '' && $intro_version > $recipient_seen;
|
||||
|
||||
// Only attach the editorial portrait to recipients
|
||||
// who actually see the intro — otherwise the bytes
|
||||
// would ride along uselessly and bloat the message.
|
||||
if ($show_intro && $intro_author instanceof Individual) {
|
||||
$author_cid = $this->avatarCidName($intro_author->xref());
|
||||
|
||||
if (isset($avatars[$author_cid])) {
|
||||
@@ -221,6 +246,10 @@ final class NewsletterDispatchService
|
||||
}
|
||||
}
|
||||
|
||||
$personalised_intro = $show_intro
|
||||
? $this->renderIntroTemplate($intro, $recipient)
|
||||
: '';
|
||||
|
||||
$html = view($module->name() . '::email', [
|
||||
'tree' => $tree,
|
||||
'birthdays' => $birthdays,
|
||||
@@ -233,8 +262,8 @@ final class NewsletterDispatchService
|
||||
'relationships' => $relationships,
|
||||
'detailed_xrefs' => $detailed_set,
|
||||
'account_url' => $account_url,
|
||||
'intro' => $this->renderIntroTemplate($intro, $recipient),
|
||||
'intro_author' => $intro_author,
|
||||
'intro' => $personalised_intro,
|
||||
'intro_author' => $show_intro ? $intro_author : null,
|
||||
]);
|
||||
|
||||
$text = $this->htmlToText($html);
|
||||
@@ -247,6 +276,20 @@ final class NewsletterDispatchService
|
||||
// added addresses always fire on every run.
|
||||
if ($recipient instanceof User) {
|
||||
Configuration::setUserLastSentAt($tree, $recipient, $now);
|
||||
|
||||
// Mark this user as up-to-date on the
|
||||
// intro so we don't re-deliver it next
|
||||
// time their cadence comes round.
|
||||
if ($show_intro) {
|
||||
Configuration::setUserIntroVersion($tree, $recipient, $lang, $intro_version);
|
||||
}
|
||||
} elseif ($show_intro) {
|
||||
// External recipient — bump the single
|
||||
// tree-level "externals served"
|
||||
// watermark after the loop so all
|
||||
// externals in this run see the same
|
||||
// intro before we move it forward.
|
||||
$external_served_this_run = true;
|
||||
}
|
||||
} else {
|
||||
$failures++;
|
||||
@@ -256,6 +299,10 @@ final class NewsletterDispatchService
|
||||
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($external_served_this_run) {
|
||||
Configuration::setExternalIntroVersion($module, $tree, $lang, $intro_version);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always restore the original locale, even if a render or
|
||||
@@ -269,14 +316,6 @@ final class NewsletterDispatchService
|
||||
Configuration::setLastSentAt($tree, $now);
|
||||
}
|
||||
|
||||
// One-shot intro: clear only once at least one recipient was
|
||||
// actually reached. A run that produced nothing but failures
|
||||
// (transport down, all addresses bouncing) preserves the
|
||||
// admin's intro for the next attempt.
|
||||
if ($sent > 0) {
|
||||
Configuration::clearIntros($module, $tree);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'Tree "%s": sent to %d recipient(s), %d failure(s).',
|
||||
$tree->name(),
|
||||
|
||||
Reference in New Issue
Block a user