The MCP server speaks JSON-RPC over Streamable HTTP with the six tools the brief names, authenticated by a personal API token — so a connector reaches exactly what its owner reaches and there is no second identity to reason about. A failed tool call returns a tool error, not a protocol error: a model can recover from the first and can do nothing with the second. CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can read, create and complete tasks. Clients authenticate with Basic using an API token as the password, never the account password — a client stores what it is given, and what it is given should not be able to sign in to the app. Completed tasks stay in the collection: a client that stops seeing one treats it as deleted and drops its own record of having done it. The AI layer is one interface with adapters for Anthropic, OpenAI, Google and any OpenAI-compatible endpoint. That last one is why the abstraction exists: an integration that only spoke to a hosted API would be useless to somebody running their own models. Every feature degrades to "off" when nothing is configured, and the stored API key is never returned to the settings page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
370 lines
10 KiB
Go
370 lines
10 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)
|
|
}
|