Render maven's own reminders to a calendar she owns (#127)
Radicale becomes a write-only render target, not a store. sqlite stays canonical: every poll mavcaldav reads the pending reminders out of core and publishes each one as a single-event iCal resource, withdrawing the ones that have fired or been cancelled. Losing the collection costs nothing — the next tick rebuilds it, and nothing is ever read back from it. It structurally cannot write to a calendar maven only reads. The render URL and credential are their own flags, and -render-url is refused at startup when it names the collection -url reads; the only paths it addresses carry the maven-reminder- prefix, so even aimed at the wrong collection it can only touch resources it created. Rendering is off unless -render-url is given. The calendar data model now lives in one place, internal/calendar: the Event, the iCal parse it comes from and the render it goes to, the fact key/value encoding, and the source constants that say which calendars may be written to. It was a parse inlined in cmd/mavcaldav and a Sprintf in two files; #126 and #128 both need to agree with it. Fixes a latent day-boundary bug moved out of that inline parse: it took the day number off a local clock reading but built the window boundaries in UTC, so on a box east of Greenwich part of the evening fell outside "today" and the poller saw an empty calendar after 20:00 UTC. Today is now the owner's day in the owner's location, which is what the busy gate and the day plan mean.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user