package notifications import ( "encoding/json" "fmt" "strings" "git.fabledsword.com/bvandeusen/minstrel/internal/words" ) // Rendered is a notification as people read it. The server renders it once, // so the web inbox, the Android inbox, the phone's shade and the email digest // all say the same thing, and a wording change is one edit here rather than // a phrase table per client. type Rendered struct { Title string `json:"title"` Body string `json:"body"` // Link is the web path the notification opens. Android maps the same // paths onto its screens. Link string `json:"link"` } // Payload is what producers put in a notification. Every field is optional; // each kind documents the ones it uses in Render. type Payload struct { RequestID string `json:"request_id,omitempty"` RequestKind string `json:"request_kind,omitempty"` // artist | album | track // Name is what was requested or flagged, as the user would say it: // "WWW", "Moe Shop", "Moe Shop – WWW". Name string `json:"name,omitempty"` // Artist and Title are Name's parts, for request_completed: the new-music // summary email groups arrivals by artist. Title is empty for an artist // request. Artist string `json:"artist,omitempty"` Title string `json:"title,omitempty"` ArtistID string `json:"artist_id,omitempty"` AlbumID string `json:"album_id,omitempty"` // Actor is the other person involved: who asked, who flagged. Actor string `json:"actor,omitempty"` Reason string `json:"reason,omitempty"` // Count is the coalesced kinds' running total since the notice was last // read. Count int64 `json:"count,omitempty"` // Items name what Count counts, one each, newest first: "Weezer – Buddy // Holly". A coalesced update puts its own in front and keeps ten (M542), // so the body can say which, not only how many. Items []string `json:"items,omitempty"` // Detail is free text: scan_failed's error message. Rows from before // Items also carry duplicates_resolved's summary here. Detail string `json:"detail,omitempty"` } // Map turns a Payload into the map Notify takes, dropping empty fields. func (p Payload) Map() map[string]any { b, _ := json.Marshal(p) var m map[string]any _ = json.Unmarshal(b, &m) return m } // Render turns a stored row into what people read. An unreadable payload // still renders, from the kind alone, rather than failing the whole inbox. func Render(kind Kind, payload []byte) Rendered { var p Payload _ = json.Unmarshal(payload, &p) name := p.Name if name == "" { name = "Your request" } switch kind { case KindRequestApproved: return Rendered{Title: "Request approved", Body: name + " is on its way.", Link: "/requests"} case KindRequestRejected: body := name + " was declined." if p.Reason != "" { body = name + " was declined: " + p.Reason } return Rendered{Title: "Request declined", Body: body, Link: "/requests"} case KindRequestCompleted: link := "/requests" switch { case p.AlbumID != "": link = "/albums/" + p.AlbumID case p.ArtistID != "": link = "/artists/" + p.ArtistID } return Rendered{Title: "Now in your library", Body: name + " has arrived.", Link: link} case KindRequestPending: who := p.Actor if who == "" { who = "Someone" } return Rendered{Title: "Request to review", Body: who + " asked for " + name + ".", Link: "/admin/requests"} case KindQuarantineFlagged: who := p.Actor if who == "" { who = "Someone" } body := who + " flagged " + name + "." if p.Reason != "" { body = who + " flagged " + name + ": " + p.Reason } return Rendered{Title: "Track flagged", Body: body, Link: "/collection/reports"} case KindScanFailed: title := "Library scan failed" if p.Count > 1 { title = fmt.Sprintf("Library scan failed %d times", p.Count) } body := "The last scan stopped with an error." if p.Detail != "" { body = "The last scan stopped: " + p.Detail } return Rendered{Title: title, Body: body, Link: "/admin/library"} case KindTracksMissing: body := "Their files are no longer where the library expects them." if names := nameList(p.Items, p.Count); names != "" { body = "No longer on disk: " + names + "." } return Rendered{ Title: plural(p.Count, "track went missing", "tracks went missing"), Body: body, Link: "/collection/missing", } case KindDuplicatesFound: body := "The duplicate sweep found tracks holding the same recording." if names := nameList(p.Items, p.Count); names != "" { body = "Held more than once: " + names + "." } return Rendered{ Title: plural(p.Count, "new duplicate to review", "new duplicates to review"), Body: body, Link: "/collection/duplicates", } case KindDuplicatesResolved: body := "Extra copies of a song were merged into the one the album keeps." switch names := nameList(p.Items, p.Count); { case names != "": body = "Extra copies removed, the right one kept: " + names + "." case p.Detail != "": body = p.Detail } return Rendered{ Title: plural(p.Count, "duplicate merged automatically", "duplicates merged automatically"), Body: body, Link: "/collection/duplicates?view=resolved", } case KindPlaybackErrors: body := "A track failed to play for someone." if names := nameList(p.Items, p.Count); names != "" { body = "Failed to play: " + names + "." } return Rendered{ Title: plural(p.Count, "playback error reported", "playback errors reported"), Body: body, Link: "/collection/reports", } default: return Rendered{Title: "Notification", Link: "/"} } } // namesShown is how many of a notice's items its body names. const namesShown = 3 // nameList names the first few items, then how many more of the total there // are: "A, B, C and 4 more". Empty when there are no items. func nameList(items []string, total int64) string { if len(items) == 0 { return "" } shown := items[:min(len(items), namesShown)] more := max(total, int64(len(items))) - int64(len(shown)) if more > 0 { return strings.Join(shown, ", ") + " and " + words.Count(more, "more", "more") } if len(shown) == 1 { return shown[0] } return strings.Join(shown[:len(shown)-1], ", ") + " and " + shown[len(shown)-1] } func plural(n int64, one, many string) string { if n < 1 { // A coalesced kind always carries a count; this only guards a // malformed row from reading "0 tracks went missing". return "Some " + many } return words.Count(n, one, many) }