Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3af290152c |
+75
-144
@@ -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
|
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
// 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
|
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||||
// nudges during meetings
|
// nudges during meetings
|
||||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||||
//
|
//
|
||||||
// Append-only discipline: a fact is written only when its value CHANGED
|
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||||
// vs the latest for that key+source.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,6 +34,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"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)")
|
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||||
pass := fs.String("pass", "", "CalDAV basic-auth password (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")
|
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
|||||||
if *url == "" || *user == "" || *pass == "" {
|
if *url == "" || *user == "" || *pass == "" {
|
||||||
return fmt.Errorf("-url, -user, -pass are required")
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
defer core.Close()
|
defer core.Close()
|
||||||
|
|
||||||
|
hc := &http.Client{Timeout: *timeout}
|
||||||
p := &poller{
|
p := &poller{
|
||||||
core: core,
|
core: core,
|
||||||
http: &http.Client{Timeout: *timeout},
|
http: hc,
|
||||||
url: strings.TrimRight(*url, "/"),
|
url: strings.TrimRight(*url, "/"),
|
||||||
user: *user,
|
user: *user,
|
||||||
pass: *pass,
|
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)
|
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)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -82,11 +117,30 @@ func run(args []string) error {
|
|||||||
log.Printf("mavcaldav: bye")
|
log.Printf("mavcaldav: bye")
|
||||||
return nil
|
return nil
|
||||||
case <-t.C:
|
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 {
|
type poller struct {
|
||||||
core ipc.CoreAPI
|
core ipc.CoreAPI
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -95,12 +149,6 @@ type poller struct {
|
|||||||
pass string
|
pass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type icalEvent struct {
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
summary string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *poller) pollOnce(ctx context.Context) {
|
func (p *poller) pollOnce(ctx context.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
events, err := p.fetchEvents(ctx, now)
|
events, err := p.fetchEvents(ctx, now)
|
||||||
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy := false
|
|
||||||
for _, e := range events {
|
|
||||||
if !now.Before(e.start) && now.Before(e.end) {
|
|
||||||
busy = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
busyVal := "false"
|
busyVal := "false"
|
||||||
if busy {
|
if calendar.Busy(events, now) {
|
||||||
busyVal = "true"
|
busyVal = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write calendar_busy on change.
|
// 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)
|
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||||
return
|
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
|
// This lets the note RAG path answer "what's on my calendar" without
|
||||||
// reaching back to Radicale.
|
// reaching back to Radicale.
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
key := calendar.FactKey(e)
|
||||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
// 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseICal(body, now), nil
|
return calendar.ParseICalDay(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.
|
// 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)
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && prev.Value == val:
|
case err == nil && prev.Value == val:
|
||||||
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
|||||||
Key: key,
|
Key: key,
|
||||||
Value: val,
|
Value: val,
|
||||||
Source: source,
|
Source: source,
|
||||||
Confidence: 1.0,
|
Confidence: confidence,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write %s: %w", key, err)
|
return fmt.Errorf("write %s: %w", key, err)
|
||||||
|
|||||||
+5
-164
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
|||||||
return int64(len(f.writeLog)), nil
|
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
|
// Core logic tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("no previous fact writes", func(t *testing.T) {
|
t.Run("no previous fact writes", func(t *testing.T) {
|
||||||
fc := &fakeCore{}
|
fc := &fakeCore{}
|
||||||
p := &poller{core: fc}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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) {
|
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||||
p := &poller{core: fc}
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
writeErr: fmt.Errorf("disk full"),
|
writeErr: fmt.Errorf("disk full"),
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user