diff --git a/cmd/mavcaldav/main.go b/cmd/mavcaldav/main.go index f0fb09f..1417ed0 100644 --- a/cmd/mavcaldav/main.go +++ b/cmd/mavcaldav/main.go @@ -1,17 +1,24 @@ -// mavcaldav — the CalDAV poller module. +// mavcaldav — the CalDAV module: reads calendars into facts, and renders +// maven's own reminders back out to a calendar she owns. // -// 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. +// 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: // -// 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. +// 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 ( @@ -27,6 +34,7 @@ import ( "syscall" "time" + "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" ) @@ -43,6 +51,10 @@ func run(args []string) error { 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 { @@ -54,6 +66,9 @@ func run(args []string) error { 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() @@ -64,16 +79,36 @@ func run(args []string) error { } defer core.Close() + hc := &http.Client{Timeout: *timeout} p := &poller{ core: core, - http: &http.Client{Timeout: *timeout}, + 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) - p.pollOnce(ctx) // fire immediately + tick := func() { + p.pollOnce(ctx) + if rend != nil { + rend.renderOnce(ctx) + } + } + tick() // fire immediately t := time.NewTicker(*interval) defer t.Stop() for { @@ -82,11 +117,30 @@ func run(args []string) error { log.Printf("mavcaldav: bye") return nil case <-t.C: - p.pollOnce(ctx) + 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 @@ -95,12 +149,6 @@ type poller struct { 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) @@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) { return } - busy := false - for _, e := range events { - if !now.Before(e.start) && now.Before(e.end) { - busy = true - break - } - } busyVal := "false" - if busy { + if calendar.Busy(events, now) { busyVal = "true" } // Write calendar_busy on change. - if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil { + 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 event summary + start). + // 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 { - 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) + 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) ([]icalEvent, error) { +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 @@ -162,120 +202,11 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e 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() + 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) error { +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: @@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts Key: key, Value: val, Source: source, - Confidence: 1.0, + Confidence: confidence, }) if err != nil { return fmt.Errorf("write %s: %w", key, err) diff --git a/cmd/mavcaldav/main_test.go b/cmd/mavcaldav/main_test.go index f5210fb..30c46dc 100644 --- a/cmd/mavcaldav/main_test.go +++ b/cmd/mavcaldav/main_test.go @@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er return int64(len(f.writeLog)), nil } -// --------------------------------------------------------------------------- -// Parsing tests -// --------------------------------------------------------------------------- - -func TestParseICal(t *testing.T) { - now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) - - body := []byte(`BEGIN:VCALENDAR -BEGIN:VEVENT -DTSTART:20260703T090000Z -DTEND:20260703T100000Z -SUMMARY:Morning standup -END:VEVENT -BEGIN:VEVENT -DTSTART:20260703T140000Z -DTEND:20260703T150000Z -SUMMARY:Team sync -END:VEVENT -BEGIN:VEVENT -DTSTART:20260702T140000Z -DTEND:20260702T150000Z -SUMMARY:Yesterday retro -END:VEVENT -BEGIN:VEVENT -DTSTART:20260704T090000Z -DTEND:20260704T100000Z -SUMMARY:Tomorrow standup -END:VEVENT -BEGIN:VEVENT -DTSTART;VALUE=DATE:20260704 -DTEND;VALUE=DATE:20260705 -SUMMARY:All-day event -END:VEVENT -END:VCALENDAR`) - - events := parseICal(body, now) - - if len(events) != 2 { - t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events)) - } - - // Morning standup — overlaps today. - if events[0].summary != "Morning standup" { - t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup") - } - wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC) - if !events[0].start.Equal(wantStart0) { - t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0) - } - wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC) - if !events[0].end.Equal(wantEnd0) { - t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0) - } - - // Team sync — overlaps today. - if events[1].summary != "Team sync" { - t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync") - } - wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) - if !events[1].start.Equal(wantStart1) { - t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1) - } - wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC) - if !events[1].end.Equal(wantEnd1) { - t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1) - } -} - -func TestParseVEVENT(t *testing.T) { - // Normal event with TZID in DTSTART and UTC DTEND. - block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" - e := parseVEVENT(block) - if e == nil { - t.Fatal("expected non-nil icalEvent") - } - wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) - if !e.start.Equal(wantStart) { - t.Errorf("start = %v, want %v", e.start, wantStart) - } - wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC) - if !e.end.Equal(wantEnd) { - t.Errorf("end = %v, want %v", e.end, wantEnd) - } - if e.summary != "Stand up meeting" { - t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting") - } - - // All-day event (VALUE=DATE) → nil. - allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" - if e2 := parseVEVENT(allDay); e2 != nil { - t.Error("expected nil for all-day event") - } -} - -func TestParseDT(t *testing.T) { - tests := []struct { - name string - line string - want time.Time - wantOK bool - }{ - { - name: "UTC", - line: "DTEND:20260703T100000Z", - want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), - wantOK: true, - }, - { - name: "local time", - line: "DTSTART;TZID=Europe/Moscow:20260703T130000", - want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), - wantOK: true, - }, - { - name: "all-day", - line: "DTSTART;VALUE=DATE:20260703", - want: time.Time{}, - wantOK: false, - }, - { - name: "invalid", - line: "DTSTART:garbage", - want: time.Time{}, - wantOK: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := parseDT(tt.line) - if ok != tt.wantOK { - t.Errorf("ok = %v, want %v", ok, tt.wantOK) - } - if !got.Equal(tt.want) { - t.Errorf("got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestSafeKey(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"Stand up meeting", "Stand-up-meeting"}, - {"Hello_World", "Hello-World"}, - {"special@#$chars!!", "specialchars"}, - {"ALL_CAPS_123", "ALL-CAPS-123"}, - } - - for _, tt := range tests { - got := safeKey(tt.input) - if got != tt.want { - t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - // --------------------------------------------------------------------------- // Core logic tests // --------------------------------------------------------------------------- @@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) { t.Run("no previous fact writes", func(t *testing.T) { fc := &fakeCore{} p := &poller{core: fc} - err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now) + err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) { }, } p := &poller{core: fc} - err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now) + err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) { }, } p := &poller{core: fc} - err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now) + err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) { t.Run("read error other than ErrNoFact returns error", func(t *testing.T) { fc := &fakeCore{readErr: fmt.Errorf("connection refused")} p := &poller{core: fc} - err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now) + err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0) if err == nil { t.Fatal("expected error, got nil") } @@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) { writeErr: fmt.Errorf("disk full"), } p := &poller{core: fc} - err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now) + err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0) if err == nil { t.Fatal("expected error, got nil") } diff --git a/cmd/mavcaldav/render.go b/cmd/mavcaldav/render.go new file mode 100644 index 0000000..ee54e2d --- /dev/null +++ b/cmd/mavcaldav/render.go @@ -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) +} diff --git a/cmd/mavcaldav/render_test.go b/cmd/mavcaldav/render_test.go new file mode 100644 index 0000000..2ba0f07 --- /dev/null +++ b/cmd/mavcaldav/render_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// reminderCore is a fakeCore that also answers ListReminders. +type reminderCore struct { + fakeCore + reminders []ipc.Reminder + listErr error +} + +func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) { + if c.listErr != nil { + return nil, c.listErr + } + return c.reminders, nil +} + +// calSrv records what a CalDAV collection received. +type calSrv struct { + mu sync.Mutex + puts map[string]string + dels []string + status int + *httptest.Server +} + +func newCalSrv() *calSrv { + s := &calSrv{puts: map[string]string{}, status: http.StatusCreated} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + defer s.mu.Unlock() + switch r.Method { + case http.MethodPut: + s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body) + case http.MethodDelete: + s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/")) + } + w.WriteHeader(s.status) + })) + return s +} + +func (s *calSrv) putCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.puts) +} + +func TestRenderOncePublishesPendingReminders(t *testing.T) { + fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC) + srv := newCalSrv() + defer srv.Close() + + core := &reminderCore{reminders: []ipc.Reminder{ + {ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"}, + {ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"}, + {ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"}, + }} + r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0) + r.renderOnce(context.Background()) + + srv.mu.Lock() + body, ok := srv.puts["maven-reminder-7.ics"] + n := len(srv.puts) + srv.mu.Unlock() + + if n != 1 { + t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n) + } + if !ok { + t.Fatal("pending reminder 7 was not published") + } + if !strings.Contains(body, "SUMMARY:позвонить маме") { + t.Errorf("payload missing from rendered body:\n%s", body) + } + if !strings.Contains(body, "UID:maven-reminder-7") { + t.Errorf("UID missing from rendered body:\n%s", body) + } +} + +func TestRenderOnceSkipsUnchanged(t *testing.T) { + srv := newCalSrv() + defer srv.Close() + core := &reminderCore{reminders: []ipc.Reminder{ + {ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"}, + }} + r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0) + r.renderOnce(context.Background()) + r.renderOnce(context.Background()) + if got := srv.putCount(); got != 1 { + t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got) + } +} + +func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) { + srv := newCalSrv() + defer srv.Close() + core := &reminderCore{reminders: []ipc.Reminder{ + {ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"}, + }} + r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0) + r.renderOnce(context.Background()) + + core.reminders[0].Status = "fired" + r.renderOnce(context.Background()) + + srv.mu.Lock() + dels := append([]string(nil), srv.dels...) + srv.mu.Unlock() + if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" { + t.Fatalf("resolved reminder was not withdrawn: %v", dels) + } + if len(r.published) != 0 { + t.Errorf("published map still holds %v", r.published) + } +} + +// A calendar maven cannot reach must never break anything: sqlite is canonical. +func TestRenderOnceSurvivesServerErrors(t *testing.T) { + srv := newCalSrv() + srv.status = http.StatusInternalServerError + defer srv.Close() + core := &reminderCore{reminders: []ipc.Reminder{ + {ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"}, + }} + r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0) + r.renderOnce(context.Background()) + if len(r.published) != 0 { + t.Error("a failed PUT must not be recorded as published, or it never retries") + } +} + +func TestRenderOnceUsesNextFireForRecurring(t *testing.T) { + srv := newCalSrv() + defer srv.Close() + next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC) + core := &reminderCore{reminders: []ipc.Reminder{{ + ID: 3, + FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), + NextFireTs: next, + Payload: "зарядка", + Status: "pending", + Cron: "0 7 * * *", + }}} + r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0) + r.renderOnce(context.Background()) + + srv.mu.Lock() + body := srv.puts["maven-reminder-3.ics"] + srv.mu.Unlock() + if !strings.Contains(body, "DTSTART:20260802T070000Z") { + t.Errorf("recurring reminder should render its next occurrence:\n%s", body) + } +} + +func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) { + read := "http://localhost:5232/kami/personal" + if err := checkRenderTarget(read, ""); err != nil { + t.Fatalf("rendering off must be fine: %v", err) + } + if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil { + t.Fatalf("a distinct collection must be accepted: %v", err) + } + if err := checkRenderTarget(read, read); err == nil { + t.Error("rendering into the read calendar must be refused") + } + if err := checkRenderTarget(read, read+"/"); err == nil { + t.Error("a trailing slash must not defeat the check") + } + if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil { + t.Error("case must not defeat the check") + } +} diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go new file mode 100644 index 0000000..01a683d --- /dev/null +++ b/internal/calendar/calendar.go @@ -0,0 +1,129 @@ +// Package calendar is the one calendar data model the rest of maven shares: +// an Event, the iCal text it is parsed from and rendered to, and the fact +// encoding that puts it in the store. +// +// It exists because three separate features read or write the same events and +// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja +// #126/#127), the write-only render target that publishes maven's own +// reminders as a calendar (#127), and the day plan that recites them (#128). +// Before this package the parse lived inline in cmd/mavcaldav and the fact key +// format was a Sprintf in two places. +// +// The package is pure: no HTTP, no store, no clock of its own. Callers own the +// impurity, the way internal/morning and internal/loop do. +package calendar + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Fact sources. A calendar event reaches the store as a +// `facts (kind=env, key=calendar_event_..., source=)` row, and +// the source is the whole provenance story: +// +// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical +// state stays in sqlite; the calendar is a render target (#127). +// - SourceWork — a work calendar, read-only by definition (#126). Nothing in +// maven ever writes to it: no code path pairs this source with a PUT. +// - SourceAmbient — inferred from an Android notification-listener relay +// rather than read from a server (#126). Confidence is below 1.0 because a +// notification is a signal about an event, not the event. +const ( + SourcePersonal = "poll:caldav" + SourceWork = "poll:caldav:work" + SourceAmbient = "ambient:notif" +) + +// AmbientConfidence — the confidence a notification-derived event is stored +// with. A parsed notification line is evidence, not a reading of the calendar, +// so it must never be indistinguishable from one (#126). +const AmbientConfidence = 0.6 + +// Sources lists every source a calendar event may legitimately carry, for the +// store query that reads the calendar back out. Ordered from most to least +// trusted. +func Sources() []string { + return []string{SourcePersonal, SourceWork, SourceAmbient} +} + +// ReadOnlySource reports whether events from this source may never be written +// back. The work calendar is read-only by definition — see #126: maven holding +// a credential that can write to an employer's calendar is the thing the task +// exists to avoid. +func ReadOnlySource(source string) bool { + return source == SourceWork || source == SourceAmbient +} + +// Event — one calendar entry. UID is the iCal UID when the event was parsed +// from a server and the identity maven renders under when it publishes one; +// Start/End are instants. All-day events are not modelled: the busy gate and +// the day plan both need a time of day, and an all-day marker answers neither. +type Event struct { + UID string + Summary string + Start time.Time + End time.Time +} + +// FactKey is the store key for an event: one key per day per summary, stable +// across polls so re-reading an unchanged calendar rewrites nothing. +// +// The date prefix is load-bearing — store.CalendarEvents selects a day range +// by key prefix, not by a timestamp column. +func FactKey(e Event) string { + return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary)) +} + +// FactValue is the human-readable rendering stored as the fact value, and the +// string the day plan and the query path read back. +func FactValue(e Event) string { + return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04")) +} + +// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store +// range-scans between two of these. +func KeyPrefixForDay(day time.Time) string { + return fmt.Sprintf("calendar_event_%s", day.Format("20060102")) +} + +// Busy reports whether any event covers the instant now — the read the loop +// gate uses to suppress nudges during a meeting. +func Busy(events []Event, now time.Time) bool { + for _, e := range events { + if !now.Before(e.Start) && now.Before(e.End) { + return true + } + } + return false +} + +// Overlapping returns the events intersecting [from, to), sorted by start. +func Overlapping(events []Event, from, to time.Time) []Event { + var out []Event + for _, e := range events { + if e.End.After(from) && e.Start.Before(to) { + out = append(out, e) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) }) + return out +} + +// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics +// and dashes). Non-Latin summaries collapse to their punctuation, which is why +// the day prefix carries the identity and this only disambiguates within a day. +func safeKey(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-': + b.WriteRune(r) + case r == ' ' || r == '_': + b.WriteRune('-') + } + } + return b.String() +} diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go new file mode 100644 index 0000000..31d3047 --- /dev/null +++ b/internal/calendar/calendar_test.go @@ -0,0 +1,202 @@ +package calendar + +import ( + "strings" + "testing" + "time" +) + +func TestParseICalDayKeepsOnlyToday(t *testing.T) { + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + + body := []byte(`BEGIN:VCALENDAR +BEGIN:VEVENT +UID:a@example +DTSTART:20260703T090000Z +DTEND:20260703T100000Z +SUMMARY:Morning standup +END:VEVENT +BEGIN:VEVENT +DTSTART:20260703T140000Z +DTEND:20260703T150000Z +SUMMARY:Team sync +END:VEVENT +BEGIN:VEVENT +DTSTART:20260702T140000Z +DTEND:20260702T150000Z +SUMMARY:Yesterday retro +END:VEVENT +BEGIN:VEVENT +DTSTART:20260704T090000Z +DTEND:20260704T100000Z +SUMMARY:Tomorrow standup +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20260704 +DTEND;VALUE=DATE:20260705 +SUMMARY:All-day event +END:VEVENT +END:VCALENDAR`) + + events := ParseICalDay(body, now) + if len(events) != 2 { + t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events)) + } + if events[0].Summary != "Morning standup" || events[0].UID != "a@example" { + t.Errorf("events[0] = %+v", events[0]) + } + if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) { + t.Errorf("events[0].Start = %v", events[0].Start) + } + if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) { + t.Errorf("events[0].End = %v", events[0].End) + } + if events[1].Summary != "Team sync" { + t.Errorf("events[1].Summary = %q", events[1].Summary) + } +} + +// Regression: "today" is the owner's day, in the owner's location. Taking the +// day number off a local clock but building the boundaries in UTC made the +// evening fall outside the window on any box east of Greenwich. +func TestParseICalDayUsesOwnersDay(t *testing.T) { + plus4 := time.FixedZone("+04", 4*60*60) + // 01:00 on Aug 1 local is 21:00 on Jul 31 UTC. + now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4) + body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" + + "END:VEVENT\nEND:VCALENDAR") + + events := ParseICalDay(body, now) + if len(events) != 1 { + t.Fatalf("got %d events, want the in-progress one", len(events)) + } + if !Busy(events, now.UTC()) { + t.Error("an event in progress right now must read as busy") + } +} + +func TestParseVEVENT(t *testing.T) { + block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" + e, ok := parseVEVENT(block) + if !ok { + t.Fatal("expected a parsed event") + } + if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) { + t.Errorf("start = %v", e.Start) + } + if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) { + t.Errorf("end = %v", e.End) + } + if e.Summary != "Stand up meeting" { + t.Errorf("summary = %q", e.Summary) + } + + allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" + if _, ok := parseVEVENT(allDay); ok { + t.Error("all-day event should be rejected") + } +} + +func TestParseDT(t *testing.T) { + tests := []struct { + name string + line string + want time.Time + wantOK bool + }{ + {"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true}, + {"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false}, + {"garbage", "DTSTART:garbage", time.Time{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseDT(tt.line) + if ok != tt.wantOK { + t.Errorf("ok = %v, want %v", ok, tt.wantOK) + } + if !got.Equal(tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestSafeKey(t *testing.T) { + tests := []struct{ in, want string }{ + {"Stand up meeting", "Stand-up-meeting"}, + {"Hello_World", "Hello-World"}, + {"special@#$chars!!", "specialchars"}, + {"ALL_CAPS_123", "ALL-CAPS-123"}, + } + for _, tt := range tests { + if got := safeKey(tt.in); got != tt.want { + t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestFactKeyAndValue(t *testing.T) { + e := Event{ + Summary: "Team sync", + Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC), + End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC), + } + if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want { + t.Errorf("FactKey = %q, want %q", got, want) + } + if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want { + t.Errorf("FactValue = %q, want %q", got, want) + } + if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want { + t.Errorf("KeyPrefixForDay = %q, want %q", got, want) + } + if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) { + t.Error("FactKey must start with the day prefix the store range-scans on") + } +} + +func TestBusyAndOverlapping(t *testing.T) { + base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) + events := []Event{ + {Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)}, + {Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)}, + } + if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) { + t.Error("should be busy inside the early event") + } + if Busy(events, base.Add(12*time.Hour)) { + t.Error("should be free at noon") + } + // Half-open: the end instant is free. + if Busy(events, base.Add(10*time.Hour)) { + t.Error("the end instant should not count as busy") + } + got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour)) + if len(got) != 1 || got[0].Summary != "early" { + t.Fatalf("Overlapping = %+v", got) + } + all := Overlapping(events, base, base.AddDate(0, 0, 1)) + if len(all) != 2 || all[0].Summary != "early" { + t.Fatalf("Overlapping must sort by start: %+v", all) + } +} + +func TestSourceTrust(t *testing.T) { + if ReadOnlySource(SourcePersonal) { + t.Error("the personal calendar is the one maven may render to") + } + if !ReadOnlySource(SourceWork) { + t.Error("the work calendar must be read-only") + } + if !ReadOnlySource(SourceAmbient) { + t.Error("an ambient notification is not a writable calendar") + } + if AmbientConfidence >= 1.0 { + t.Error("ambient events must be less trusted than a calendar read") + } + if len(Sources()) != 3 { + t.Errorf("Sources() = %v", Sources()) + } +} diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go new file mode 100644 index 0000000..c7617f7 --- /dev/null +++ b/internal/calendar/ical.go @@ -0,0 +1,145 @@ +package calendar + +import ( + "fmt" + "strings" + "time" +) + +// ParseICal scans iCal text for VEVENT components and returns the events +// overlapping [from, to). All-day events are skipped: parseDT reports no time +// for a VALUE=DATE value, and an event with no clock reading answers neither +// the busy gate nor the day plan. +func ParseICal(body []byte, from, to time.Time) []Event { + var events []Event + text := string(body) + for { + i := strings.Index(text, "BEGIN:VEVENT") + if i < 0 { + break + } + text = text[i+len("BEGIN:VEVENT"):] + j := strings.Index(text, "END:VEVENT") + if j < 0 { + break + } + block := text[:j] + text = text[j+len("END:VEVENT"):] + + e, ok := parseVEVENT(block) + if !ok { + continue + } + if e.End.After(from) && e.Start.Before(to) { + events = append(events, e) + } + } + return events +} + +// ParseICalDay is ParseICal over the calendar day containing now, in now's own +// location — the window cmd/mavcaldav polls. +// +// The location matters. The old inline version took the day number off a local +// clock reading but built the boundaries in UTC, so east of Greenwich the +// window was shifted by the offset and part of the evening fell outside +// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The +// owner's day is the day the day plan and the busy gate mean. +func ParseICalDay(body []byte, now time.Time) []Event { + y, m, d := now.Date() + start := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) + return ParseICal(body, start, start.AddDate(0, 0, 1)) +} + +// parseVEVENT extracts UID, start, end and summary from a VEVENT block. +// Reports false for all-day events and parse failures. +func parseVEVENT(block string) (Event, bool) { + var e Event + for _, line := range strings.Split(block, "\n") { + 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"): + e.Summary = afterColon(line) + case strings.HasPrefix(line, "UID"): + e.UID = afterColon(line) + } + } + if e.Start.IsZero() || e.End.IsZero() { + return Event{}, false + } + return e, true +} + +func afterColon(line string) string { + if i := strings.Index(line, ":"); i >= 0 { + return strings.TrimSpace(line[i+1:]) + } + return "" +} + +// parseDT parses a DTSTART/DTEND value: +// +// - UTC: DTEND:20260703T100000Z +// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000 +// - All-day: DTSTART;VALUE=DATE:20260703 (rejected) +// +// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the +// CalDAV server and the poller run in the same timezone, and the busy gate only +// needs busy/not-busy to be right. +func parseDT(line string) (time.Time, bool) { + if strings.Contains(line, "VALUE=DATE:") { + return time.Time{}, false + } + i := strings.LastIndex(line, ":") + if i < 0 { + return time.Time{}, false + } + val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z") + t, err := time.Parse("20060102T150405", val) + if err != nil { + return time.Time{}, false + } + return t.UTC(), true +} + +// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV +// collection. One event per file is the CalDAV convention, so callers normally +// pass a single event. +// +// This is the write half of #127 and it only ever renders: the canonical state +// is sqlite, the calendar is a view of it. Nothing reads a rendered file back. +func RenderICal(events []Event) string { + var b strings.Builder + b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n") + for _, e := range events { + b.WriteString("BEGIN:VEVENT\r\n") + fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID)) + fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z")) + fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z")) + fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z")) + fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary)) + b.WriteString("END:VEVENT\r\n") + } + b.WriteString("END:VCALENDAR\r\n") + return b.String() +} + +// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that +// would otherwise let a reminder payload inject iCal properties. +func escapeText(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ";", "\\;") + s = strings.ReplaceAll(s, ",", "\\,") + s = strings.ReplaceAll(s, "\r\n", "\\n") + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\n") + return s +} diff --git a/internal/calendar/ical_render_test.go b/internal/calendar/ical_render_test.go new file mode 100644 index 0000000..6ca89de --- /dev/null +++ b/internal/calendar/ical_render_test.go @@ -0,0 +1,69 @@ +package calendar + +import ( + "strings" + "testing" + "time" +) + +func TestRenderICalRoundTrips(t *testing.T) { + e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0) + if e.UID != "maven-reminder-7" { + t.Errorf("UID = %q", e.UID) + } + if got := e.End.Sub(e.Start); got != DefaultReminderDuration { + t.Errorf("duration = %v, want %v", got, DefaultReminderDuration) + } + if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want { + t.Errorf("ReminderPath = %q, want %q", got, want) + } + + body := RenderICal([]Event{e}) + if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") { + t.Fatalf("not a VCALENDAR body:\n%s", body) + } + + back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour)) + if len(back) != 1 { + t.Fatalf("got %d events back, want 1:\n%s", len(back), body) + } + if back[0].UID != e.UID || back[0].Summary != e.Summary { + t.Errorf("round trip lost identity: %+v", back[0]) + } + if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) { + t.Errorf("round trip lost times: %+v", back[0]) + } +} + +func TestRenderICalIsDeterministic(t *testing.T) { + e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0) + if RenderICal([]Event{e}) != RenderICal([]Event{e}) { + t.Error("the same reminder must render byte-identically, or every poll re-PUTs it") + } +} + +// A reminder payload is owner-supplied text. It must not be able to close the +// VEVENT and inject properties of its own. +func TestRenderICalEscapesInjection(t *testing.T) { + e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), + "обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0) + body := RenderICal([]Event{e}) + // Count line-initial occurrences: the escaped text still contains the + // characters "BEGIN:VEVENT", it just can no longer start a line. + if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 { + t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body) + } + if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 { + t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body) + } + if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) { + t.Errorf("newlines should be escaped, not dropped:\n%s", body) + } +} + +func TestReminderEventEmptyPayload(t *testing.T) { + e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0) + if e.Summary != "напоминание" { + t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary) + } +} diff --git a/internal/calendar/reminder.go b/internal/calendar/reminder.go new file mode 100644 index 0000000..86356f2 --- /dev/null +++ b/internal/calendar/reminder.go @@ -0,0 +1,45 @@ +package calendar + +import ( + "fmt" + "strings" + "time" +) + +// ReminderUIDPrefix namespaces every event maven publishes. Two reasons it is a +// fixed prefix and not a random UUID: the render is idempotent (the same +// reminder always lands on the same UID, so re-rendering overwrites instead of +// duplicating), and everything maven owns in the target collection is +// identifiable at a glance — she never touches a file she did not create. +const ReminderUIDPrefix = "maven-reminder-" + +// DefaultReminderDuration — how long a rendered reminder occupies. A reminder +// is an instant, a calendar entry is a span, so one has to be invented; 30 +// minutes reads as a block in a calendar app without swallowing the afternoon. +const DefaultReminderDuration = 30 * time.Minute + +// ReminderEvent maps a reminder to the event that represents it. id and fire +// come from the store; payload is the RU text as the owner said it, rendered +// verbatim as the summary — the calendar is a view of sqlite, not a place to +// rephrase. +func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) Event { + if dur <= 0 { + dur = DefaultReminderDuration + } + summary := strings.TrimSpace(payload) + if summary == "" { + summary = "напоминание" + } + return Event{ + UID: fmt.Sprintf("%s%d", ReminderUIDPrefix, id), + Summary: summary, + Start: fire, + End: fire.Add(dur), + } +} + +// ReminderPath is the collection-relative filename for a rendered reminder. +// One event per resource, per the CalDAV convention. +func ReminderPath(id int64) string { + return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id) +}