feat(caldav): link-existing picker + projax-tagged VTODOs for shared lists
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 (<select> of available calendars) when AvailableCalendars
is non-empty AND the Create-new button (when the item has no
linked list yet). Same surface serves both the "first link" flow
and the "+ link another" flow per athena's brief.
## 2. Tag-on-create (CATEGORIES carries projax:<path>)
- caldav package gains Categories []string on Todo + the same on
VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
parseVTodos parses CATEGORIES comma-list into the slice with per-
entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
`Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
edit/complete/delete paths preserve existing CATEGORIES via the
unknown-property pass-through that's been tested since Phase 5
(TestApplyVTodoEditPreservesUnknown).
## 3. Per-item filter (managed-vs-legacy)
- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
whether the linked list is projax-managed (any projax: tag
anywhere) or legacy/unmanaged (zero projax: tags).
- Managed → filter to VTODOs whose CATEGORIES include this
item's projax:<path>. Multiple projax: tags are AND-of-OR — a
VTODO with two projax tags appears on both items per athena's
multi-tag contract.
- Legacy → show every VTODO untouched. Existing pre-5j users with
untagged lists keep seeing everything; the detail page doesn't
suddenly hide their tasks.
## Helpers (caldav package, exported)
- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal
## Tests
caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
TestParseVTodosMultiCategory.
web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
picker renders, POST link-existing creates the link, second GET
drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
Trip A and Trip B with three tagged VTODOs; A sees A+shared,
B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
tags renders ALL VTODOs (legacy fallback).
Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.
Net: +795 / -14.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user