| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | // The low-level readers: one content line into name, parameters and |
| 9 | // value, and the handful of value formats iCalendar uses. Split from |
| 10 | // ics_import.go so neither file passes the 300-line cap, and because |
| 11 | // these are the pieces with no knowledge of what a VEVENT is. |
| 12 | |
| 13 | // splitLine breaks "DTSTART;TZID=Europe/Dublin:20260902T090000" into |
| 14 | // its name, parameters and value. |
| 15 | func splitLine(s string) (name, params, value string) { |
| 16 | colon := strings.Index(s, ":") |
| 17 | if colon < 0 { |
| 18 | return strings.ToUpper(strings.TrimSpace(s)), "", "" |
| 19 | } |
| 20 | head, value := s[:colon], s[colon+1:] |
| 21 | if semi := strings.Index(head, ";"); semi >= 0 { |
| 22 | return strings.ToUpper(head[:semi]), head[semi+1:], value |
| 23 | } |
| 24 | return strings.ToUpper(head), "", value |
| 25 | } |
| 26 | |
| 27 | func tzidOf(params string) string { |
| 28 | for _, p := range strings.Split(params, ";") { |
| 29 | if k, v, ok := strings.Cut(p, "="); ok && strings.EqualFold(k, "TZID") { |
| 30 | return strings.Trim(strings.TrimSpace(v), `"`) |
| 31 | } |
| 32 | } |
| 33 | return "" |
| 34 | } |
| 35 | |
| 36 | // parseICSTime reads the three forms a DTSTART can take, and reports |
| 37 | // whether it was a date. |
| 38 | func parseICSTime(v, params string) (local string, allDay bool, ok bool) { |
| 39 | v = strings.TrimSpace(v) |
| 40 | if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(v) == 8 { |
| 41 | if t, err := time.Parse("20060102", v); err == nil { |
| 42 | return t.Format(LayoutDate), true, true |
| 43 | } |
| 44 | } |
| 45 | // A trailing Z is UTC. The wall time is kept as written and the |
| 46 | // zone recorded separately, so the record stays "what was said". |
| 47 | if strings.HasSuffix(v, "Z") { |
| 48 | if t, err := time.Parse("20060102T150405Z", v); err == nil { |
| 49 | return t.Format(LayoutLocal), false, true |
| 50 | } |
| 51 | } |
| 52 | if t, err := time.Parse("20060102T150405", v); err == nil { |
| 53 | return t.Format(LayoutLocal), false, true |
| 54 | } |
| 55 | return "", false, false |
| 56 | } |
| 57 | |
| 58 | // parseDurationMinutes reads the subset of ISO 8601 durations a |
| 59 | // calendar actually emits. |
| 60 | func parseDurationMinutes(v string) int { |
| 61 | v = strings.TrimSpace(strings.ToUpper(v)) |
| 62 | v = strings.TrimPrefix(v, "P") |
| 63 | var total, n int |
| 64 | timePart := false |
| 65 | for _, c := range v { |
| 66 | switch { |
| 67 | case c >= '0' && c <= '9': |
| 68 | n = n*10 + int(c-'0') |
| 69 | case c == 'T': |
| 70 | timePart = true |
| 71 | n = 0 |
| 72 | case c == 'W': |
| 73 | total += n * 7 * 24 * 60 |
| 74 | n = 0 |
| 75 | case c == 'D': |
| 76 | total += n * 24 * 60 |
| 77 | n = 0 |
| 78 | case c == 'H': |
| 79 | total += n * 60 |
| 80 | n = 0 |
| 81 | case c == 'M' && timePart: |
| 82 | total += n |
| 83 | n = 0 |
| 84 | default: |
| 85 | n = 0 |
| 86 | } |
| 87 | } |
| 88 | return total |
| 89 | } |
| 90 | |
| 91 | // unescapeText reverses the RFC's TEXT escaping. Backslash LAST, the |
| 92 | // mirror of escapeText's first — otherwise "\\n" (an escaped |
| 93 | // backslash followed by an n) becomes a newline. |
| 94 | func unescapeText(s string) string { |
| 95 | s = strings.ReplaceAll(s, `\n`, "\n") |
| 96 | s = strings.ReplaceAll(s, `\N`, "\n") |
| 97 | s = strings.ReplaceAll(s, `\,`, ",") |
| 98 | s = strings.ReplaceAll(s, `\;`, ";") |
| 99 | s = strings.ReplaceAll(s, `\\`, `\`) |
| 100 | return s |
| 101 | } |
| 102 | |