Files

151 lines
3.7 KiB
Go
Raw Permalink Normal View History

2026-08-09 18:03:09 +01:00
// 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
}