Files
bvandeusenandClaude Opus 5.5 c94c09aeae
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
- /settings lands on Account: the Android app, profile, password, signed-in
  devices. Then Playback, Appearance, Notifications, Connections, About.
- Each setting is a row (name, one short line, control at the end) in a
  capped-width column; one-of-N choices are a shared Segmented control.
- Collection and Settings share one SectionRail.
- Connections leads ListenBrainz with a status line: not connected,
  connected and not sending, or sending with the last scrobble's age.
- Notifications prints its column labels once; the digest email's
  settings link points at /settings/notifications.
- #5596: the profile form now loads what is stored and sends only the
  fields that changed. It used to start blank, and the server reads a
  blank as "clear", so one Save wiped the display name and email.
- Recommendation metrics moves to Library › Your listening, and Hidden
  tracks gets a Library tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:51 -04:00

121 lines
3.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notifications
import (
"bytes"
"embed"
"encoding/json"
"fmt"
htmltemplate "html/template"
"strings"
texttemplate "text/template"
)
//go:embed templates/digest.txt templates/digest.html
var digestFS embed.FS
var (
digestText = texttemplate.Must(texttemplate.ParseFS(digestFS, "templates/digest.txt"))
digestHTML = htmltemplate.Must(htmltemplate.ParseFS(digestFS, "templates/digest.html"))
)
// digestEmail is one rendered email.
type digestEmail struct {
Subject string
Text string
HTML string
}
type digestLine struct {
Title string
Body string
URL string
}
type digestArtist struct {
Name string
URL string
Titles []digestLine
}
type digestVars struct {
Name string
Intro string
Items []digestLine
Artists []digestArtist
SettingsURL string
}
// renderDigest renders one group's email. base is the public address; with
// none set, the email still goes out, without links.
func renderDigest(group EmailGroup, name string, items []pendingItem, base string) (digestEmail, error) {
vars := digestVars{Name: name, SettingsURL: joinURL(base, "/settings/notifications")}
var subject string
if group == EmailSummary {
vars.Artists = summaryArtists(items, base)
vars.Intro = "New in your library since the last summary:"
subject = "Minstrel: new music in your library"
} else {
for _, it := range items {
r := Render(it.Kind, it.Payload)
vars.Items = append(vars.Items, digestLine{Title: r.Title, Body: r.Body, URL: joinURL(base, r.Link)})
}
vars.Intro = "Here's what happened on Minstrel:"
subject = fmt.Sprintf("Minstrel: %d updates", len(items))
if len(items) == 1 {
subject = "Minstrel: " + vars.Items[0].Title
}
}
var text, html bytes.Buffer
if err := digestText.Execute(&text, vars); err != nil {
return digestEmail{}, fmt.Errorf("render text: %w", err)
}
if err := digestHTML.Execute(&html, vars); err != nil {
return digestEmail{}, fmt.Errorf("render html: %w", err)
}
return digestEmail{Subject: subject, Text: text.String(), HTML: html.String()}, nil
}
// summaryArtists groups new-music arrivals by artist, in the order each
// artist first arrived, with each artist's albums and tracks under them. An
// artist request has no title of its own; the artist links to its page.
func summaryArtists(items []pendingItem, base string) []digestArtist {
var out []digestArtist
index := map[string]int{}
for _, it := range items {
var p Payload
_ = json.Unmarshal(it.Payload, &p)
artist, title := p.Artist, p.Title
if artist == "" {
// Rows from before Artist was recorded carry only "Artist – Title".
artist, title, _ = strings.Cut(p.Name, " – ")
}
if artist == "" {
artist = "Something you asked for"
}
i, ok := index[artist]
if !ok {
i = len(out)
index[artist] = i
out = append(out, digestArtist{Name: artist})
}
link := Render(it.Kind, it.Payload).Link
if title == "" {
if p.ArtistID != "" {
out[i].URL = joinURL(base, link)
}
continue
}
out[i].Titles = append(out[i].Titles, digestLine{Title: title, URL: joinURL(base, link)})
}
return out
}
// joinURL is base followed by path, or "" when no public address is set.
func joinURL(base, path string) string {
if base == "" || path == "" {
return ""
}
return strings.TrimRight(base, "/") + path
}