| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- package caldav
- /*
- ics.go - ICS / iCalendar conversion helpers
- Converts between ArozOS CalendarEvent (JSON) and the iCalendar format
- (RFC 5545) used by CalDAV. Only the subset needed for iOS Calendar
- bidirectional sync is handled.
- */
- import (
- "crypto/md5"
- "encoding/json"
- "fmt"
- "strconv"
- "strings"
- "time"
- )
- // eventToICS serialises a CalendarEvent as a VCALENDAR / VEVENT string.
- func eventToICS(ev CalendarEvent) string {
- var sb strings.Builder
- sb.WriteString("BEGIN:VCALENDAR\r\n")
- sb.WriteString("VERSION:2.0\r\n")
- sb.WriteString("PRODID:-//ArozOS//CalDAV//EN\r\n")
- sb.WriteString("BEGIN:VEVENT\r\n")
- sb.WriteString("UID:" + ev.ID + "@arozos\r\n")
- sb.WriteString("SUMMARY:" + escapeICSText(ev.Title) + "\r\n")
- if ev.AllDay {
- startT := time.UnixMilli(ev.Start).UTC()
- endT := time.UnixMilli(ev.End).UTC()
- sb.WriteString("DTSTART;VALUE=DATE:" + startT.Format("20060102") + "\r\n")
- sb.WriteString("DTEND;VALUE=DATE:" + endT.Format("20060102") + "\r\n")
- } else {
- startT := time.UnixMilli(ev.Start).UTC()
- endT := time.UnixMilli(ev.End).UTC()
- sb.WriteString("DTSTART:" + startT.Format("20060102T150405Z") + "\r\n")
- sb.WriteString("DTEND:" + endT.Format("20060102T150405Z") + "\r\n")
- }
- if ev.Address != "" {
- sb.WriteString("LOCATION:" + escapeICSText(ev.Address) + "\r\n")
- }
- if ev.Notes != "" {
- sb.WriteString("DESCRIPTION:" + escapeICSText(ev.Notes) + "\r\n")
- }
- if ev.Color != "" {
- if hex := arozColorToHex(ev.Color); hex != "" {
- sb.WriteString("X-APPLE-CALENDAR-COLOR:" + hex + "\r\n")
- }
- }
- if rrule := normalizeRRule(ev.RRule); rrule != "" {
- sb.WriteString("RRULE:" + rrule + "\r\n")
- }
- if ev.Reminder != nil {
- trigger := reminderToTrigger(ev.Reminder)
- sb.WriteString("BEGIN:VALARM\r\n")
- sb.WriteString("TRIGGER:" + trigger + "\r\n")
- sb.WriteString("ACTION:DISPLAY\r\n")
- sb.WriteString("DESCRIPTION:Reminder\r\n")
- sb.WriteString("END:VALARM\r\n")
- }
- sb.WriteString("END:VEVENT\r\n")
- sb.WriteString("END:VCALENDAR\r\n")
- return sb.String()
- }
- // icsToEvent parses a VCALENDAR string and returns a CalendarEvent.
- // idHint is used as the event ID when the UID is absent or needs normalising.
- func icsToEvent(icsData string, idHint string) (CalendarEvent, error) {
- lines := unfoldICSLines(icsData)
- ev := CalendarEvent{
- ID: idHint,
- Color: "blue",
- }
- inVEvent := false
- inVAlarm := false
- var alarmTrigger string
- for _, line := range lines {
- switch line {
- case "BEGIN:VEVENT":
- inVEvent = true
- continue
- case "END:VEVENT":
- inVEvent = false
- continue
- case "BEGIN:VALARM":
- inVAlarm = true
- continue
- case "END:VALARM":
- inVAlarm = false
- continue
- }
- if !inVEvent {
- continue
- }
- if inVAlarm {
- if strings.HasPrefix(strings.ToUpper(line), "TRIGGER") {
- _, val := splitICSLine(line)
- alarmTrigger = strings.TrimSpace(val)
- }
- continue
- }
- key, val := splitICSLine(line)
- baseKey := strings.ToUpper(strings.Split(key, ";")[0])
- switch baseKey {
- case "UID":
- uid := unescapeICSText(strings.TrimSpace(val))
- uid = strings.TrimSuffix(uid, "@arozos")
- if uid != "" {
- ev.ID = uid
- }
- case "SUMMARY":
- ev.Title = unescapeICSText(val)
- case "LOCATION":
- ev.Address = unescapeICSText(val)
- case "DESCRIPTION":
- ev.Notes = unescapeICSText(val)
- case "X-APPLE-CALENDAR-COLOR":
- ev.Color = hexToArozColor(strings.TrimSpace(val))
- case "DTSTART":
- t, allDay := parseICSDateTime(key, val)
- ev.Start = t.UnixMilli()
- ev.AllDay = allDay
- case "DTEND":
- t, _ := parseICSDateTime(key, val)
- ev.End = t.UnixMilli()
- case "RRULE":
- ev.RRule = normalizeRRule(strings.TrimSpace(val))
- }
- }
- if alarmTrigger != "" {
- ev.Reminder = triggerToReminder(alarmTrigger)
- }
- return ev, nil
- }
- // unfoldICSLines normalises line endings and joins continuation lines.
- func unfoldICSLines(data string) []string {
- data = strings.ReplaceAll(data, "\r\n", "\n")
- data = strings.ReplaceAll(data, "\r", "\n")
- raw := strings.Split(data, "\n")
- var lines []string
- for _, line := range raw {
- if len(line) == 0 {
- continue
- }
- if (line[0] == ' ' || line[0] == '\t') && len(lines) > 0 {
- lines[len(lines)-1] += line[1:]
- } else {
- lines = append(lines, line)
- }
- }
- return lines
- }
- // splitICSLine splits "KEY;PARAMS:VALUE" at the first colon.
- func splitICSLine(line string) (string, string) {
- idx := strings.Index(line, ":")
- if idx < 0 {
- return line, ""
- }
- return line[:idx], line[idx+1:]
- }
- func parseICSDateTime(key, val string) (time.Time, bool) {
- val = strings.TrimSpace(val)
- keyUpper := strings.ToUpper(key)
- // All-day: VALUE=DATE
- if strings.Contains(keyUpper, "VALUE=DATE") {
- t, err := time.Parse("20060102", val)
- if err != nil {
- return time.Now().UTC(), true
- }
- return t.UTC(), true
- }
- // UTC: trailing Z
- if strings.HasSuffix(val, "Z") {
- t, err := time.Parse("20060102T150405Z", val)
- if err != nil {
- return time.Now().UTC(), false
- }
- return t.UTC(), false
- }
- // TZID-parameterized local time, e.g. DTSTART;TZID=Asia/Tokyo:20260616T110000
- if tzid := extractTZID(key); tzid != "" {
- loc, err := time.LoadLocation(tzid)
- if err == nil {
- t, err := time.ParseInLocation("20060102T150405", val, loc)
- if err == nil {
- return t.UTC(), false
- }
- }
- }
- // Floating local time – treat as UTC
- t, err := time.Parse("20060102T150405", val)
- if err != nil {
- return time.Now().UTC(), false
- }
- return t.UTC(), false
- }
- // extractTZID pulls the TZID value out of an ICS property key such as
- // "DTSTART;TZID=Asia/Tokyo" or "DTEND;VALUE=DATE;TZID=America/New_York".
- func extractTZID(key string) string {
- for _, param := range strings.Split(key, ";") {
- if strings.HasPrefix(strings.ToUpper(param), "TZID=") {
- return param[5:]
- }
- }
- return ""
- }
- // escapeICSText escapes special characters per RFC 5545 §3.3.11.
- func escapeICSText(s string) string {
- s = strings.ReplaceAll(s, "\\", "\\\\")
- s = strings.ReplaceAll(s, ";", "\\;")
- s = strings.ReplaceAll(s, ",", "\\,")
- s = strings.ReplaceAll(s, "\n", "\\n")
- s = strings.ReplaceAll(s, "\r", "")
- return s
- }
- func unescapeICSText(s string) string {
- s = strings.ReplaceAll(s, "\\n", "\n")
- s = strings.ReplaceAll(s, "\\N", "\n")
- s = strings.ReplaceAll(s, "\\;", ";")
- s = strings.ReplaceAll(s, "\\,", ",")
- s = strings.ReplaceAll(s, "\\\\", "\\")
- return s
- }
- // arozColorToHex maps the ArozOS colour names to Apple iCal hex codes.
- func arozColorToHex(color string) string {
- switch color {
- case "blue":
- return "#4A90D9"
- case "red":
- return "#D94040"
- case "orange":
- return "#E87C25"
- case "green":
- return "#56B969"
- case "purple":
- return "#8E6BAD"
- case "teal":
- return "#1BAFD6"
- default:
- return ""
- }
- }
- // hexToArozColor maps a hex colour string back to an ArozOS colour name.
- func hexToArozColor(hex string) string {
- switch strings.ToUpper(hex) {
- case "#4A90D9", "#007AFF", "#0000FF":
- return "blue"
- case "#D94040", "#FF0000", "#FF3B30":
- return "red"
- case "#E87C25", "#FF8C00", "#FF9500":
- return "orange"
- case "#56B969", "#00FF00", "#34C759", "#008000":
- return "green"
- case "#8E6BAD", "#800080", "#AF52DE":
- return "purple"
- case "#1BAFD6", "#008080", "#5AC8FA":
- return "teal"
- default:
- return "blue"
- }
- }
- // reminderToTrigger converts an EventReminder to a VALARM TRIGGER value.
- func reminderToTrigger(r *EventReminder) string {
- if r == nil {
- return "-PT15M"
- }
- switch r.Unit {
- case "hours":
- return fmt.Sprintf("-PT%dH", r.Value)
- case "days":
- return fmt.Sprintf("-P%dD", r.Value)
- default: // "mins"
- return fmt.Sprintf("-PT%dM", r.Value)
- }
- }
- // triggerToReminder parses a VALARM TRIGGER string into an EventReminder.
- // Only the common negative-duration forms used by iOS are handled.
- func triggerToReminder(trigger string) *EventReminder {
- trigger = strings.TrimSpace(trigger)
- if !strings.HasPrefix(trigger, "-P") {
- return nil
- }
- s := trigger[2:] // strip "-P"
- if strings.HasPrefix(s, "T") {
- s = s[1:] // strip "T" time-designator
- }
- if strings.HasSuffix(s, "M") {
- v, err := strconv.Atoi(s[:len(s)-1])
- if err != nil {
- return nil
- }
- return &EventReminder{Value: v, Unit: "mins"}
- }
- if strings.HasSuffix(s, "H") {
- v, err := strconv.Atoi(s[:len(s)-1])
- if err != nil {
- return nil
- }
- return &EventReminder{Value: v, Unit: "hours"}
- }
- if strings.HasSuffix(s, "D") {
- v, err := strconv.Atoi(s[:len(s)-1])
- if err != nil {
- return nil
- }
- return &EventReminder{Value: v, Unit: "days"}
- }
- return nil
- }
- // normalizeRRule sanitises a recurrence rule for safe inclusion in an ICS
- // property line. It strips a redundant leading "RRULE:" prefix, removes any
- // embedded line breaks, and trims surrounding whitespace. The rule is passed
- // through verbatim otherwise so any valid RFC 5545 RRULE survives a round trip.
- func normalizeRRule(rrule string) string {
- rrule = strings.TrimSpace(rrule)
- if rrule == "" {
- return ""
- }
- if strings.HasPrefix(strings.ToUpper(rrule), "RRULE:") {
- rrule = rrule[len("RRULE:"):]
- }
- rrule = strings.ReplaceAll(rrule, "\r", "")
- rrule = strings.ReplaceAll(rrule, "\n", "")
- return strings.TrimSpace(rrule)
- }
- // eventETag returns a quoted MD5 ETag for the given event.
- func eventETag(ev CalendarEvent) string {
- data, _ := json.Marshal(ev)
- h := md5.Sum(data)
- return fmt.Sprintf(`"%x"`, h)
- }
- // collectionCTag returns an unquoted MD5 sync token for the whole collection.
- func collectionCTag(events []CalendarEvent) string {
- data, _ := json.Marshal(events)
- h := md5.Sum(data)
- return fmt.Sprintf("%x", h)
- }
|