1c94df76b7
Withdrawal read published, which is in-memory, so the second loop only ever withdrew reminders this process had published. Fire a reminder, restart mavcaldav, and its event stayed in the collection forever with nothing left to revisit it. "Losing it costs nothing, the next tick rebuilds it" holds for events that should be there and not for the ones that should not. The first tick now PROPFINDs the collection and reconciles what it finds against what is pending. Only hrefs carrying ReminderUIDPrefix are read back, so the pass can never propose deleting a file maven did not create. A failed read is retried on the next tick rather than skipped for the life of the process. Two smaller things from the same review. checkRenderTarget takes the whole read set, so a second calendar to read cannot quietly fall outside the guarantee the package comment makes. writeIfChanged loses its confidence parameter, which every caller passed 1.0 and nothing read. Found in review of #56.
258 lines
7.5 KiB
Go
258 lines
7.5 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([]string{*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 one of the read URLs.
|
|
// 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 a calendar
|
|
// maven is known to only read is rejected as a target at startup rather than
|
|
// trusted at runtime.
|
|
//
|
|
// It takes the whole read set, not one URL. The guarantee in the package
|
|
// comment is about every calendar maven reads, and a second read target added
|
|
// later must not quietly fall outside the check.
|
|
func checkRenderTarget(readURLs []string, renderURL string) error {
|
|
if renderURL == "" {
|
|
return nil
|
|
}
|
|
for _, read := range readURLs {
|
|
if read == "" {
|
|
continue
|
|
}
|
|
if sameCollection(read, renderURL) {
|
|
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
|
|
}
|
|
}
|
|
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); 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); 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.
|
|
// Everything this poller writes is a calendar read, which is full confidence by
|
|
// definition; a source that is not, such as the notification relay, does not
|
|
// come through here.
|
|
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
|
|
}
|