Files
Maven/internal/calendar/ical.go
T
claude 7dba1b7935 calendar: read a wall clock as a wall clock, and unfold iCal (V-581)
FactSpan built both instants by adding a duration to local midnight, so on
the two DST changeover days every span was an hour off. A day is 23 or 25
hours wide there, and the busy gate then read a 14:00 meeting as 13:00 or
15:00. Both readings are time.Date now, and the midnight crossing is AddDate
rather than a 24-hour add.

The iCal parse did not unfold content lines. A server folds a property at 75
octets and a Russian summary is two bytes a letter, so the tail of an
ordinary weekly standup was read as an unknown property and dropped, and the
event was filed under a truncated name. RFC 5545 TEXT escapes are also
reversed now, which RenderICal has always written and the parse never undid.

Two regression tests: a folded and escaped summary, and a span across the
start of DST in Europe/Berlin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:23:26 +04:00

231 lines
7.7 KiB
Go

package calendar
import (
"fmt"
"strings"
"time"
// The TZID of a DTSTART names an IANA zone, and resolving it needs the zone
// database. The deploy image has no system tzdata, so embed it: without it
// every zoned event would silently fall back to the box's own offset, which
// is the bug this package had before.
_ "time/tzdata"
)
// 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.
//
// from's location is the fallback zone for a floating DTSTART — one with
// neither a Z suffix nor a TZID. RFC 5545 says a floating time is local to
// wherever it is read, and here that is the box the poller runs on.
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, from.Location())
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, on both sides of the comparison. An older 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". Building the window locally is only half the
// fix: parseDT used to stamp a zoned DTSTART as UTC, which lost the mirror
// image of the same evening. Both sides are real instants now.
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, loc *time.Location) (Event, bool) {
var e Event
for _, line := range strings.Split(unfold(block), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "DTSTART"):
if t, ok := parseDT(line, loc); ok {
e.Start = t
}
case strings.HasPrefix(line, "DTEND"):
if t, ok := parseDT(line, loc); ok {
e.End = t
}
case strings.HasPrefix(line, "SUMMARY"):
e.Summary = unescapeText(afterColon(line))
case strings.HasPrefix(line, "UID"):
e.UID = unescapeText(afterColon(line))
}
}
if e.Start.IsZero() || e.End.IsZero() {
return Event{}, false
}
return e, true
}
// unfold undoes RFC 5545 content-line folding, where a long property is split
// with a CRLF and the continuation begins with one space or tab.
//
// It runs before the block is split into lines, because splitting first and
// trimming each line destroys the leading space that marks a continuation. A
// server folds at 75 octets and a Russian summary is two bytes a letter, so
// "Еженедельная планёрка с командой" crosses the limit easily — without this
// the tail of the summary was read as an unknown property and dropped, and the
// event was filed under a truncated name.
func unfold(block string) string {
if !strings.Contains(block, "\n ") && !strings.Contains(block, "\n\t") {
return block
}
return strings.NewReplacer("\r\n ", "", "\r\n\t", "", "\n ", "", "\n\t", "").Replace(block)
}
// unescapeText reverses the RFC 5545 TEXT escaping escapeText applies. Without
// it a summary a server wrote as "Обед\, потом созвон" reaches the day plan
// with the backslash still in it, and FactKey folds that literal into the key.
func unescapeText(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 >= len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case 'n', 'N':
b.WriteByte('\n')
default:
// ";", ",", "\\" and anything else a writer escaped needlessly.
b.WriteByte(s[i])
}
}
return b.String()
}
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 into a real instant:
//
// - UTC: DTEND:20260703T100000Z
// - Zoned: DTSTART;TZID=Europe/Moscow:20260703T130000
// - Floating: DTSTART:20260703T130000 (read in loc)
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
//
// A zoned value is resolved against its own TZID, not stamped as UTC. The old
// behaviour was "the server and the poller share a timezone, and the busy gate
// only needs busy/not-busy to be right", and that stopped being enough when the
// day plan started reciting the wall clock: a 13:00 Moscow meeting read as
// 13:00Z was recited at 17:00 on a +04 box, and a 21:00 one fell out of the day
// altogether. An unknown or unloadable TZID falls back to loc, which is the
// closest thing to the reader's own wall clock we have.
func parseDT(line string, loc *time.Location) (time.Time, bool) {
if strings.Contains(line, "VALUE=DATE:") {
return time.Time{}, false
}
i := strings.LastIndex(line, ":")
if i < 0 {
return time.Time{}, false
}
if loc == nil {
loc = time.UTC
}
raw := strings.TrimSpace(line[i+1:])
if strings.HasSuffix(raw, "Z") {
t, err := time.ParseInLocation("20060102T150405", strings.TrimSuffix(raw, "Z"), time.UTC)
if err != nil {
return time.Time{}, false
}
return t, true
}
if tz := tzidOf(line[:i]); tz != "" {
if l, err := time.LoadLocation(tz); err == nil {
loc = l
}
}
t, err := time.ParseInLocation("20060102T150405", raw, loc)
if err != nil {
return time.Time{}, false
}
return t, true
}
// tzidOf pulls the TZID out of a property's parameter list ("DTSTART;TZID=..."
// up to the value colon). The value may be quoted, per RFC 5545 param syntax.
func tzidOf(params string) string {
for _, p := range strings.Split(params, ";")[1:] {
if !strings.HasPrefix(strings.ToUpper(p), "TZID=") {
continue
}
return strings.Trim(strings.TrimSpace(p[len("TZID="):]), `"`)
}
return ""
}
// 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
}