package main import ( "context" "encoding/xml" "fmt" "io" "log" "net/http" "net/url" "strings" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/store" ) // 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 // reconciled — whether the collection has been read once since start. It // has to be, because published is in-memory: withdrawal used to cover only // the reminders THIS process published, so a reminder that fired while the // daemon was down kept its event in the calendar forever, and nothing ever // revisited it. reconciled bool } 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 != store.ReminderPending { 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) } stale := make(map[int64]bool) for id := range r.published { if !live[id] { stale[id] = true } } if !r.reconciled { remote, err := r.listPublished(ctx) if err != nil { // Try again next tick. A collection maven cannot read is not a // reason to stop publishing to it. log.Printf("mavcaldav: reconcile: %v", err) } else { r.reconciled = true for _, id := range remote { if !live[id] { stale[id] = true } } } } for id := range stale { 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) } } // listPublished PROPFINDs the collection and returns the reminder ids maven has // events for in it. Only resources carrying calendar.ReminderUIDPrefix are // reported, so a reconciliation pass can never propose deleting a file maven // did not create — the same bound every other path in this file has. func (r *renderer) listPublished(ctx context.Context) ([]int64, error) { const body = `` + `` req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body)) if err != nil { return nil, err } req.SetBasicAuth(r.user, r.pass) req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", "1") resp, err := r.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) { return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status) } var ms struct { Responses []struct { Href string `xml:"href"` } `xml:"response"` } if err := xml.Unmarshal(raw, &ms); err != nil { return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err) } var ids []int64 for _, resp := range ms.Responses { href, err := url.PathUnescape(strings.TrimSpace(resp.Href)) if err != nil { continue } if id, ok := calendar.ReminderIDFromPath(href); ok { ids = append(ids, id) } } return ids, nil } // 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) }