Files
projax/caldav/parse.go
mAi 83c965f111 feat(phase 2.b caldav): full read/write VTODO writeback from projax
caldav package:
- Todo carries URL, ETag, Raw so ListTodos rows can be PUT/DELETEd in place
- BuildVTodoICS for new VTODOs, ApplyVTodoEdit for in-place edits that
  preserve unknown properties (DESCRIPTION, CATEGORIES, X-*)
- PutTodo/DeleteTodo with If-Match optimistic concurrency
- ErrPreconditionFailed/ErrNotFound for 412/404
- RFC 5545 fold-at-75 + CRLF + text escape, hand-rolled UUID v4
- httptest round-trip (create -> list -> complete -> delete) plus 412 path

web:
- POST /i/{path}/caldav/todo/{complete,reopen,edit,delete,todo-create}
- Re-fetches the live ETag before each PUT/DELETE so ordinary use never
  trips 412; on actual 412 the section reloads with a banner
- Calendar URL must already be linked to the item (anti-forgery guard)
- tasks_section partial drives both the initial page render and HTMX
  swaps; detail.tmpl reduces to a one-liner template call

docs/design.md §5: rewrite for full read/write semantics + ETag concurrency.
2026-05-15 17:16:38 +02:00

384 lines
11 KiB
Go

package caldav
import (
"crypto/rand"
"fmt"
"strconv"
"strings"
"time"
)
// parseVTodos extracts every VTODO block from a calendar-data string. Hand-
// rolled because importing a full iCalendar parser for the half-dozen fields
// projax cares about is overkill. Tolerates folded lines per RFC 5545 §3.1.
func parseVTodos(ics string) []Todo {
ics = unfold(ics)
lines := strings.Split(ics, "\n")
var out []Todo
var inTodo bool
var cur Todo
for _, ln := range lines {
ln = strings.TrimRight(ln, "\r")
if ln == "BEGIN:VTODO" {
inTodo = true
cur = Todo{Status: "NEEDS-ACTION"}
continue
}
if ln == "END:VTODO" {
if cur.UID != "" {
out = append(out, cur)
}
inTodo = false
continue
}
if !inTodo {
continue
}
key, val := splitLine(ln)
switch key {
case "UID":
cur.UID = val
case "SUMMARY":
cur.Summary = unescapeText(val)
case "STATUS":
cur.Status = strings.ToUpper(val)
case "PRIORITY":
if n, err := strconv.Atoi(val); err == nil {
cur.Priority = n
}
case "DUE":
if t, ok := parseICalTime(val); ok {
cur.Due = &t
}
case "LAST-MODIFIED":
if t, ok := parseICalTime(val); ok {
cur.LastModified = &t
}
}
}
return out
}
// unfold collapses RFC 5545 line continuations (a CRLF followed by a single
// SP or HT continues the previous line).
func unfold(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
var b strings.Builder
lines := strings.Split(s, "\n")
for i, ln := range lines {
if i > 0 && len(ln) > 0 && (ln[0] == ' ' || ln[0] == '\t') {
b.WriteString(ln[1:])
continue
}
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(ln)
}
return b.String()
}
// splitLine separates "KEY;PARAMS:VALUE" into ("KEY", "VALUE"). Params dropped
// — we don't need TZID etc. for v1.
func splitLine(ln string) (string, string) {
colon := strings.Index(ln, ":")
if colon < 0 {
return "", ""
}
head := ln[:colon]
val := ln[colon+1:]
if semi := strings.Index(head, ";"); semi >= 0 {
head = head[:semi]
}
return head, val
}
// parseICalTime recognises both `YYYYMMDDTHHMMSSZ` (UTC) and bare `YYYYMMDD`.
// Floating local-time forms are coerced to UTC for ranking — single user, no
// tz acrobatics needed at v1.
func parseICalTime(v string) (time.Time, bool) {
v = strings.TrimSpace(v)
if len(v) == 8 {
if t, err := time.Parse("20060102", v); err == nil {
return t, true
}
}
if len(v) >= 15 {
layouts := []string{"20060102T150405Z", "20060102T150405"}
for _, l := range layouts {
if t, err := time.Parse(l, v); err == nil {
return t, true
}
}
}
return time.Time{}, false
}
// unescapeText reverses RFC 5545 §3.3.11 text encoding.
func unescapeText(s string) string {
s = strings.ReplaceAll(s, `\n`, "\n")
s = strings.ReplaceAll(s, `\N`, "\n")
s = strings.ReplaceAll(s, `\,`, ",")
s = strings.ReplaceAll(s, `\;`, ";")
s = strings.ReplaceAll(s, `\\`, `\`)
return s
}
// escapeText applies RFC 5545 §3.3.11 escaping. CR/LF become \n; backslash,
// comma, and semicolon are backslash-escaped. Everything else passes through.
func escapeText(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "\r\n", `\n`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\n`)
s = strings.ReplaceAll(s, `,`, `\,`)
s = strings.ReplaceAll(s, `;`, `\;`)
return s
}
// foldLine wraps a single logical iCal line so no physical line exceeds 75
// octets, prepending a single space to each continuation line as per RFC 5545
// §3.1. Folding is octet-based, not rune-based — but we keep care not to split
// in the middle of a UTF-8 sequence.
func foldLine(line string) string {
const limit = 75
if len(line) <= limit {
return line
}
var b strings.Builder
for i := 0; i < len(line); {
end := i + limit
if i > 0 {
// Continuation lines reserve one octet for the leading space.
end = i + (limit - 1)
}
if end > len(line) {
end = len(line)
}
// Back off so we don't split inside a multi-byte UTF-8 sequence.
for end < len(line) && end > i && (line[end]&0xC0) == 0x80 {
end--
}
chunk := line[i:end]
if i > 0 {
b.WriteString("\r\n ")
}
b.WriteString(chunk)
i = end
}
return b.String()
}
// joinICS folds each logical line and joins them with CRLF terminators
// (RFC 5545 §3.1 — content lines MUST be terminated with CRLF).
func joinICS(lines []string) string {
var b strings.Builder
for _, ln := range lines {
b.WriteString(foldLine(ln))
b.WriteString("\r\n")
}
return b.String()
}
// formatICalUTC formats t in `YYYYMMDDTHHMMSSZ` form (RFC 5545 UTC date-time).
func formatICalUTC(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
// formatICalDate formats t in `YYYYMMDD` form (RFC 5545 DATE).
func formatICalDate(t time.Time) string { return t.UTC().Format("20060102") }
// NewUID generates an RFC 4122 v4 UUID rendered as a hyphenated lowercase
// string. The "@projax" suffix that some clients append is intentionally
// omitted — the UID is opaque to projax and we treat it as such.
func NewUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failure on Linux is extraordinary; fall back to time-based
// so projax keeps functioning, with the trade-off that the new UID is
// less random.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
b[i] = byte(now >> (i * 4))
}
}
b[6] = (b[6] & 0x0F) | 0x40 // version 4
b[8] = (b[8] & 0x3F) | 0x80 // variant RFC 4122
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// VTodoEdit describes a partial update to an existing VTODO. Fields left nil
// are not changed in the stored ICS. To clear a field, supply a pointer to an
// empty value where allowed; the writer will emit the bare key with no value.
// Use ClearDue to clear DUE explicitly.
type VTodoEdit struct {
Summary *string
Status *string
Completed *time.Time // sets COMPLETED; pass time.Time{} via ClearCompleted to remove the line
Due *time.Time
ClearDue bool
Priority *int
}
// BuildVTodoICS serialises a fresh VTODO as a complete VCALENDAR document,
// suitable for PUT to a CalDAV server. UID is the only required input; the
// other VTodoEdit fields populate optional properties. DTSTAMP is set to now.
func BuildVTodoICS(uid string, e VTodoEdit) string {
now := time.Now().UTC()
lines := []string{
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//projax//caldav writeback//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VTODO",
"UID:" + uid,
"DTSTAMP:" + formatICalUTC(now),
"CREATED:" + formatICalUTC(now),
"LAST-MODIFIED:" + formatICalUTC(now),
}
if e.Summary != nil {
lines = append(lines, "SUMMARY:"+escapeText(*e.Summary))
}
status := "NEEDS-ACTION"
if e.Status != nil && *e.Status != "" {
status = strings.ToUpper(*e.Status)
}
lines = append(lines, "STATUS:"+status)
if status == "COMPLETED" {
ct := now
if e.Completed != nil && !e.Completed.IsZero() {
ct = *e.Completed
}
lines = append(lines, "COMPLETED:"+formatICalUTC(ct))
lines = append(lines, "PERCENT-COMPLETE:100")
}
if e.Due != nil && !e.Due.IsZero() {
lines = append(lines, dueLine(*e.Due))
}
if e.Priority != nil {
lines = append(lines, fmt.Sprintf("PRIORITY:%d", *e.Priority))
}
lines = append(lines, "END:VTODO", "END:VCALENDAR")
return joinICS(lines)
}
// dueLine emits a DUE property. If the time has no clock component (00:00:00),
// it is encoded as DATE (`DUE;VALUE=DATE:YYYYMMDD`); otherwise as UTC
// date-time. Single-user, single-timezone — no VTIMEZONE acrobatics required.
func dueLine(t time.Time) string {
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
return "DUE;VALUE=DATE:" + formatICalDate(t)
}
return "DUE:" + formatICalUTC(t)
}
// ApplyVTodoEdit returns a new ICS document derived from the existing one with
// the supplied edits applied. Unknown properties (DESCRIPTION, CATEGORIES,
// ATTENDEE, X-*) are preserved — only the changed keys are rewritten. Folded
// lines are normalised on read. LAST-MODIFIED is always bumped to now.
func ApplyVTodoEdit(ics string, e VTodoEdit) string {
now := time.Now().UTC()
lines := strings.Split(unfold(ics), "\n")
// Helper to locate the VTODO segment so we don't touch VCALENDAR-level keys.
vtStart, vtEnd := -1, -1
for i, raw := range lines {
ln := strings.TrimRight(raw, "\r")
if ln == "BEGIN:VTODO" {
vtStart = i
}
if ln == "END:VTODO" {
vtEnd = i
break
}
}
if vtStart < 0 || vtEnd < 0 {
// Malformed; fall back to a from-scratch build with a fresh UID so the
// caller gets *something* well-formed back rather than silent garbage.
return BuildVTodoICS(NewUID(), e)
}
// Build a set of keys we plan to overwrite. We also drop COMPLETED when
// status flips away from COMPLETED.
overwrite := map[string]string{}
if e.Summary != nil {
overwrite["SUMMARY"] = "SUMMARY:" + escapeText(*e.Summary)
}
if e.Status != nil && *e.Status != "" {
s := strings.ToUpper(*e.Status)
overwrite["STATUS"] = "STATUS:" + s
switch s {
case "COMPLETED":
ct := now
if e.Completed != nil && !e.Completed.IsZero() {
ct = *e.Completed
}
overwrite["COMPLETED"] = "COMPLETED:" + formatICalUTC(ct)
overwrite["PERCENT-COMPLETE"] = "PERCENT-COMPLETE:100"
default:
// reopen / cancel: clear COMPLETED and PERCENT-COMPLETE if present.
overwrite["COMPLETED"] = ""
overwrite["PERCENT-COMPLETE"] = ""
}
}
if e.Due != nil && !e.Due.IsZero() {
overwrite["DUE"] = dueLine(*e.Due)
}
if e.ClearDue {
overwrite["DUE"] = ""
}
if e.Priority != nil {
overwrite["PRIORITY"] = fmt.Sprintf("PRIORITY:%d", *e.Priority)
}
// LAST-MODIFIED always bumps.
overwrite["LAST-MODIFIED"] = "LAST-MODIFIED:" + formatICalUTC(now)
// DTSTAMP per RFC 5545 also reflects last sync; safe to bump.
overwrite["DTSTAMP"] = "DTSTAMP:" + formatICalUTC(now)
seen := map[string]bool{}
out := make([]string, 0, len(lines))
for i, raw := range lines {
ln := strings.TrimRight(raw, "\r")
// Pass everything outside the VTODO block through verbatim.
if i <= vtStart || i >= vtEnd {
out = append(out, ln)
continue
}
key, _ := splitLine(ln)
key = strings.ToUpper(key)
if repl, ok := overwrite[key]; ok {
seen[key] = true
if repl == "" {
continue // explicit clear
}
out = append(out, repl)
continue
}
out = append(out, ln)
}
// Insert any overwrite keys that weren't present in the source ICS just
// before END:VTODO. Skip explicit clears (repl == "") so we don't append
// empty lines.
endIdx := -1
for i, ln := range out {
if strings.TrimRight(ln, "\r") == "END:VTODO" {
endIdx = i
break
}
}
if endIdx >= 0 {
extras := []string{}
for key, repl := range overwrite {
if seen[key] || repl == "" {
continue
}
extras = append(extras, repl)
}
if len(extras) > 0 {
before := append([]string{}, out[:endIdx]...)
before = append(before, extras...)
before = append(before, out[endIdx:]...)
out = before
}
}
// joinICS handles fold + CRLF on each logical line.
return joinICS(out)
}