// Package ics writes iCalendar documents. // // Tasks go out as VTODO, not VEVENT. A to-do has a due date and a completion state; // an event has a start and an end and neither of those things. Apple Reminders and // Thunderbird read VTODO natively, and it is the same component the CalDAV server // will serve later β€” so a task exported today is the task synchronised tomorrow, // with no second representation to keep in step. // // Google Calendar is the awkward exception: it subscribes to ICS but ignores VTODO // entirely. Tasks that carry a clock time are therefore also emitted as VEVENT, // which is what makes a feed useful in the calendar most people actually keep. package ics import ( "fmt" "strconv" "strings" "time" ) // Task is the subset of a task a calendar needs. Defined here rather than importing // the store's type, so this package stays a pure formatter with no database in it. type Task struct { ID string Content string Description string ProjectName string DueDate string // "YYYY-MM-DD" DueDatetime *time.Time DurationMin *int Priority int Recurrence string Completed bool CompletedAt *time.Time CreatedAt time.Time UpdatedAt time.Time } type Calendar struct { Name string // Domain appears in every UID. It has to be stable for the lifetime of the // feed: a client that sees a new UID for the same task treats it as a new item // and leaves the old one behind as a duplicate. Domain string Tasks []Task } // Render writes the whole document. func Render(cal Calendar) string { var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\n") b.WriteString("VERSION:2.0\r\n") b.WriteString("PRODID:-//verdande//verdande//DA\r\n") b.WriteString("CALSCALE:GREGORIAN\r\n") b.WriteString("METHOD:PUBLISH\r\n") writeLine(&b, "X-WR-CALNAME:"+escape(cal.Name)) // Tells a subscribing client how often to poll. Without it Google refetches on // its own schedule, which can be many hours. b.WriteString("X-PUBLISHED-TTL:PT1H\r\n") b.WriteString("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\n") for _, t := range cal.Tasks { writeTodo(&b, t, cal.Domain) // A dated task also goes out as an event, for clients that ignore VTODO. if t.DueDatetime != nil && !t.Completed { writeEvent(&b, t, cal.Domain) } } b.WriteString("END:VCALENDAR\r\n") return b.String() } func writeTodo(b *strings.Builder, t Task, domain string) { b.WriteString("BEGIN:VTODO\r\n") writeLine(b, "UID:"+t.ID+"@"+domain) writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt)) writeLine(b, "CREATED:"+utc(t.CreatedAt)) writeLine(b, "SUMMARY:"+escape(t.Content)) if t.Description != "" { writeLine(b, "DESCRIPTION:"+escape(t.Description)) } if t.ProjectName != "" { writeLine(b, "CATEGORIES:"+escape(t.ProjectName)) } switch { case t.DueDatetime != nil: writeLine(b, "DUE:"+utc(*t.DueDatetime)) case t.DueDate != "": // A whole-day due date is a DATE, not a DATE-TIME. Emitting midnight in // some timezone instead would move the task a day for anybody east or // west of the server. writeLine(b, "DUE;VALUE=DATE:"+strings.ReplaceAll(t.DueDate, "-", "")) } if t.Recurrence != "" { writeLine(b, "RRULE:"+strings.TrimPrefix(t.Recurrence, "RRULE:")) } // RFC 5545 priority runs 1 (highest) to 9, with 0 meaning undefined. verdande's // P4 is "no priority stated" rather than "lowest", so it maps to 0 β€” otherwise // every unprioritised task arrives in a client marked explicitly low. switch t.Priority { case 1: writeLine(b, "PRIORITY:1") case 2: writeLine(b, "PRIORITY:5") case 3: writeLine(b, "PRIORITY:7") default: writeLine(b, "PRIORITY:0") } if t.Completed { writeLine(b, "STATUS:COMPLETED") writeLine(b, "PERCENT-COMPLETE:100") if t.CompletedAt != nil { writeLine(b, "COMPLETED:"+utc(*t.CompletedAt)) } } else { writeLine(b, "STATUS:NEEDS-ACTION") } b.WriteString("END:VTODO\r\n") } func writeEvent(b *strings.Builder, t Task, domain string) { start := *t.DueDatetime // Without a stated duration a task is shown as half an hour. A zero-length // event collapses to an invisible sliver in most calendar grids. minutes := 30 if t.DurationMin != nil && *t.DurationMin > 0 { minutes = *t.DurationMin } end := start.Add(time.Duration(minutes) * time.Minute) b.WriteString("BEGIN:VEVENT\r\n") // A distinct UID suffix: the event and the to-do describe the same task, and // sharing a UID would make a client treat them as two versions of one item. writeLine(b, "UID:"+t.ID+"-event@"+domain) writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt)) writeLine(b, "DTSTART:"+utc(start)) writeLine(b, "DTEND:"+utc(end)) writeLine(b, "SUMMARY:"+escape(t.Content)) if t.Description != "" { writeLine(b, "DESCRIPTION:"+escape(t.Description)) } if t.Recurrence != "" { writeLine(b, "RRULE:"+strings.TrimPrefix(t.Recurrence, "RRULE:")) } writeLine(b, "TRANSP:TRANSPARENT") b.WriteString("END:VEVENT\r\n") } func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") } // escape applies RFC 5545 text escaping. A comma or semicolon in a task title is // a value separator in iCalendar, so an unescaped one silently truncates the // summary at that character in every client. func escape(s string) string { r := strings.NewReplacer( "\\", "\\\\", ";", "\\;", ",", "\\,", "\n", "\\n", "\r", "", ) return r.Replace(s) } // writeLine folds at 75 octets, as the spec requires, with a space beginning each // continuation. Folding is by byte with a check for UTF-8 continuation bytes: a // break in the middle of "ΓΈ" produces a line no parser can read. func writeLine(b *strings.Builder, line string) { const limit = 73 // 75 minus CRLF if len(line) <= limit { b.WriteString(line) b.WriteString("\r\n") return } for i := 0; i < len(line); { end := i + limit if i > 0 { end = i + limit - 1 // the leading space on a continuation counts } if end > len(line) { end = len(line) } // Never split a multi-byte character. for end > i && end < len(line) && line[end]&0xC0 == 0x80 { end-- } if i > 0 { b.WriteString(" ") } b.WriteString(line[i:end]) b.WriteString("\r\n") i = end } } // Filename is what a browser saves the feed as. func Filename(name string) string { safe := strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': return r case r >= 'A' && r <= 'Z': return r + 32 case r == ' ', r == '_': return '-' } return -1 }, name) if safe == "" { safe = "verdande" } return fmt.Sprintf("%s.ics", safe) } // --- reading ---------------------------------------------------------------------- // Parsed is what a client's VTODO says, reduced to the fields verdande stores. type Parsed struct { UID string Summary string Description string DueDate string DueDatetime *time.Time Priority int Recurrence string Completed bool } // ParseVTODO reads a calendar object sent by a CalDAV client. // // Deliberately forgiving: this is the input side of a protocol spoken by Apple // Reminders, Thunderbird and a long tail of phone apps, each with its own habits // about folding, parameters and which properties it bothers to send. Anything not // understood is ignored rather than refused, because refusing a PUT makes a client // show a sync error and stop β€” losing the edit the person just made. func ParseVTODO(body string) (Parsed, error) { var p Parsed inTodo := false for _, line := range unfold(body) { name, params, value := splitLine(line) switch strings.ToUpper(name) { case "BEGIN": if strings.EqualFold(value, "VTODO") { inTodo = true } case "END": if strings.EqualFold(value, "VTODO") { inTodo = false } } if !inTodo { continue } switch strings.ToUpper(name) { case "UID": p.UID = value case "SUMMARY": p.Summary = unescape(value) case "DESCRIPTION": p.Description = unescape(value) case "RRULE": p.Recurrence = value case "STATUS": p.Completed = strings.EqualFold(value, "COMPLETED") case "PERCENT-COMPLETE": if value == "100" { p.Completed = true } case "COMPLETED": p.Completed = true case "PRIORITY": // RFC 5545 runs 1..9 with 0 meaning undefined; verdande runs 1..4 with // 4 meaning none. The bands here are the inverse of what the writer does. switch n, _ := strconv.Atoi(value); { case n == 0: p.Priority = 4 case n <= 2: p.Priority = 1 case n <= 5: p.Priority = 2 case n <= 7: p.Priority = 3 default: p.Priority = 4 } case "DUE": if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(value) == 8 { if t, err := time.Parse("20060102", value); err == nil { p.DueDate = t.Format("2006-01-02") } continue } for _, layout := range []string{"20060102T150405Z", "20060102T150405"} { if t, err := time.Parse(layout, value); err == nil { utc := t.UTC() p.DueDate = utc.Format("2006-01-02") p.DueDatetime = &utc break } } } } if p.Priority == 0 { p.Priority = 4 } if p.Summary == "" && p.UID == "" { return p, fmt.Errorf("ics: no VTODO found") } return p, nil } // unfold rejoins continuation lines, which the spec folds at 75 octets by beginning // the next line with a space or tab. func unfold(body string) []string { raw := strings.Split(strings.ReplaceAll(body, "\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:] continue } out = append(out, line) } return out } // splitLine separates NAME;PARAMS:VALUE. The parameters can themselves contain a // colon inside quotes, so the split is on the first colon outside quotes. func splitLine(line string) (name, params, value string) { inQuotes := false for i := 0; i < len(line); i++ { switch line[i] { case '"': inQuotes = !inQuotes case ':': if inQuotes { continue } left := line[:i] value = line[i+1:] if semi := strings.Index(left, ";"); semi >= 0 { return left[:semi], left[semi+1:], value } return left, "", value } } return line, "", "" } func unescape(s string) string { r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`) return r.Replace(s) }