Files
verdande/internal/ics/ics.go
T
kristianwindandClaude Opus 5 60d66b728e fix(ics): a date-only task now shows in a subscribed calendar
The calendar feed emitted every task as a VTODO and, on top of that, an event only
for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or
Google ignores VTODO entirely and draws only events — so a task with just a due
date, which is most of them, appeared in the verdande app's own calendar and
nowhere in the calendar it had been subscribed into. "Verdande does not update in my
calendar, only in the verdande app."

Every dated, unfinished task is an event now: a clock time makes a timed event as
before, a bare date an all-day one (a DATE start and an exclusive next-day end, so
it stays on its own day). Finished tasks stay off the calendar, as they were.

Test updated: the sample's timed task and its two date-only tasks all become events
now, the all-day ones in the DATE form, and the finished one still makes none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:42:25 +02:00

572 lines
17 KiB
Go

// 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 the calendar apps that ignore
// VTODO — which is all of them: Apple Calendar and Google both drop a
// subscribed to-do on the floor and show only events. A task with a clock
// time becomes a timed event; one with only a date, an all-day event. Once
// this was limited to timed tasks, so a date-only task — most of them —
// simply never appeared in the calendar it was subscribed into.
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
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) {
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))
if t.DueDatetime != nil {
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
}
writeLine(b, "DTSTART:"+utc(start))
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
} else {
// A date with no time is an all-day event. DTEND is exclusive — a one-day
// event ends on the following date — which is what keeps it a single day in
// the grid rather than bleeding into the next.
day := strings.ReplaceAll(t.DueDate, "-", "")
writeLine(b, "DTSTART;VALUE=DATE:"+day)
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
}
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") }
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
// DTEND. A malformed date is returned as its own compacted form rather than
// dropped, so a task never loses its event over a bad string.
func nextDay(date string) string {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return strings.ReplaceAll(date, "-", "")
}
return d.AddDate(0, 0, 1).Format("20060102")
}
// 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)
}
// --- reading a whole calendar ------------------------------------------------------
// Event is one VEVENT, reduced to what a grid needs — the same shape the Google
// side already stores, so the two arrive in the database identically.
//
// Days at both ends, inclusive, because that is the question a grid asks: which
// cells does this cover. StartsAt keeps the stamp as the file wrote it, offset and
// all: it is what the chip shows, and reparsing it here would ask *this process*
// what time zone it is in.
type Event struct {
UID string
Summary string
Location string
URL string
AllDay bool
StartsAt string // RFC3339, empty for an all-day event
EndsAt string
StartDay string // YYYY-MM-DD
EndDay string
}
// Subscribed is what an .ics file somebody subscribed to says.
//
// Its own name rather than `Calendar`: that one is the *writing* side — what
// verdande publishes as a feed — and the two are different documents that happen
// to share a format. One name for both would be a promise that a round trip
// through this package is lossless, which it is not and does not need to be.
type Subscribed struct {
Name string
TimeZone string
Events []Event
}
// ParseCalendar reads an .ics file that somebody subscribed to.
//
// Forgiving in the same way ParseVTODO is, and for a sharper reason: this file was
// written by somebody else's software and is fetched on a schedule with nobody
// watching. One property this does not understand must not cost the other four
// hundred events in the file — so anything unrecognised is skipped, and an event
// without the two things a grid cannot do without (a uid and a start) is dropped
// rather than stored half-made.
//
// # What it deliberately does not do
//
// **Recurrence is not expanded.** RRULE in a subscribed calendar means "and then
// every Tuesday until 2031", and expanding it here would mean writing a year of
// rows per rule and re-deciding what happens when the file changes. A recurring
// event is stored as its first occurrence, which is honest — it is what the file
// says on its own terms — and the day it matters, the answer is an expander shared
// with `internal/recurrence`, not a second one written here.
//
// **Time zones are not resolved.** A DTSTART with TZID=Europe/Copenhagen is stored
// with the offset the file carries or, when it carries none, as a local stamp. The
// alternative is shipping a tz database and deciding what to do when the file
// names a zone this build has never heard of. The day that matters, it is one
// place.
func ParseCalendar(body string) Subscribed {
var cal Subscribed
var current *Event
depth := 0
for _, line := range unfold(body) {
name, params, value := splitLine(line)
upper := strings.ToUpper(name)
switch upper {
case "BEGIN":
if strings.EqualFold(value, "VEVENT") {
current = &Event{}
}
depth++
continue
case "END":
depth--
if strings.EqualFold(value, "VEVENT") && current != nil {
if current.UID != "" && current.StartDay != "" {
cal.Events = append(cal.Events, *current)
}
current = nil
}
continue
}
if current == nil {
// Calendar-level properties. X-WR-CALNAME is what every publisher uses
// to say what the calendar is called; there is no standard property for
// it, which is why a non-standard one is read here.
switch upper {
case "X-WR-CALNAME":
cal.Name = unescape(value)
case "X-WR-TIMEZONE":
cal.TimeZone = value
}
continue
}
switch upper {
case "UID":
current.UID = value
case "SUMMARY":
current.Summary = unescape(value)
case "LOCATION":
current.Location = unescape(value)
case "URL":
current.URL = value
case "DTSTART":
current.AllDay = isDateOnly(params, value)
current.StartDay, current.StartsAt = readStamp(params, value)
case "DTEND":
day, at := readStamp(params, value)
current.EndsAt = at
// DTEND is exclusive for an all-day event: a one-day event ends on the
// next morning. Stored inclusive, or every all-day event would cover one
// cell too many — and a public holiday would eat the day after it.
if current.AllDay {
day = shiftDay(day, -1)
}
current.EndDay = day
}
}
// A missing DTEND means the event ends when it starts. Filled after the loop
// rather than inside it, because DTEND may arrive before DTSTART in a file
// nobody promised would be in order.
for i := range cal.Events {
if cal.Events[i].EndDay == "" || cal.Events[i].EndDay < cal.Events[i].StartDay {
cal.Events[i].EndDay = cal.Events[i].StartDay
}
}
return cal
}
// isDateOnly reports whether a stamp is a day rather than a moment. VALUE=DATE
// says so outright; an eight-character value says it by being one.
func isDateOnly(params, value string) bool {
if strings.Contains(strings.ToUpper(params), "VALUE=DATE") {
return true
}
return len(value) == 8 && !strings.ContainsAny(value, "T")
}
// readStamp returns the day and, for a timed event, the moment as RFC3339.
//
// Three shapes exist in the wild: 20260821 (a day), 20260821T140000Z (UTC), and
// 20260821T140000 with a TZID parameter (a local time in a named zone). The third
// is kept as written, without a zone: resolving it needs a tz database, and a
// stamp that says 14:00 is closer to the truth than one this process has moved.
func readStamp(params, value string) (day, at string) {
value = strings.TrimSpace(value)
if len(value) < 8 {
return "", ""
}
day = value[0:4] + "-" + value[4:6] + "-" + value[6:8]
if len(value) < 15 || value[8] != 'T' {
return day, ""
}
clock := value[9:11] + ":" + value[11:13] + ":" + value[13:15]
switch {
case strings.HasSuffix(value, "Z"):
return day, day + "T" + clock + "Z"
default:
// A local stamp, with or without a TZID. Written without an offset, which
// is what the interface already treats as "the clock the file said".
return day, day + "T" + clock
}
}
// shiftDay moves a YYYY-MM-DD by whole days.
func shiftDay(day string, by int) string {
t, err := time.Parse("2006-01-02", day)
if err != nil {
return day
}
return t.AddDate(0, 0, by).Format("2006-01-02")
}