3af290152c
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.
146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
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
|
|
}
|