ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
381 lines
14 KiB
Go
381 lines
14 KiB
Go
package web
|
|
|
|
// The operator's list (novox/hq ADR 0515 §1, §3, §4; design 63 milestone 1; issue 1859): the mail page opens
|
|
// on what needs the operator — a greeting with what the mail signal told today, the tasks under today, this
|
|
// week and later, the promises by kind, good to know, read later, handled today with its undo, and the scam
|
|
// warnings in their own place — for the mode chosen (Work, Personal, Everything). Everything here is
|
|
// mail-reading's own answer (list, status), rendered through html/template alone; the page keeps no state of
|
|
// its own (ADR 0440 §4). The classical list stays one link away, as All mail (ADR 0515 §5).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
_ "time/tzdata"
|
|
)
|
|
|
|
// Brussels is the operator's time zone (design 63 §2): the greeting and the times the page shows.
|
|
var Brussels = func() *time.Location {
|
|
loc, err := time.LoadLocation("Europe/Brussels")
|
|
if err != nil {
|
|
panic(err) // time/tzdata carries it
|
|
}
|
|
return loc
|
|
}()
|
|
|
|
// Mode is one of the page's modes (ADR 0515 §4).
|
|
type Mode struct {
|
|
Name, Label string
|
|
}
|
|
|
|
// Modes are the page's three modes, in the order the page offers them.
|
|
var Modes = []Mode{{"work", "Work"}, {"personal", "Personal"}, {"everything", "Everything"}}
|
|
|
|
// modeNamed answers the mode of that name, or Everything for none or one not known.
|
|
func modeNamed(name string) Mode {
|
|
for _, m := range Modes {
|
|
if m.Name == name {
|
|
return m
|
|
}
|
|
}
|
|
return Modes[2]
|
|
}
|
|
|
|
// listItem is one line as mail-reading.list answers it (modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go
|
|
// listItemOut): the fields the page shows — never a mail body.
|
|
type listItem struct {
|
|
ID int64 `json:"id"`
|
|
Item string `json:"item"`
|
|
Kind string `json:"kind"`
|
|
Line string `json:"line"`
|
|
Account string `json:"account"`
|
|
Folder string `json:"folder"`
|
|
UID int64 `json:"uid"`
|
|
SenderName string `json:"sender-name"`
|
|
SenderAddress string `json:"sender-address"`
|
|
Subject string `json:"subject"`
|
|
Date string `json:"date"`
|
|
Summary string `json:"summary"`
|
|
Mark string `json:"mark"`
|
|
Why string `json:"why"`
|
|
DueDate string `json:"due-date"`
|
|
PromiseDate string `json:"promise-date"`
|
|
PromiseKind string `json:"promise-kind"`
|
|
Who string `json:"who"`
|
|
Reminded bool `json:"reminded"`
|
|
Handled string `json:"handled"`
|
|
}
|
|
|
|
// listAnswer is mail-reading.list's own answer (listverbs.go listTool of mesh-mail-reader).
|
|
type listAnswer struct {
|
|
Mode string `json:"mode"`
|
|
TodayIs string `json:"today-is"`
|
|
LastLooked string `json:"last-looked"`
|
|
Today []listItem `json:"today"`
|
|
ThisWeek []listItem `json:"this-week"`
|
|
Later []listItem `json:"later"`
|
|
YouPromised []listItem `json:"you-promised"`
|
|
WaitingOnOthers []listItem `json:"waiting-on-others"`
|
|
OnItsWay []listItem `json:"on-its-way"`
|
|
GoodToKnow []listItem `json:"good-to-know"`
|
|
ReadLater []listItem `json:"read-later"`
|
|
HandledToday []listItem `json:"handled-today"`
|
|
ScamWarnings []listItem `json:"scam-warnings"`
|
|
ToldToday []listItem `json:"told-today"`
|
|
}
|
|
|
|
// SenderShown is how the page names a sender (issue 1850): its name, else its address, else the mail
|
|
// signal's own words for neither (mesh-mail-reader signal.go mailLine).
|
|
func SenderShown(name, address string) string {
|
|
switch {
|
|
case strings.TrimSpace(name) != "":
|
|
return name
|
|
case strings.TrimSpace(address) != "":
|
|
return address
|
|
default:
|
|
return "a sender without a name"
|
|
}
|
|
}
|
|
|
|
// itemRow is one line as the page renders it, carrying what its forms need.
|
|
type itemRow struct {
|
|
ID int64
|
|
Item, Kind, Line, Sender, Subject, Account string
|
|
Due, PromiseKind, Who, Why, Anchor, ReadLink string
|
|
Reminded, Handled bool
|
|
Mode string
|
|
TokenField template.HTML
|
|
}
|
|
|
|
// section is one section of the list, in the page's order (1859/AC1).
|
|
type section struct {
|
|
ID, Title string
|
|
Items []itemRow
|
|
}
|
|
|
|
// modeChoice is one mode as the page offers it.
|
|
type modeChoice struct {
|
|
Mode
|
|
On bool
|
|
}
|
|
|
|
// listPageData is what the list page renders.
|
|
type listPageData struct {
|
|
Title string
|
|
Mode string
|
|
Modes []modeChoice
|
|
TokenField template.HTML
|
|
Notice string // an act that failed, with its cause (ADR 0517 §5); or a setting that names nothing
|
|
Greeting string
|
|
ToldLine string
|
|
Told []itemRow
|
|
LastLooked string
|
|
Health string
|
|
Unavailable string
|
|
Sections []section
|
|
Pane *paneData
|
|
}
|
|
|
|
// pageTemplates are the list page and its reading pane (readpane.go): one stylesheet and one script, both
|
|
// from this origin (ADR 0515 §6); no inline script and no style attribute anywhere.
|
|
var pageTemplates = template.Must(template.New("pages").Parse(`
|
|
{{define "top"}}<!DOCTYPE html>
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{{.Title}}</title><link rel="stylesheet" href="/static/app.css"><script src="/static/app.js" defer></script></head>
|
|
<body>
|
|
<header class="topbar"><a class="logo" href="/?mode={{.Mode}}">Mail</a>
|
|
<nav class="modes" aria-label="Modes">{{range .Modes}}<form method="post" action="/mode" class="act">{{$.TokenField}}<input type="hidden" name="mode" value="{{.Name}}"><button type="submit" class="mode{{if .On}} on{{end}}"{{if .On}} aria-current="page"{{end}}>{{.Label}}</button></form>{{end}}</nav>
|
|
<a class="btn all" href="/all">All mail</a></header>
|
|
{{end}}
|
|
|
|
{{define "item"}}<li class="tk {{.Kind}}{{if .Reminded}} reminded{{end}}" id="{{.Anchor}}">
|
|
<form method="post" action="/item" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="item" value="{{.Item}}"><input type="hidden" name="mode" value="{{.Mode}}">{{if .Handled}}<input type="hidden" name="act" value="unhandle"><button type="submit" class="box done" title="Undo">Undo</button>{{else}}<input type="hidden" name="act" value="handle"><button type="submit" class="box" title="Mark done">Done</button>{{end}}</form>
|
|
<div class="tb"><a class="tt" href="{{.ReadLink}}">{{.Line}}</a>
|
|
<span class="tm">{{if .Due}}<span class="due">{{.Due}}</span> · {{end}}{{if .PromiseKind}}{{.PromiseKind}}{{if .Who}}: {{.Who}}{{end}} · {{end}}{{if .Reminded}}reminded · {{end}}<span class="sender">{{.Sender}}</span> · {{.Subject}} · <span class="acct">{{.Account}}</span></span>{{if .Why}}<span class="why">{{.Why}}</span>{{end}}</div>
|
|
{{if not .Handled}}<form method="post" action="/item" class="act">{{.TokenField}}<input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="item" value="{{.Item}}"><input type="hidden" name="mode" value="{{.Mode}}"><input type="hidden" name="act" value="remind"><button type="submit" class="btn later" title="Remind me tomorrow">Tomorrow</button></form>{{end}}
|
|
</li>{{end}}
|
|
|
|
{{define "pane"}}{{end}}
|
|
|
|
{{define "list"}}{{template "top" .}}
|
|
<main class="page{{if .Pane}} with-pane{{end}}">
|
|
<div class="list" id="list">
|
|
{{if .Notice}}<p class="notice failed" role="alert">{{.Notice}}</p>{{end}}
|
|
<section id="greeting" class="greet"><h1>{{.Greeting}}</h1>
|
|
<p class="lede">{{.ToldLine}}</p>
|
|
{{if .Told}}<ul class="told">{{range .Told}}<li><a href="{{.ReadLink}}"><span class="sender">{{.Sender}}</span> — {{.Line}}</a></li>{{end}}</ul>{{end}}
|
|
{{if .LastLooked}}<p class="muted">You last looked at {{.LastLooked}}.</p>{{end}}
|
|
{{if .Health}}<p class="notice">{{.Health}}</p>{{end}}
|
|
</section>
|
|
{{if .Unavailable}}<p class="notice failed">Mail reading is unavailable: {{.Unavailable}}</p>
|
|
{{else}}{{range .Sections}}<section id="{{.ID}}" class="sec"><h2>{{.Title}} <span class="cnt">{{len .Items}}</span></h2>
|
|
{{if .Items}}<ul>{{range .Items}}{{template "item" .}}{{end}}</ul>{{else}}<p class="muted empty">Nothing here.</p>{{end}}
|
|
</section>
|
|
{{end}}{{end}}
|
|
</div>
|
|
{{if .Pane}}{{template "pane" .Pane}}{{end}}
|
|
<aside class="owly" aria-label="Owly"></aside>
|
|
</main>
|
|
</body></html>{{end}}
|
|
`))
|
|
|
|
// paneData is the reading pane (readpane.go).
|
|
type paneData struct{}
|
|
|
|
// modeAccounts answers the accounts mode shows, nil for every account (modes.go).
|
|
func (b *Bundle) modeAccounts(mode Mode) ([]string, string) { return nil, "" }
|
|
|
|
// sectionOrder is the page's order of sections (1859/AC1), each with the list answer's own part.
|
|
func sectionsOf(l listAnswer) []struct {
|
|
ID, Title string
|
|
Items []listItem
|
|
} {
|
|
return []struct {
|
|
ID, Title string
|
|
Items []listItem
|
|
}{
|
|
{"today", "Today", l.Today},
|
|
{"this-week", "This week", l.ThisWeek},
|
|
{"later", "Later", l.Later},
|
|
{"you-promised", "You promised", l.YouPromised},
|
|
{"waiting-on-others", "Waiting on others", l.WaitingOnOthers},
|
|
{"on-its-way", "On its way", l.OnItsWay},
|
|
{"good-to-know", "Good to know", l.GoodToKnow},
|
|
{"read-later", "Read later", l.ReadLater},
|
|
{"handled-today", "Handled today", l.HandledToday},
|
|
{"scam-warnings", "Scam warnings", l.ScamWarnings},
|
|
}
|
|
}
|
|
|
|
// rowOf is one list line as the page renders it.
|
|
func rowOf(it listItem, mode string, token template.HTML) itemRow {
|
|
line := it.Line
|
|
if line == "" {
|
|
line = it.Subject
|
|
}
|
|
if line == "" {
|
|
line = "(no subject)"
|
|
}
|
|
due := it.DueDate
|
|
if it.PromiseDate != "" {
|
|
due = it.PromiseDate
|
|
}
|
|
row := itemRow{
|
|
ID: it.ID, Item: it.Item, Kind: it.Kind, Line: line, Sender: SenderShown(it.SenderName, it.SenderAddress),
|
|
Subject: it.Subject, Account: it.Account, Due: due, PromiseKind: it.PromiseKind, Who: it.Who,
|
|
Reminded: it.Reminded, Handled: it.Handled != "", Mode: mode, TokenField: token,
|
|
Anchor: "i-" + fmt.Sprint(it.ID) + "-" + strings.ReplaceAll(it.Item, " ", "-"),
|
|
ReadLink: "/read?" + url.Values{"id": {fmt.Sprint(it.ID)}, "mode": {mode}}.Encode(),
|
|
}
|
|
if it.Mark == "scam warning" {
|
|
row.Why = it.Why
|
|
}
|
|
return row
|
|
}
|
|
|
|
// greeting is the page's own hello, by the operator's hour.
|
|
func greeting(now time.Time) string {
|
|
switch h := now.In(Brussels).Hour(); {
|
|
case h < 5:
|
|
return "Good night"
|
|
case h < 12:
|
|
return "Good morning"
|
|
case h < 18:
|
|
return "Good afternoon"
|
|
default:
|
|
return "Good evening"
|
|
}
|
|
}
|
|
|
|
// localTime is an RFC 3339 time as the operator reads it: their own zone, the hour and minute, and the day
|
|
// when it is not today.
|
|
func localTime(rfc3339 string, now time.Time) string {
|
|
t, err := time.Parse(time.RFC3339, rfc3339)
|
|
if err != nil {
|
|
return rfc3339
|
|
}
|
|
t, today := t.In(Brussels), now.In(Brussels)
|
|
if t.Format("2006-01-02") == today.Format("2006-01-02") {
|
|
return t.Format("15:04")
|
|
}
|
|
return t.Format("15:04 on Mon 2 Jan")
|
|
}
|
|
|
|
// statusLine is mail-reading.status as one sentence for the greeting (issue 1547: its own words), or "" when
|
|
// all is well.
|
|
func (b *Bundle) statusLine(ctx context.Context) string {
|
|
raw, err := b.mailStatusAsk(ctx, map[string]any{})
|
|
if err != nil {
|
|
return "Mail reading does not answer: " + err.Error()
|
|
}
|
|
var s mailStatusAnswer
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return "Mail reading's status could not be read."
|
|
}
|
|
switch {
|
|
case !s.Healthy:
|
|
return fmt.Sprintf("Mail reading is unhealthy: %s (%s), since %s.", s.Cause, s.Why, s.Since)
|
|
case s.Why != "":
|
|
return "Mail reading: " + s.Why + "."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// listData builds the list page's data for mode: asks mail-reading's status and list (for the mode's accounts,
|
|
// modes.go), and says the page opened (record, ADR 0517 §4) — a failure of that is said, never dropped.
|
|
func (b *Bundle) listData(ctx context.Context, r *http.Request, mode Mode) listPageData {
|
|
now := b.now()
|
|
data := listPageData{Title: "Mail", Mode: mode.Name, Greeting: greeting(now)}
|
|
for _, m := range Modes {
|
|
data.Modes = append(data.Modes, modeChoice{Mode: m, On: m == mode})
|
|
}
|
|
if sess, ok := SessionFrom(r); ok {
|
|
data.TokenField = FormTokenInput(sess)
|
|
}
|
|
data.Health = b.statusLine(ctx)
|
|
args := map[string]any{"mode": mode.Name}
|
|
accounts, why := b.modeAccounts(mode)
|
|
if why != "" {
|
|
data.Notice = why
|
|
data.Sections = emptySections()
|
|
data.ToldLine = "The mail signal's mails show in every mode they belong to."
|
|
return data
|
|
}
|
|
if accounts != nil {
|
|
list := make([]any, len(accounts))
|
|
for i, a := range accounts {
|
|
list[i] = a
|
|
}
|
|
args["accounts"] = list
|
|
}
|
|
raw, err := b.mailListAsk(ctx, args)
|
|
var answer listAnswer
|
|
if err == nil {
|
|
err = json.Unmarshal(raw, &answer)
|
|
}
|
|
if err != nil {
|
|
data.Unavailable = err.Error()
|
|
data.ToldLine = "What the mail signal told today cannot be shown."
|
|
return data
|
|
}
|
|
for _, s := range sectionsOf(answer) {
|
|
sec := section{ID: s.ID, Title: s.Title}
|
|
for _, it := range s.Items {
|
|
sec.Items = append(sec.Items, rowOf(it, mode.Name, data.TokenField))
|
|
}
|
|
data.Sections = append(data.Sections, sec)
|
|
}
|
|
for _, it := range answer.ToldToday {
|
|
data.Told = append(data.Told, rowOf(it, mode.Name, data.TokenField))
|
|
}
|
|
switch n := len(data.Told); n {
|
|
case 0:
|
|
data.ToldLine = "The mail signal told you of nothing today."
|
|
case 1:
|
|
data.ToldLine = "The mail signal told you of 1 mail today:"
|
|
default:
|
|
data.ToldLine = fmt.Sprintf("The mail signal told you of %d mails today:", n)
|
|
}
|
|
if answer.LastLooked != "" {
|
|
data.LastLooked = localTime(answer.LastLooked, now)
|
|
}
|
|
if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "page opened", "mode": mode.Name}); err != nil {
|
|
data.Notice = "That you opened the page could not be kept: " + err.Error()
|
|
}
|
|
return data
|
|
}
|
|
|
|
func emptySections() []section {
|
|
var out []section
|
|
for _, s := range sectionsOf(listAnswer{}) {
|
|
out = append(out, section{ID: s.ID, Title: s.Title})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// listPage is GET / (1859/AC1): the operator's list for the mode the address names (Everything when none).
|
|
func (b *Bundle) listPage(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
data := b.listData(ctx, r, modeNamed(r.URL.Query().Get("mode")))
|
|
renderNamed(w, http.StatusOK, "list", data)
|
|
}
|
|
|
|
// renderNamed executes one of pageTemplates with its status.
|
|
func renderNamed(w http.ResponseWriter, status int, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if err := pageTemplates.ExecuteTemplate(w, name, data); err != nil {
|
|
fmt.Fprintf(w, "\n<!-- the page could not be rendered whole: %v -->", err)
|
|
}
|
|
}
|