Files
verdande/internal/ics/ics.go
T
Kristian WindandClaude Opus 5 dcb71f4c14
Release / Build and publish image (push) Failing after 10m3s
feat(calendar): abonnement på flere kalendere, ICS og webcal
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den
eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google
afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op.

**Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er
præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet,
farven, `shown` og hele fejemaskinen omkring sig. En tabel til ville være en anden
vej til det samme sted, og to veje til ét sted er to steder at rette den dag noget
om begivenheder ændrer sig.

Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og
udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har
flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden
adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene
ting, SQLite kan her, som ellers krævede en tabelombygning.

**`webcal://` skrives om til `https://`.** Det er ikke en protokol, noget taler —
det er en konvention, der får et skrivebord til at give adressen til
kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres links
sådan. At afvise den ville være at afvise folk på en teknikalitet.

**Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så
den, der står med feltet, får svaret med det samme — og igen på den *opslåede*
adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen,
kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den
sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et
SSRF-fund i morges.

**Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl
langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen
står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes,
er den samme tavshed, notifikationerne havde.

Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje
ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke
tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til
den dag de skal.

En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den,
som filen skriver den, dækker hver eneste helligdag også dagen efter sig.

Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons
server, og den ordinære tilstand for en anden persons server er, at den er i orden
indtil en morgen hvor den ikke er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:26:13 +02:00

547 lines
16 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 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)
}
// --- 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")
}