Compare commits

..

26 Commits

Author SHA1 Message Date
Alexander f9698cf7bc Drop wide-screen right padding on tree-home block 2026-05-15 17:26:34 +02:00
Alexander 68b347a61f Add tree-home block; merge birthday/anniversary timeline
- New "Upcoming family events" block for the tree home page,
  rendering the same card + timeline visualisation as the
  newsletter email but adapted for web context: avatars resolve
  to media-file URLs (no CID), the silhouette placeholder reuses
  BockenTheme's .person-card .photo-placeholder rules so the
  Nord-mixed shades and dark-mode handling stay in sync with the
  full-diagram plugin, and per-viewer relationship labels surface
  when the signed-in user is linked to an Individual on the tree.
- Default window 30 days, configurable via the standard block
  config UI. Wide-screen wrapper caps at 760 px with a small
  right-side breathing margin.
- Block renders via AJAX and caches its HTML for 5 minutes per
  (tree, window, viewer, locale), so the tree home page paints
  instantly and repeat visits skip the heavy event/query +
  relationship-BFS work.
- Living-kin section is now a single date-sorted timeline that
  mixes birthdays and intact-couple anniversaries. Each row's
  icon + label key off the fact's tag, so a mixed run shares
  one rail. Applies to both block and email.
- Newsletter subscription menu entry removed from the header;
  the form is still reachable on the standard /my-account page
  via the registerCustomView override.
2026-05-15 17:16:51 +02:00
Alexander 2f174bb229 Skip intro-author avatar embed when no intro is being sent
Two-level gate: resolve and embed the tree-contact portrait only
when (a) at least one locale on the tree has a non-empty intro on
file, and (b) the specific recipient is still pending delivery
for the current intro version. Recipients who have already seen
this intro, or whose locale has no intro, no longer carry the
extra image bytes.
2026-05-15 16:08:11 +02:00
Alexander 7402843d07 Email dark-mode skin; tree title links to tree, hostname to site root
- Add @media (prefers-color-scheme: dark) rules that re-tint every
  surface, border, and text color via case-insensitive
  attribute-substring selectors on style="...". Palette mirrors
  BockenTheme dark mode so the email reads as one product with
  the website on clients that honour prefers-color-scheme
  (Apple Mail / iOS Mail / Outlook for Mac and iOS, Gmail web).
- Add color-scheme + supported-color-schemes meta tags.
- Masthead H1 (tree title) now links to TreePage; the hostname
  line below now links to HomePage (site root) and displays the
  bare hostname without the /tree/<name> suffix.
2026-05-15 16:05:16 +02:00
Alexander 90ad060421 Per-user intro versioning + admin pending-delivery view
Replaces "clear intro after first send", which dropped the message
for any subscriber still queued on a slower cadence.

- Each non-empty admin save bumps a per-locale version counter on
  the tree. The dispatcher includes the intro only for recipients
  whose last-seen version is behind, then advances their watermark
  after a successful send. Webtrees users get a per-user watermark;
  external addresses share one tree-level watermark. Re-saving the
  same text is a no-op.
- The preferences page now shows delivery progress per locale:
  "Delivered to X of Y subscriber(s)" plus a collapsible Pending
  list with name + email of each subscriber who hasn't received
  the current intro yet, and a single "External recipients (N)"
  row when the external watermark is behind.
- README rewritten to reflect every feature shipped since the
  initial commit (BockenTheme skin, embedded avatars, relationship
  labels, kin-distance filter, per-user cadence, bilingual subject
  prefix, locale-aware subject date, SiteUser as From, three
  subscriber sources, Markdown intro with personalisation tokens).
2026-05-15 15:57:16 +02:00
Alexander 9458867d4d One-shot bilingual intro paragraph with markdown + author avatar
- Admin can set a per-locale intro paragraph for the next issue on
  the preferences page; cleared automatically after a successful
  send. Stored in module_setting (longText) so multi-paragraph
  notes fit.
- Intro is rendered via webtrees' CommonMark factory (same flavour
  as notes) with raw HTML escaped, supports {{first_name}},
  {{last_name}}, {{username}}, {{email}} substitution per recipient.
- Two-column intro layout: tree contact user's linked Individual
  becomes the editorial portrait on the left. Their avatar is
  added to the per-recipient embed set so the inline image always
  resolves rather than falling through to a tree-page login link.
- Masthead now shows the tree URL under the title.
- Avatar source dimensions bumped 96→192 px and JPEG quality 75→88
  so portraits stay crisp at retina display ratios.
2026-05-15 15:32:30 +02:00
Alexander 9ccc636105 Admin user roster; per-locale subject; SiteUser as From
- Admin preferences page can now subscribe existing webtrees users
  per tree, not just external addresses.
- Subject prefix is now configurable per locale (en/de), and the
  date in the subject is formatted via IntlDateFormatter in the
  recipient's locale.
- "From:" header now uses SiteUser (SMTP_FROM_NAME/SMTP_DISP_NAME)
  to match webtrees' own system-mail convention; the tree contact
  becomes the Reply-To.
2026-05-15 14:30:01 +02:00
Alexander 00478e2466 Single frequency setting; per-user override; footer line
Admin-facing simplification:
- Dropped separate \"lookahead\" and \"historical lookahead\" tree
  prefs (and the once-per-month historical gate). A single
  \"send every N days\" number now drives both the cron cadence
  and the window each issue looks ahead for living + deceased
  events.
- Default 14, range 1–90, applies uniformly.

User-facing addition:
- The /my-account/{tree} subscription card gained an \"Email
  frequency\" select with options: use site default, weekly,
  every 2 weeks, monthly, every 2 months, quarterly. Stored as
  a per-tree-per-user preference.
- Dispatch now checks each recipient's own cadence against
  their own last-sent timestamp. Admin-added external addresses
  with no webtrees account always receive every run (no
  per-user state).
- Newsletter footer now reads \"You can change how often you
  receive this email, or unsubscribe entirely, in the Newsletter
  subscription section on your My account page\" — true now
  that the control exists.

German translations updated for the new strings; stale ones
removed.
2026-05-15 14:12:39 +02:00
Alexander 355a888e3b Let timeline column shrink to its content width
The fixed 140px width on the timeline TD reserved a lot of empty
space to the right of the date — on narrow viewports it squeezed
the cards severely. Replaced the hard width with the standard
\`width:1%\` email-table trick so the cell collapses to whatever
the date line actually needs (about 80-95px); the card column
inherits the rest.

Right padding dropped from 14 to 4 to tighten further.
2026-05-15 13:57:00 +02:00
Alexander f0858bb604 Apply user-tuned arrow head offsets 2026-05-15 13:54:56 +02:00
Alexander d8ba577e1e Drop em-dash before event label; show on own line below name 2026-05-15 13:49:10 +02:00
Alexander eaad7f844c Round rail ends; drop arrowhead lower and shift right
- Two new stub rows now bookend each section's rail. The top
  cap is a 4px-high TR whose rail TD carries border-top-left/
  -right-radius so the rail's top end visually rounds off.
  The bottom cap is an 18px-high TR with border-bottom-left/
  -right-radius so the rail's tail tapers into a rounded stop
  beneath the last card.
- The chevron arrowhead now lives in its own TR with no rail
  border, sitting 14px below the rounded rail-end and another
  ~18px of cell height below that — visibly separated from the
  rail rather than sitting flush against its tip.
- Chevron's horizontal offset shifted from -38 to -37 (one px
  right) and SVG width bumped to 26 to give the arrow a little
  more visual weight at its new lower position.
2026-05-15 13:43:00 +02:00
Alexander 6bad52e68d Centre dot + arrow on rail; chevron arrowhead with rounded caps
- Bumped the dot's margin-left from -31 to -33 so its centre
  lands on the rail's centre (the border-collapsed 4px border
  effectively leaves 26px between rail centre and content-start;
  half of a 14px dot is 7px, so margin = -(26+7)). margin-right
  bumped to 16 to preserve the gap to the date text.
- Replaced the CSS-triangle arrowhead with an inline SVG chevron
  (stroke-linecap:round, stroke-linejoin:round, stroke-width:3)
  so the cap matches the rounded "v" arrow look the user asked
  for. SVG sits at the bottom of its cell with the same -38
  offset as the dot pile so dot, chevron and rail share a single
  vertical axis.
2026-05-15 13:37:38 +02:00
Alexander 6f0a55de5c Continuous timeline rail across card gaps; arrowhead cap
The previous border-spacing approach made each card stand on its
own — but it also fragmented the rail. Each rail segment ended
at the bottom of its row, leaving visible breaks between cards.

Restructured each event row to wrap the avatar+content cells in
a nested card table sitting in the row's left outer TD. The
right TD carries the rail as its border-left. With the outer
section table now using border-collapse:collapse, consecutive
rows' left-borders touch and merge into one unbroken line that
runs through the card gaps.

Added a downward triangle TR after the last event of each
section as a visual cap on the rail. Pure CSS (border-style
trick), coloured to match the rail.
2026-05-15 13:31:18 +02:00
Alexander abe77a9b9d Gap between event cards; dotless rows drop day, keep just year
- Each event row is now a self-contained card (full border on
  all four sides, rounded all four corners). border-spacing on
  the section table inserts a 10px vertical gap between cards
  so they read as distinct entries. The timeline rail breaks
  along with the cards, which actually reads better — each event
  feels like its own beat on the timeline rather than rungs of a
  ladder.
- When two events share an upcoming day, the second row
  (dotless) used to repeat "17. MAI" — wasted vertical space.
  It now only prints the year, bumped up to 14px so it carries
  on its own. The always-visible year is also slightly larger
  (13px vs 11px) and tonally lifted from #aaa muted to #777
  tertiary so it pairs evenly with the day-month line above it.
2026-05-15 13:26:15 +02:00
Alexander 461c99fcd1 Rework timeline: outside the card, thicker rail, day-first date
Addresses feedback that the previous timeline competed for
attention with the year and felt visually trapped inside the
event card.

- The card surface (cream background, hairline border, rounded
  corners) is now built per-row from the avatar + content TDs.
  The timeline TD sits on the page background to the right of
  the card with a 16px gutter between them.
- Rail bumped from 1px to 4px, in a warm grey #cdc7be that
  reads as a deliberate ribbon rather than a divider.
- Dots are 14px (up from 10), with a 4px page-coloured halo so
  they punch through the rail. Single colour (nord10 blue) for
  every event — no more per-event-type tinting.
- Each calendar day shows exactly one dot: rows are walked in
  upcoming-anniversary order and any row whose $fact->jd matches
  the previous row renders without a dot but keeps its date
  text, so two May-17 deaths share one marker on the rail.
- Date display split into "17. MAI" (semi-bold 13px tracking)
  and "1759" (light 11px, muted) on a second line, so the day
  of the year reads as the primary axis and the year as
  supporting context.
- Relationship label moved from inline "(your great-aunt)" to a
  separate italic muted line beneath the name, so long
  relationship strings don't crowd the event label.
2026-05-15 13:21:35 +02:00
Alexander 105b09c4c5 Fix kin-distance metric: shortest descent from direct lineage
Replaces the previous "depth in generations along the strict
lineal chain" definition (which excluded siblings, aunts, cousins
entirely) with the metric the user actually wants: the number of
descent-steps separating the target from the recipient's closest
direct ancestor or descendant.

Examples relative to the recipient:
- sibling:        1  (parent → sibling)
- great-aunt:     1  (great-grandparent → great-aunt)
- nephew:         2  (parent → sibling → nephew)
- first cousin:   2  (grandparent → aunt → cousin)
- second cousin:  3
- ego, parents, grandparents, ..., children, ..., great-greats: 0
- own spouse, step-parents, brothers-in-law: inherit partner's
  distance (so spouse-of-distance-1 is also distance 1)

Implementation:
- Anchor set seeded with R's direct ancestors + R + direct
  descendants (capped at 25 generations to bound runaway data).
- Multi-source BFS expanding by descent only.
- Spouse propagation at every level so a person and their
  spouse always share the same distance.
- Memoised per (recipient xref, max distance).

Tree preference key and range kept (NEWSLETTER_LINEAL_DEPTH,
0–10, default 3); only the semantics and the user-facing label
+ help text change, with concrete examples in both English and
German.
2026-05-15 13:10:38 +02:00
Alexander ff743e484f Limit detailed view to lineal kin; rest as summary bullets
Per-recipient: only direct ancestors and direct descendants
within a configurable number of generations (default 3) get the
full row treatment (avatar, icon, timeline). Everyone else falls
through to a compact text-only bullet list at the bottom of the
same section.

- New tree preference NEWSLETTER_LINEAL_DEPTH (range 0–10,
  default 3) with a clearly-explained admin input.
- RelationshipPathFinder::linealKin() does two cheap recursive
  expansions (ancestors and descendants only — no spouse or
  sibling traversal) and returns the xref set. Memoised per
  recipient within a dispatch run.
- Avatar attachments are filtered per recipient to only the
  embeds actually referenced in their HTML, so summary-only rows
  no longer inflate per-email size with unused images.
- Recipients with no PREF_TREE_ACCOUNT_XREF (external admin
  addresses, users not linked to a record) see the entire
  newsletter in detail — no lineal anchor to filter against.
- German translations for the three new section kickers ("Other
  birthdays", etc.) and the admin input help text.
2026-05-15 13:01:41 +02:00
Alexander 3bc25a2bdb Add per-recipient relationship labels in newsletter
Each featured person now carries a parenthetical label relative
to the recipient: "Jane Doe (your mother) — 45th birthday",
"Karl Müller (your 4th great-grandfather) — death". Labels are
italic, muted, and only appear when a path can be computed.

- New RelationshipPathFinder service mirrors webtrees'
  RelationshipService::getCloseRelationship BFS but with a
  configurable depth (default 14 hops ≈ 7 generations) so it
  reaches great-great-grandparents and beyond. Results are
  memoised per (recipient xref, target xref) within one
  dispatch run.
- nameFromPath() formatting is delegated to webtrees so the
  label honours the configured UI language (German, English,
  etc.) and gendered/inflected forms.
- The recipient's tree-bound Individual is looked up via
  Tree::getUserPreference(user, PREF_TREE_ACCOUNT_XREF). External
  admin-added recipients (no webtrees account, no linked record)
  silently get no labels — names render plain.
- Trade-off: the view now renders once per recipient (instead of
  once per language group), because the relationship map is
  personalised. For typical subscriber counts the extra string-
  concat cost is negligible compared to the SMTP send itself.
2026-05-15 12:53:22 +02:00
Alexander a065d64c67 Fix: $font_stack missing from $avatar closure use() clause 2026-05-15 12:41:39 +02:00
Alexander a8511d2a1b Re-skin newsletter to BockenTheme light mode
Drops the editorial-serif palette in favour of the actual
website tokens so the newsletter and the site read as one
product:

- Cream #f8f6f1 page, surface #efecea card patches with #ddd
  hairline borders matching the .card component.
- Open Sans 300/400/500/600 throughout (Google Fonts @import,
  Helvetica/Arial fallback).
- Nord accent colours: nord10 blue for birth + links, nord3
  graphite for death, nord15 mauve for marriage anniversaries.
  Each event row's timeline dot is colour-coded to its event
  type, which gives the right-hand rail a quiet ribbon of
  meaning when several event types appear in one card.
- Soft elevation on cards (1px shadow), thin underlines on
  links — same affordance the site uses.
- Header/footer chrome simplified: small caps kicker in nord10,
  light-weight site title, no ornaments.
2026-05-15 12:38:33 +02:00
Alexander 4ceade9079 Editorial redesign: event icons, timeline, person links
Reworks the newsletter as a family-chronicle layout: ivory paper
background, deep oxblood ink, aged-gold accents, EB Garamond
display with Georgia body fallback.

- Inline SVG event icons (sparkle for birth, dagger for death,
  interlocked rings for marriage). Falls back silently in
  Outlook desktop; modern Gmail / Apple / iOS / Outlook 365
  render them.
- Right-side gold hairline timeline running through the date
  column of every event row, with a filled dot per entry.
- Person names link to their webtrees Individual page via
  Individual::url() (absolute URL through route() → BASE_URL),
  including the avatar circles.
- German strings added for the new section kickers
  ("Family Chronicle", "Living kin who will celebrate this
  fortnight.", "Marriages still intact.").
2026-05-15 12:31:51 +02:00
Alexander 51c1e36125 Resize avatar images before embedding
Source media files can easily be multiple megabytes — embedding
the originals made a per-recipient email balloon to 10MB+. Each
avatar is now cover-cropped to 96x96 (HiDPI for the rendered
48px circle) and re-encoded as JPEG q=75 via Intervention\Image,
which webtrees already depends on. Typical avatar payload drops
from megabytes to ~5-15KB.

Falls back to the original bytes (with a log warning) if neither
Imagick nor GD is loaded — better an oversized email than none.
2026-05-15 12:24:28 +02:00
Alexander 12b44edfa5 Always include historical section on forced sends
The "once-per-calendar-month" gate that prevents the historical
section from appearing on every regular send also suppressed it
on admin "Send now" previews after the first run of the month —
making the section silently disappear when re-testing the email.

Force-send now bypasses the gate but still updates the
last-historical-month stamp, so the real monthly cadence stays
intact for cron-driven sends.
2026-05-15 12:20:26 +02:00
Alexander a07184ab3a Embed circular profile pictures in newsletter emails
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.
2026-05-15 12:14:29 +02:00
Alexander 7ce8201082 Initial commit: webtrees Email Newsletter module
Recurring email newsletter for webtrees 2.2+. Each enabled tree
sends upcoming birthdays of living individuals, optional marriage
anniversaries of intact couples, and a once-per-calendar-month
historical section of births and deaths of deceased individuals.

Triggered exclusively by an external scheduler (system cron,
systemd timer, etc.) hitting a token-gated HTTP endpoint — never
on visitor page loads. The "is it due?" decision is idempotent
within the configured frequency window.

Per-user subscription is integrated into the built-in
/my-account/{tree} page via a custom view + a decorated
AccountUpdate handler. Admins can add external addresses and
trigger an immediate send for testing. Email body renders in
German for German-language users; English otherwise. Birthdays
and anniversaries are formatted with the upcoming-event ordinal
age (e.g. "45th birthday" / "45. Geburtstag").
2026-05-15 12:00:39 +02:00
11 changed files with 3384 additions and 328 deletions
+115 -46
View File
@@ -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
+192 -30
View File
@@ -8,11 +8,13 @@ use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Illuminate\Support\Collection;
/**
* @var Module $module
* @var Collection<int,Tree> $all_trees
* @var Collection<int,User> $all_users
* @var string $cron_token
* @var string $cron_url
* @var string $title
@@ -42,11 +44,10 @@ use Illuminate\Support\Collection;
$id = $tree->id();
$enabled = Configuration::isEnabled($tree);
$frequency = Configuration::frequencyDays($tree);
$lookahead = Configuration::lookaheadDays($tree);
$histLook = Configuration::historicalLookaheadDays($tree);
$annivs = Configuration::includeAnniversaries($tree);
$subject = Configuration::subjectPrefix($tree);
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
$lineal = Configuration::linealDepth($tree);
$last_sent = Configuration::lastSentAt($tree);
?>
@@ -80,22 +81,9 @@ use Illuminate\Support\Collection;
max="<?= Configuration::MAX_FREQUENCY_DAYS ?>" required>
<span class="input-group-text"><?= I18N::translate('days') ?></span>
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="lookahead-<?= $id ?>">
<?= I18N::translate('Look ahead') ?>
</label>
<div class="col-sm-9">
<div class="input-group" style="max-width: 18rem;">
<input class="form-control" type="number"
id="lookahead-<?= $id ?>" name="lookahead-<?= $id ?>"
value="<?= e((string) $lookahead) ?>"
min="<?= Configuration::MIN_LOOKAHEAD_DAYS ?>"
max="<?= Configuration::MAX_LOOKAHEAD_DAYS ?>" required>
<span class="input-group-text"><?= I18N::translate('days') ?></span>
</div>
<small class="form-text text-muted">
<?= I18N::translate('Each issue looks the same number of days ahead, for both living relatives and historical events of those who have passed away. Default 14.') ?>
</small>
</div>
</div>
@@ -116,29 +104,58 @@ use Illuminate\Support\Collection;
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="historical-<?= $id ?>">
<?= I18N::translate('Historical look-ahead (days)') ?>
<label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>">
<?= I18N::translate('Detailed view distance') ?>
</label>
<div class="col-sm-9">
<input class="form-control" type="number" style="max-width: 18rem;"
id="historical-<?= $id ?>" name="historical-<?= $id ?>"
value="<?= e((string) $histLook) ?>" min="7" max="60" required>
id="lineal-<?= $id ?>" name="lineal-<?= $id ?>"
value="<?= e((string) $lineal) ?>"
min="<?= Configuration::MIN_LINEAL_DEPTH ?>"
max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required>
<small class="form-text text-muted">
<?= I18N::translate('Births and deaths of deceased people are included once per calendar month.') ?>
<?= I18N::translate('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.') ?>
</small>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
<fieldset class="row mb-3">
<legend class="col-sm-3 col-form-label">
<?= I18N::translate('Subject prefix') ?>
</label>
</legend>
<div class="col-sm-9">
<input class="form-control" type="text"
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
value="<?= e($subject) ?>">
<small class="form-text text-muted d-block mb-2">
<?= I18N::translate('Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.') ?>
</small>
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
<?php
$field = 'subject-' . $id . '-' . $code;
$val = $tree->getPreference(
Configuration::PREF_SUBJECT_PREFIX_PREFIX . $code,
'',
);
?>
<div class="input-group input-group-sm mb-2">
<span class="input-group-text" style="min-width: 7rem;">
<?= e($label) ?>
</span>
<input class="form-control" type="text"
id="<?= e($field) ?>" name="<?= e($field) ?>"
value="<?= e($val) ?>"
placeholder="<?= e('[' . $tree->title() . '] ') ?>">
</div>
<?php endforeach ?>
<div class="input-group input-group-sm">
<span class="input-group-text" style="min-width: 7rem;">
<?= I18N::translate('Generic') ?>
</span>
<input class="form-control" type="text"
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
value="<?= e($subject) ?>">
</div>
</div>
</div>
</fieldset>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="extras-<?= $id ?>">
@@ -150,6 +167,151 @@ use Illuminate\Support\Collection;
</div>
</div>
<fieldset class="row mb-3">
<legend class="col-sm-3 col-form-label">
<?= I18N::translate('Intro paragraph for the next email') ?>
</legend>
<div class="col-sm-9">
<small class="form-text text-muted d-block mb-2">
<?= 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>
<?= I18N::translate('Personalisation tokens:') ?>
<code>{{first_name}}</code>,
<code>{{last_name}}</code>,
<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-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">&check;</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">&#9203;</span>
<?= e($user->realName()) ?>
<span class="text-muted">&lt;<?= e($user->email()) ?>&gt;</span>
</li>
<?php endforeach ?>
<?php if ($externals_pending) : ?>
<li>
<span class="text-warning">&#9203;</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>
</fieldset>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">
<?= I18N::translate('Subscribed users') ?>
</label>
<div class="col-sm-9">
<input type="hidden" name="users-submitted-<?= $id ?>" value="1">
<?php if ($all_users->isEmpty()) : ?>
<small class="form-text text-muted">
<?= I18N::translate('No users with email addresses found.') ?>
</small>
<?php else : ?>
<small class="form-text text-muted d-block mb-2">
<?= I18N::translate('Tick a user to subscribe them to this trees newsletter. Users can still adjust their own subscription on their account page.') ?>
</small>
<div class="border rounded p-2"
style="max-height: 18rem; overflow-y: auto;">
<?php foreach ($all_users as $user) : ?>
<?php
$field = 'subscribe-' . $id . '-' . $user->id();
$is_subbed = $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1';
?>
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="<?= e($field) ?>" name="<?= e($field) ?>"
value="1" <?= $is_subbed ? 'checked' : '' ?>>
<label class="form-check-label" for="<?= e($field) ?>">
<?= e($user->realName()) ?>
<small class="text-muted">
&lt;<?= e($user->email()) ?>&gt;
</small>
</label>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
</div>
</div>
<?php if ($last_sent > 0) : ?>
<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
+581
View File
@@ -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(' &amp; ', $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(' &amp; ', $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>
+26
View File
@@ -181,6 +181,32 @@ use Fisharebest\Webtrees\Tree;
<div class="form-text">
<?= I18N::translate('You will receive a periodic email with upcoming birthdays and other family events from %s.', e($tree->title())) ?>
</div>
<?php
$current_freq = Configuration::userFrequencyDays($tree, $user);
$tree_freq = Configuration::frequencyDays($tree);
$freq_labels = [
0 => I18N::translate('Use site default (every %d days)', $tree_freq),
7 => I18N::translate('Weekly'),
14 => I18N::translate('Every 2 weeks'),
30 => I18N::translate('Monthly'),
60 => I18N::translate('Every 2 months'),
90 => I18N::translate('Quarterly'),
];
?>
<div class="mt-3">
<label for="newsletter_frequency" class="form-label">
<?= I18N::translate('Email frequency') ?>
</label>
<select class="form-select" id="newsletter_frequency"
name="newsletter_frequency" style="max-width: 22rem;">
<?php foreach ($freq_labels as $days => $label) : ?>
<option value="<?= $days ?>" <?= $days === $current_freq ? 'selected' : '' ?>>
<?= e($label) ?>
</option>
<?php endforeach ?>
</select>
</div>
</div>
</fieldset>
<?php endif ?>
+779 -111
View File
@@ -5,8 +5,11 @@ 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;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Tree;
use Illuminate\Support\Collection;
@@ -16,35 +19,131 @@ use Illuminate\Support\Collection;
* @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 $window_days Shared lookahead window for living + deceased events
* @var int $generated_at
* @var array<string,string> $avatar_cids xref => CID name
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
* @var array<string,true> $detailed_xrefs xref-set — render in detail; others as summary bullet
* @var string $account_url
* @var string $intro Admin-supplied one-shot intro paragraph; "" = skip block
* @var Individual|null $intro_author Tree contact's linked record, if any — avatar source for the intro
*/
$record_label = static function (Fact $fact): string {
// ─── BockenTheme light-mode palette ─────────────────────────────────────
// Pulled from src/scss/theme.scss + config/_theme-variables.scss in the
// Bocken theme so the newsletter and the website read as one product.
$palette = [
'bg' => '#f8f6f1', // body / page background (--color-bg-primary)
'surface' => '#efecea', // raised section cards (--color-bg-secondary)
'elevated' => '#dfdcd8', // tertiary surface (--color-bg-elevated)
'border' => '#ddd', // hairline borders (--color-border)
'ink' => '#2a2a2a', // primary text (--color-text-primary)
'ink2' => '#555', // secondary text (--color-text-secondary)
'ink3' => '#777', // tertiary text (--color-text-tertiary)
'mute' => '#aaa', // muted text (--color-text-muted)
'link' => '#5E81AC', // nord10 — primary link
'link_hov' => '#81A1C1', // nord9
'accent' => '#BF616A', // nord11 — red FAB accent
'birth' => '#5E81AC', // nord10 — birth (blue)
'death' => '#4C566A', // nord3 — death (graphite)
'marr' => '#B48EAD', // nord15 — marriage (purple/pink)
'rail' => '#cdc7be', // muted warm grey for the timeline rail
'dot' => '#5E81AC', // single unified colour for every timeline dot
];
$font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif";
$avatar_size = 56;
// ─── Helpers ────────────────────────────────────────────────────────────
$relationships = $relationships ?? [];
$detailed_xrefs = $detailed_xrefs ?? [];
/**
* Returns true if this fact's primary record should render as a full
* detailed row (avatar + icon + timeline), or false for the compact
* text-only summary bullet at the bottom of the section.
*
* A Family fact (anniversary) counts as detailed if either spouse is
* in the recipient's lineal set.
*/
$is_detailed = static function (Fact $fact) use ($detailed_xrefs): bool {
$record = $fact->record();
if ($record instanceof Individual) {
return strip_tags($record->fullName());
return isset($detailed_xrefs[$record->xref()]);
}
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);
foreach ([$record->husband(), $record->wife()] as $spouse) {
if ($spouse instanceof Individual && isset($detailed_xrefs[$spouse->xref()])) {
return true;
}
}
}
return $record->xref();
return false;
};
$event_date = static function (Fact $fact): string {
/**
* Just the linked name (no relationship), used both inline and inside
* the multi-line name+relationship block below.
*/
$linked_name = static function (Individual $individual) use ($palette): 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 '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
};
/**
* Inline list of names ("Hans Doe" or "Hans Doe & Lotte Doe") plus
* the combined relationship line on a second line beneath.
*/
$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(' &amp; ', $names);
if ($rels !== []) {
// Drop near-duplicate labels ("your father", "your father")
// so couples don't print the same relationship twice.
$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(' &amp; ', $rels)
. '</div>';
}
return $html;
};
$event_date_display = static function (Fact $fact): string {
$date = $fact->date();
if (!$date instanceof Date || !$date->isOK()) {
@@ -55,13 +154,168 @@ $event_date = static function (Fact $fact): string {
};
/**
* 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.
* Split a Fact's event date into (day+month, year) for the timeline
* rail where the day-of-the-anniversary is the meaningful sort key
* and the year is supporting information.
*
* @return array{day_month:string,year:string}
*/
$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);
};
/**
* Compact inline SVG glyph for each event type. Coloured to match the
* BockenTheme Nord palette: birth in nord10 blue, death in nord3
* graphite, marriage in nord15 mauve.
*/
$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) {
// Eight-point sparkle — celebration.
'BIRT' => $svg_open . 'viewBox="0 0 24 24" width="18" height="18">'
. '<path 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>',
// Latin obelus — the typographic mark for "died" in obituaries.
'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">'
. '<rect x="10.5" y="2" width="3" height="20" fill="' . $palette['death'] . '"/>'
. '<rect x="5.5" y="7" width="13" height="3" fill="' . $palette['death'] . '"/>'
. '</svg>',
// Two interlocking rings — universal mark of marriage.
'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">'
. '<circle cx="13" cy="11" r="8" fill="none" stroke="' . $palette['marr'] . '" stroke-width="1.8"/>'
. '<circle cx="23" cy="11" r="8" fill="none" stroke="' . $palette['marr'] . '" stroke-width="1.8"/>'
. '</svg>',
default => '',
};
};
/**
* Map an event type to the colour used for that row's timeline dot
* (so the right rail reads as a colour-coded ribbon).
*/
$event_color = static function (string $kind) use ($palette): string {
return match ($kind) {
'BIRT' => $palette['birth'],
'DEAT' => $palette['death'],
'MARR' => $palette['marr'],
default => $palette['ink3'],
};
};
$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette, $font_stack): string {
if (!$individual instanceof Individual) {
return '';
}
$alt = e(strip_tags($individual->fullName()));
$shadow = 'box-shadow:0 0 0 1px ' . $palette['border'] . ',0 1px 3px rgba(0,0,0,0.08);';
if (isset($avatar_cids[$individual->xref()])) {
$cid = $avatar_cids[$individual->xref()];
$inner = '<img src="cid:' . e($cid) . '" alt="' . $alt . '"'
. ' width="' . $avatar_size . '" height="' . $avatar_size . '"'
. ' style="border-radius:50%;object-fit:cover;display:block;' . $shadow . '">';
} else {
// Initial-disc fallback. Hue is hashed from the xref so the
// same person keeps the same colour across editions.
$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'] ?? '');
$initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1)));
$inner = '<span aria-label="' . $alt . '" style="'
. 'display:block;width:' . $avatar_size . 'px;height:' . $avatar_size . 'px;'
. 'border-radius:50%;background:hsl(' . (int) $hue . ',32%,60%);color:#fff;'
. "font:600 19px/{$avatar_size}px " . $font_stack . ';text-align:center;'
. 'letter-spacing:0.3px;' . $shadow . '">' . $initials . '</span>';
}
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 '';
};
// Locale-aware ordinal: German uses "N.", English uses st/nd/rd/th.
$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();
@@ -84,115 +338,529 @@ $upcoming_age = static function (Fact $fact): int {
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));
return $age > 0
? I18N::translate('%s birthday', $ordinal($age))
: I18N::translate('Birthday');
};
$anniversary_label = static function (int $age) use ($ordinal): string {
if ($age <= 0) {
return I18N::translate('Wedding anniversary');
return $age > 0
? I18N::translate('%s wedding anniversary', $ordinal($age))
: I18N::translate('Wedding anniversary');
};
$masthead_date = static function (int $timestamp): string {
if (str_starts_with(I18N::languageTag(), 'de')) {
$months = [
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
return (int) date('j', $timestamp) . '. '
. $months[(int) date('n', $timestamp)] . ' '
. date('Y', $timestamp);
}
return I18N::translate('%s wedding anniversary', $ordinal($age));
return date('F j, Y', $timestamp);
};
// ─── Row styles ─────────────────────────────────────────────────────────
$section_title_style = 'margin:0 0 4px;'
. 'font-family:' . $font_stack . ';'
. 'font-weight:400;font-size:24px;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 = 18;
$card_corner_radius = 8;
$row_gap = 12; // vertical gap between consecutive cards
// Inner card table — full rounded surface containing the avatar +
// content cells. Each event row drops one of these into the outer
// section table's left column.
$card_outer_style = 'width:100%;'
. 'background:' . $palette['surface'] . ';'
. 'border:1px solid ' . $palette['border'] . ';'
. 'border-radius:' . $card_corner_radius . 'px;'
. 'box-shadow:0 1px 3px rgba(0,0,0,0.04);';
$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 row cells: card on the left, a small gutter, then the rail.
$outer_card_td = 'vertical-align:middle;padding-bottom:' . $row_gap . 'px;';
$outer_gutter_td = 'width:16px;padding-bottom:' . $row_gap . 'px;';
// `width:1%` is the standard email-safe trick to make a TD shrink
// to its content, giving the card column on the left whatever
// width is left over. Combined with white-space:nowrap, the cell
// is exactly as wide as the longest "MONTH 1898" date line.
$outer_rail_td = 'width:1%;vertical-align:middle;'
. 'padding:0 4px ' . $row_gap . 'px 24px;'
. 'border-left:4px solid ' . $palette['rail'] . ';'
. 'font-family:' . $font_stack . ';'
. 'color:' . $palette['ink3'] . ';white-space:nowrap;';
// Renders one event row in the consistent 3-column layout shared by
// every section (avatar | content | date on the timeline rail).
$summary_kicker_style = 'margin:18px 0 8px;'
. 'font-family:' . $font_stack . ';'
. 'font-weight:500;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;'
. 'color:' . $palette['ink3'] . ';'
. 'padding-top:10px;border-top:1px dashed ' . $palette['border'] . ';';
$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;';
// Renders one event row in the 4-column layout
// [avatar] [content] [gutter] [timeline rail + date]
//
// The avatar+content TDs carry the card surface (background + side
// borders + corner radii on the first/last rows so the visual card
// wraps just those two cells). The timeline TD sits on the page
// background with a thick coloured rail on its left edge.
//
// $position is 'first', 'middle', or 'last' — used to add corner
// radii and outer borders only where they belong.
//
// $show_dot suppresses the dot on rows whose upcoming-day matches
// the previous row, so each calendar day has exactly one dot.
// Each event row builds a fully-rounded inner card (a nested table)
// and parks it in the outer row's left TD; the rail TD on the right
// carries the continuous timeline rail as a left-border. The outer
// table uses border-collapse:collapse so adjacent rail TDs merge
// their left-borders into ONE unbroken line across the row gaps.
//
// $show_dot suppresses the dot AND the day+month line on rows whose
// upcoming-day matches the previous (only the year is then shown).
$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 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);
// The rail TD has padding-left:24 + border-left:4. Inline
// content therefore starts 26px right of the rail centre
// (with border-collapse:collapse the 4px border straddles the
// cell boundary, putting half its width into our cell). A
// 14px-wide dot wants margin-left = -(26 + 7) = -33 to land
// its centre on the rail.
$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 style="' . $outer_rail_td . '">' . $dot_html . $date_html . '</td>'
. '</tr>';
};
// Outer section table uses border-collapse:collapse so the rail
// border-lefts merge into a continuous line across all row gaps.
$card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
. 'style="width:100%;border-collapse:collapse;">';
$card_close = '</table>';
// The rail visual is built from three pieces:
//
// 1. $timeline_top_cap — a 4px tall stub at the start of the
// section whose border-top-left-radius rounds the top end of
// the rail.
// 2. Event rows — each carries the rail as a border-left
// on its right-hand TD; with border-collapse:collapse those
// adjacent borders merge into one continuous line.
// 3. $timeline_bottom_cap — a longer stub after the last event
// that extends the rail visually below the cards, with a
// border-bottom-left-radius rounding the bottom end.
//
// Then $timeline_arrow_row sits below the rail with no border (so
// the rail terminates cleanly and the arrow is visually separated)
// and renders the chevron arrowhead in inline SVG.
$timeline_top_cap = '<tr>'
. '<td></td><td></td>'
. '<td style="height:4px;padding:0;'
. 'border-left:4px solid ' . $palette['rail'] . ';'
. '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 style="height:18px;padding:0;'
. 'border-left:4px solid ' . $palette['rail'] . ';'
. 'border-bottom-left-radius:4px;border-bottom-right-radius:4px;'
. 'font-size:0;line-height:0;"></td>'
. '</tr>';
// Chevron arrowhead — inline SVG, stroke-linecap:round +
// stroke-linejoin:round so the strokes have soft ends. Sits below
// the rail's rounded tail in its own row so the rail terminates
// cleanly without the chevron's strokes painting over the round.
$arrow_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="22"'
. ' viewBox="0 0 26 22" fill="none" 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>';
?><!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>
<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="font-family: Helvetica, Arial, sans-serif; color: #222; max-width: 640px; margin: 0 auto;">
<body style="margin:0;padding:0;background:<?= $palette['bg'] ?>;color:<?= $palette['ink'] ?>;">
<h1 style="border-bottom: 2px solid #888; padding-bottom: 0.3rem;">
<?= e($tree->title()) ?>
</h1>
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
style="width:100%;background:<?= $palette['bg'] ?>;">
<tr>
<td align="center" style="padding:36px 16px;">
<p style="color: #555;">
<?= e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
style="width:100%;max-width:720px;background:<?= $palette['bg'] ?>;font-family:<?= $font_stack ?>;">
<?php if (!$birthdays->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
<ul>
<?php foreach ($birthdays as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($birthday_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<!-- Masthead ─────────────────────────────────────────── -->
<tr>
<td style="padding:8px 8px 24px;">
<div style="font-size:11px;font-weight:600;letter-spacing:0.22em;text-transform:uppercase;color:<?= $palette['link'] ?>;">
<?= e(I18N::translate('Family Chronicle')) ?>
</div>
<?php
// 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()]);
$site_url = route(HomePage::class);
$site_lbl = preg_replace('~^https?://~i', '', rtrim($site_url, '/'));
?>
<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($site_lbl) ?>
</a>
</div>
<div style="margin-top:6px;font-size:13px;font-weight:300;color:<?= $palette['ink3'] ?>;">
<?= e($masthead_date($generated_at)) ?>
<span style="color:<?= $palette['mute'] ?>;">·</span>
<?= e(I18N::translate('Events in the next %d days.', $window_days)) ?>
</div>
</td>
</tr>
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
<ul>
<?php foreach ($anniversaries as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($anniversary_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<?php if (trim($intro) !== '') : ?>
<!-- Editorial: one-shot intro paragraph ──────────── -->
<?php
// Render via webtrees' Markdown factory: CommonMark
// with autolinks, the same flavour used elsewhere
// in the site. Raw HTML in the source is escaped
// 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>';
?>
<tr>
<td style="padding:0 8px 24px;">
<?php if ($intro_author instanceof Individual) : ?>
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:72px;vertical-align:top;padding-top:4px;">
<?= $avatar($intro_author) ?>
</td>
<td style="vertical-align:top;">
<?= $intro_inner ?>
</td>
</tr>
</table>
<?php else : ?>
<?= $intro_inner ?>
<?php endif ?>
</td>
</tr>
<?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>
<?php foreach ($historical as $fact) : ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<?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 ($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 events')) ?></h2>
<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>
<?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);
$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 ?>
<?php endif ?>
<?php if ($summary !== []) : ?>
<div style="<?= $summary_kicker_style ?>">
<?= e(I18N::translate('Other upcoming events')) ?>
</div>
<ul style="<?= $summary_list_style ?>">
<?php foreach ($summary as $fact) : ?>
<?php
$kind = $event_kind($fact);
$age = $upcoming_age($fact);
$label = $kind === 'MARR' ? $anniversary_label($age) : $birthday_label($age);
?>
<li style="<?= $summary_item_style ?>">
<?= $record_label($fact) ?>
<span style="color:<?= $palette['ink3'] ?>;"><?= e($label) ?></span>
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</td></tr>
<?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>
<?php if ($historical !== null && !$historical->isEmpty()) : ?>
<?php
$detailed = [];
$summary = [];
foreach ($historical 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('On this month in history')) ?></h2>
<p style="<?= $section_kicker_style ?>">
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $window_days)) ?>
</p>
<?php if ($detailed !== []) : ?>
<?= $card_open ?>
<?= $timeline_top_cap ?>
<?php $prev_jd = null; ?>
<?php foreach ($detailed 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 ?>
<?php endif ?>
<?php if ($summary !== []) : ?>
<div style="<?= $summary_kicker_style ?>">
<?= e(I18N::translate('Other historical events')) ?>
</div>
<ul style="<?= $summary_list_style ?>">
<?php foreach ($summary as $fact) : ?>
<li style="<?= $summary_item_style ?>">
<?= $record_label($fact) ?>
<span style="color:<?= $palette['ink3'] ?>;"><?= e($fact->label()) ?></span>
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</td></tr>
<?php endif ?>
<!-- Footer ─────────────────────────────────────────── -->
<tr><td style="padding:40px 8px 8px;">
<div style="height:1px;background:<?= $palette['border'] ?>;margin-bottom:18px;"></div>
<p style="margin:0;font-size:12px;line-height:1.6;font-weight:300;color:<?= $palette['ink3'] ?>;">
<?= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
<br>
<?= I18N::translate(
'You can change how often you receive this email, or unsubscribe entirely, in the “Newsletter subscription” section on your %s page.',
'<a href="' . e($account_url) . '" style="color:' . $palette['link'] . ';text-decoration:none;border-bottom:1px solid ' . $palette['link'] . ';">' . e(I18N::translate('My account')) . '</a>',
) ?>
</p>
</td></tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+242 -34
View File
@@ -4,14 +4,17 @@ 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.
*
* All values are persisted via $tree->setPreference()/getPreference().
* State values (last-sent timestamp, last historical-section month) are
* also stored on the tree because each tree produces its own newsletter.
* All values are persisted via $tree->setPreference() / getPreference().
* State values (last-sent timestamp) are also stored on the tree because
* each tree produces its own newsletter on its own cadence.
*/
final class Configuration
{
@@ -19,33 +22,74 @@ final class Configuration
// column. Keys here MUST stay <= 32 characters.
public const string PREF_ENABLED = 'NEWSLETTER_ENABLED';
public const string PREF_FREQUENCY_DAYS = 'NEWSLETTER_FREQ_DAYS';
public const string PREF_LOOKAHEAD_DAYS = 'NEWSLETTER_LOOK_DAYS';
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS';
public const string PREF_LINEAL_DEPTH = 'NEWSLETTER_LINEAL_DEPTH';
public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT';
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
public const string PREF_SUBJECT_PREFIX_PREFIX = 'NEWSLETTER_SUBJ_PFX_';
/**
* Per-locale one-shot intro paragraph. Cleared automatically the
* first time the dispatch service successfully sends an issue
* containing it, so each setting is "for the next email only".
*
* Stored in webtrees' module_setting table (longText column) so
* we can hold multi-paragraph intros — gedcom_setting.setting_value
* is varchar(255) and would truncate anything beyond a sentence
* or two. The full preference name is
* intro_{tree_id}_{locale-code}
* which stays well within the 32-char setting_name limit.
*/
public const string MODULE_PREF_INTRO_PREFIX = 'intro_';
/**
* Languages we render newsletters in. The dispatch service groups
* recipients by these short codes (German for users whose webtrees
* language starts with "de", English for everyone else), so the
* admin only has to set a subject prefix for these two locales.
*
* Keep these as short two-letter codes so the per-locale storage
* keys stay well below the webtrees 32-char setting_name limit.
*
* @return array<string,string> short-code => display label
*/
public static function supportedSubjectLocales(): array
{
return [
'en' => 'English',
'de' => 'Deutsch',
];
}
// Module-level (not tree-bound) settings.
public const string MODULE_PREF_CRON_TOKEN = 'cron_token';
// Per-user subscription preference. Set via $user->setPreference().
// Per-tree-per-user preferences. Stored via
// $tree->setUserPreference($user, ...).
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_LOOKAHEAD_DAYS = 14;
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
public const int DEFAULT_LINEAL_DEPTH = 3;
public const int MIN_FREQUENCY_DAYS = 1;
public const int MAX_FREQUENCY_DAYS = 90;
public const int MIN_LOOKAHEAD_DAYS = 1;
public const int MAX_LOOKAHEAD_DAYS = 60;
public const int MIN_LINEAL_DEPTH = 0;
public const int MAX_LINEAL_DEPTH = 10;
public static function isEnabled(Tree $tree): bool
{
return $tree->getPreference(self::PREF_ENABLED) === '1';
}
/**
* The single cadence number: newsletters are sent every N days
* and each issue looks N days ahead for events. Used as both the
* send-interval and the look-ahead window for living and
* deceased relatives alike.
*/
public static function frequencyDays(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_FREQUENCY_DAYS, (string) self::DEFAULT_FREQUENCY_DAYS);
@@ -53,26 +97,60 @@ final class Configuration
return max(self::MIN_FREQUENCY_DAYS, min(self::MAX_FREQUENCY_DAYS, $value));
}
public static function lookaheadDays(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_LOOKAHEAD_DAYS, (string) self::DEFAULT_LOOKAHEAD_DAYS);
return max(self::MIN_LOOKAHEAD_DAYS, min(self::MAX_LOOKAHEAD_DAYS, $value));
}
public static function includeAnniversaries(Tree $tree): bool
{
return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1';
}
public static function historicalLookaheadDays(Tree $tree): int
public static function linealDepth(Tree $tree): int
{
$value = (int) $tree->getPreference(
self::PREF_HISTORICAL_LOOKAHEAD,
(string) self::DEFAULT_HISTORICAL_LOOKAHEAD,
);
$value = (int) $tree->getPreference(self::PREF_LINEAL_DEPTH, (string) self::DEFAULT_LINEAL_DEPTH);
return max(7, min(60, $value));
return max(self::MIN_LINEAL_DEPTH, min(self::MAX_LINEAL_DEPTH, $value));
}
/**
* The set of user-selectable cadences, in days. 0 is a sentinel
* meaning "fall back to the tree's frequency". Other values are
* weekly / biweekly / monthly / bimonthly / quarterly.
*
* @return array<int,int>
*/
public static function userFrequencyOptions(): array
{
return [0, 7, 14, 30, 60, 90];
}
public static function userFrequencyDays(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user): int
{
$raw = (int) $tree->getUserPreference($user, self::USER_PREF_FREQUENCY_DAYS, '0');
if (!in_array($raw, self::userFrequencyOptions(), true)) {
return 0;
}
return $raw;
}
/**
* Resolved cadence for a recipient: the user's choice if they
* picked one, otherwise the tree default.
*/
public static function effectiveFrequencyDays(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user): int
{
$user_pref = self::userFrequencyDays($tree, $user);
return $user_pref > 0 ? $user_pref : self::frequencyDays($tree);
}
public static function userLastSentAt(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user): int
{
return (int) $tree->getUserPreference($user, self::USER_PREF_LAST_SENT_AT, '0');
}
public static function setUserLastSentAt(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user, int $timestamp): void
{
$tree->setUserPreference($user, self::USER_PREF_LAST_SENT_AT, (string) $timestamp);
}
public static function subjectPrefix(Tree $tree): string
@@ -80,6 +158,146 @@ final class Configuration
return $tree->getPreference(self::PREF_SUBJECT_PREFIX, '[' . $tree->title() . '] ');
}
/**
* Normalises a webtrees language tag (e.g. "de", "de-DE", "en-US")
* to one of the short codes we offer in the admin UI.
*/
public static function canonicalSubjectLocale(string $language): string
{
$lower = strtolower($language);
foreach (array_keys(self::supportedSubjectLocales()) as $code) {
if (str_starts_with($lower, $code)) {
return $code;
}
}
return 'en';
}
/**
* Per-locale subject prefix. Falls back to the generic
* (locale-agnostic) prefix, then to "[Tree Title] " so admins
* who never opened the form still get something sensible.
*/
public static function subjectPrefixForLocale(Tree $tree, string $language): string
{
$code = self::canonicalSubjectLocale($language);
$specific = $tree->getPreference(self::PREF_SUBJECT_PREFIX_PREFIX . $code, '');
if ($specific !== '') {
return $specific;
}
return self::subjectPrefix($tree);
}
public static function setSubjectPrefixForLocale(Tree $tree, string $language, string $prefix): void
{
$code = self::canonicalSubjectLocale($language);
$tree->setPreference(self::PREF_SUBJECT_PREFIX_PREFIX . $code, $prefix);
}
/**
* One-shot intro paragraph for the next issue, in the recipient's
* locale. Empty string means "no intro" — the email view skips
* the block entirely.
*
* Stored on the module (longText) rather than the tree (varchar(255))
* so a real paragraph fits.
*/
public static function introForLocale(AbstractModule $module, Tree $tree, string $language): string
{
$code = self::canonicalSubjectLocale($language);
return $module->getPreference(self::introKey($tree, $code), '');
}
public static function setIntroForLocale(AbstractModule $module, Tree $tree, string $language, string $intro): void
{
$code = self::canonicalSubjectLocale($language);
$module->setPreference(self::introKey($tree, $code), $intro);
}
/**
* 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 introVersion(AbstractModule $module, Tree $tree, string $language): int
{
$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
{
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).
*
@@ -103,14 +321,4 @@ final class Configuration
{
$tree->setPreference(self::PREF_LAST_SENT_AT, (string) $timestamp);
}
public static function lastHistoricalMonth(Tree $tree): string
{
return $tree->getPreference(self::PREF_LAST_HISTORICAL_MONTH, '');
}
public static function setLastHistoricalMonth(Tree $tree, string $yearMonth): void
{
$tree->setPreference(self::PREF_LAST_HISTORICAL_MONTH, $yearMonth);
}
}
+14
View File
@@ -49,6 +49,20 @@ final class AccountUpdateDecorator implements RequestHandlerInterface
Configuration::USER_PREF_SUBSCRIBED,
$subscribed ? '1' : '0',
);
// Newsletter frequency: only persist a value the user
// actually picked from the offered options (0 = "use site
// default"). Anything else is silently dropped so a
// malformed POST can't pin them on an arbitrary cadence.
$frequency = Validator::parsedBody($request)
->isInArray(Configuration::userFrequencyOptions())
->integer('newsletter_frequency', 0);
$tree->setUserPreference(
$user,
Configuration::USER_PREF_FREQUENCY_DAYS,
(string) $frequency,
);
}
return $response;
+331 -46
View File
@@ -11,40 +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\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,
) {
}
@@ -108,9 +123,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Subscribe to the newsletter' => 'Newsletter abonnieren',
'Send newsletters every' => 'Newsletter senden alle',
'days' => 'Tage',
'Look ahead' => 'Vorschau',
'Each issue looks the same number of days ahead, for both living relatives and historical events of those who have passed away. Default 14.'
=> 'Jede Ausgabe blickt um die gleiche Anzahl Tage in die Zukunft, sowohl für lebende Verwandte als auch für historische Ereignisse bereits verstorbener Personen. Standardwert 14.',
'Include marriage anniversaries' => 'Hochzeitstage einbeziehen',
'Historical look-ahead (days)' => 'Historische Vorschau (Tage)',
'Extra recipient email addresses (one per line)' => 'Zusätzliche Empfänger-E-Mail-Adressen (eine pro Zeile)',
'Subject prefix' => 'Betreff-Präfix',
'Save' => 'Speichern',
@@ -118,6 +133,20 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Cron token' => 'Cron-Token',
'Regenerate token' => 'Token neu generieren',
'Your subscription has been updated.' => 'Ihr Abonnement wurde aktualisiert.',
'Family Chronicle' => 'Familienchronik',
'Living kin who will celebrate this fortnight.'
=> 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.',
'Marriages still intact.' => 'Noch bestehende Ehen.',
'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.',
'%s birthday' => '%s Geburtstag',
'%s wedding anniversary' => '%s Hochzeitstag',
'Birthday' => 'Geburtstag',
@@ -131,20 +160,49 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
=> 'Sie erhalten regelmäßig eine E-Mail mit anstehenden Geburtstagen und weiteren Familienereignissen aus %s.',
'You are receiving this email because you subscribed to the %s newsletter.'
=> 'Sie erhalten diese E-Mail, weil Sie den Newsletter „%s“ abonniert haben.',
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.'
=> 'Um Ihr Abonnement zu ändern oder zu kündigen, bearbeiten Sie den Abschnitt „Newsletter-Abonnement“ auf Ihrer Seite %s.',
'You can change how often you receive this email, or unsubscribe entirely, in the “Newsletter subscription” section on your %s page.'
=> 'Wie oft Sie diese E-Mail erhalten oder ob Sie sie ganz abbestellen möchten können Sie im Abschnitt „Newsletter-Abonnement“ auf Ihrer Seite %s ändern.',
'Email frequency' => 'E-Mail-Häufigkeit',
'Use site default (every %d days)' => 'Standard der Seite verwenden (alle %d Tage)',
'Weekly' => 'Wöchentlich',
'Every 2 weeks' => 'Alle 2 Wochen',
'Monthly' => 'Monatlich',
'Every 2 months' => 'Alle 2 Monate',
'Quarterly' => 'Vierteljährlich',
'Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).'
=> 'Newsletter-Versand pro Stammbaum konfigurieren. Absender ist die Kontaktperson des jeweiligen Baums (alternativ der Webmaster der Seite).',
'Enable newsletter for this tree' => 'Newsletter für diesen Baum aktivieren',
'Only intact marriages of still-living couples are included.'
=> 'Nur bestehende Ehen lebender Paare werden berücksichtigt.',
'Births and deaths of deceased people are included once per calendar month.'
=> 'Geburten und Todestage verstorbener Personen werden einmal pro Kalendermonat einbezogen.',
'Last sent: %s' => 'Zuletzt gesendet: %s',
'Configure your system cron, systemd timer, or any external scheduler to call the URL below. The schedule decides when newsletters are actually due — calling more frequently is safe.'
=> 'Richten Sie System-Cron, systemd-Timer oder einen externen Scheduler so ein, dass er die untenstehende URL aufruft. Der Versandplan entscheidet, wann tatsächlich gesendet wird — häufiger aufrufen ist unbedenklich.',
'Send the newsletter now for every enabled tree?'
=> 'Newsletter jetzt für jeden aktivierten Baum senden?',
'Subscribed users' => 'Abonnierte Nutzer',
'No users with email addresses found.' => 'Keine Nutzer mit E-Mail-Adresse gefunden.',
'Tick a user to subscribe them to this trees newsletter. Users can still adjust their own subscription on their account page.'
=> 'Setzen Sie einen Haken, um den Nutzer für den Newsletter dieses Stammbaums zu abonnieren. Nutzer können ihr Abonnement weiterhin selbst auf ihrer Kontoseite anpassen.',
'Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.'
=> 'Wird der E-Mail-Betreffzeile vorangestellt. Ein leeres Feld greift auf das generische Präfix unten zurück.',
'Generic' => 'Allgemein',
'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',
@@ -174,40 +232,26 @@ 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
{
$this->layout = 'layouts/administration';
// Surface every webtrees user with an email so the admin can
// toggle subscription per tree without having to ask each
// member to opt in themselves. Sorted alphabetically by real
// name so the list stays scannable in long member rosters.
$users = $this->user_service->all()
->filter(static fn (User $user): bool => trim($user->email()) !== '')
->sortBy(static fn (User $user): string => mb_strtolower($user->realName()))
->values();
return $this->viewResponse($this->name() . '::admin', [
'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'),
'module' => $this,
'all_trees' => $this->tree_service->all(),
'all_users' => $users,
'cron_token' => $this->cronToken(),
'cron_url' => $this->cronUrl(),
]);
@@ -215,31 +259,75 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
public function postAdminAction(ServerRequestInterface $request): ResponseInterface
{
// Cache the user list so we don't query it once per tree.
$users = $this->user_service->all()
->filter(static fn (User $user): bool => trim($user->email()) !== '');
foreach ($this->tree_service->all() as $tree) {
$id = $tree->id();
$enabled = Validator::parsedBody($request)->string('enabled-' . $id, '0') === '1';
$frequency = Validator::parsedBody($request)
->isBetween(Configuration::MIN_FREQUENCY_DAYS, Configuration::MAX_FREQUENCY_DAYS)
->integer('frequency-' . $id, Configuration::DEFAULT_FREQUENCY_DAYS);
$lookahead = Validator::parsedBody($request)
->isBetween(Configuration::MIN_LOOKAHEAD_DAYS, Configuration::MAX_LOOKAHEAD_DAYS)
->integer('lookahead-' . $id, Configuration::DEFAULT_LOOKAHEAD_DAYS);
$histLook = Validator::parsedBody($request)
->isBetween(7, 60)
->integer('historical-' . $id, Configuration::DEFAULT_HISTORICAL_LOOKAHEAD);
$lineal = Validator::parsedBody($request)
->isBetween(Configuration::MIN_LINEAL_DEPTH, Configuration::MAX_LINEAL_DEPTH)
->integer('lineal-' . $id, Configuration::DEFAULT_LINEAL_DEPTH);
$annivs = Validator::parsedBody($request)->string('anniversaries-' . $id, '0') === '1';
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
$subject = Validator::parsedBody($request)->string('subject-' . $id, '');
$tree->setPreference(Configuration::PREF_ENABLED, $enabled ? '1' : '0');
$tree->setPreference(Configuration::PREF_FREQUENCY_DAYS, (string) $frequency);
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
$tree->setPreference(Configuration::PREF_HISTORICAL_LOOKAHEAD, (string) $histLook);
$tree->setPreference(Configuration::PREF_LINEAL_DEPTH, (string) $lineal);
$tree->setPreference(Configuration::PREF_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0');
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
if ($subject !== '') {
$tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject);
// Generic prefix — used when no per-locale override is set.
// We always write it (even empty) so admins can clear a
// previously-saved value.
$tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject);
foreach (array_keys(Configuration::supportedSubjectLocales()) as $code) {
$locale_prefix = Validator::parsedBody($request)
->string('subject-' . $id . '-' . $code, '');
Configuration::setSubjectPrefixForLocale($tree, $code, $locale_prefix);
$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
// always submitted (hidden field "users-submitted-<id>")
// so we can tell an unchecked-everyone POST apart from a
// legacy form that omits the section entirely — we only
// touch subscriptions when the marker is present.
$roster_present = Validator::parsedBody($request)
->string('users-submitted-' . $id, '0') === '1';
if ($roster_present) {
foreach ($users as $user) {
$field = 'subscribe-' . $id . '-' . $user->id();
$subscribed = Validator::parsedBody($request)->string($field, '0') === '1';
$tree->setUserPreference(
$user,
Configuration::USER_PREF_SUBSCRIBED,
$subscribed ? '1' : '0',
);
}
}
}
@@ -270,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) ─────────────────────
/**
+664 -61
View File
@@ -6,16 +6,28 @@ namespace EmailNewsletter\Services;
use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Fact;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Module\ModuleInterface;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Site;
use Fisharebest\Webtrees\SiteUser;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Illuminate\Support\Collection;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\ImageManager;
use IntlDateFormatter;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Exception\RfcComplianceException;
use Throwable;
/**
* Decides which trees are due, builds per-tree newsletters, and dispatches
@@ -34,11 +46,30 @@ final class NewsletterDispatchService
*/
private const string FROM_NAME = 'webtrees newsletter';
/**
* Target dimensions for embedded avatars, in pixels. Rendered at
* 56 CSS pixels in the email view, so 192 covers ~3.4× retina /
* HiDPI displays without visible blur. Larger source images are
* cover-cropped down; smaller ones are left untouched.
*/
private const int AVATAR_SIZE = 192;
/**
* JPEG quality used when re-encoding resized avatars. 88 keeps
* portraits crisp without ballooning the email size — a 192px
* face encodes to ~2540 KB at this quality.
*/
private const int AVATAR_JPEG_QUALITY = 88;
private ImageManager|null $image_manager = null;
private bool $image_manager_resolved = false;
public function __construct(
private readonly EventQueryService $event_query_service,
private readonly EmailService $email_service,
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,
) {
}
@@ -49,7 +80,7 @@ final class NewsletterDispatchService
* happened, suitable for the cron endpoint
* to return to the caller.
*/
public function dispatch(ModuleInterface $module, bool $force = false): array
public function dispatch(AbstractModule $module, bool $force = false): array
{
$log = [];
$now = time();
@@ -60,39 +91,30 @@ final class NewsletterDispatchService
continue;
}
$due_at = Configuration::lastSentAt($tree)
+ Configuration::frequencyDays($tree) * 86400;
if (!$force && $now < $due_at) {
$log[] = sprintf(
'Tree "%s": not due yet (next send in %d hours).',
$tree->name(),
(int) (($due_at - $now) / 3600),
);
continue;
}
$log[] = $this->dispatchForTree($tree, $module, $now);
// Per-tree "is anyone due?" is decided inside dispatchForTree
// — each recipient has their own cadence and last-sent
// timestamp, so the gate is per-user, not per-tree.
$log[] = $this->dispatchForTree($tree, $module, $now, $force);
}
return $log;
}
private function dispatchForTree(Tree $tree, ModuleInterface $module, int $now): string
private function dispatchForTree(Tree $tree, AbstractModule $module, int $now, bool $force): string
{
$include_anniversaries = Configuration::includeAnniversaries($tree);
$lookahead = Configuration::lookaheadDays($tree);
$historical_lookahead = Configuration::historicalLookaheadDays($tree);
$current_month = date('Y-m', $now);
$include_historical = Configuration::lastHistoricalMonth($tree) !== $current_month;
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $lookahead);
// One number controls everything: how often the newsletter is
// sent AND how far ahead each issue looks for events. Same
// window applies to living birthdays/anniversaries and to the
// historical (deceased) section.
$window = Configuration::frequencyDays($tree);
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $window);
$anniversaries = $include_anniversaries
? $this->event_query_service->upcomingAnniversaries($tree, $lookahead)
: null;
$historical = $include_historical
? $this->event_query_service->upcomingHistoricalEvents($tree, $historical_lookahead)
? $this->event_query_service->upcomingAnniversaries($tree, $window)
: null;
$historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window);
// Suppress entirely empty newsletters so subscribers don't get
// a near-empty email on a slow fortnight.
@@ -112,9 +134,52 @@ final class NewsletterDispatchService
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
}
$from = $this->siteContact($tree);
$original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients);
// Match webtrees' own convention for system-generated email
// (registration confirmations, password resets, "new version
// available" notices): the From: header is the SiteUser
// (SMTP_FROM_NAME), while Reply-To: points at the family-tree
// contact person so replies still reach a human. If the site
// admin hasn't configured SMTP_FROM_NAME we fall back to the
// tree contact for From: too, otherwise the transport may
// reject the message for lacking a sender envelope.
$reply_to = $this->siteContact($tree);
$from = $this->siteFrom($reply_to);
$original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients);
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
$featured = $this->collectFeaturedIndividuals($birthdays, $anniversaries, $historical);
// 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.
// 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);
if ($author_avatar !== null) {
$avatars[$this->avatarCidName($intro_author->xref())] = $author_avatar;
}
}
$avatar_cids = $this->avatarCids($avatars);
$account_url = route(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()],
);
$sent = 0;
$failures = 0;
@@ -123,33 +188,109 @@ final class NewsletterDispatchService
foreach ($groups as $lang => $group) {
I18N::init($lang);
$subject = Configuration::subjectPrefix($tree) . I18N::translate(
'Family newsletter — %s',
date('F j, Y', $now),
);
$subject = Configuration::subjectPrefixForLocale($tree, $lang)
. I18N::translate(
'Family newsletter — %s',
$this->formatSubjectDate($now, $lang),
);
$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,
'account_url' => route(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()],
),
]);
$text = $this->htmlToText($html);
// One-shot intro paragraph (admin-supplied). Empty
// string is "no intro" — the view simply omits the
// 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
// record the recipient is linked to in this tree.
foreach ($group as $recipient) {
if (!$this->recipientIsDue($tree, $recipient, $now, $force)) {
continue;
}
$relationships = $this->relationshipMap($tree, $recipient, $featured);
$detailed_set = $this->detailedXrefs($tree, $recipient, $featured);
// Trim the embedded image set to only the avatars
// we'll actually reference (detailed rows). Summary
// bullets render without pictures.
$recipient_avatars = array_intersect_key(
$avatars,
$this->avatarKeysForXrefs(array_keys($detailed_set)),
);
// 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])) {
$recipient_avatars[$author_cid] = $avatars[$author_cid];
}
}
$personalised_intro = $show_intro
? $this->renderIntroTemplate($intro, $recipient)
: '';
$html = view($module->name() . '::email', [
'tree' => $tree,
'birthdays' => $birthdays,
'anniversaries' => $anniversaries,
'historical' => $historical,
'include_anniversaries' => $include_anniversaries,
'window_days' => $window,
'generated_at' => $now,
'avatar_cids' => $avatar_cids,
'relationships' => $relationships,
'detailed_xrefs' => $detailed_set,
'account_url' => $account_url,
'intro' => $personalised_intro,
'intro_author' => $show_intro ? $intro_author : null,
]);
$text = $this->htmlToText($html);
try {
if ($this->email_service->send($from, $recipient, $from, $subject, $text, $html)) {
if ($this->mailer->sendWithEmbeds($from, $recipient, $reply_to, $subject, $text, $html, $recipient_avatars)) {
$sent++;
// Per-user last-sent is stored only for real
// webtrees users (id > 0). External admin-
// 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++;
}
@@ -158,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
@@ -165,20 +310,380 @@ final class NewsletterDispatchService
I18N::init($original_locale);
}
Configuration::setLastSentAt($tree, $now);
if ($include_historical) {
Configuration::setLastHistoricalMonth($tree, $current_month);
// Tree-level last-sent is kept for the admin "Last sent" line
// on the preferences page; it no longer gates dispatch.
if ($sent > 0 || $failures > 0) {
Configuration::setLastSentAt($tree, $now);
}
return sprintf(
'Tree "%s": sent to %d recipient(s), %d failure(s)%s.',
'Tree "%s": sent to %d recipient(s), %d failure(s).',
$tree->name(),
$sent,
$failures,
$include_historical ? ', monthly historical section included' : '',
);
}
/**
* True if the recipient's own cadence has elapsed since their
* last newsletter (or if `$force` is set). External non-user
* recipients have no per-user timestamp — they always fire so
* admin-managed mailing lists still get an issue every run.
*/
private function recipientIsDue(Tree $tree, UserInterface $recipient, int $now, bool $force): bool
{
if ($force) {
return true;
}
if (!$recipient instanceof User) {
return true;
}
$last = Configuration::userLastSentAt($tree, $recipient);
$freq = Configuration::effectiveFrequencyDays($tree, $recipient);
$due_at = $last + $freq * 86400;
return $now >= $due_at;
}
/**
* Resolve a "highlighted" image for every individual mentioned in
* the newsletter and return a CID-keyed map of bytes + MIME type
* that NewsletterMailer can embed.
*
* @param Collection<int,Fact>|null $anniversaries
* @param Collection<int,Fact>|null $historical
*
* @return array<string,array{bytes:string,mime:string}>
*/
/**
* Set of Individual records that appear in any of the newsletter
* sections. Used by the relationship-map builder so each row can
* be labelled "your mother", "3rd cousin once removed", etc.
*
* @param Collection<int,Fact>|null $anniversaries
* @param Collection<int,Fact>|null $historical
*
* @return array<string,Individual> xref => Individual
*/
private function collectFeaturedIndividuals(
Collection $birthdays,
Collection|null $anniversaries,
Collection|null $historical,
): array {
$individuals = [];
foreach ($birthdays as $fact) {
$this->indexIndividualsFromFact($fact, $individuals);
}
if ($anniversaries !== null) {
foreach ($anniversaries as $fact) {
$this->indexIndividualsFromFact($fact, $individuals);
}
}
if ($historical !== null) {
foreach ($historical as $fact) {
$this->indexIndividualsFromFact($fact, $individuals);
}
}
return $individuals;
}
/**
* Which featured xrefs deserve the full detailed row (avatar +
* timeline) for this recipient?
*
* If the recipient is unmapped to the tree (external address or a
* user with no PREF_TREE_ACCOUNT_XREF), every featured xref counts
* as "detailed" — they have no lineal context to filter against.
*
* Otherwise, only the recipient's direct ancestors and descendants
* within Configuration::linealDepth() generations qualify. For
* Family records (anniversaries), either spouse being lineal
* promotes the row.
*
* @param array<string,Individual> $featured
*
* @return array<string,true> Set of featured xrefs to render in detail.
*/
private function detailedXrefs(Tree $tree, UserInterface $recipient, array $featured): array
{
$self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($self_xref === '') {
// Unmapped recipient — no lineal anchor, show everything.
return array_fill_keys(array_keys($featured), true);
}
$self = Registry::individualFactory()->make($self_xref, $tree);
if (!$self instanceof Individual) {
return array_fill_keys(array_keys($featured), true);
}
$max_distance = Configuration::linealDepth($tree);
$distances = $this->relationship_finder->kinDistances($self, $max_distance);
$detailed = [];
foreach ($featured as $xref => $_individual) {
if (isset($distances[$xref]) && $distances[$xref] <= $max_distance) {
$detailed[$xref] = true;
}
}
return $detailed;
}
/**
* Translates a list of xrefs into the cid-name keys used by the
* avatar embed map ("avatar-<xref>" => true). Lets us
* array_intersect_key the embeds map cheaply.
*
* @param array<int,string> $xrefs
* @return array<string,true>
*/
private function avatarKeysForXrefs(array $xrefs): array
{
$keys = [];
foreach ($xrefs as $xref) {
$keys['avatar-' . $xref] = true;
}
return $keys;
}
/**
* Build a "xref => relationship label" map for one recipient.
*
* Returns an empty map for recipients we cannot label: external
* addresses (no webtrees account), users with no linked Individual
* record on this tree, or users whose linked record can't be
* resolved (privacy-hidden, broken xref).
*
* @param array<string,Individual> $featured
*
* @return array<string,string>
*/
private function relationshipMap(Tree $tree, UserInterface $recipient, array $featured): array
{
$self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($self_xref === '') {
return [];
}
$self = Registry::individualFactory()->make($self_xref, $tree);
if (!$self instanceof Individual) {
return [];
}
$map = [];
foreach ($featured as $xref => $individual) {
$label = $this->relationship_finder->label($self, $individual);
if ($label !== null && $label !== '') {
$map[$xref] = $label;
}
}
return $map;
}
private function collectAvatars(
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);
}
}
$avatars = [];
foreach ($individuals as $xref => $individual) {
$payload = $this->resolveAvatar($individual);
if ($payload !== null) {
$avatars[$this->avatarCidName($xref)] = $payload;
}
}
return $avatars;
}
/**
* @param array<string,Individual> $bag
*/
private function indexIndividualsFromFact(Fact $fact, array &$bag): void
{
$record = $fact->record();
if ($record instanceof Individual) {
$bag[$record->xref()] = $record;
return;
}
if ($record instanceof Family) {
foreach ([$record->husband(), $record->wife()] as $spouse) {
if ($spouse instanceof Individual) {
$bag[$spouse->xref()] = $spouse;
}
}
}
}
/**
* @return array{bytes:string,mime:string}|null
*/
private function resolveAvatar(Individual $individual): array|null
{
try {
$media_file = $individual->findHighlightedMediaFile();
} catch (Throwable $ex) {
Log::addErrorLog('Newsletter avatar lookup failed for ' . $individual->xref() . ': ' . $ex->getMessage());
return null;
}
if ($media_file === null) {
return null;
}
// External URLs cannot be embedded as inline parts. Skipping
// these gracefully falls back to the placeholder silhouette.
if ($media_file->isExternal() || !$media_file->isImage()) {
return null;
}
try {
$bytes = $media_file->fileContents();
} catch (Throwable $ex) {
Log::addErrorLog('Newsletter avatar read failed for ' . $individual->xref() . ': ' . $ex->getMessage());
return null;
}
if ($bytes === '') {
return null;
}
$resized = $this->resizeAvatar($bytes);
if ($resized !== null) {
return $resized;
}
// Fall back to the original bytes if no image library is
// available — better an oversized email than no avatar at all.
return [
'bytes' => $bytes,
'mime' => $media_file->mimeType() ?: 'application/octet-stream',
];
}
/**
* Cover-crop the source image to a small square and re-encode it
* as JPEG. Drops a multi-MB source down to ~515 KB.
*
* Returns null if no image library is loaded (in which case the
* caller keeps the original bytes), or if Intervention failed to
* read the file.
*
* @return array{bytes:string,mime:string}|null
*/
private function resizeAvatar(string $bytes): array|null
{
$manager = $this->imageManager();
if ($manager === null) {
return null;
}
try {
$image = $manager->read($bytes);
$encoded = $image
->cover(self::AVATAR_SIZE, self::AVATAR_SIZE)
->encode(new JpegEncoder(self::AVATAR_JPEG_QUALITY))
->toString();
} catch (Throwable $ex) {
Log::addErrorLog('Newsletter avatar resize failed: ' . $ex->getMessage());
return null;
}
return [
'bytes' => $encoded,
'mime' => 'image/jpeg',
];
}
/**
* Lazily build (and cache) an Intervention ImageManager, preferring
* Imagick over GD — mirrors what webtrees' own ImageFactory does.
* Returns null if neither extension is present so the caller can
* gracefully fall back to the original image bytes.
*/
private function imageManager(): ImageManager|null
{
if (!$this->image_manager_resolved) {
$this->image_manager_resolved = true;
if (extension_loaded('imagick')) {
$this->image_manager = new ImageManager(new ImagickDriver());
} elseif (extension_loaded('gd')) {
$this->image_manager = new ImageManager(new GdDriver());
} else {
Log::addErrorLog(
'Newsletter: neither Imagick nor GD is available; avatars will be embedded at their original size.',
);
}
}
return $this->image_manager;
}
/**
* Build the map the view consults: xref -> CID name. Only entries
* for individuals with a successfully resolved avatar are present;
* the view treats absence as "use the placeholder".
*
* @param array<string,array{bytes:string,mime:string}> $avatars
* @return array<string,string>
*/
private function avatarCids(array $avatars): array
{
$cids = [];
foreach (array_keys($avatars) as $cid_name) {
// CID name is "avatar-{xref}" — reverse the prefix to recover the xref.
$cids[substr($cid_name, strlen('avatar-'))] = $cid_name;
}
return $cids;
}
private function avatarCidName(string $xref): string
{
return 'avatar-' . $xref;
}
/**
* Group recipients by the language we will render their email in.
* German users get "de"; everyone else (including admin-added
@@ -240,7 +745,7 @@ final class NewsletterDispatchService
foreach (Configuration::extraRecipients($tree) as $email) {
$key = strtolower($email);
if (isset($seen[$key]) || !$this->email_service->isValidEmail($email)) {
if (isset($seen[$key]) || !$this->mailer->isValidEmail($email)) {
continue;
}
@@ -251,6 +756,22 @@ final class NewsletterDispatchService
return $recipients;
}
/**
* Resolve the From: identity, mirroring webtrees' own behaviour:
* the SMTP_FROM_NAME / SMTP_DISP_NAME site preferences if set,
* otherwise the tree contact user (so the transport always has
* a usable envelope sender). This is the address recipients see
* in their mail client, not the one their replies go to.
*/
private function siteFrom(UserInterface $fallback): UserInterface
{
if (trim(Site::getPreference('SMTP_FROM_NAME')) !== '') {
return new SiteUser();
}
return $fallback;
}
/**
* Pick a sender identity. Fall back to a synthetic UserInterface if
* the site has no contact user configured for this tree.
@@ -286,6 +807,88 @@ final class NewsletterDispatchService
return new ExtraRecipient($address, self::FROM_NAME);
}
/**
* Format the issue date for the email subject in the recipient's
* locale: "15. Mai 2026" for de, "May 15, 2026" for en-US. Falls
* back to the English date() format if ext-intl is unavailable —
* webtrees lists intl as required so this should not happen in
* practice, but we don't want to fatal a dispatch over it.
*/
private function formatSubjectDate(int $timestamp, string $language): string
{
if (class_exists(IntlDateFormatter::class)) {
$formatter = IntlDateFormatter::create(
str_replace('-', '_', $language),
IntlDateFormatter::LONG,
IntlDateFormatter::NONE,
);
if ($formatter instanceof IntlDateFormatter) {
$formatted = $formatter->format($timestamp);
if (is_string($formatted) && $formatted !== '') {
return $formatted;
}
}
}
return date('F j, Y', $timestamp);
}
/**
* The intro paragraph is signed by the tree contact user — the
* same person webtrees uses for replies. If they have a linked
* Individual record on this tree, return it so the email view
* can render their avatar beside the intro; otherwise return
* null and the view falls back to a single-column layout.
*/
private function resolveIntroAuthor(Tree $tree, UserInterface $author): Individual|null
{
if (!$author instanceof User) {
return null;
}
$xref = $tree->getUserPreference($author, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($xref === '') {
return null;
}
$individual = Registry::individualFactory()->make($xref, $tree);
return $individual instanceof Individual ? $individual : null;
}
/**
* Replace `{{first_name}}`, `{{last_name}}`, `{{username}}` and
* `{{email}}` placeholders in the admin-supplied intro with values
* from the current recipient.
*
* Webtrees users only have a single `realName()` field; we split
* on the first whitespace run to derive first/last. External
* (non-user) recipients fall through with their email in place of
* a name — they have no username, so `{{username}}` resolves to
* an empty string for them.
*/
private function renderIntroTemplate(string $intro, UserInterface $recipient): string
{
if ($intro === '' || !str_contains($intro, '{{')) {
return $intro;
}
$real_name = trim($recipient->realName());
$parts = $real_name === '' ? [] : preg_split('/\s+/', $real_name, 2);
$first = $parts[0] ?? '';
$last = $parts[1] ?? '';
return strtr($intro, [
'{{first_name}}' => $first,
'{{last_name}}' => $last,
'{{username}}' => $recipient->userName(),
'{{email}}' => $recipient->email(),
]);
}
private function htmlToText(string $html): string
{
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Site;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Crypto\DkimOptions;
use Symfony\Component\Mime\Crypto\DkimSigner;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Exception\RfcComplianceException;
use Symfony\Component\Mime\Message;
/**
* Thin extension of the built-in EmailService that supports inline
* image embeds (used for circular avatars in the newsletter).
*
* We re-use the protected transport() method from the parent so the
* existing webtrees SMTP / sendmail configuration (and DKIM signing)
* still applies — only the message-construction step differs.
*
* @phpstan-type Avatar array{bytes:string,mime:string}
*/
final class NewsletterMailer extends EmailService
{
/**
* @param array<string,Avatar> $embeds Keyed by CID name (without "@host"
* suffix); referenced in HTML as
* `<img src="cid:KEY">`.
*/
public function sendWithEmbeds(
UserInterface $from,
UserInterface $to,
UserInterface $reply_to,
string $subject,
string $message_text,
string $message_html,
array $embeds,
): bool {
try {
$message = $this->buildMessage($from, $to, $reply_to, $subject, $message_text, $message_html, $embeds);
$transport = $this->transport();
$mailer = new Mailer($transport);
$mailer->send($message);
} catch (RfcComplianceException $ex) {
Log::addErrorLog('Cannot create newsletter email: ' . $ex->getMessage());
return false;
} catch (TransportExceptionInterface $ex) {
Log::addErrorLog('Cannot send newsletter email: ' . $ex->getMessage());
return false;
}
return true;
}
/**
* Mirrors the parent's message() builder, but with inline image
* parts and without the multipart/alternative DKIM workaround
* (DKIM still works because we sign after attachments are added).
*
* @param array<string,Avatar> $embeds
*/
private function buildMessage(
UserInterface $from,
UserInterface $to,
UserInterface $reply_to,
string $subject,
string $message_text,
string $message_html,
array $embeds,
): Message {
$message_text = str_replace("\n", "\r\n", $message_text);
$message_html = str_replace("\n", "\r\n", $message_html);
$email = (new Email())
->subject($subject)
->from(new Address($from->email(), $from->realName()))
->to(new Address($to->email(), $to->realName()))
->replyTo(new Address($reply_to->email(), $reply_to->realName()))
->text($message_text)
->html($message_html);
foreach ($embeds as $name => $avatar) {
$email->embed($avatar['bytes'], $name, $avatar['mime']);
}
$dkim_domain = Site::getPreference('DKIM_DOMAIN');
$dkim_selector = Site::getPreference('DKIM_SELECTOR');
$dkim_key = Site::getPreference('DKIM_KEY');
if ($dkim_domain !== '' && $dkim_selector !== '' && $dkim_key !== '') {
$signer = new DkimSigner($dkim_key, $dkim_domain, $dkim_selector);
$options = (new DkimOptions())
->headerCanon('relaxed')
->bodyCanon('relaxed');
return $signer->sign($email, $options->toArray());
}
return $email;
}
}
+330
View File
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Module\ModuleLanguageInterface;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\ModuleService;
use Fisharebest\Webtrees\Services\RelationshipService;
/**
* Computes a human-readable relationship label between two individuals
* (e.g. "your mother", "4th great-grandfather") using webtrees' own
* relationship matcher.
*
* webtrees ships RelationshipService::getCloseRelationshipName which
* caps the BFS at four hops — enough for nephews and grandparents,
* not for great-great-grandparents. This class mirrors that algorithm
* (childFamilies + spouseFamilies expansion, visited-set bookkeeping)
* but with a configurable depth so we can label distant ancestors.
*
* The expensive step is the BFS; nameFromPath() formatting is cheap.
* Results are memoised per (recipient xref, target xref) for the
* lifetime of one dispatch run.
*/
final class RelationshipPathFinder
{
/** Default BFS depth — ~7 generations of ancestry / descent. */
public const int DEFAULT_MAX_DEPTH = 14;
/** @var array<string,array<string,int>> Cache: "rootXref|maxDistance" => {xref => distance} */
private array $distance_cache = [];
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
private array $label_cache = [];
/** @var array<string,ModuleLanguageInterface|null> Cache: languageTag -> module */
private array $language_cache = [];
public function __construct(
private readonly RelationshipService $relationship_service,
private readonly ModuleService $module_service,
) {
}
/**
* Returns the relationship label, or null if no path was found
* within the configured depth.
*/
public function label(Individual $from, Individual $to, int $max_depth = self::DEFAULT_MAX_DEPTH): string|null
{
$cache_key = $from->xref() . '|' . $to->xref();
if (array_key_exists($cache_key, $this->label_cache)) {
return $this->label_cache[$cache_key];
}
$path = $this->findPath($from, $to, $max_depth);
if ($path === []) {
return $this->label_cache[$cache_key] = null;
}
$language = $this->languageForCurrentLocale();
if ($language === null) {
// Every webtrees install has at least the English language
// module enabled by default; if not, we can't label.
return $this->label_cache[$cache_key] = null;
}
return $this->label_cache[$cache_key] = $this->relationship_service->nameFromPath($path, $language);
}
/**
* Hard cap on how far up / down we'll walk when seeding the direct
* lineal anchor set. 25 generations is well beyond practical
* genealogy and prevents pathological data from running away.
*/
private const int LINEAL_SEED_DEPTH = 25;
/**
* Distance from `$root` to every reachable kin within `$max_distance`,
* measured by the "branch length from the closest direct ancestor
* or descendant of `$root`" metric:
*
* 1. Take all of `$root`'s direct ancestors and descendants (and
* `$root` itself). These are *anchors* at distance 0.
* 2. Walk downward (child links only) from every anchor. Each
* hop increases distance by one — so e.g. sibling = 1
* (anchor = parent, one hop down), great-aunt = 1 (anchor =
* great-grandparent, one hop down to great-aunt), nephew = 2
* (anchor = parent → sibling → nephew), first cousin = 2.
* 3. At every level, a person's spouses inherit that level too —
* so brothers-in-law share the sibling's distance, and step-
* grandparents share grandparents' distance.
*
* The search stops at `$max_distance` to keep the result bounded.
*
* @return array<string,int> xref => smallest distance found
*/
public function kinDistances(Individual $root, int $max_distance): array
{
$cache_key = $root->xref() . '|' . $max_distance;
if (isset($this->distance_cache[$cache_key])) {
return $this->distance_cache[$cache_key];
}
// Anchors: every direct-line ancestor and descendant, plus root.
$anchors = [$root->xref() => $root];
$this->collectAncestors($root, self::LINEAL_SEED_DEPTH, $anchors);
$this->collectDescendants($root, self::LINEAL_SEED_DEPTH, $anchors);
/** @var array<string,int> $distance */
$distance = [];
/** @var array<string,Individual> $frontier */
$frontier = [];
foreach ($anchors as $xref => $individual) {
$distance[$xref] = 0;
$frontier[$xref] = $individual;
}
// Spouses of anchors inherit distance 0 (so own spouse,
// step-parents, step-grandparents, ... all qualify).
$this->propagateSpouses($frontier, 0, $distance, $frontier);
for ($d = 1; $d <= $max_distance && $frontier !== []; $d++) {
$next = [];
foreach ($frontier as $individual) {
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
$xref = $child->xref();
if (!isset($distance[$xref])) {
$distance[$xref] = $d;
$next[$xref] = $child;
}
}
}
}
// Spouses of new arrivals share their distance.
$this->propagateSpouses($next, $d, $distance, $next);
$frontier = $next;
}
return $this->distance_cache[$cache_key] = $distance;
}
/**
* For every individual in $sources, mark each of their spouses as
* being at $level (if not already labelled at a smaller distance)
* and add them to $into so subsequent iterations also descend from
* them.
*
* @param array<string,Individual> $sources
* @param array<string,int> $distance
* @param array<string,Individual> $into
*/
private function propagateSpouses(array $sources, int $level, array &$distance, array &$into): void
{
foreach ($sources as $individual) {
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
$xref = $spouse->xref();
if (!isset($distance[$xref])) {
$distance[$xref] = $level;
$into[$xref] = $spouse;
}
}
}
}
}
/**
* @param array<string,Individual> $bag
*/
private function collectAncestors(Individual $indi, int $depth, array &$bag): void
{
if ($depth <= 0) {
return;
}
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
if (!isset($bag[$parent->xref()])) {
$bag[$parent->xref()] = $parent;
$this->collectAncestors($parent, $depth - 1, $bag);
}
}
}
}
/**
* @param array<string,Individual> $bag
*/
private function collectDescendants(Individual $indi, int $depth, array &$bag): void
{
if ($depth <= 0) {
return;
}
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
if (!isset($bag[$child->xref()])) {
$bag[$child->xref()] = $child;
$this->collectDescendants($child, $depth - 1, $bag);
}
}
}
}
/**
* BFS over child- and spouse-families, exactly matching
* RelationshipService::getCloseRelationship's traversal but with
* an externally configurable max depth.
*
* @return array<Individual|Family>
*/
private function findPath(Individual $from, Individual $to, int $max_depth): array
{
if ($from === $to || $from->xref() === $to->xref()) {
return [$from];
}
$visited = [$from->xref() => true];
$paths = [[$from]];
while ($max_depth >= 0) {
$max_depth--;
foreach ($paths as $i => $path) {
$indi = $path[count($path) - 1];
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
$result = $this->expandFamily($family, $path, $to, $visited, $paths);
if ($result !== null) {
return $result;
}
}
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
$result = $this->expandFamily($family, $path, $to, $visited, $paths);
if ($result !== null) {
return $result;
}
}
unset($paths[$i]);
}
}
return [];
}
/**
* @param array<Individual|Family> $path
* @param array<string,bool> $visited
* @param array<int,array<Individual|Family>> $paths
*
* @return array<Individual|Family>|null Completed path if $to was reached.
*/
private function expandFamily(
Family $family,
array $path,
Individual $to,
array &$visited,
array &$paths,
): array|null {
$visited[$family->xref()] = true;
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
if (isset($visited[$spouse->xref()])) {
continue;
}
$new_path = $path;
$new_path[] = $family;
$new_path[] = $spouse;
if ($spouse->xref() === $to->xref()) {
return $new_path;
}
$paths[] = $new_path;
$visited[$spouse->xref()] = true;
}
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
if (isset($visited[$child->xref()])) {
continue;
}
$new_path = $path;
$new_path[] = $family;
$new_path[] = $child;
if ($child->xref() === $to->xref()) {
return $new_path;
}
$paths[] = $new_path;
$visited[$child->xref()] = true;
}
return null;
}
private function languageForCurrentLocale(): ModuleLanguageInterface|null
{
$tag = I18N::languageTag();
if (!array_key_exists($tag, $this->language_cache)) {
$this->language_cache[$tag] = $this->module_service
->findByInterface(ModuleLanguageInterface::class, true)
->first(fn (ModuleLanguageInterface $module): bool => $module->locale()->languageTag() === $tag);
}
return $this->language_cache[$tag];
}
}