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 }