So the operator can click through the page on their own machine before it is delivered: cmd/mail-page-dev serves internal/web's real handlers on 127.0.0.1 alone, over invented mail in two accounts and an in-memory mail reading that fills every section, with an always-signed-in stand-in; nothing reaches the bus, a database, Keycloak or a mail server. -now fixes today. The README says how to run it and what is a stand-in.
518 lines
18 KiB
Go
518 lines
18 KiB
Go
package main
|
|
|
|
// The local run's mail reading (issue 1859.5): mail-reading's verbs — entries, groups, status, correct,
|
|
// lessons, undo, list, handle, unhandle, remind, record — over invented entries held in memory, answered in
|
|
// mesh-mail-reader's own shapes (tools.go entryOut, listverbs.go, list.go listItemOut) and by its rules
|
|
// (list.go BuildList: today, this week and later by due date in Europe/Brussels; promises by kind; good to
|
|
// know; read later; handled today with its undo until the day ends; a reminder hides an item until the next
|
|
// day). A stand-in: no judgement, no database, nothing kept past the run.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type devTask struct {
|
|
N int
|
|
Line string
|
|
Due time.Time // a day, midnight UTC; zero for none
|
|
}
|
|
|
|
type devPromise struct {
|
|
N int
|
|
Kind, Line, Who string
|
|
Date time.Time
|
|
}
|
|
|
|
type devEntry struct {
|
|
ID int64
|
|
Mail *devMail
|
|
Summary, Mark, Why, Group string
|
|
Tasks []devTask
|
|
Promises []devPromise
|
|
ReadLater, Told bool
|
|
Judged time.Time
|
|
}
|
|
|
|
type handling struct{ Handled, ShowsAgain time.Time }
|
|
|
|
type devLesson struct {
|
|
ID int64
|
|
Sender, Subject, Change string
|
|
Made time.Time
|
|
Undone bool
|
|
}
|
|
|
|
type actionLine struct {
|
|
What, Item, Mode string
|
|
Entry int64
|
|
At time.Time
|
|
}
|
|
|
|
// reading is the in-memory mail reading.
|
|
type reading struct {
|
|
mu sync.Mutex
|
|
now func() time.Time
|
|
entries []*devEntry
|
|
handlings map[string]handling // "<id> <item>"
|
|
lessons []devLesson
|
|
actions []actionLine
|
|
}
|
|
|
|
var brussels = func() *time.Location {
|
|
loc, err := time.LoadLocation("Europe/Brussels")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return loc
|
|
}()
|
|
|
|
func dayOf(t time.Time) time.Time {
|
|
y, m, d := t.In(brussels).Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func endOfWeek(day time.Time) time.Time { return day.AddDate(0, 0, (7-int(day.Weekday()))%7) }
|
|
|
|
func newReading(start time.Time, now func() time.Time, m map[string]*devMail) *reading {
|
|
today := dayOf(start)
|
|
sunday := endOfWeek(today)
|
|
thisWeek := today.AddDate(0, 0, 2)
|
|
if thisWeek.After(sunday) {
|
|
thisWeek = sunday
|
|
}
|
|
r := &reading{now: now, handlings: map[string]handling{}}
|
|
id := int64(0)
|
|
add := func(mail *devMail, mark, summary, why, group string, tasks []devTask, promises []devPromise, readLater, told bool) *devEntry {
|
|
id++
|
|
e := &devEntry{ID: id, Mail: mail, Mark: mark, Summary: summary, Why: why, Group: group, Tasks: tasks,
|
|
Promises: promises, ReadLater: readLater, Told: told, Judged: mail.Date.Add(3 * time.Minute)}
|
|
r.entries = append(r.entries, e)
|
|
return e
|
|
}
|
|
add(m["offer"], "matters", "Sara needs the signed Harbor offer back today", "a contract to sign, due today", "the Harbor demo",
|
|
[]devTask{{1, "Sign the Harbor offer and send it back to Sara", today}}, nil, false, true)
|
|
add(m["quote"], "matters", "Tim is preparing the revised quote", "a quote the demo waits on", "the Harbor demo",
|
|
nil, []devPromise{{1, "waiting on others", "Tim sends the revised quote before Friday", "Tim Jacobs", thisWeek}}, false, false)
|
|
add(m["hostco"], "matters", "Hostco's renewal needs approving; the contract is on its way", "a renewal to approve", "suppliers",
|
|
[]devTask{{1, "Approve the Hostco renewal", thisWeek}, {2, "Forward the Hostco invoice to finance", time.Time{}}},
|
|
[]devPromise{{1, "on its way", "The new Hostco contract comes by post", "Hostco", time.Time{}}}, false, false)
|
|
add(m["room"], "matters", "Sara asks for a room for the demo", "a booking to make", "the Harbor demo",
|
|
[]devTask{{1, "Book a room for twelve for the Harbor demo", today.AddDate(0, 0, 14)}}, nil, false, false)
|
|
add(m["slides"], "matters", "Tim waits for the demo slides you promised", "a promise of yours", "the Harbor demo",
|
|
nil, []devPromise{{1, "you promised", "Send Tim the demo slides", "Tim Jacobs", today.AddDate(0, 0, 1)}}, false, false)
|
|
add(m["newsletter"], "neither", "The Harbor team's autumn newsletter", "news, nothing asked", "newsletters", nil, nil, true, false)
|
|
add(m["router"], "neither", "The new Telco router arrives tomorrow", "a delivery", "deliveries",
|
|
nil, []devPromise{{1, "on its way", "The Telco router arrives tomorrow", "Telco", today.AddDate(0, 0, 1)}}, false, false)
|
|
add(m["digest"], "neither", "This week's digest of the list", "a digest", "lists", nil, nil, false, false)
|
|
add(m["parcel"], "scam warning", "A courier fee asked by an unknown sender", "a fee asked by a sender you never had mail from, with a link elsewhere", "",
|
|
nil, nil, false, true)
|
|
add(m["thanks"], "neither", "Lena thanks you for Tuesday", "thanks, nothing asked", "suppliers", nil, nil, false, false)
|
|
agenda := add(m["agenda"], "matters", "Sara asks which kick-off slot suits you", "a question to answer", "the Harbor demo",
|
|
[]devTask{{1, "Tell Sara which kick-off slot suits you", today}, {2, "Check the kick-off time slots", today}}, nil, false, false)
|
|
add(m["bill"], "matters", "The Telco bill of 34.50 EUR is due on the 28th", "a bill to pay", "bills",
|
|
[]devTask{{1, "Pay the Telco bill of 34.50 EUR", today.AddDate(0, 0, 6)}}, nil, false, false)
|
|
add(m["photos"], "neither", "Lena's photos from the weekend", "photos, no hurry", "", nil, nil, true, false)
|
|
add(m["club"], "neither", "The book club's next book", "news of the club", "clubs", nil, nil, false, false)
|
|
add(m["dentist"], "matters", "The dentist asks you to confirm Tuesday's appointment", "an appointment to confirm", "",
|
|
[]devTask{{1, "Confirm the dentist appointment of next Tuesday", today.AddDate(0, 0, 1)}}, nil, false, false)
|
|
// Handled earlier today, so handled today shows something with its undo.
|
|
r.handlings[key(agenda.ID, "task 2")] = handling{Handled: start.Add(-30 * time.Minute)}
|
|
r.lessons = append(r.lessons, devLesson{ID: 1, Sender: "news@harbor.example", Subject: "Harbor news", Change: "mark: matters -> neither",
|
|
Made: start.Add(-72 * time.Hour)})
|
|
r.actions = append(r.actions, actionLine{What: "page opened", Mode: "everything", At: start.Add(-3 * time.Hour)})
|
|
return r
|
|
}
|
|
|
|
func key(id int64, item string) string { return strconv.FormatInt(id, 10) + " " + item }
|
|
|
|
func date(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format("2006-01-02")
|
|
}
|
|
|
|
func (r *reading) entry(id int64) *devEntry {
|
|
for _, e := range r.entries {
|
|
if e.ID == id {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *reading) holds(e *devEntry, item string) bool {
|
|
if item == "mail" {
|
|
return true
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(item, "task %d", &n); err == nil {
|
|
for _, t := range e.Tasks {
|
|
if t.N == n {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if _, err := fmt.Sscanf(item, "promise %d", &n); err == nil {
|
|
for _, p := range e.Promises {
|
|
if p.N == n {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// verb is a mail-reading verb over this store, answered as JSON.
|
|
func (r *reading) verb(f func(args map[string]any) (any, error)) func(context.Context, map[string]any) (json.RawMessage, error) {
|
|
return func(_ context.Context, args map[string]any) (json.RawMessage, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out, err := f(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
}
|
|
|
|
func entryOut(e *devEntry) map[string]any {
|
|
tasks := []map[string]any{}
|
|
for _, t := range e.Tasks {
|
|
tasks = append(tasks, map[string]any{"n": t.N, "line": t.Line, "due-date": date(t.Due)})
|
|
}
|
|
promises := []map[string]any{}
|
|
for _, p := range e.Promises {
|
|
promises = append(promises, map[string]any{"n": p.N, "kind": p.Kind, "line": p.Line, "who": p.Who, "date": date(p.Date)})
|
|
}
|
|
m := e.Mail
|
|
return map[string]any{
|
|
"id": e.ID, "account": m.Account, "message-id": m.MessageID, "date": m.Date.UTC().Format(time.RFC3339),
|
|
"sender-name": m.From.Name, "subject": m.Subject, "group": e.Group, "summary": e.Summary, "mark": e.Mark,
|
|
"why": e.Why, "due-date": "", "folder": m.Folder, "uid": m.UID, "sender-address": m.From.Address,
|
|
"tasks": tasks, "promises": promises, "read-later": e.ReadLater,
|
|
}
|
|
}
|
|
|
|
func (r *reading) entriesVerb(args map[string]any) (any, error) {
|
|
id := int64(number(args, "id"))
|
|
var out []map[string]any
|
|
for _, e := range r.entries {
|
|
if id != 0 && e.ID != id {
|
|
continue
|
|
}
|
|
if mark := text(args, "mark"); mark != "" && e.Mark != mark {
|
|
continue
|
|
}
|
|
out = append(out, entryOut(e))
|
|
}
|
|
return map[string]any{"entries": out}, nil
|
|
}
|
|
|
|
func (r *reading) groupsVerb(map[string]any) (any, error) {
|
|
counts := map[string]int{}
|
|
for _, e := range r.entries {
|
|
if e.Group != "" {
|
|
counts[e.Group]++
|
|
}
|
|
}
|
|
var out []map[string]any
|
|
for label, n := range counts {
|
|
out = append(out, map[string]any{"label": label, "count": n})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i]["count"].(int) > out[j]["count"].(int) })
|
|
return map[string]any{"groups": out}, nil
|
|
}
|
|
|
|
func (r *reading) statusVerb(map[string]any) (any, error) {
|
|
monday := dayOf(r.now()).AddDate(0, 0, -((int(dayOf(r.now()).Weekday()) + 6) % 7))
|
|
return map[string]any{"healthy": true, "last-success": r.now().Add(-2 * time.Minute).UTC().Format(time.RFC3339),
|
|
"weeks": []map[string]any{{"week-start": date(monday), "mails-read": len(r.entries), "lessons": len(r.lessons),
|
|
"lessons-per-100": float64(len(r.lessons)) * 100 / float64(len(r.entries))}}}, nil
|
|
}
|
|
|
|
func (r *reading) lessonsVerb(map[string]any) (any, error) {
|
|
var out []map[string]any
|
|
for i := len(r.lessons) - 1; i >= 0; i-- {
|
|
l := r.lessons[i]
|
|
if l.Undone {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{"id": l.ID, "sender-name": "", "sender-address": l.Sender, "subject": l.Subject,
|
|
"change": l.Change, "made": l.Made.UTC().Format(time.RFC3339), "undone": false})
|
|
}
|
|
return map[string]any{"lessons": out}, nil
|
|
}
|
|
|
|
func (r *reading) undoVerb(args map[string]any) (any, error) {
|
|
id := int64(number(args, "id"))
|
|
for i := range r.lessons {
|
|
if r.lessons[i].ID == id {
|
|
r.lessons[i].Undone = true
|
|
return map[string]any{"undone": id}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("mail-reading.undo: no reading lesson %d is held", id)
|
|
}
|
|
|
|
func (r *reading) line(what string, id int64, item, mode string) {
|
|
if mode == "" {
|
|
mode = "everything"
|
|
}
|
|
r.actions = append(r.actions, actionLine{What: what, Entry: id, Item: item, Mode: mode, At: r.now()})
|
|
}
|
|
|
|
func (r *reading) correctVerb(args map[string]any) (any, error) {
|
|
e := r.entry(int64(number(args, "id")))
|
|
if e == nil {
|
|
return nil, fmt.Errorf("mail-reading.correct: no mail entry %d is held", number(args, "id"))
|
|
}
|
|
var changes []string
|
|
item := "mail"
|
|
if mark := text(args, "mark"); mark != "" && mark != e.Mark {
|
|
changes = append(changes, "mark: "+e.Mark+" -> "+mark)
|
|
e.Mark = mark
|
|
}
|
|
if group := text(args, "group"); group != "" && group != e.Group {
|
|
changes = append(changes, "mail group: "+e.Group+" -> "+group)
|
|
e.Group = group
|
|
}
|
|
if n := number(args, "not-a-task"); n != 0 {
|
|
var kept []devTask
|
|
for _, t := range e.Tasks {
|
|
if t.N == n {
|
|
changes = append(changes, "not a task: "+t.Line)
|
|
item = "task " + strconv.Itoa(n)
|
|
continue
|
|
}
|
|
kept = append(kept, t)
|
|
}
|
|
e.Tasks = kept
|
|
}
|
|
if n := number(args, "remove-promise"); n != 0 {
|
|
var kept []devPromise
|
|
for _, p := range e.Promises {
|
|
if p.N == n {
|
|
changes = append(changes, "not a promise ("+p.Kind+"): "+p.Line)
|
|
item = "promise " + strconv.Itoa(n)
|
|
continue
|
|
}
|
|
kept = append(kept, p)
|
|
}
|
|
e.Promises = kept
|
|
}
|
|
if v, ok := args["read-later"].(bool); ok && v != e.ReadLater {
|
|
changes = append(changes, fmt.Sprintf("read later: %v -> %v", e.ReadLater, v))
|
|
e.ReadLater = v
|
|
}
|
|
if len(changes) == 0 {
|
|
return nil, fmt.Errorf("mail-reading.correct needs a change that actually changes the entry")
|
|
}
|
|
r.lessons = append(r.lessons, devLesson{ID: int64(len(r.lessons) + 1), Sender: e.Mail.From.Address, Subject: e.Mail.Subject,
|
|
Change: strings.Join(changes, "; "), Made: r.now()})
|
|
r.line("corrected", e.ID, item, text(args, "mode"))
|
|
return entryOut(e), nil
|
|
}
|
|
|
|
// act is handle, unhandle and remind (ADR 0517 §1, §2), each refusing as mesh-mail-reader's listverbs.go does.
|
|
func (r *reading) act(what string) func(args map[string]any) (any, error) {
|
|
return func(args map[string]any) (any, error) {
|
|
id := int64(number(args, "id"))
|
|
item := text(args, "item")
|
|
if item == "" {
|
|
item = "mail"
|
|
}
|
|
e := r.entry(id)
|
|
if e == nil {
|
|
return nil, fmt.Errorf("mail-reading.%s: no mail entry %d is held", what, id)
|
|
}
|
|
if !r.holds(e, item) {
|
|
return nil, fmt.Errorf("mail-reading.%s: the mail entry %d holds no %s", what, id, item)
|
|
}
|
|
h := r.handlings[key(id, item)]
|
|
now := r.now()
|
|
switch what {
|
|
case "handle":
|
|
if !h.Handled.IsZero() {
|
|
return nil, fmt.Errorf("mail-reading.handle: %s of mail entry %d is handled already", item, id)
|
|
}
|
|
h.Handled = now
|
|
r.line("handled", id, item, text(args, "mode"))
|
|
case "unhandle":
|
|
if h.Handled.IsZero() {
|
|
return nil, fmt.Errorf("mail-reading.unhandle: %s of mail entry %d is not handled", item, id)
|
|
}
|
|
if !dayOf(h.Handled).Equal(dayOf(now)) {
|
|
return nil, fmt.Errorf("mail-reading.unhandle: %s of mail entry %d was handled before today, and an undo lasts until the day ends", item, id)
|
|
}
|
|
h.Handled = time.Time{}
|
|
r.line("undone", id, item, text(args, "mode"))
|
|
case "remind":
|
|
if !h.Handled.IsZero() {
|
|
return nil, fmt.Errorf("mail-reading.remind: %s of mail entry %d is handled; undo it first", item, id)
|
|
}
|
|
h.ShowsAgain = dayOf(now).AddDate(0, 0, 1)
|
|
r.line("reminded", id, item, text(args, "mode"))
|
|
}
|
|
r.handlings[key(id, item)] = h
|
|
return map[string]any{what: map[string]any{"id": id, "item": item}}, nil
|
|
}
|
|
}
|
|
|
|
func (r *reading) recordVerb(args map[string]any) (any, error) {
|
|
what := text(args, "what")
|
|
if what != "opened" && what != "mode" && what != "page opened" {
|
|
return nil, fmt.Errorf("mail-reading.record records opened, mode or page opened; %q is none of them", what)
|
|
}
|
|
item := ""
|
|
if what == "opened" {
|
|
item = "mail"
|
|
}
|
|
r.line(what, int64(number(args, "id")), item, text(args, "mode"))
|
|
return map[string]any{"recorded": what}, nil
|
|
}
|
|
|
|
// itemOut is one line of the list (mesh-mail-reader list.go listItemOut).
|
|
func itemOut(e *devEntry, item, kind, line string, due time.Time, promiseKind, who string, reminded bool, handled time.Time) map[string]any {
|
|
m := e.Mail
|
|
out := map[string]any{"id": e.ID, "item": item, "kind": kind, "line": line, "account": m.Account, "folder": m.Folder,
|
|
"uid": m.UID, "sender-name": m.From.Name, "sender-address": m.From.Address, "subject": m.Subject,
|
|
"date": m.Date.UTC().Format(time.RFC3339), "summary": e.Summary, "mark": e.Mark, "why": e.Why}
|
|
if !due.IsZero() {
|
|
if kind == "promise" {
|
|
out["promise-date"] = date(due)
|
|
} else {
|
|
out["due-date"] = date(due)
|
|
}
|
|
}
|
|
if kind == "promise" {
|
|
out["promise-kind"], out["who"] = promiseKind, who
|
|
}
|
|
if reminded {
|
|
out["reminded"] = true
|
|
}
|
|
if !handled.IsZero() {
|
|
out["handled"], out["undo"] = handled.UTC().Format(time.RFC3339), true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// listVerb is mesh-mail-reader's list (list.go BuildList), over this store.
|
|
func (r *reading) listVerb(args map[string]any) (any, error) {
|
|
now := r.now()
|
|
today := dayOf(now)
|
|
sunday := endOfWeek(today)
|
|
wanted := map[string]bool{}
|
|
if names, ok := args["accounts"].([]any); ok {
|
|
for _, n := range names {
|
|
wanted[fmt.Sprint(n)] = true
|
|
}
|
|
}
|
|
sections := map[string][]map[string]any{}
|
|
put := func(name string, v map[string]any) { sections[name] = append(sections[name], v) }
|
|
for _, e := range r.entries {
|
|
if len(wanted) > 0 && !wanted[e.Mail.Account] {
|
|
continue
|
|
}
|
|
if e.Told && dayOf(e.Judged).Equal(today) {
|
|
put("told-today", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
|
|
}
|
|
mail := r.handlings[key(e.ID, "mail")]
|
|
if !mail.Handled.IsZero() {
|
|
if dayOf(mail.Handled).Equal(today) {
|
|
put("handled-today", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, mail.Handled))
|
|
}
|
|
continue
|
|
}
|
|
if mail.ShowsAgain.After(today) {
|
|
continue
|
|
}
|
|
mailToday := mail.ShowsAgain.Equal(today)
|
|
if e.Mark == "scam warning" {
|
|
put("scam-warnings", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
|
|
continue
|
|
}
|
|
type it struct {
|
|
item, kind, line, pkind, who string
|
|
due time.Time
|
|
}
|
|
var items []it
|
|
for _, t := range e.Tasks {
|
|
items = append(items, it{"task " + strconv.Itoa(t.N), "task", t.Line, "", "", t.Due})
|
|
}
|
|
for _, p := range e.Promises {
|
|
items = append(items, it{"promise " + strconv.Itoa(p.N), "promise", p.Line, p.Kind, p.Who, p.Date})
|
|
}
|
|
for _, x := range items {
|
|
h := r.handlings[key(e.ID, x.item)]
|
|
switch {
|
|
case !h.Handled.IsZero():
|
|
if dayOf(h.Handled).Equal(today) {
|
|
put("handled-today", itemOut(e, x.item, x.kind, x.line, x.due, x.pkind, x.who, false, h.Handled))
|
|
}
|
|
case h.ShowsAgain.After(today):
|
|
case h.ShowsAgain.Equal(today) || mailToday:
|
|
put("today", itemOut(e, x.item, x.kind, x.line, x.due, x.pkind, x.who, true, time.Time{}))
|
|
case x.kind == "task":
|
|
section := "later"
|
|
if !x.due.IsZero() && !x.due.After(today) {
|
|
section = "today"
|
|
} else if !x.due.IsZero() && !x.due.After(sunday) {
|
|
section = "this-week"
|
|
}
|
|
put(section, itemOut(e, x.item, x.kind, x.line, x.due, "", "", false, time.Time{}))
|
|
default:
|
|
put(strings.ReplaceAll(x.pkind, " ", "-"), itemOut(e, x.item, x.kind, x.line, x.due, x.pkind, x.who, false, time.Time{}))
|
|
}
|
|
}
|
|
switch {
|
|
case len(items) == 0 && mailToday:
|
|
put("today", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", true, time.Time{}))
|
|
case e.ReadLater:
|
|
put("read-later", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
|
|
case len(items) == 0:
|
|
put("good-to-know", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
|
|
}
|
|
}
|
|
out := map[string]any{"mode": text(args, "mode"), "today-is": date(today)}
|
|
for _, name := range []string{"today", "this-week", "later", "you-promised", "waiting-on-others", "on-its-way",
|
|
"good-to-know", "read-later", "handled-today", "scam-warnings", "told-today"} {
|
|
s := sections[name]
|
|
sort.SliceStable(s, func(i, j int) bool {
|
|
a, _ := s[i]["due-date"].(string)
|
|
b, _ := s[j]["due-date"].(string)
|
|
if a == "" {
|
|
a, _ = s[i]["promise-date"].(string)
|
|
}
|
|
if b == "" {
|
|
b, _ = s[j]["promise-date"].(string)
|
|
}
|
|
if (a == "") != (b == "") {
|
|
return b == ""
|
|
}
|
|
return a < b
|
|
})
|
|
if s == nil {
|
|
s = []map[string]any{}
|
|
}
|
|
out[name] = s
|
|
}
|
|
var looked time.Time
|
|
for _, a := range r.actions {
|
|
if a.What == "page opened" && a.At.After(looked) {
|
|
looked = a.At
|
|
}
|
|
}
|
|
if !looked.IsZero() {
|
|
out["last-looked"] = looked.UTC().Format(time.RFC3339)
|
|
}
|
|
return out, nil
|
|
}
|