Merge branch 'mai/knuth/phase-2-b-caldav-vtodo'
This commit is contained in:
109
caldav/caldav.go
109
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))
|
||||
|
||||
@@ -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(`<?xml version="1.0"?>` + "\n")
|
||||
b.WriteString(`<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
for href, r := range s.rows {
|
||||
b.WriteString(fmt.Sprintf(`<d:response><d:href>%s</d:href><d:propstat><d:prop><d:getetag>%s</d:getetag><cal:calendar-data>%s</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`, href, r.etag, r.ics))
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
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 = `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:response>
|
||||
|
||||
260
caldav/parse.go
260
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)
|
||||
}
|
||||
|
||||
@@ -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=<absolute calendar URL>`, `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 `<base>/<item.slug>/` with display name `<item.title>`. 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 `<base>/<item.slug>/` with display name `<item.title>`. 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:<UTC>`), 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: <ETag>` 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.
|
||||
|
||||
|
||||
189
web/caldav.go
189
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -14,42 +14,7 @@
|
||||
{{end}}
|
||||
|
||||
{{if .CalDAVOn}}
|
||||
<section class="tasks">
|
||||
<h2>Tasks</h2>
|
||||
{{if .Tasks}}
|
||||
{{range .Tasks}}
|
||||
<div class="cal-block">
|
||||
<h3>{{.DisplayName}}</h3>
|
||||
{{if .Open}}
|
||||
<ul class="todo open">
|
||||
{{range .Open}}
|
||||
<li>
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
{{.Summary}}
|
||||
{{if .Due}}<small class="muted">due {{.Due.Format "2006-01-02"}}</small>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">No open tasks.</p>
|
||||
{{end}}
|
||||
{{if .DoneRecent}}
|
||||
<details>
|
||||
<summary class="muted">{{len .DoneRecent}} completed in last 30 days</summary>
|
||||
<ul class="todo done">
|
||||
{{range .DoneRecent}}<li>{{.Summary}}</li>{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">No CalDAV list linked.</p>
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">Create CalDAV list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{template "tasks-section" .}}
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||
|
||||
102
web/templates/tasks_section.tmpl
Normal file
102
web/templates/tasks_section.tmpl
Normal file
@@ -0,0 +1,102 @@
|
||||
{{define "tasks-section"}}
|
||||
<section class="tasks" id="tasks-section">
|
||||
<h2>Tasks</h2>
|
||||
{{if .Banner}}<p class="banner warn" role="alert">{{.Banner}}</p>{{end}}
|
||||
{{if .Tasks}}
|
||||
{{$root := .}}
|
||||
{{range .Tasks}}
|
||||
{{$calURL := .CalendarURL}}
|
||||
<div class="cal-block" data-cal="{{$calURL}}">
|
||||
<h3>{{.DisplayName}}</h3>
|
||||
{{if .Error}}<p class="banner warn">{{.Error}}</p>{{end}}
|
||||
|
||||
<form class="todo-create"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="text" name="summary" placeholder="Add a task…" required>
|
||||
<input type="date" name="due" title="due date (optional)">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
{{if .Open}}
|
||||
<ul class="todo open">
|
||||
{{range .Open}}
|
||||
<li class="todo-row" data-uid="{{.UID}}">
|
||||
<form class="todo-complete inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
|
||||
</form>
|
||||
|
||||
<form class="todo-edit inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<input type="text" name="summary" value="{{.Summary}}" required>
|
||||
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
|
||||
<button type="submit" title="Save edits">Save</button>
|
||||
</form>
|
||||
|
||||
<form class="todo-delete inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">No open tasks.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .DoneRecent}}
|
||||
<details>
|
||||
<summary class="muted">{{len .DoneRecent}} completed in last 30 days</summary>
|
||||
<ul class="todo done">
|
||||
{{range .DoneRecent}}
|
||||
<li class="todo-row" data-uid="{{.UID}}">
|
||||
<form class="todo-reopen inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML"
|
||||
title="Reopen">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="check" aria-label="Reopen">☑</button>
|
||||
</form>
|
||||
<span class="summary">{{.Summary}}</span>
|
||||
<form class="todo-delete inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">No CalDAV list linked.</p>
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">Create CalDAV list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user