ics.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. package caldav
  2. /*
  3. ics.go - ICS / iCalendar conversion helpers
  4. Converts between ArozOS CalendarEvent (JSON) and the iCalendar format
  5. (RFC 5545) used by CalDAV. Only the subset needed for iOS Calendar
  6. bidirectional sync is handled.
  7. */
  8. import (
  9. "crypto/md5"
  10. "encoding/json"
  11. "fmt"
  12. "strconv"
  13. "strings"
  14. "time"
  15. )
  16. // eventToICS serialises a CalendarEvent as a VCALENDAR / VEVENT string.
  17. func eventToICS(ev CalendarEvent) string {
  18. var sb strings.Builder
  19. sb.WriteString("BEGIN:VCALENDAR\r\n")
  20. sb.WriteString("VERSION:2.0\r\n")
  21. sb.WriteString("PRODID:-//ArozOS//CalDAV//EN\r\n")
  22. sb.WriteString("BEGIN:VEVENT\r\n")
  23. sb.WriteString("UID:" + ev.ID + "@arozos\r\n")
  24. sb.WriteString("SUMMARY:" + escapeICSText(ev.Title) + "\r\n")
  25. if ev.AllDay {
  26. startT := time.UnixMilli(ev.Start).UTC()
  27. endT := time.UnixMilli(ev.End).UTC()
  28. sb.WriteString("DTSTART;VALUE=DATE:" + startT.Format("20060102") + "\r\n")
  29. sb.WriteString("DTEND;VALUE=DATE:" + endT.Format("20060102") + "\r\n")
  30. } else {
  31. startT := time.UnixMilli(ev.Start).UTC()
  32. endT := time.UnixMilli(ev.End).UTC()
  33. sb.WriteString("DTSTART:" + startT.Format("20060102T150405Z") + "\r\n")
  34. sb.WriteString("DTEND:" + endT.Format("20060102T150405Z") + "\r\n")
  35. }
  36. if ev.Address != "" {
  37. sb.WriteString("LOCATION:" + escapeICSText(ev.Address) + "\r\n")
  38. }
  39. if ev.Notes != "" {
  40. sb.WriteString("DESCRIPTION:" + escapeICSText(ev.Notes) + "\r\n")
  41. }
  42. if ev.Color != "" {
  43. if hex := arozColorToHex(ev.Color); hex != "" {
  44. sb.WriteString("X-APPLE-CALENDAR-COLOR:" + hex + "\r\n")
  45. }
  46. }
  47. if rrule := normalizeRRule(ev.RRule); rrule != "" {
  48. sb.WriteString("RRULE:" + rrule + "\r\n")
  49. }
  50. if ev.Reminder != nil {
  51. trigger := reminderToTrigger(ev.Reminder)
  52. sb.WriteString("BEGIN:VALARM\r\n")
  53. sb.WriteString("TRIGGER:" + trigger + "\r\n")
  54. sb.WriteString("ACTION:DISPLAY\r\n")
  55. sb.WriteString("DESCRIPTION:Reminder\r\n")
  56. sb.WriteString("END:VALARM\r\n")
  57. }
  58. sb.WriteString("END:VEVENT\r\n")
  59. sb.WriteString("END:VCALENDAR\r\n")
  60. return sb.String()
  61. }
  62. // icsToEvent parses a VCALENDAR string and returns a CalendarEvent.
  63. // idHint is used as the event ID when the UID is absent or needs normalising.
  64. func icsToEvent(icsData string, idHint string) (CalendarEvent, error) {
  65. lines := unfoldICSLines(icsData)
  66. ev := CalendarEvent{
  67. ID: idHint,
  68. Color: "blue",
  69. }
  70. inVEvent := false
  71. inVAlarm := false
  72. var alarmTrigger string
  73. for _, line := range lines {
  74. switch line {
  75. case "BEGIN:VEVENT":
  76. inVEvent = true
  77. continue
  78. case "END:VEVENT":
  79. inVEvent = false
  80. continue
  81. case "BEGIN:VALARM":
  82. inVAlarm = true
  83. continue
  84. case "END:VALARM":
  85. inVAlarm = false
  86. continue
  87. }
  88. if !inVEvent {
  89. continue
  90. }
  91. if inVAlarm {
  92. if strings.HasPrefix(strings.ToUpper(line), "TRIGGER") {
  93. _, val := splitICSLine(line)
  94. alarmTrigger = strings.TrimSpace(val)
  95. }
  96. continue
  97. }
  98. key, val := splitICSLine(line)
  99. baseKey := strings.ToUpper(strings.Split(key, ";")[0])
  100. switch baseKey {
  101. case "UID":
  102. uid := unescapeICSText(strings.TrimSpace(val))
  103. uid = strings.TrimSuffix(uid, "@arozos")
  104. if uid != "" {
  105. ev.ID = uid
  106. }
  107. case "SUMMARY":
  108. ev.Title = unescapeICSText(val)
  109. case "LOCATION":
  110. ev.Address = unescapeICSText(val)
  111. case "DESCRIPTION":
  112. ev.Notes = unescapeICSText(val)
  113. case "X-APPLE-CALENDAR-COLOR":
  114. ev.Color = hexToArozColor(strings.TrimSpace(val))
  115. case "DTSTART":
  116. t, allDay := parseICSDateTime(key, val)
  117. ev.Start = t.UnixMilli()
  118. ev.AllDay = allDay
  119. case "DTEND":
  120. t, _ := parseICSDateTime(key, val)
  121. ev.End = t.UnixMilli()
  122. case "RRULE":
  123. ev.RRule = normalizeRRule(strings.TrimSpace(val))
  124. }
  125. }
  126. if alarmTrigger != "" {
  127. ev.Reminder = triggerToReminder(alarmTrigger)
  128. }
  129. return ev, nil
  130. }
  131. // unfoldICSLines normalises line endings and joins continuation lines.
  132. func unfoldICSLines(data string) []string {
  133. data = strings.ReplaceAll(data, "\r\n", "\n")
  134. data = strings.ReplaceAll(data, "\r", "\n")
  135. raw := strings.Split(data, "\n")
  136. var lines []string
  137. for _, line := range raw {
  138. if len(line) == 0 {
  139. continue
  140. }
  141. if (line[0] == ' ' || line[0] == '\t') && len(lines) > 0 {
  142. lines[len(lines)-1] += line[1:]
  143. } else {
  144. lines = append(lines, line)
  145. }
  146. }
  147. return lines
  148. }
  149. // splitICSLine splits "KEY;PARAMS:VALUE" at the first colon.
  150. func splitICSLine(line string) (string, string) {
  151. idx := strings.Index(line, ":")
  152. if idx < 0 {
  153. return line, ""
  154. }
  155. return line[:idx], line[idx+1:]
  156. }
  157. func parseICSDateTime(key, val string) (time.Time, bool) {
  158. val = strings.TrimSpace(val)
  159. keyUpper := strings.ToUpper(key)
  160. // All-day: VALUE=DATE
  161. if strings.Contains(keyUpper, "VALUE=DATE") {
  162. t, err := time.Parse("20060102", val)
  163. if err != nil {
  164. return time.Now().UTC(), true
  165. }
  166. return t.UTC(), true
  167. }
  168. // UTC: trailing Z
  169. if strings.HasSuffix(val, "Z") {
  170. t, err := time.Parse("20060102T150405Z", val)
  171. if err != nil {
  172. return time.Now().UTC(), false
  173. }
  174. return t.UTC(), false
  175. }
  176. // TZID-parameterized local time, e.g. DTSTART;TZID=Asia/Tokyo:20260616T110000
  177. if tzid := extractTZID(key); tzid != "" {
  178. loc, err := time.LoadLocation(tzid)
  179. if err == nil {
  180. t, err := time.ParseInLocation("20060102T150405", val, loc)
  181. if err == nil {
  182. return t.UTC(), false
  183. }
  184. }
  185. }
  186. // Floating local time – treat as UTC
  187. t, err := time.Parse("20060102T150405", val)
  188. if err != nil {
  189. return time.Now().UTC(), false
  190. }
  191. return t.UTC(), false
  192. }
  193. // extractTZID pulls the TZID value out of an ICS property key such as
  194. // "DTSTART;TZID=Asia/Tokyo" or "DTEND;VALUE=DATE;TZID=America/New_York".
  195. func extractTZID(key string) string {
  196. for _, param := range strings.Split(key, ";") {
  197. if strings.HasPrefix(strings.ToUpper(param), "TZID=") {
  198. return param[5:]
  199. }
  200. }
  201. return ""
  202. }
  203. // escapeICSText escapes special characters per RFC 5545 §3.3.11.
  204. func escapeICSText(s string) string {
  205. s = strings.ReplaceAll(s, "\\", "\\\\")
  206. s = strings.ReplaceAll(s, ";", "\\;")
  207. s = strings.ReplaceAll(s, ",", "\\,")
  208. s = strings.ReplaceAll(s, "\n", "\\n")
  209. s = strings.ReplaceAll(s, "\r", "")
  210. return s
  211. }
  212. func unescapeICSText(s string) string {
  213. s = strings.ReplaceAll(s, "\\n", "\n")
  214. s = strings.ReplaceAll(s, "\\N", "\n")
  215. s = strings.ReplaceAll(s, "\\;", ";")
  216. s = strings.ReplaceAll(s, "\\,", ",")
  217. s = strings.ReplaceAll(s, "\\\\", "\\")
  218. return s
  219. }
  220. // arozColorToHex maps the ArozOS colour names to Apple iCal hex codes.
  221. func arozColorToHex(color string) string {
  222. switch color {
  223. case "blue":
  224. return "#4A90D9"
  225. case "red":
  226. return "#D94040"
  227. case "orange":
  228. return "#E87C25"
  229. case "green":
  230. return "#56B969"
  231. case "purple":
  232. return "#8E6BAD"
  233. case "teal":
  234. return "#1BAFD6"
  235. default:
  236. return ""
  237. }
  238. }
  239. // hexToArozColor maps a hex colour string back to an ArozOS colour name.
  240. func hexToArozColor(hex string) string {
  241. switch strings.ToUpper(hex) {
  242. case "#4A90D9", "#007AFF", "#0000FF":
  243. return "blue"
  244. case "#D94040", "#FF0000", "#FF3B30":
  245. return "red"
  246. case "#E87C25", "#FF8C00", "#FF9500":
  247. return "orange"
  248. case "#56B969", "#00FF00", "#34C759", "#008000":
  249. return "green"
  250. case "#8E6BAD", "#800080", "#AF52DE":
  251. return "purple"
  252. case "#1BAFD6", "#008080", "#5AC8FA":
  253. return "teal"
  254. default:
  255. return "blue"
  256. }
  257. }
  258. // reminderToTrigger converts an EventReminder to a VALARM TRIGGER value.
  259. func reminderToTrigger(r *EventReminder) string {
  260. if r == nil {
  261. return "-PT15M"
  262. }
  263. switch r.Unit {
  264. case "hours":
  265. return fmt.Sprintf("-PT%dH", r.Value)
  266. case "days":
  267. return fmt.Sprintf("-P%dD", r.Value)
  268. default: // "mins"
  269. return fmt.Sprintf("-PT%dM", r.Value)
  270. }
  271. }
  272. // triggerToReminder parses a VALARM TRIGGER string into an EventReminder.
  273. // Only the common negative-duration forms used by iOS are handled.
  274. func triggerToReminder(trigger string) *EventReminder {
  275. trigger = strings.TrimSpace(trigger)
  276. if !strings.HasPrefix(trigger, "-P") {
  277. return nil
  278. }
  279. s := trigger[2:] // strip "-P"
  280. if strings.HasPrefix(s, "T") {
  281. s = s[1:] // strip "T" time-designator
  282. }
  283. if strings.HasSuffix(s, "M") {
  284. v, err := strconv.Atoi(s[:len(s)-1])
  285. if err != nil {
  286. return nil
  287. }
  288. return &EventReminder{Value: v, Unit: "mins"}
  289. }
  290. if strings.HasSuffix(s, "H") {
  291. v, err := strconv.Atoi(s[:len(s)-1])
  292. if err != nil {
  293. return nil
  294. }
  295. return &EventReminder{Value: v, Unit: "hours"}
  296. }
  297. if strings.HasSuffix(s, "D") {
  298. v, err := strconv.Atoi(s[:len(s)-1])
  299. if err != nil {
  300. return nil
  301. }
  302. return &EventReminder{Value: v, Unit: "days"}
  303. }
  304. return nil
  305. }
  306. // normalizeRRule sanitises a recurrence rule for safe inclusion in an ICS
  307. // property line. It strips a redundant leading "RRULE:" prefix, removes any
  308. // embedded line breaks, and trims surrounding whitespace. The rule is passed
  309. // through verbatim otherwise so any valid RFC 5545 RRULE survives a round trip.
  310. func normalizeRRule(rrule string) string {
  311. rrule = strings.TrimSpace(rrule)
  312. if rrule == "" {
  313. return ""
  314. }
  315. if strings.HasPrefix(strings.ToUpper(rrule), "RRULE:") {
  316. rrule = rrule[len("RRULE:"):]
  317. }
  318. rrule = strings.ReplaceAll(rrule, "\r", "")
  319. rrule = strings.ReplaceAll(rrule, "\n", "")
  320. return strings.TrimSpace(rrule)
  321. }
  322. // eventETag returns a quoted MD5 ETag for the given event.
  323. func eventETag(ev CalendarEvent) string {
  324. data, _ := json.Marshal(ev)
  325. h := md5.Sum(data)
  326. return fmt.Sprintf(`"%x"`, h)
  327. }
  328. // collectionCTag returns an unquoted MD5 sync token for the whole collection.
  329. func collectionCTag(events []CalendarEvent) string {
  330. data, _ := json.Marshal(events)
  331. h := md5.Sum(data)
  332. return fmt.Sprintf("%x", h)
  333. }