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 }