package main import ( "context" "fmt" "io" "log" "net/http" "strings" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" ) // renderer is the write half of maven's own local calendar (Vikunja #127). // // It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the // renderer reads the pending reminders out of core and publishes each one as a // single-event iCal resource in a CalDAV collection maven owns. Nothing is ever // read back from that collection, and losing it costs nothing — the next tick // rebuilds it. // // It structurally cannot write to a calendar maven only reads. The URL comes // from its own flag, checked at startup against every read URL (see // run in main.go), and the only paths it ever addresses carry // calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can // only touch resources it created. type renderer struct { core ipc.CoreAPI http *http.Client url string user string pass string dur time.Duration // published maps reminder id → the body last successfully PUT, so an // unchanged reminder costs nothing. Purely an optimisation: a restart // re-publishes every reminder once, which is idempotent. published map[int64]string } func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer { return &renderer{ core: core, http: hc, url: strings.TrimRight(url, "/"), user: user, pass: pass, dur: dur, published: make(map[int64]string), } } // renderOnce publishes every pending reminder and withdraws the ones that are // no longer pending. Errors are logged and skipped: a calendar maven cannot // reach must never break the reminder itself, which lives in sqlite. func (r *renderer) renderOnce(ctx context.Context) { reminders, err := r.core.ListReminders(ctx, renderMaxReminders) if err != nil { log.Printf("mavcaldav: list reminders: %v", err) return } live := make(map[int64]bool, len(reminders)) for _, rem := range reminders { if rem.Status != "pending" { continue } live[rem.ID] = true e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur) body := calendar.RenderICal([]calendar.Event{e}) if r.published[rem.ID] == body { continue } if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil { log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err) continue } r.published[rem.ID] = body log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary) } for id := range r.published { if live[id] { continue } if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil { log.Printf("mavcaldav: withdraw reminder %d: %v", id, err) continue } delete(r.published, id) log.Printf("mavcaldav: withdrew reminder %d", id) } } // renderMaxReminders bounds the read. Reminders past this count are older than // anything a calendar view is useful for. const renderMaxReminders = 200 // fireTime prefers NextFireTs — for a recurring reminder that is the occurrence // worth showing; FireTs is the original statement. func fireTime(rem ipc.Reminder) time.Time { if !rem.NextFireTs.IsZero() { return rem.NextFireTs } return rem.FireTs } func (r *renderer) put(ctx context.Context, name, body string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body)) if err != nil { return err } req.SetBasicAuth(r.user, r.pass) req.Header.Set("Content-Type", "text/calendar; charset=utf-8") return r.do(req, name) } func (r *renderer) delete(ctx context.Context, name string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil) if err != nil { return err } req.SetBasicAuth(r.user, r.pass) return r.do(req, name) } // do runs the request and treats any 2xx, plus 404 on a DELETE, as success — // a resource that is already gone is the state the caller wanted. func (r *renderer) do(req *http.Request, name string) error { resp, err := r.http.Do(req) if err != nil { return err } defer resp.Body.Close() io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: return nil case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound: return nil } return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status) }