// Package ical implements a minimal RFC 5545 iCalendar parser/builder — just // VEVENT with the fields CalDAV needs: UID, SUMMARY, DTSTART, DTEND, // DESCRIPTION, LOCATION. Not full RFC 5545 (no VTODO/VJOURNAL/VALARM, no // RRULE recurrence) — enough for real calendar clients to create, fetch, and // list single events, with recurrence and other component types as natural // next additions once client compatibility testing calls for them. package ical import ( "fmt" "strings" "time" ) const icalTimeLayout = "20060102T150405Z" type Event struct { UID string Summary string Description string Location string DTStart time.Time DTEnd time.Time } // Parse reads a VCALENDAR containing one VEVENT. func Parse(data string) (*Event, error) { lines := unfold(data) e := &Event{} inEvent := false for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } upper := strings.ToUpper(line) switch { case upper == "BEGIN:VEVENT": inEvent = true continue case upper == "END:VEVENT": inEvent = false continue } if !inEvent { continue } name, value, found := splitProperty(line) if !found { continue } switch strings.ToUpper(name) { case "UID": e.UID = value case "SUMMARY": e.Summary = unescape(value) case "DESCRIPTION": e.Description = unescape(value) case "LOCATION": e.Location = unescape(value) case "DTSTART": if t, err := time.Parse(icalTimeLayout, value); err == nil { e.DTStart = t } case "DTEND": if t, err := time.Parse(icalTimeLayout, value); err == nil { e.DTEnd = t } } } if e.UID == "" { return nil, fmt.Errorf("ical missing required UID property") } return e, nil } // Build renders an Event back into a full VCALENDAR/VEVENT block, CRLF line // endings per spec. func (e *Event) Build() string { var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\n") b.WriteString("VERSION:2.0\r\n") b.WriteString("PRODID:-//GoMail//CalDAV//EN\r\n") b.WriteString("BEGIN:VEVENT\r\n") b.WriteString("UID:" + e.UID + "\r\n") if !e.DTStart.IsZero() { b.WriteString("DTSTART:" + e.DTStart.UTC().Format(icalTimeLayout) + "\r\n") } if !e.DTEnd.IsZero() { b.WriteString("DTEND:" + e.DTEnd.UTC().Format(icalTimeLayout) + "\r\n") } if e.Summary != "" { b.WriteString("SUMMARY:" + escape(e.Summary) + "\r\n") } if e.Description != "" { b.WriteString("DESCRIPTION:" + escape(e.Description) + "\r\n") } if e.Location != "" { b.WriteString("LOCATION:" + escape(e.Location) + "\r\n") } b.WriteString("END:VEVENT\r\n") b.WriteString("END:VCALENDAR\r\n") return b.String() } func splitProperty(line string) (name, value string, found bool) { colonIdx := strings.Index(line, ":") if colonIdx == -1 { return "", "", false } namePart := line[:colonIdx] value = line[colonIdx+1:] if semiIdx := strings.Index(namePart, ";"); semiIdx != -1 { namePart = namePart[:semiIdx] } return namePart, value, true } // unfold reverses RFC 5545 §3.1 line folding, same rule as vCard's. func unfold(data string) []string { raw := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") var out []string for _, line := range raw { if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 { out[len(out)-1] += line[1:] } else { out = append(out, line) } } return out } func escape(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, ",", "\\,") s = strings.ReplaceAll(s, ";", "\\;") s = strings.ReplaceAll(s, "\n", "\\n") return s } func unescape(s string) string { s = strings.ReplaceAll(s, "\\n", "\n") s = strings.ReplaceAll(s, "\\,", ",") s = strings.ReplaceAll(s, "\\;", ";") s = strings.ReplaceAll(s, "\\\\", "\\") return s }