From 7dba1b7935a185f73df28326e99d6bca41e430a8 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 03:23:26 +0400 Subject: [PATCH] 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 --- internal/calendar/calendar.go | 13 ++++++-- internal/calendar/ical.go | 48 +++++++++++++++++++++++++-- internal/calendar/ical_render_test.go | 38 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 40bf11b..d32a82a 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -140,6 +140,12 @@ const EventKeyPrefix = "calendar_event_" // // An end at or before the start is read as crossing midnight, so a 23:30-00:15 // meeting covers the quarter hour it actually covers. +// +// Both readings are built with time.Date rather than added to midnight as a +// duration. A day is 23 or 25 hours wide on the two DST changeovers, so +// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy +// gate would then read a 14:00 meeting an hour off. The same goes for the +// midnight crossing, which is AddDate and not a 24-hour add. func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) { if !strings.HasPrefix(key, EventKeyPrefix) { return time.Time{}, time.Time{}, false @@ -172,10 +178,11 @@ func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok b if !ok1 || !ok2 { return time.Time{}, time.Time{}, false } - start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute) - end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute) + y, mo, d := day.Date() + start = time.Date(y, mo, d, sh, sm, 0, 0, loc) + end = time.Date(y, mo, d, eh, em, 0, 0, loc) if !end.After(start) { - end = end.Add(24 * time.Hour) + end = end.AddDate(0, 0, 1) } return start, end, true } diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go index 1aa40de..3800d68 100644 --- a/internal/calendar/ical.go +++ b/internal/calendar/ical.go @@ -66,7 +66,7 @@ func ParseICalDay(body []byte, now time.Time) []Event { // 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(block, "\n") { + for _, line := range strings.Split(unfold(block), "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "DTSTART"): @@ -78,9 +78,9 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) { e.End = t } case strings.HasPrefix(line, "SUMMARY"): - e.Summary = afterColon(line) + e.Summary = unescapeText(afterColon(line)) case strings.HasPrefix(line, "UID"): - e.UID = afterColon(line) + e.UID = unescapeText(afterColon(line)) } } if e.Start.IsZero() || e.End.IsZero() { @@ -89,6 +89,48 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) { 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:]) diff --git a/internal/calendar/ical_render_test.go b/internal/calendar/ical_render_test.go index 6ca89de..45c4c46 100644 --- a/internal/calendar/ical_render_test.go +++ b/internal/calendar/ical_render_test.go @@ -61,6 +61,44 @@ func TestRenderICalEscapesInjection(t *testing.T) { } } +// A folded SUMMARY is one property, not a property plus a dropped tail. Servers +// fold at 75 octets and a Russian summary is two bytes a letter. +func TestParseICalUnfoldsAndUnescapes(t *testing.T) { + body := []byte("BEGIN:VEVENT\r\n" + + "UID:u1\r\n" + + "DTSTART:20260703T130000Z\r\n" + + "DTEND:20260703T140000Z\r\n" + + "SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" + + "END:VEVENT\r\n") + from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) + events := ParseICal(body, from, from.AddDate(0, 0, 1)) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want { + t.Errorf("Summary = %q, want %q", events[0].Summary, want) + } +} + +// A day is 23 hours wide where DST starts, so a wall clock reading has to be +// built with time.Date and never as midnight plus a duration. +func TestFactSpanAcrossDSTStart(t *testing.T) { + loc, err := time.LoadLocation("Europe/Berlin") + if err != nil { + t.Skipf("no tzdata for Europe/Berlin: %v", err) + } + start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc) + if !ok { + t.Fatal("FactSpan reported not ok") + } + if start.Hour() != 14 || start.Minute() != 0 { + t.Errorf("start = %s, want a 14:00 wall clock", start) + } + if end.Hour() != 15 { + t.Errorf("end = %s, want a 15:00 wall clock", end) + } +} + func TestReminderEventEmptyPayload(t *testing.T) { e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0) if e.Summary != "напоминание" {