From f94758966fe60a1cfe8218d66d2d29b8a5d8cda2 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 13:19:47 +0200 Subject: [PATCH] cmd/mavcaldav: new CalDAV poller module Polls Radicale for today's events, writes calendar_busy and calendar_event facts through CoreAPI. Only writes on value change (same append-only discipline as mavpoll). Usage: mavcaldav -socket -url -user -pass

Flags: -interval (default 5m), -timeout (default 10s). Fires immediately on start, then on interval. iCal parser supports UTC and local DTSTART/DTEND, skips all-day events. --- cmd/mavcaldav/main.go | 314 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 cmd/mavcaldav/main.go diff --git a/cmd/mavcaldav/main.go b/cmd/mavcaldav/main.go new file mode 100644 index 0000000..e9d6f05 --- /dev/null +++ b/cmd/mavcaldav/main.go @@ -0,0 +1,314 @@ +// mavcaldav — the CalDAV poller module. +// +// 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 written: +// - calendar_busy ("true"/"false") — read by the loop gate to suppress +// nudges during meetings +// - calendar_event ("

@ -") — per-event for query +// +// Append-only discipline: a fact is written only when its value CHANGED +// vs the latest for that key+source. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "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)") + 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") + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + core, err := ipc.Dial(*socket) + if err != nil { + return err + } + defer core.Close() + + p := &poller{ + core: core, + http: &http.Client{Timeout: *timeout}, + url: strings.TrimRight(*url, "/"), + user: *user, + pass: *pass, + } + + log.Printf("mavcaldav: polling %s every %s", *url, *interval) + p.pollOnce(ctx) // fire immediately + t := time.NewTicker(*interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + log.Printf("mavcaldav: bye") + return nil + case <-t.C: + p.pollOnce(ctx) + } + } +} + +type poller struct { + core ipc.CoreAPI + http *http.Client + url string + user string + pass string +} + +type icalEvent struct { + start time.Time + end time.Time + summary 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 + } + + busy := false + for _, e := range events { + if !now.Before(e.start) && now.Before(e.end) { + busy = true + break + } + } + busyVal := "false" + if busy { + busyVal = "true" + } + + // Write calendar_busy on change. + if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil { + log.Printf("mavcaldav: write calendar_busy: %v", err) + return + } + + // Write per-event facts (one per event, keyed by event summary + start). + // This lets the note RAG path answer "what's on my calendar" without + // reaching back to Radicale. + for _, e := range events { + val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04")) + eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary)) + if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil { + log.Printf("mavcaldav: write %s: %v", eventKey, err) + } + } +} + +// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response. +func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, 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 parseICal(body, now), nil +} + +// parseICal scans iCal text for VEVENT components. Returns events that overlap +// with today (UTC day boundaries) to keep the response manageable. +func parseICal(body []byte, now time.Time) []icalEvent { + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + todayEnd := todayStart.AddDate(0, 0, 1) + + var events []icalEvent + text := string(body) + for { + veventStart := strings.Index(text, "BEGIN:VEVENT") + if veventStart < 0 { + break + } + text = text[veventStart+len("BEGIN:VEVENT"):] + veventEnd := strings.Index(text, "END:VEVENT") + if veventEnd < 0 { + break + } + block := text[:veventEnd] + text = text[veventEnd+len("END:VEVENT"):] + + e := parseVEVENT(block) + if e == nil { + continue + } + // Only keep events overlapping today. + if e.end.After(todayStart) && e.start.Before(todayEnd) { + events = append(events, *e) + } + } + return events +} + +// parseVEVENT extracts start, end, summary from a VEVENT block. +// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...) +// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or +// parse failures. +func parseVEVENT(block string) *icalEvent { + var e icalEvent + lines := strings.Split(block, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "DTSTART"): + if t, ok := parseDT(line); ok { + e.start = t + } + case strings.HasPrefix(line, "DTEND"): + if t, ok := parseDT(line); ok { + e.end = t + } + case strings.HasPrefix(line, "SUMMARY"): + if idx := strings.Index(line, ":"); idx >= 0 { + e.summary = strings.TrimSpace(line[idx+1:]) + } + } + } + if e.start.IsZero() || e.end.IsZero() { + return nil + } + return &e +} + +// parseDT parses a DTSTART/DTEND value. Supports: +// - UTC: DTEND:20260703T100000Z +// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000 +// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time) +func parseDT(line string) (time.Time, bool) { + if strings.Contains(line, "VALUE=DATE:") { + return time.Time{}, false // all-day, skip + } + idx := strings.LastIndex(line, ":") + if idx < 0 { + return time.Time{}, false + } + val := line[idx+1:] + val = strings.TrimSuffix(val, "Z") + + // Try UTC first (has Z suffix, or ended in Z before TrimSuffix). + if strings.HasSuffix(line, "Z") { + t, err := time.Parse("20060102T150405", val) + if err != nil { + return time.Time{}, false + } + return t.UTC(), true + } + + // Local time — treat as UTC for simplicity (CalDAV server and poller + // run in the same timezone; the gate only needs busy/not-busy accuracy). + t, err := time.Parse("20060102T150405", val) + if err != nil { + return time.Time{}, false + } + return t.UTC(), true +} + +// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash). +func safeKey(s string) string { + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else if r == ' ' || r == '_' { + b.WriteRune('-') + } + } + return b.String() +} + +// 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) 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: 1.0, + }) + 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 +}