2026-08-17 07:05:24 +02:00
|
|
|
// 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"
|
2026-08-17 07:53:53 +02:00
|
|
|
"strconv"
|
2026-08-17 07:05:24 +02:00
|
|
|
"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)
|
|
|
|
|
}
|
2026-08-17 07:53:53 +02:00
|
|
|
|
|
|
|
|
// --- 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)
|
|
|
|
|
}
|