3af290152c
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.
146 lines
4.4 KiB
Go
146 lines
4.4 KiB
Go
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)
|
|
}
|