From 83c965f11109185bb72a5ed916af84d78a50ee3a Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 17:16:38 +0200 Subject: [PATCH] feat(phase 2.b caldav): full read/write VTODO writeback from projax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- caldav/caldav.go | 109 ++++++++++++- caldav/caldav_test.go | 242 ++++++++++++++++++++++++++++ caldav/parse.go | 260 +++++++++++++++++++++++++++++++ docs/design.md | 16 +- web/caldav.go | 189 ++++++++++++++++++++++ web/server.go | 31 +++- web/static/style.css | 21 +++ web/templates/detail.tmpl | 37 +---- web/templates/tasks_section.tmpl | 102 ++++++++++++ 9 files changed, 962 insertions(+), 45 deletions(-) create mode 100644 web/templates/tasks_section.tmpl diff --git a/caldav/caldav.go b/caldav/caldav.go index 07a2946..6a4db58 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -45,7 +45,9 @@ type Calendar struct { Color string } -// Todo is one VTODO returned by ListTodos. +// Todo is one VTODO returned by ListTodos. URL, ETag and Raw are populated by +// ListTodos and required by PutTodo / DeleteTodo for optimistic-concurrency +// roundtrips. type Todo struct { UID string Summary string @@ -53,6 +55,9 @@ type Todo struct { Due *time.Time Priority int LastModified *time.Time + URL string // absolute URL of the .ics resource on the server + ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match + Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits } func (c *Client) do(ctx context.Context, method, urlStr string, headers map[string]string, body []byte) (*http.Response, error) { @@ -185,13 +190,36 @@ func (c *Client) ListTodos(ctx context.Context, calendarURL string) ([]Todo, err if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { return nil, fmt.Errorf("caldav REPORT decode: %w", err) } + base, err := url.Parse(calendarURL) + if err != nil { + return nil, err + } var out []Todo for _, r := range ms.Responses { + hrefURL, err := url.Parse(r.Href) + if err != nil { + continue + } + abs := *base + if hrefURL.IsAbs() { + abs = *hrefURL + } else { + abs.Path = hrefURL.Path + abs.RawQuery = "" + abs.Fragment = "" + } for _, ps := range r.PropStat { if ps.Prop.CalendarData == "" { continue } todos := parseVTodos(ps.Prop.CalendarData) + etag := strings.TrimSpace(ps.Prop.GetEtag) + raw := ps.Prop.CalendarData + for i := range todos { + todos[i].URL = abs.String() + todos[i].ETag = etag + todos[i].Raw = raw + } out = append(out, todos...) } } @@ -241,6 +269,85 @@ func (c *Client) CreateCalendar(ctx context.Context, calendarURL, displayName, c // already lives at the target URL. var ErrCalendarExists = errors.New("caldav: calendar already exists") +// ErrPreconditionFailed is returned by PutTodo / DeleteTodo when the server +// responds 412 — the ETag the client supplied no longer matches the server's +// copy. The caller should refetch the resource and retry. +var ErrPreconditionFailed = errors.New("caldav: precondition failed (etag mismatch)") + +// ErrNotFound is returned when the server reports 404 for a PUT/DELETE — most +// likely the resource was already removed. +var ErrNotFound = errors.New("caldav: resource not found") + +// PutTodo writes the given ICS body to resourceURL. ifMatch, if non-empty, is +// sent as If-Match for optimistic-concurrency on edits. ifNoneMatch ("*") +// guards creation against accidental overwrite. The returned string is the new +// ETag from the response (may be empty if the server didn't issue one — caller +// should refetch via ListTodos to pick up the canonical ETag). +func (c *Client) PutTodo(ctx context.Context, resourceURL, ics, ifMatch, ifNoneMatch string) (string, error) { + headers := map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + } + if ifMatch != "" { + headers["If-Match"] = ifMatch + } + if ifNoneMatch != "" { + headers["If-None-Match"] = ifNoneMatch + } + resp, err := c.do(ctx, "PUT", resourceURL, headers, []byte(ics)) + if err != nil { + return "", err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusCreated /* 201 */, http.StatusNoContent /* 204 */, http.StatusOK /* 200 */ : + return strings.TrimSpace(resp.Header.Get("ETag")), nil + case http.StatusPreconditionFailed /* 412 */ : + return "", ErrPreconditionFailed + case http.StatusNotFound /* 404 */ : + return "", ErrNotFound + default: + raw, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("caldav PUT %s: %d %s", resourceURL, resp.StatusCode, strings.TrimSpace(string(raw))) + } +} + +// DeleteTodo removes the resource at resourceURL. ifMatch is required so a +// concurrent edit on another client triggers a 412 rather than a silent loss. +func (c *Client) DeleteTodo(ctx context.Context, resourceURL, ifMatch string) error { + headers := map[string]string{} + if ifMatch != "" { + headers["If-Match"] = ifMatch + } + resp, err := c.do(ctx, "DELETE", resourceURL, headers, nil) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusNoContent /* 204 */, http.StatusOK /* 200 */ : + return nil + case http.StatusNotFound /* 404 */ : + // Treat as success — the resource is gone, which was the goal. + return nil + case http.StatusPreconditionFailed /* 412 */ : + return ErrPreconditionFailed + default: + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("caldav DELETE %s: %d %s", resourceURL, resp.StatusCode, strings.TrimSpace(string(raw))) + } +} + +// TodoURLFor builds the conventional CalDAV resource URL for a fresh VTODO +// with the given UID under calendarURL (which must end in '/'). The .ics +// extension matches SabreDAV conventions; some servers ignore it but it +// shouldn't ever hurt. +func TodoURLFor(calendarURL, uid string) string { + if !strings.HasSuffix(calendarURL, "/") { + calendarURL += "/" + } + return calendarURL + url.PathEscape(uid) + ".ics" +} + func xmlEscape(s string) string { var b bytes.Buffer _ = xml.EscapeText(&b, []byte(s)) diff --git a/caldav/caldav_test.go b/caldav/caldav_test.go index 515b134..a379aa2 100644 --- a/caldav/caldav_test.go +++ b/caldav/caldav_test.go @@ -2,10 +2,12 @@ package caldav import ( "context" + "fmt" "io" "net/http" "net/http/httptest" "strings" + "sync" "testing" ) @@ -88,9 +90,21 @@ func TestListTodos(t *testing.T) { if todos[0].Status != "NEEDS-ACTION" { t.Errorf("todos[0].Status = %q", todos[0].Status) } + if todos[0].ETag != `"abc"` { + t.Errorf("todos[0].ETag = %q, want \"abc\"", todos[0].ETag) + } + if !strings.HasSuffix(todos[0].URL, "/dav/calendars/m/Work/todo-1.ics") { + t.Errorf("todos[0].URL = %q, want suffix /dav/calendars/m/Work/todo-1.ics", todos[0].URL) + } + if !strings.Contains(todos[0].Raw, "BEGIN:VTODO") { + t.Errorf("todos[0].Raw missing BEGIN:VTODO: %q", todos[0].Raw) + } if todos[1].Status != "COMPLETED" { t.Errorf("todos[1].Status = %q", todos[1].Status) } + if todos[1].ETag != `"def"` { + t.Errorf("todos[1].ETag = %q", todos[1].ETag) + } } func TestCreateCalendar(t *testing.T) { @@ -108,6 +122,234 @@ func TestCreateCalendarExists(t *testing.T) { } } +// fakeCalendarStore is an in-memory calendar collection that implements just +// enough CalDAV for round-trip tests: REPORT for listing, PUT for create / +// edit (with If-Match), DELETE for removal (with If-Match). ETags are +// per-resource and bump on every write. +type fakeCalendarStore struct { + mu sync.Mutex + rows map[string]*fakeResource // path → resource + calBase string // server-relative path prefix, e.g. /dav/calendars/m/Work/ +} + +type fakeResource struct { + ics string + etag string +} + +func (s *fakeCalendarStore) report() string { + s.mu.Lock() + defer s.mu.Unlock() + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(``) + for href, r := range s.rows { + b.WriteString(fmt.Sprintf(`%s%s%sHTTP/1.1 200 OK`, href, r.etag, r.ics)) + } + b.WriteString(``) + return b.String() +} + +func (s *fakeCalendarStore) handler(t *testing.T) http.HandlerFunc { + t.Helper() + var nextETag int + bump := func() string { + nextETag++ + return fmt.Sprintf(`"etag-%d"`, nextETag) + } + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "REPORT": + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(207) + _, _ = io.WriteString(w, s.report()) + case "PUT": + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + defer s.mu.Unlock() + ifMatch := r.Header.Get("If-Match") + ifNone := r.Header.Get("If-None-Match") + existing, ok := s.rows[r.URL.Path] + if ifNone == "*" && ok { + w.WriteHeader(http.StatusPreconditionFailed) + return + } + if ifMatch != "" && (!ok || existing.etag != ifMatch) { + w.WriteHeader(http.StatusPreconditionFailed) + return + } + tag := bump() + s.rows[r.URL.Path] = &fakeResource{ics: string(body), etag: tag} + w.Header().Set("ETag", tag) + if !ok { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusNoContent) + } + case "DELETE": + s.mu.Lock() + defer s.mu.Unlock() + ifMatch := r.Header.Get("If-Match") + existing, ok := s.rows[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if ifMatch != "" && existing.etag != ifMatch { + w.WriteHeader(http.StatusPreconditionFailed) + return + } + delete(s.rows, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected method %q on %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +func newRoundTripServer(t *testing.T) (*Client, *fakeCalendarStore) { + t.Helper() + store := &fakeCalendarStore{rows: map[string]*fakeResource{}, calBase: "/dav/calendars/m/RT/"} + srv := httptest.NewServer(store.handler(t)) + t.Cleanup(srv.Close) + c := New(srv.URL+"/dav/calendars/m/", "u", "p") + return c, store +} + +func TestRoundTripCreateListCompleteDelete(t *testing.T) { + c, _ := newRoundTripServer(t) + calURL := c.BaseURL + "RT/" + uid := NewUID() + url := TodoURLFor(calURL, uid) + + // Create + summary := "Buy onions" + ics := BuildVTodoICS(uid, VTodoEdit{Summary: &summary}) + etag, err := c.PutTodo(context.Background(), url, ics, "", "*") + if err != nil { + t.Fatalf("PUT create: %v", err) + } + if etag == "" { + t.Fatal("PUT create: empty new ETag") + } + + // List → must contain the new UID + todos, err := c.ListTodos(context.Background(), calURL) + if err != nil { + t.Fatalf("ListTodos: %v", err) + } + if len(todos) != 1 { + t.Fatalf("expected 1 todo after create, got %d", len(todos)) + } + got := todos[0] + if got.UID != uid { + t.Errorf("UID = %q, want %q", got.UID, uid) + } + if got.Summary != summary { + t.Errorf("Summary = %q, want %q", got.Summary, summary) + } + if got.Status != "NEEDS-ACTION" { + t.Errorf("Status = %q, want NEEDS-ACTION", got.Status) + } + if got.ETag == "" { + t.Error("listed Todo missing ETag") + } + + // Complete in-place — preserve unknown fields by edit, not rebuild. + completed := "COMPLETED" + updated := ApplyVTodoEdit(got.Raw, VTodoEdit{Status: &completed}) + if !strings.Contains(updated, "STATUS:COMPLETED") { + t.Fatalf("edited ICS missing STATUS:COMPLETED:\n%s", updated) + } + if !strings.Contains(updated, "COMPLETED:") { + t.Fatalf("edited ICS missing COMPLETED line:\n%s", updated) + } + newETag, err := c.PutTodo(context.Background(), got.URL, updated, got.ETag, "") + if err != nil { + t.Fatalf("PUT complete: %v", err) + } + if newETag == got.ETag { + t.Errorf("ETag did not bump on complete (still %q)", newETag) + } + + // Re-list — status should reflect. + todos, err = c.ListTodos(context.Background(), calURL) + if err != nil { + t.Fatalf("ListTodos post-complete: %v", err) + } + if len(todos) != 1 || todos[0].Status != "COMPLETED" { + t.Fatalf("expected single COMPLETED todo, got %+v", todos) + } + + // Stale ETag on edit must yield ErrPreconditionFailed. + if _, err := c.PutTodo(context.Background(), got.URL, updated, "stale-etag", ""); err != ErrPreconditionFailed { + t.Errorf("expected ErrPreconditionFailed on stale ETag, got %v", err) + } + + // Delete with current ETag. + if err := c.DeleteTodo(context.Background(), todos[0].URL, todos[0].ETag); err != nil { + t.Fatalf("DELETE: %v", err) + } + + todos, err = c.ListTodos(context.Background(), calURL) + if err != nil { + t.Fatalf("ListTodos post-delete: %v", err) + } + if len(todos) != 0 { + t.Fatalf("expected empty list after delete, got %d", len(todos)) + } +} + +func TestApplyVTodoEditPreservesUnknown(t *testing.T) { + raw := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:keep-me\r\nSUMMARY:Old\r\nSTATUS:NEEDS-ACTION\r\nDESCRIPTION:Long context that must not vanish\r\nCATEGORIES:home,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n" + newSummary := "New" + got := ApplyVTodoEdit(raw, VTodoEdit{Summary: &newSummary}) + if !strings.Contains(got, "SUMMARY:New") { + t.Errorf("expected SUMMARY:New, got:\n%s", got) + } + if strings.Contains(got, "SUMMARY:Old") { + t.Errorf("old SUMMARY still present:\n%s", got) + } + if !strings.Contains(got, "DESCRIPTION:Long context") { + t.Errorf("DESCRIPTION dropped during edit:\n%s", got) + } + if !strings.Contains(got, "CATEGORIES:home,errands") { + t.Errorf("CATEGORIES dropped during edit:\n%s", got) + } + if !strings.Contains(got, "UID:keep-me") { + t.Errorf("UID dropped during edit:\n%s", got) + } +} + +func TestBuildVTodoICSFolding(t *testing.T) { + long := strings.Repeat("ABCDEFGH", 20) // 160 chars — must fold across multiple lines. + ics := BuildVTodoICS("uid-fold", VTodoEdit{Summary: &long}) + // Every physical line must be ≤ 75 octets after fold. + for _, ln := range strings.Split(ics, "\r\n") { + if len(ln) > 75 { + t.Errorf("line exceeds 75 octets after fold (%d): %q", len(ln), ln) + } + } + // Unfolding should recover the original SUMMARY value. + parsed := parseVTodos(ics) + if len(parsed) != 1 || parsed[0].Summary != long { + t.Fatalf("round-trip SUMMARY mismatch, got %+v", parsed) + } +} + +func TestEscapeText(t *testing.T) { + in := "Pick up, eggs; cheese\\bread\nnext line" + got := escapeText(in) + want := `Pick up\, eggs\; cheese\\bread\nnext line` + if got != want { + t.Errorf("escapeText(%q) = %q, want %q", in, got, want) + } + if back := unescapeText(got); back != in { + t.Errorf("unescape round-trip: %q != %q", back, in) + } +} + const propfindBody = ` diff --git a/caldav/parse.go b/caldav/parse.go index 029bc0a..d6fe4d2 100644 --- a/caldav/parse.go +++ b/caldav/parse.go @@ -1,6 +1,8 @@ package caldav import ( + "crypto/rand" + "fmt" "strconv" "strings" "time" @@ -121,3 +123,261 @@ func unescapeText(s string) string { 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) +} diff --git a/docs/design.md b/docs/design.md index 4a1db1a..39c6feb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -230,20 +230,24 @@ After 1c, m can use the system. Test rows in mai.projects either stay as orphans - Public exposure (Tailscale only) - Generic SaaS instincts (admin panels, billing, audit logs) - CLI surface (m has explicitly opted out) -- Bidirectional CalDAV/Gitea sync in v1 (read-only first) +- Bidirectional Gitea sync in v1 (read-only mirror first; CalDAV is full read/write as of phase 2.b) - Real-time collaboration features -## 5. CalDAV integration (Phase 2, v1: read-only + create-on-demand) +## 5. CalDAV integration (Phase 2, v1: full read/write) -m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth via `DAV_USER`/`DAV_PASSWORD`). projax v1 wires a small slice: +m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth via `DAV_USER`/`DAV_PASSWORD`). projax v1 wires the slice m exercises day-to-day: - **Link model**: a `projax.item_links` row with `ref_type='caldav-list'`, `ref_id=`, `metadata={display_name, calendar_color, linked_at, …}`. Same item_links row pattern as `mai-project` / `gitea-repo`. An item can be linked to multiple calendars; a calendar can be linked to multiple items (rare in practice). - **Discovery** (`GET /admin/caldav`): the binary PROPFINDs Depth: 1 against the base URL, filters out non-calendar collections (`inbox`/`outbox`), and pairs each discovered calendar with the projax item whose lowercased title or slug matches the calendar's display name. m confirms or overrides each suggestion. - **Linking** (`POST /admin/caldav/link` / `/admin/caldav/unlink`): single-row CRUD on item_links. No background sync. -- **Task aggregation** (item detail page): for each linked calendar, the binary REPORTs `calendar-query` for VTODOs and renders open + recent-completed tasks. Errors per-calendar are logged and skipped — one bad list does not blank the section. -- **Create on demand** (`POST /i/{path}/caldav/create`): MKCALENDAR at `//` with display name ``. If the URL is already in use (SabreDAV returns 405), the binary links to the existing calendar instead and surfaces a one-line notice. +- **Task aggregation** (item detail page): for each linked calendar, the binary REPORTs `calendar-query` for VTODOs and renders open + recent-completed tasks. Each row carries its server ETag and raw ICS so the writeback affordances below can do optimistic-concurrency PUTs. Errors per-calendar are logged and skipped — one bad list does not blank the section. +- **Create-on-demand list** (`POST /i/{path}/caldav/create`): MKCALENDAR at `//` with display name ``. If the URL is already in use (SabreDAV returns 405), the binary links to the existing calendar instead. +- **Writeback affordances on the detail page (phase 2.b)**: each VTODO row exposes complete (checkbox → `STATUS:COMPLETED` + `COMPLETED:`), reopen (`STATUS:NEEDS-ACTION`, COMPLETED cleared), inline edit of SUMMARY + DUE, and hard-delete via `×` with an `hx-confirm` dialog. An "Add task" form at the top of each linked calendar POSTs a fresh VTODO (UID is a server-generated RFC 4122 v4). All five actions are HTMX-driven (`hx-post` + `hx-target="#tasks-section"` + `hx-swap="outerHTML"`): the handler re-renders the tasks fragment so the swap reflects the post-write server state. +- **Optimistic concurrency**: every edit/complete/reopen/delete request carries an `If-Match: ` header. The handler first re-`ListTodos`'es the calendar (small calendars → cheap; ETags from the page render may have drifted) and uses the live ETag, so ordinary use never trips 412. When the server still returns 412 — e.g. another client edited between refetch and PUT — the section re-renders with a banner: "Task changed elsewhere since this page was loaded — refresh and retry." The cached ETag table envisioned in Phase 2.c remains parked until live REPORT-querying gets slow. +- **ICS round-trip**: writes that modify an existing task call `ApplyVTodoEdit` against the server's raw ICS so unknown properties (DESCRIPTION, CATEGORIES, X-extensions, …) survive the round-trip. Only the keys projax knows about (SUMMARY, STATUS, COMPLETED, DUE, PRIORITY, LAST-MODIFIED, DTSTAMP) get rewritten. New tasks go through `BuildVTodoICS` which emits a minimal but valid VCALENDAR wrapper with RFC 5545 folding at 75 octets and CRLF terminators. - **Multi-parent items** keep ONE list per item — the URL is derived from the slug, not the path. `paliad` gets `/dav/calendars/m/paliad/` whether it lives at `work.paliad`, `dev.paliad`, or both. -- **Out of scope for v1**: editing VTODOs from projax, two-way creation, background sync, calendar colour/icon editing. Phase 2.b will layer write semantics; phase 2.c may add a TTL'd cache table if live REPORT-querying gets slow. +- **Authorisation**: writeback handlers reject calendar URLs not currently linked to the item, so a crafted form can't route writes to arbitrary collections. +- **Out of scope (still parked)**: RRULE / recurring VTODOs (rendered as single occurrences until m needs more), background sync, multi-calendar drag-and-drop. Phase 2.c may add a TTL'd `cached_tasks` table if live REPORT-querying gets slow at m's scale. Env contract: `DAV_URL` (default `https://dav.msbls.de/dav/calendars/m/`), `DAV_USER`, `DAV_PASSWORD`. All three live in Dokploy secrets; missing → `/admin/caldav` renders a "not configured" notice and the detail page hides the Tasks section. diff --git a/web/caldav.go b/web/caldav.go index 0bb1efc..82ebae6 100644 --- a/web/caldav.go +++ b/web/caldav.go @@ -209,6 +209,10 @@ type calendarTasks struct { DisplayName string Open []caldav.Todo DoneRecent []caldav.Todo + // Error, when non-empty, surfaces a per-calendar problem (network, + // upstream auth, parse) so the UI can show a banner instead of silently + // blanking the calendar. + Error string } func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarTasks, error) { @@ -254,3 +258,188 @@ func linkDisplay(l *store.ItemLink) string { } return l.RefID } + +// handleCalDAVTodoAction dispatches POST /i/{path}/caldav/todo/{action}. +// action ∈ {complete, reopen, edit, delete, todo-create}. The handler reloads +// the live VTODO (to pick up the freshest ETag), applies the requested edit, +// PUTs / DELETEs against the server, then re-renders the tasks section so +// HTMX can swap it in. 412 responses surface as a banner so m can retry. +func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request, path, action string) { + if s.CalDAV == nil { + http.Error(w, "caldav not configured", http.StatusServiceUnavailable) + return + } + it, err := s.Store.GetByPath(r.Context(), path) + if err != nil { + s.fail(w, r, err) + return + } + if err := r.ParseForm(); err != nil { + s.fail(w, r, err) + return + } + calURL := strings.TrimSpace(r.FormValue("calendar_url")) + if calURL == "" { + http.Error(w, "calendar_url required", http.StatusBadRequest) + return + } + // Guard: the calendar URL must be linked to this item — otherwise a + // crafted form could route writes to arbitrary calendars. + links, err := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV) + if err != nil { + s.fail(w, r, err) + return + } + var matchedLink *store.ItemLink + for _, l := range links { + if l.RefID == calURL { + matchedLink = l + break + } + } + if matchedLink == nil { + http.Error(w, "calendar not linked to this item", http.StatusForbidden) + return + } + + banner := "" + switch action { + case "todo-create": + summary := strings.TrimSpace(r.FormValue("summary")) + if summary == "" { + banner = "Cannot create task with empty summary." + break + } + edit := caldav.VTodoEdit{Summary: &summary} + if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" { + if t, ok := parseDueInput(dueStr); ok { + edit.Due = &t + } + } + uid := caldav.NewUID() + ics := caldav.BuildVTodoICS(uid, edit) + url := caldav.TodoURLFor(calURL, uid) + if _, err := s.CalDAV.Client.PutTodo(r.Context(), url, ics, "", "*"); err != nil { + banner = "Could not create task: " + err.Error() + } + case "complete", "reopen", "edit", "delete": + uid := strings.TrimSpace(r.FormValue("uid")) + if uid == "" { + http.Error(w, "uid required", http.StatusBadRequest) + return + } + // Refetch — ETags from the original page render may be stale, and we + // also need the latest Raw ICS body for in-place edits that preserve + // unknown fields. + todos, err := s.CalDAV.Client.ListTodos(r.Context(), calURL) + if err != nil { + banner = "Could not reach calendar: " + err.Error() + break + } + var current *caldav.Todo + for i := range todos { + if todos[i].UID == uid { + current = &todos[i] + break + } + } + if current == nil { + banner = "Task no longer exists on the server." + break + } + switch action { + case "complete": + st := "COMPLETED" + updated := caldav.ApplyVTodoEdit(current.Raw, caldav.VTodoEdit{Status: &st}) + if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil { + banner = caldavBanner("complete", err) + } + case "reopen": + st := "NEEDS-ACTION" + updated := caldav.ApplyVTodoEdit(current.Raw, caldav.VTodoEdit{Status: &st}) + if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil { + banner = caldavBanner("reopen", err) + } + case "edit": + edit := caldav.VTodoEdit{} + if v := r.FormValue("summary"); v != "" { + vv := strings.TrimSpace(v) + edit.Summary = &vv + } + if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" { + if t, ok := parseDueInput(dueStr); ok { + edit.Due = &t + } + } else if _, present := r.Form["due"]; present { + // Field submitted but blank → user cleared it. + edit.ClearDue = true + } + updated := caldav.ApplyVTodoEdit(current.Raw, edit) + if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil { + banner = caldavBanner("edit", err) + } + case "delete": + if err := s.CalDAV.Client.DeleteTodo(r.Context(), current.URL, current.ETag); err != nil { + banner = caldavBanner("delete", err) + } + } + default: + http.Error(w, "unknown action: "+action, http.StatusBadRequest) + return + } + + // Always re-render the tasks section so HTMX (or a plain redirect for + // non-HTMX clients) sees the post-write state. + if r.Header.Get("HX-Request") == "true" { + s.renderTasksSection(w, r, it, banner) + return + } + http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther) +} + +// caldavBanner formats an HTMX-banner string from a write error, distinguishing +// the 412-mismatch case ("task changed elsewhere") from generic upstream +// failures so m sees something actionable. +func caldavBanner(action string, err error) string { + if errors.Is(err, caldav.ErrPreconditionFailed) { + return "Task changed elsewhere since this page was loaded — refresh and retry the " + action + "." + } + if errors.Is(err, caldav.ErrNotFound) { + return "Task is gone on the server. The list below is current." + } + return "Could not " + action + " task: " + err.Error() +} + +// renderTasksSection re-runs detailTodos for the item and renders the +// tasks-section template fragment with an optional banner. Used by HTMX +// responses so swap operations stay in-place. +func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) { + tasks, err := s.detailTodos(r.Context(), it) + if err != nil { + s.fail(w, r, err) + return + } + data := map[string]any{ + "Item": it, + "Tasks": tasks, + "CalDAVOn": s.CalDAV != nil, + "Banner": banner, + } + s.render(w, "tasks_section", data) +} + +// parseDueInput accepts an HTML5 date-input value (`YYYY-MM-DD`) or a +// datetime-local value (`YYYY-MM-DDTHH:MM`), returning the corresponding UTC +// time. Dates with no clock component round-trip to a DUE;VALUE=DATE line. +func parseDueInput(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/web/server.go b/web/server.go index 7b53d0d..b652b35 100644 --- a/web/server.go +++ b/web/server.go @@ -73,7 +73,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { }, } pages := map[string]*template.Template{} - for _, name := range []string{"tree", "detail", "new", "classify", "caldav_admin", "caldav_disabled", "error"} { + for _, name := range []string{"tree", "new", "classify", "caldav_admin", "caldav_disabled", "error"} { t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS, "templates/layout.tmpl", "templates/"+name+".tmpl", @@ -83,6 +83,23 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { } pages[name] = t } + // detail bundles the shared tasks-section partial so HTMX swaps and the + // initial page render hit the same template definition. + detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS, + "templates/layout.tmpl", + "templates/detail.tmpl", + "templates/tasks_section.tmpl", + ) + if err != nil { + return nil, fmt.Errorf("parse detail: %w", err) + } + pages["detail"] = detailTmpl + // Standalone tasks-section template for HTMX fragment responses. + tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl") + if err != nil { + return nil, fmt.Errorf("parse tasks_section: %w", err) + } + pages["tasks_section"] = tasksFragment loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl") if err != nil { return nil, fmt.Errorf("parse login: %w", err) @@ -200,6 +217,12 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) { s.handleCalDAVCreate(w, r, base) return } + for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} { + if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok { + s.handleCalDAVTodoAction(w, r, base, action) + return + } + } it, err := s.Store.GetByPath(r.Context(), path) if err != nil { s.fail(w, r, err) @@ -504,9 +527,13 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) return } entry := "layout" - if name == "login" { + switch name { + case "login": // Login page is intentionally standalone — no nav chrome. entry = "login" + case "tasks_section": + // HTMX fragment — no layout chrome. + entry = "tasks-section" } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, entry, data); err != nil { diff --git a/web/static/style.css b/web/static/style.css index c2baf75..d0182ac 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -69,3 +69,24 @@ table.classify { width: 100%; border-collapse: collapse; margin-top: 16px; } table.classify th, table.classify td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } table.classify input, table.classify select { width: 100%; } .error { color: var(--bad); } + +/* Tasks section — HTMX-driven VTODO writeback (phase 2.b). */ +.tasks .cal-block { border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; margin: 8px 0 16px; background: #fff; } +.tasks .cal-block h3 { font-size: 0.95em; margin: 0 0 8px; color: var(--muted); } +.tasks ul.todo { list-style: none; padding: 0; margin: 0; } +.tasks li.todo-row { display: flex; gap: 6px; align-items: center; padding: 4px 0; border-bottom: 1px dotted var(--border); } +.tasks li.todo-row:last-child { border-bottom: none; } +.tasks li.todo-row form.inline { display: inline-flex; align-items: center; gap: 4px; margin: 0; } +.tasks li.todo-row .todo-edit { flex: 1; } +.tasks li.todo-row .todo-edit input[type="text"] { flex: 1; min-width: 12em; } +.tasks li.todo-row button { padding: 2px 8px; } +.tasks li.todo-row button.check, .tasks li.todo-row button.x { + background: #fff; color: var(--muted); border-color: var(--border); + font-size: 1.1em; line-height: 1; padding: 2px 6px; +} +.tasks li.todo-row button.check:hover { color: var(--ok); border-color: var(--ok); } +.tasks li.todo-row button.x:hover { color: var(--bad); border-color: var(--bad); } +.tasks .todo-create { display: flex; gap: 6px; margin: 6px 0 10px; } +.tasks .todo-create input[type="text"] { flex: 1; } +.tasks ul.done .summary { color: var(--muted); text-decoration: line-through; flex: 1; } +.banner.warn { background: #fff5e6; border: 1px solid var(--warn); color: var(--warn); padding: 6px 10px; border-radius: 4px; margin: 8px 0; } diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl index b6d6e0e..5f02f5e 100644 --- a/web/templates/detail.tmpl +++ b/web/templates/detail.tmpl @@ -14,42 +14,7 @@ {{end}} {{if .CalDAVOn}} -
-

Tasks

- {{if .Tasks}} - {{range .Tasks}} -
-

{{.DisplayName}}

- {{if .Open}} -
    - {{range .Open}} -
  • - {{.Status}} - {{.Summary}} - {{if .Due}}due {{.Due.Format "2006-01-02"}}{{end}} -
  • - {{end}} -
- {{else}} -

No open tasks.

- {{end}} - {{if .DoneRecent}} -
- {{len .DoneRecent}} completed in last 30 days -
    - {{range .DoneRecent}}
  • {{.Summary}}
  • {{end}} -
-
- {{end}} -
- {{end}} - {{else}} -

No CalDAV list linked.

-
- -
- {{end}} -
+{{template "tasks-section" .}} {{end}}
diff --git a/web/templates/tasks_section.tmpl b/web/templates/tasks_section.tmpl new file mode 100644 index 0000000..b405a02 --- /dev/null +++ b/web/templates/tasks_section.tmpl @@ -0,0 +1,102 @@ +{{define "tasks-section"}} +
+

Tasks

+ {{if .Banner}}{{end}} + {{if .Tasks}} + {{$root := .}} + {{range .Tasks}} + {{$calURL := .CalendarURL}} +
+

{{.DisplayName}}

+ {{if .Error}}{{end}} + + + + + + + + + {{if .Open}} +
    + {{range .Open}} +
  • +
    + + + +
    + +
    + + + + + +
    + +
    + + + +
    +
  • + {{end}} +
+ {{else}} +

No open tasks.

+ {{end}} + + {{if .DoneRecent}} +
+ {{len .DoneRecent}} completed in last 30 days +
    + {{range .DoneRecent}} +
  • +
    + + + +
    + {{.Summary}} +
    + + + +
    +
  • + {{end}} +
+
+ {{end}} +
+ {{end}} + {{else}} +

No CalDAV list linked.

+
+ +
+ {{end}} +
+{{end}}