151 lines
3.7 KiB
Go
151 lines
3.7 KiB
Go
// 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
|
|
}
|