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.
246 lines
7.0 KiB
Go
246 lines
7.0 KiB
Go
// mavcaldav — the CalDAV module: reads calendars into facts, and renders
|
|
// maven's own reminders back out to a calendar she owns.
|
|
//
|
|
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
|
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
|
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
|
// touch the store key, worst case a stale calendar_busy fact until the next
|
|
// poll. Two facts:
|
|
//
|
|
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
|
// nudges during meetings
|
|
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
|
//
|
|
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
|
// latest for that key+source.
|
|
//
|
|
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
|
|
// pending reminder as a single-event iCal resource in a collection maven owns.
|
|
// The calendar is a view, sqlite is the store — see render.go. The render URL
|
|
// must differ from the read URL, checked at startup, so the render target can
|
|
// never be a calendar maven is only supposed to read.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/calendar"
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "mavcaldav:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string) error {
|
|
fs := flag.NewFlagSet("mavcaldav", flag.ContinueOnError)
|
|
socket := fs.String("socket", "", "core IPC socket path (required)")
|
|
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
|
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
|
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
|
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
|
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
|
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
|
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
|
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
|
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *socket == "" {
|
|
return fmt.Errorf("-socket is required")
|
|
}
|
|
if *url == "" || *user == "" || *pass == "" {
|
|
return fmt.Errorf("-url, -user, -pass are required")
|
|
}
|
|
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
core, err := ipc.DialWait(*socket, 60*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer core.Close()
|
|
|
|
hc := &http.Client{Timeout: *timeout}
|
|
p := &poller{
|
|
core: core,
|
|
http: hc,
|
|
url: strings.TrimRight(*url, "/"),
|
|
user: *user,
|
|
pass: *pass,
|
|
}
|
|
|
|
var rend *renderer
|
|
if *renderURL != "" {
|
|
ru, rp := *renderUser, *renderPass
|
|
if ru == "" {
|
|
ru = *user
|
|
}
|
|
if rp == "" {
|
|
rp = *pass
|
|
}
|
|
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
|
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
|
}
|
|
|
|
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
|
tick := func() {
|
|
p.pollOnce(ctx)
|
|
if rend != nil {
|
|
rend.renderOnce(ctx)
|
|
}
|
|
}
|
|
tick() // fire immediately
|
|
t := time.NewTicker(*interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("mavcaldav: bye")
|
|
return nil
|
|
case <-t.C:
|
|
tick()
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
|
// structural half of #127's "cannot write to your work calendar": the write
|
|
// credential and the write URL are separate flags, and the one calendar maven
|
|
// is known to only read is rejected as a target at startup rather than trusted
|
|
// at runtime.
|
|
func checkRenderTarget(readURL, renderURL string) error {
|
|
if renderURL == "" {
|
|
return nil
|
|
}
|
|
if sameCollection(readURL, renderURL) {
|
|
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameCollection(a, b string) bool {
|
|
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
|
}
|
|
|
|
type poller struct {
|
|
core ipc.CoreAPI
|
|
http *http.Client
|
|
url string
|
|
user string
|
|
pass string
|
|
}
|
|
|
|
func (p *poller) pollOnce(ctx context.Context) {
|
|
now := time.Now()
|
|
events, err := p.fetchEvents(ctx, now)
|
|
if err != nil {
|
|
log.Printf("mavcaldav: fetch: %v", err)
|
|
return
|
|
}
|
|
|
|
busyVal := "false"
|
|
if calendar.Busy(events, now) {
|
|
busyVal = "true"
|
|
}
|
|
|
|
// Write calendar_busy on change.
|
|
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
|
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
|
return
|
|
}
|
|
|
|
// Write per-event facts (one per event, keyed by day + event summary).
|
|
// This lets the note RAG path answer "what's on my calendar" without
|
|
// reaching back to Radicale.
|
|
for _, e := range events {
|
|
key := calendar.FactKey(e)
|
|
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
|
log.Printf("mavcaldav: write %s: %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
|
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.SetBasicAuth(p.user, p.pass)
|
|
req.Header.Set("Accept", "text/calendar")
|
|
|
|
resp, err := p.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
|
}
|
|
|
|
return calendar.ParseICalDay(body, now), nil
|
|
}
|
|
|
|
// writeIfChanged writes a fact only when the value differs from the latest.
|
|
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
|
switch {
|
|
case err == nil && prev.Value == val:
|
|
return nil // unchanged
|
|
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
|
return fmt.Errorf("read %s: %w", key, err)
|
|
}
|
|
_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{
|
|
Ts: ts,
|
|
Kind: "env",
|
|
Key: key,
|
|
Value: val,
|
|
Source: source,
|
|
Confidence: confidence,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("write %s: %w", key, err)
|
|
}
|
|
log.Printf("mavcaldav: %s=%s (%s)", key, val, source)
|
|
return nil
|
|
}
|
|
|
|
// isNoFact unwarps error chains to find ipc.ErrNoFact.
|
|
func isNoFact(err error) bool {
|
|
for e := err; e != nil; {
|
|
if e == ipc.ErrNoFact {
|
|
return true
|
|
}
|
|
u, ok := e.(interface{ Unwrap() error })
|
|
if !ok {
|
|
return false
|
|
}
|
|
e = u.Unwrap()
|
|
}
|
|
return false
|
|
}
|