diff --git a/caldav/caldav.go b/caldav/caldav.go index a68ec53..9d8f859 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -55,9 +55,14 @@ 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 + // Categories carries the RFC 5545 CATEGORIES property as a flat + // slice (already comma-split, trimmed). Phase 5j uses entries + // prefixed `projax:` to tag VTODOs to projax items — + // see HasProjaxTag + ProjaxCategoryFor in this package. + Categories []string + 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 } // Event is one VEVENT returned by ListEvents. Phase 3l: read-only, no diff --git a/caldav/parse.go b/caldav/parse.go index e16705b..53304cc 100644 --- a/caldav/parse.go +++ b/caldav/parse.go @@ -54,11 +54,70 @@ func parseVTodos(ics string) []Todo { 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:` 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 @@ -296,6 +355,13 @@ type VTodoEdit struct { 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, @@ -336,6 +402,15 @@ func BuildVTodoICS(uid string, e VTodoEdit) string { 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) } diff --git a/caldav/projax_tags_test.go b/caldav/projax_tags_test.go new file mode 100644 index 0000000..43b9a72 --- /dev/null +++ b/caldav/projax_tags_test.go @@ -0,0 +1,114 @@ +package caldav + +import ( + "strings" + "testing" +) + +// TestProjaxCategoryFor pins the tag string format. The format is part +// of the projax↔CalDAV contract — `projax:` — and other +// tooling (admin triage, future migration scripts) will rely on the +// prefix. A typo here silently breaks the per-item filter. +func TestProjaxCategoryFor(t *testing.T) { + got := ProjaxCategoryFor("admin.vacations.greece") + want := "projax:admin.vacations.greece" + if got != want { + t.Errorf("ProjaxCategoryFor = %q, want %q", got, want) + } +} + +// TestHasProjaxTagAndFor exercises the two read-side helpers that drive +// the per-item filter on the detail page: HasProjaxTag (any projax: tag +// at all) and HasProjaxTagFor (matches THIS path). +func TestHasProjaxTagAndFor(t *testing.T) { + tagged := Todo{Categories: []string{"home", "projax:admin.vacations.greece", "errands"}} + if !HasProjaxTag(tagged) { + t.Errorf("HasProjaxTag should fire for any projax: category") + } + if !HasProjaxTagFor(tagged, "admin.vacations.greece") { + t.Errorf("HasProjaxTagFor should match exact projax:") + } + if HasProjaxTagFor(tagged, "admin.vacations.spain") { + t.Errorf("HasProjaxTagFor should NOT match a different path") + } + + multi := Todo{Categories: []string{"projax:work.proj1", "projax:work.proj2"}} + if !HasProjaxTagFor(multi, "work.proj1") { + t.Errorf("multi-tag todo should match first projax: tag") + } + if !HasProjaxTagFor(multi, "work.proj2") { + t.Errorf("multi-tag todo should match second projax: tag") + } + + untagged := Todo{Categories: []string{"home", "errands"}} + if HasProjaxTag(untagged) { + t.Errorf("HasProjaxTag should be false on a no-projax: list") + } + if HasProjaxTagFor(untagged, "anything") { + t.Errorf("HasProjaxTagFor must be false when no projax: tag exists") + } +} + +// TestAnyTodoHasProjaxTag drives the list-level managed-vs-legacy +// decision in detailTodos. Untagged lists keep their pre-5j show-all +// behaviour; one tagged todo flips the entire list into managed mode. +func TestAnyTodoHasProjaxTag(t *testing.T) { + none := []Todo{ + {Categories: []string{"home"}}, + {Categories: nil}, + } + if AnyTodoHasProjaxTag(none) { + t.Errorf("untagged list should NOT be projax-managed") + } + mixed := []Todo{ + {Categories: []string{"home"}}, + {Categories: []string{"projax:admin.vacations.greece"}}, + } + if !AnyTodoHasProjaxTag(mixed) { + t.Errorf("list with one projax-tagged todo should be projax-managed") + } +} + +// TestBuildVTodoICSEmitsCategories proves the tag-on-create path. The +// Phase 5j write side passes Categories into VTodoEdit; BuildVTodoICS +// must render the CATEGORIES line so the server-side round-trip +// (parseVTodos picks it back up) carries the tag through. +func TestBuildVTodoICSEmitsCategories(t *testing.T) { + summary := "Buy gear" + ics := BuildVTodoICS("uid-tagged", VTodoEdit{ + Summary: &summary, + Categories: []string{"projax:admin.vacations.greece"}, + }) + if !strings.Contains(ics, "CATEGORIES:projax:admin.vacations.greece") { + t.Errorf("BuildVTodoICS should emit CATEGORIES line, got:\n%s", ics) + } + // Round-trip: parse it back, the Categories slice must be populated. + todos := parseVTodos(ics) + if len(todos) != 1 { + t.Fatalf("parseVTodos round-trip expected 1 todo, got %d", len(todos)) + } + if !HasProjaxTagFor(todos[0], "admin.vacations.greece") { + t.Errorf("round-trip lost CATEGORIES: %#v", todos[0].Categories) + } +} + +// TestParseVTodosMultiCategory proves the parser handles RFC 5545 +// comma-separated CATEGORIES correctly (a single CATEGORIES line with +// multiple values, not multiple CATEGORIES lines). This is the wire +// shape Apple Calendar + Thunderbird + Radicale all emit. +func TestParseVTodosMultiCategory(t *testing.T) { + ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:multi\r\nSUMMARY:Multi\r\nSTATUS:NEEDS-ACTION\r\nCATEGORIES:home,projax:admin.vacations.greece,projax:work.someproj,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n" + todos := parseVTodos(ics) + if len(todos) != 1 { + t.Fatalf("expected 1 todo, got %d", len(todos)) + } + want := []string{"home", "projax:admin.vacations.greece", "projax:work.someproj", "errands"} + if len(todos[0].Categories) != len(want) { + t.Fatalf("Categories = %v, want %v", todos[0].Categories, want) + } + for i, c := range todos[0].Categories { + if c != want[i] { + t.Errorf("Categories[%d] = %q, want %q", i, c, want[i]) + } + } +} diff --git a/web/caldav.go b/web/caldav.go index f7e8d9e..b572cb5 100644 --- a/web/caldav.go +++ b/web/caldav.go @@ -151,6 +151,93 @@ func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther) } +// availableCalendarsForItem returns the discoverable CalDAV calendars +// minus the ones already linked to this item — feeds the per-item +// "Link existing list" picker on the detail page. Errors during +// discovery (network, auth, parse) are surfaced to the caller; callers +// downgrade to an empty list so the rest of the page still renders. +// +// "Already linked" is computed by the caller's `links` slice rather +// than a fresh fetch, since handleDetail/renderTasksSection already +// loaded the per-item caldav-list links inside detailTodos and we +// avoid a second LinksByType round-trip. +func (s *Server) availableCalendarsForItem(ctx context.Context, links []*store.ItemLink) ([]caldav.Calendar, error) { + if s.CalDAV == nil { + return nil, nil + } + cals, err := s.CalDAV.Client.ListCalendars(ctx) + if err != nil { + return nil, err + } + linkedURLs := map[string]struct{}{} + for _, l := range links { + linkedURLs[l.RefID] = struct{}{} + } + out := make([]caldav.Calendar, 0, len(cals)) + for _, c := range cals { + if _, already := linkedURLs[c.URL]; already { + continue + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName }) + return out, nil +} + +// handleCalDAVLinkExisting handles POST /i/{path}/caldav/link-existing — +// the per-item picker for sharing an existing CalDAV list across +// multiple projax items. Re-runs ListCalendars to validate that the +// submitted URL is genuinely discoverable (defence against a crafted +// form pointing at an arbitrary URL), then inserts the item_link. +func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request, path 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 + } + // Validate the URL is in the discoverable set — a malicious form must + // not be able to seed an item_link pointing at arbitrary HTTP servers. + cals, err := s.CalDAV.Client.ListCalendars(r.Context()) + if err != nil { + s.fail(w, r, err) + return + } + var matched *caldav.Calendar + for i := range cals { + if cals[i].URL == calURL { + matched = &cals[i] + break + } + } + if matched == nil { + http.Error(w, "calendar not in discoverable set", http.StatusBadRequest) + return + } + meta := map[string]any{ + "display_name": matched.DisplayName, + "calendar_color": matched.Color, + "linked_at": time.Now().UTC().Format(time.RFC3339), + } + if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil { + s.fail(w, r, err) + return + } + http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther) +} + // handleCalDAVCreate handles POST /i/{path}/caldav/create — MKCALENDAR on // dav.msbls.de derived from the item slug, then the item_link insert. func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path string) { @@ -231,6 +318,22 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT s.Logger.Warn("caldav todos", "calendar", l.RefID, "err", err) continue } + // Phase 5j per-item filter: when the linked list contains ANY + // projax-tagged VTODO it's a managed list — narrow to entries + // carrying this item's `projax:` tag. A list with zero + // projax tags is a legacy/unmanaged list and renders unfiltered + // (existing pre-5j behaviour, untouched). The cutoff still + // applies to DoneRecent on the post-filter slice. + if caldav.AnyTodoHasProjaxTag(todos) { + want := item.PrimaryPath() + filtered := todos[:0:0] + for _, td := range todos { + if caldav.HasProjaxTagFor(td, want) { + filtered = append(filtered, td) + } + } + todos = filtered + } ct := calendarTasks{ CalendarURL: l.RefID, DisplayName: linkDisplay(l), @@ -310,7 +413,14 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request, banner = "Cannot create task with empty summary." break } - edit := caldav.VTodoEdit{Summary: &summary} + // Phase 5j tag-on-create: every VTODO created from a per-item Add + // form gets `projax:` in CATEGORIES so multiple + // projax items can share one CalDAV list and the per-item filter + // only surfaces the right ones. + edit := caldav.VTodoEdit{ + Summary: &summary, + Categories: []string{caldav.ProjaxCategoryFor(it.PrimaryPath())}, + } if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" { if t, ok := parseDueInput(dueStr); ok { edit.Due = &t @@ -426,11 +536,27 @@ func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it * s.fail(w, r, err) return } + // HTMX swaps re-render the section in place; the picker needs the same + // AvailableCalendars data the full /i/{path} render computes. Errors + // here are non-fatal — degrade to an empty picker. + var available []caldav.Calendar + if s.CalDAV != nil { + caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV) + if lerr != nil { + s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr) + } + acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks) + if aerr != nil { + s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr) + } + available = acs + } data := map[string]any{ - "Item": it, - "Tasks": tasks, - "CalDAVOn": s.CalDAV != nil, - "Banner": banner, + "Item": it, + "Tasks": tasks, + "AvailableCalendars": available, + "CalDAVOn": s.CalDAV != nil, + "Banner": banner, } s.render(w, r, "tasks_section", data) } diff --git a/web/caldav_link_existing_test.go b/web/caldav_link_existing_test.go new file mode 100644 index 0000000..53ef583 --- /dev/null +++ b/web/caldav_link_existing_test.go @@ -0,0 +1,419 @@ +package web_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/m/projax/caldav" + "github.com/m/projax/web" +) + +// fakeCalDAVServer is a minimal in-memory CalDAV server: a PROPFIND on +// /dav/calendars/m/ returns a fixed two-calendar list, REPORT on each +// calendar returns whichever VTODOs the test seeded into todos[url], +// and PUT to a calendar URL captures the body so the test can assert +// on what projax wrote. Mirrors the pattern in dashboard_events_test.go +// but tailored to the Phase 5j flows. +type fakeCalDAVServer struct { + mu sync.Mutex + srv *httptest.Server + calendars []caldav.Calendar + todos map[string][]string // calendarURL → list of VTODO ICS docs + puts map[string]string // url → body of the latest PUT to that url +} + +func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer { + t.Helper() + f := &fakeCalDAVServer{ + todos: map[string][]string{}, + puts: map[string]string{}, + } + mux := http.NewServeMux() + mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PROPFIND" { + f.mu.Lock() + cs := f.calendars + f.mu.Unlock() + w.WriteHeader(207) + _, _ = io.WriteString(w, propfindMultistatus(cs)) + return + } + http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed) + }) + // Per-calendar handler. Keyed by URL PATH so both the registration + // loop and the test's seed lookup (`fake.todos[calURL]`) resolve to + // the same map entry regardless of how the httptest host gets baked + // into the full URL. + for _, c := range cals { + path := urlPathOf(c.URL) + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "REPORT": + f.mu.Lock() + body := buildReportMultistatus(path, f.todos[path]) + f.mu.Unlock() + w.WriteHeader(207) + _, _ = io.WriteString(w, body) + case "PUT": + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.puts[r.URL.String()] = string(body) + f.todos[path] = append(f.todos[path], string(body)) + f.mu.Unlock() + w.Header().Set("ETag", `"fresh"`) + w.WriteHeader(http.StatusCreated) + default: + http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed) + } + }) + } + f.srv = httptest.NewServer(mux) + f.calendars = make([]caldav.Calendar, len(cals)) + // Rewrite URLs to point at the httptest server's host. + for i, c := range cals { + f.calendars[i] = caldav.Calendar{ + URL: f.srv.URL + urlPathOf(c.URL), + HRef: urlPathOf(c.URL), + DisplayName: c.DisplayName, + Color: c.Color, + } + } + t.Cleanup(f.srv.Close) + return f +} + +func urlPathOf(absURL string) string { + u, _ := url.Parse(absURL) + return u.Path +} + +// propfindMultistatus builds the PROPFIND response for the slice of +// calendars. Includes the collection itself + each calendar entry, plus +// an "inbox" non-calendar that ListCalendars must filter out. +func propfindMultistatus(cals []caldav.Calendar) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(`/dav/calendars/m/HTTP/1.1 200 OK`) + for _, c := range cals { + b.WriteString(`` + urlPathOf(c.URL) + `` + c.DisplayName + `HTTP/1.1 200 OK`) + } + b.WriteString(``) + return b.String() +} + +// buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT +// multistatus body, one per VTODO. +func buildReportMultistatus(calPath string, vtodos []string) string { + if len(vtodos) == 0 { + return `` + } + var b strings.Builder + b.WriteString(``) + for i, ics := range vtodos { + b.WriteString(`` + calPath + "t" + itoa(i) + `.ics"e` + itoa(i) + `"`) + b.WriteString(ics) + b.WriteString(`HTTP/1.1 200 OK`) + } + b.WriteString(``) + return b.String() +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + neg := false + if n < 0 { + neg = true + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// seedItemUnderDev inserts a fresh projax item under dev and returns +// its id + primary path. Callers defer cleanup. +func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id, primaryPath string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var dev string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids) + values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[]) + returning id`, + title, slug, dev, + ).Scan(&id); err != nil { + t.Fatalf("seed item: %v", err) + } + return id, "dev." + slug +} + +// TestDetailLinkExistingCalendar walks the original ask end-to-end: +// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs. +// 2. Seed an unlinked projax item under dev. +// 3. GET /i/{path} — assert the "link existing" + + {{range .AvailableCalendars}}{{end}} + + + + {{end}} + {{if not .Tasks}} +
+ +
+ {{end}} + {{end}}