Files
projax/caldav/parse.go
mAi 311cf943bc feat(caldav): link-existing picker + projax-tagged VTODOs for shared lists
m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.

Three layers, one branch:

## 1. Link-existing picker (the original ask)

- New POST /i/{path}/caldav/link-existing handler validates the
  submitted calendar_url is in the discoverable PROPFIND set (defence
  against crafted forms pointing at arbitrary HTTP servers), then
  inserts the item_link row with display_name + color metadata
  preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
  availableCalendarsForItem(ctx, links) — calendars from
  s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
  item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
  picker (<select> of available calendars) when AvailableCalendars
  is non-empty AND the Create-new button (when the item has no
  linked list yet). Same surface serves both the "first link" flow
  and the "+ link another" flow per athena's brief.

## 2. Tag-on-create (CATEGORIES carries projax:<path>)

- caldav package gains Categories []string on Todo + the same on
  VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
  parseVTodos parses CATEGORIES comma-list into the slice with per-
  entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
  `Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
  VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
  edit/complete/delete paths preserve existing CATEGORIES via the
  unknown-property pass-through that's been tested since Phase 5
  (TestApplyVTodoEditPreservesUnknown).

## 3. Per-item filter (managed-vs-legacy)

- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
  whether the linked list is projax-managed (any projax: tag
  anywhere) or legacy/unmanaged (zero projax: tags).
  - Managed → filter to VTODOs whose CATEGORIES include this
    item's projax:<path>. Multiple projax: tags are AND-of-OR — a
    VTODO with two projax tags appears on both items per athena's
    multi-tag contract.
  - Legacy → show every VTODO untouched. Existing pre-5j users with
    untagged lists keep seeing everything; the detail page doesn't
    suddenly hide their tasks.

## Helpers (caldav package, exported)

- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal

## Tests

caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
  TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
  TestParseVTodosMultiCategory.

web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
  picker renders, POST link-existing creates the link, second GET
  drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
  VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
  Trip A and Trip B with three tagged VTODOs; A sees A+shared,
  B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
  tags renders ALL VTODOs (legacy fallback).

Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.

Net: +795 / -14.
2026-05-27 14:16:04 +02:00

539 lines
16 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
}
case "CATEGORIES":
// CATEGORIES is comma-separated per RFC 5545. Some clients emit
// multiple CATEGORIES lines; we merge by appending. The unescape
// is per-entry because commas inside a category value MUST be
// escaped (`\,`), so we split on bare commas only after unescape.
for _, raw := range strings.Split(val, ",") {
t := strings.TrimSpace(unescapeText(raw))
if t == "" {
continue
}
cur.Categories = append(cur.Categories, t)
}
}
}
return out
}
// ProjaxCategoryFor returns the projax-namespaced CATEGORIES entry for
// the given primary-path (e.g. "projax:admin.vacations.greece"). Used by
// both the write side (tag-on-create) and the read side (per-item filter).
func ProjaxCategoryFor(primaryPath string) string {
return "projax:" + primaryPath
}
// HasProjaxTag reports whether the VTODO carries any `projax:` category.
// Used to decide whether the per-item filter kicks in: a list with at
// least one projax: tag is "managed" by projax and the detail page only
// shows todos matching THIS item's path; a list with zero projax: tags
// is a legacy/unmanaged list and the detail page shows everything.
func HasProjaxTag(t Todo) bool {
for _, c := range t.Categories {
if strings.HasPrefix(c, "projax:") {
return true
}
}
return false
}
// HasProjaxTagFor reports whether the VTODO carries the specific
// `projax:<primaryPath>` category. A todo can carry multiple projax: tags
// (when it belongs to multiple projax items) — any match returns true.
func HasProjaxTagFor(t Todo, primaryPath string) bool {
want := ProjaxCategoryFor(primaryPath)
for _, c := range t.Categories {
if c == want {
return true
}
}
return false
}
// AnyTodoHasProjaxTag reports whether the slice contains at least one
// projax-tagged VTODO. The detail page uses this to decide between the
// projax-managed filter (show only matching) and the legacy unmanaged
// path (show all).
func AnyTodoHasProjaxTag(todos []Todo) bool {
for _, t := range todos {
if HasProjaxTag(t) {
return true
}
}
return false
}
// parseVEvents extracts every VEVENT block from a calendar-data string.
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
// before splitLine drops params. RRULE presence flips Recurring=true; the
// rule itself is intentionally NOT parsed — projax surfaces the literal
// DTSTART occurrence and a recurring badge.
func parseVEvents(ics string) []Event {
ics = unfold(ics)
lines := strings.Split(ics, "\n")
var out []Event
var inEvent bool
var cur Event
for _, ln := range lines {
ln = strings.TrimRight(ln, "\r")
if ln == "BEGIN:VEVENT" {
inEvent = true
cur = Event{}
continue
}
if ln == "END:VEVENT" {
if cur.UID != "" {
out = append(out, cur)
}
inEvent = false
continue
}
if !inEvent {
continue
}
key, val := splitLine(ln)
switch key {
case "UID":
cur.UID = val
case "SUMMARY":
cur.Summary = unescapeText(val)
case "DESCRIPTION":
cur.Description = unescapeText(val)
case "LOCATION":
cur.Location = unescapeText(val)
case "DTSTART":
if t, ok := parseICalTime(val); ok {
cur.Start = t
}
if hasDateOnlyParam(ln) {
cur.AllDay = true
}
case "DTEND":
if t, ok := parseICalTime(val); ok {
cur.End = t
}
case "RRULE":
cur.Recurring = true
}
}
return out
}
// hasDateOnlyParam reports whether the property line carried VALUE=DATE
// (rather than DATE-TIME) before the value separator. This matters because
// splitLine throws params away, so the caller has to inspect the raw line
// to know if the date is all-day or has a clock component.
func hasDateOnlyParam(ln string) bool {
colon := strings.Index(ln, ":")
if colon < 0 {
return false
}
head := ln[:colon]
semi := strings.Index(head, ";")
if semi < 0 {
return false
}
params := strings.ToUpper(head[semi+1:])
for _, p := range strings.Split(params, ";") {
if p == "VALUE=DATE" {
return true
}
}
return false
}
// 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
// Categories: optional CATEGORIES list. BuildVTodoICS writes them
// directly on a fresh VTODO. ApplyVTodoEdit intentionally ignores
// this field — existing categories pass through unchanged via the
// unknown-property preserve path, which is what every edit/complete/
// delete flow wants. Tag-on-create is the only write path that
// uses it.
Categories []string
}
// 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))
}
if len(e.Categories) > 0 {
// RFC 5545 CATEGORIES — comma-separated, single line. Escape commas
// inside individual entries so the round-trip survives parseVTodos.
escaped := make([]string, 0, len(e.Categories))
for _, c := range e.Categories {
escaped = append(escaped, escapeText(c))
}
lines = append(lines, "CATEGORIES:"+strings.Join(escaped, ","))
}
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)
}