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>
This commit is contained in:
2026-08-06 03:23:26 +04:00
parent 69270f4cfb
commit 7dba1b7935
3 changed files with 93 additions and 6 deletions
+45 -3
View File
@@ -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:])