Merge branch 'mai/knuth/caldav-link-existing' (feat: per-item CalDAV link-existing + projax-tagged VTODOs for shared lists)
This commit is contained in:
@@ -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:<primary-path>` 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
|
||||
|
||||
@@ -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:<primaryPath>` category. A todo can carry multiple projax: tags
|
||||
// (when it belongs to multiple projax items) — any match returns true.
|
||||
func HasProjaxTagFor(t Todo, primaryPath string) bool {
|
||||
want := ProjaxCategoryFor(primaryPath)
|
||||
for _, c := range t.Categories {
|
||||
if c == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AnyTodoHasProjaxTag reports whether the slice contains at least one
|
||||
// projax-tagged VTODO. The detail page uses this to decide between the
|
||||
// projax-managed filter (show only matching) and the legacy unmanaged
|
||||
// path (show all).
|
||||
func AnyTodoHasProjaxTag(todos []Todo) bool {
|
||||
for _, t := range todos {
|
||||
if HasProjaxTag(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseVEvents extracts every VEVENT block from a calendar-data string.
|
||||
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
|
||||
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
114
caldav/projax_tags_test.go
Normal file
114
caldav/projax_tags_test.go
Normal file
@@ -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:<primary-path>` — 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:<path>")
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
136
web/caldav.go
136
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:<path>` 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:<primary-path>` 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)
|
||||
}
|
||||
|
||||
419
web/caldav_link_existing_test.go
Normal file
419
web/caldav_link_existing_test.go
Normal file
@@ -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(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
b.WriteString(`<d:response><d:href>/dav/calendars/m/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
for _, c := range cals {
|
||||
b.WriteString(`<d:response><d:href>` + urlPathOf(c.URL) + `</d:href><d:propstat><d:prop><d:displayname>` + c.DisplayName + `</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT
|
||||
// multistatus body, one <d:response> per VTODO.
|
||||
func buildReportMultistatus(calPath string, vtodos []string) string {
|
||||
if len(vtodos) == 0 {
|
||||
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"></d:multistatus>`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
for i, ics := range vtodos {
|
||||
b.WriteString(`<d:response><d:href>` + calPath + "t" + itoa(i) + `.ics</d:href><d:propstat><d:prop><d:getetag>"e` + itoa(i) + `"</d:getetag><cal:calendar-data>`)
|
||||
b.WriteString(ics)
|
||||
b.WriteString(`</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
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" <select> 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, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
|
||||
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:<path>` 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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -94,9 +94,29 @@
|
||||
{{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}}
|
||||
|
||||
{{/* 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. */}}
|
||||
<div class="caldav-actions">
|
||||
{{if .AvailableCalendars}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
|
||||
<label class="visually-hidden" for="caldav-link-existing-select">Link existing CalDAV list</label>
|
||||
<select id="caldav-link-existing-select" name="calendar_url" required>
|
||||
<option value="">— link existing list —</option>
|
||||
{{range .AvailableCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Link</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Tasks}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">+ Create new list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user