Render maven's own reminders to a calendar she owns (#127)

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.
This commit is contained in:
kami
2026-08-01 01:55:51 +04:00
parent dc7c72a3d7
commit 3af290152c
9 changed files with 1001 additions and 308 deletions
+129
View File
@@ -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=<one of these>)` 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()
}
+202
View File
@@ -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())
}
}
+145
View File
@@ -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
}
+69
View File
@@ -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)
}
}
+45
View File
@@ -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)
}