From 311cf943bc1be7f55b9072b127bf196e46fea191 Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 27 May 2026 14:16:04 +0200 Subject: [PATCH] feat(caldav): link-existing picker + projax-tagged VTODOs for shared lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's ask: per-item CalDAV linking should support existing lists, not just create-new. Athena's design update extended it: also tag VTODOs on create so multiple projax items can SHARE one CalDAV list, with projax doing tag-based slicing on read. Three layers, one branch: ## 1. Link-existing picker (the original ask) - New POST /i/{path}/caldav/link-existing handler validates the submitted calendar_url is in the discoverable PROPFIND set (defence against crafted forms pointing at arbitrary HTTP servers), then inserts the item_link row with display_name + color metadata preserved from the discovery payload. - handleDetail + renderTasksSection pre-load availableCalendarsForItem(ctx, links) — calendars from s.CalDAV.Client.ListCalendars MINUS the ones already linked to this item. Errors degrade to an empty picker (non-fatal). - tasks_section.tmpl gains a .caldav-actions block rendering the picker ( renders with +// all 3 calendars. +// 4. POST /i/{path}/caldav/link-existing with one URL. +// 5. GET /i/{path} again — assert the linked URL is gone from the +// picker (already linked) but appears in the tasks section. +func TestDetailLinkExistingCalendar(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + + cals := []caldav.Calendar{ + {URL: "https://dav.test/dav/calendars/m/Family/", DisplayName: "Family"}, + {URL: "https://dav.test/dav/calendars/m/Travel/", DisplayName: "Travel"}, + {URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"}, + } + fake := newFakeCalDAVServer(t, cals) + srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")} + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + slug := "caldav-link-" + stamp + id, primary := seedItemUnderDev(t, pool, slug, "Caldav link test") + defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) + + h := srv.Routes() + + // Step 3: picker renders with three calendars. + _, body := get(t, h, "/i/"+primary) + for _, want := range []string{ + `action="/i/` + primary + `/caldav/link-existing"`, + `>Family<`, + `>Travel<`, + `>Vacations 2026<`, + `+ Create new list`, + } { + if !strings.Contains(body, want) { + t.Errorf("unlinked detail page missing %q", want) + } + } + + // Step 4: POST link-existing. Pick the Vacations 2026 calendar. + pickedURL := fake.calendars[2].URL + form := url.Values{"calendar_url": {pickedURL}} + resp, _ := post(t, h, "/i/"+primary+"/caldav/link-existing", form) + if resp != http.StatusSeeOther { + t.Fatalf("link-existing POST → %d, want 303", resp) + } + defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL) + + // Step 5: picker no longer offers Vacations 2026 (already linked); + // the tasks section now shows the linked calendar's block. + _, body = get(t, h, "/i/"+primary) + if strings.Contains(body, ``) { + t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL") + } + if !strings.Contains(body, "Vacations 2026") { + t.Errorf("tasks section should display the linked Vacations 2026 list") + } + if !strings.Contains(body, `data-cal="`+pickedURL+`"`) { + t.Errorf("tasks section missing cal-block for the linked URL") + } +} + +// TestVTodoCreateAttachesProjaxCategory exercises the tag-on-create +// half of Phase 5j. Posting the Add-task form from /i/{path} must send +// a VTODO whose CATEGORIES contains `projax:` so a shared list +// can later be filtered per-item. +func TestVTodoCreateAttachesProjaxCategory(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + + cals := []caldav.Calendar{ + {URL: "https://dav.test/dav/calendars/m/Shared/", DisplayName: "Shared"}, + } + fake := newFakeCalDAVServer(t, cals) + srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")} + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + slug := "caldav-tag-" + stamp + id, primary := seedItemUnderDev(t, pool, slug, "Tag-on-create test") + defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) + calURL := fake.calendars[0].URL + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := pool.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'caldav-list', $2, 'contains')`, + id, calURL, + ); err != nil { + t.Fatalf("seed link: %v", err) + } + defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id) + + h := srv.Routes() + form := url.Values{ + "calendar_url": {calURL}, + "summary": {"Buy travel gear"}, + } + resp, _ := post(t, h, "/i/"+primary+"/caldav/todo/todo-create", form) + if resp != http.StatusSeeOther && resp != http.StatusOK { + t.Fatalf("todo-create POST → %d", resp) + } + + // Inspect what the fake CalDAV server received. + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.puts) == 0 { + t.Fatalf("expected at least one PUT to the fake CalDAV server") + } + var got string + for _, body := range fake.puts { + got = body + break + } + wantTag := "projax:" + primary + if !strings.Contains(got, "CATEGORIES:"+wantTag) { + t.Errorf("PUT body missing CATEGORIES tag %q. Body:\n%s", wantTag, got) + } +} + +// TestDetailFilterByProjaxCategory exercises the read-side filter: +// when the linked list has ANY projax: tag, the detail page only shows +// the VTODOs whose CATEGORIES include THIS item's tag. VTODOs tagged +// for OTHER items must NOT leak through. +func TestDetailFilterByProjaxCategory(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + + cals := []caldav.Calendar{ + {URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"}, + } + fake := newFakeCalDAVServer(t, cals) + srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")} + calURL := fake.calendars[0].URL + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + idA, primaryA := seedItemUnderDev(t, pool, "trip-a-"+stamp, "Trip A") + idB, primaryB := seedItemUnderDev(t, pool, "trip-b-"+stamp, "Trip B") + defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, idA, idB) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, id := range []string{idA, idB} { + if _, err := pool.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'caldav-list', $2, 'contains')`, + id, calURL, + ); err != nil { + t.Fatalf("seed link: %v", err) + } + } + defer pool.Exec(context.Background(), `delete from projax.item_links where ref_id=$1`, calURL) + + // Three VTODOs on the SHARED list: one tagged for A, one for B, one + // for both. + tagA := "projax:" + primaryA + tagB := "projax:" + primaryB + fake.mu.Lock() + fake.todos[urlPathOf(calURL)] = []string{ + todoICS("uid-only-a", "Book flight A", []string{tagA}), + todoICS("uid-only-b", "Book flight B", []string{tagB}), + todoICS("uid-shared", "Travel insurance", []string{tagA, tagB}), + } + fake.mu.Unlock() + + h := srv.Routes() + _, body := get(t, h, "/i/"+primaryA) + if !strings.Contains(body, "Book flight A") { + t.Errorf("Trip A detail missing tagged-A summary") + } + if strings.Contains(body, "Book flight B") { + t.Errorf("Trip A detail leaked tagged-B summary — filter broken") + } + if !strings.Contains(body, "Travel insurance") { + t.Errorf("Trip A detail missing dual-tagged summary (multi-tag contract)") + } + + // Trip B sees the mirror image: B + shared, not A. + _, body = get(t, h, "/i/"+primaryB) + if strings.Contains(body, "Book flight A") { + t.Errorf("Trip B detail leaked tagged-A summary") + } + if !strings.Contains(body, "Book flight B") { + t.Errorf("Trip B detail missing tagged-B summary") + } + if !strings.Contains(body, "Travel insurance") { + t.Errorf("Trip B detail missing dual-tagged summary") + } +} + +// TestDetailUntaggedListShowsAll proves the legacy fallback: a linked +// list with ZERO projax: tags is treated as unmanaged — every VTODO +// renders, untouched. Without this users with pre-5j lists would see +// the detail page suddenly hide all their existing tasks. +func TestDetailUntaggedListShowsAll(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + + cals := []caldav.Calendar{ + {URL: "https://dav.test/dav/calendars/m/Home/", DisplayName: "Home"}, + } + fake := newFakeCalDAVServer(t, cals) + srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")} + calURL := fake.calendars[0].URL + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + id, primary := seedItemUnderDev(t, pool, "home-legacy-"+stamp, "Home legacy") + defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := pool.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'caldav-list', $2, 'contains')`, + id, calURL, + ); err != nil { + t.Fatalf("seed link: %v", err) + } + defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id) + + fake.mu.Lock() + fake.todos[urlPathOf(calURL)] = []string{ + todoICS("legacy-1", "Pick up bread", nil), + todoICS("legacy-2", "Call dentist", []string{"home", "errands"}), + } + fake.mu.Unlock() + + h := srv.Routes() + _, body := get(t, h, "/i/"+primary) + if !strings.Contains(body, "Pick up bread") { + t.Errorf("untagged-list detail missing legacy todo 'Pick up bread'") + } + if !strings.Contains(body, "Call dentist") { + t.Errorf("untagged-list detail missing legacy todo with non-projax categories") + } +} + +// todoICS builds a minimal VTODO ICS doc with optional CATEGORIES. +func todoICS(uid, summary string, categories []string) string { + cat := "" + if len(categories) > 0 { + cat = "CATEGORIES:" + strings.Join(categories, ",") + "\r\n" + } + return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + uid + "\r\nSUMMARY:" + summary + "\r\nSTATUS:NEEDS-ACTION\r\n" + cat + "END:VTODO\r\nEND:VCALENDAR" +} diff --git a/web/server.go b/web/server.go index a5823d6..2cd98d3 100644 --- a/web/server.go +++ b/web/server.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/m/projax/caldav" "github.com/m/projax/internal/aggregate" "github.com/m/projax/internal/cache" "github.com/m/projax/internal/itemwrite" @@ -572,6 +573,22 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) { if err != nil { s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err) } + // Phase 5j: pre-load discoverable CalDAV calendars (minus the ones + // already linked) so the per-item Tasks section can offer a "Link + // existing list" picker alongside the create-new affordance. Errors + // are non-fatal — the section falls back to its pre-5j shape. + var availableCalendars []caldav.Calendar + if s.CalDAV != nil { + caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV) + if lerr != nil { + s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr) + } + acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks) + if aerr != nil { + s.Logger.Warn("detail available caldav", "path", it.PrimaryPath(), "err", aerr) + } + availableCalendars = acs + } issues, err := s.detailIssues(r.Context(), it) if err != nil { s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err) @@ -590,9 +607,10 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) { "Item": it, "ParentOptions": parents, "StatusOptions": []string{"active", "done", "archived"}, - "Tasks": tasks, - "CalDAVOn": s.CalDAV != nil, - "Issues": issues, + "Tasks": tasks, + "AvailableCalendars": availableCalendars, + "CalDAVOn": s.CalDAV != nil, + "Issues": issues, "IssuesOpenTotal": openTotal, "GiteaOn": s.Gitea != nil, "Documents": documents, @@ -610,6 +628,10 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) { s.handleCalDAVCreate(w, r, base) return } + if base, ok := strings.CutSuffix(path, "/caldav/link-existing"); ok { + s.handleCalDAVLinkExisting(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) diff --git a/web/templates/tasks_section.tmpl b/web/templates/tasks_section.tmpl index b405a02..82abce4 100644 --- a/web/templates/tasks_section.tmpl +++ b/web/templates/tasks_section.tmpl @@ -94,9 +94,29 @@ {{end}} {{else}}

No CalDAV list linked.

-
- -
{{end}} + + {{/* Phase 5j: per-item picker for sharing an existing list across + multiple projax items (e.g. one "Vacations 2026" list under + several admin.vacations sub-items). Renders in BOTH states: + unlinked items see it next to Create-new; already-linked items + see it as "+ link another" for the multi-list flow. */}} +
+ {{if .AvailableCalendars}} + + {{end}} + {{if not .Tasks}} +
+ +
+ {{end}} +
{{end}}