Files
projax/web/caldav.go
mAi b22f50ca7b feat(adapter): Phase 6 Slice B — mBrian-backed read path live
Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live
on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This
commit makes the projax read path source from there behind an env
switch.

CLIENT ARCH: direct pgxpool against mbrian.* schema (same
SUPABASE_DATABASE_URL the projax binary already uses for projax.*) —
matches flexsiebels/head's cross-coupling pattern. No MCP token
plumbing.

CONTRACT (all three honoured)
- External links are SELF-EDGES (source=target=item, rel='projax-*',
  payload in edges.metadata). linkFromEdge reads the node's outbound
  projax-* edges; ref_id derived per ref_type from metadata (caldav
  url, gitea owner/repo, mai-project mai_project_id).
- Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes;
  projax-side squatters (renamed-aside, not deleted) are documented in
  the parity test as legacy-only and skipped from field comparison.
- created_at/updated_at NOT preserved — ItemsCreatedInRange orders off
  metadata.projax.start_time when present, fall back to mBrian
  created_at. Aggregator surfaces (timeline / dashboard) read off
  caldav DTSTART + gitea updated_at, so they're unaffected.

NEW FILES
- store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax-
  managed nodes + child_of edges in one pair of queries per call,
  builds a graphContext in memory, derives Paths via ancestor walk
  (depth-capped at 64 like projax's trigger). Implements every
  ItemReader method.
- store/mbrian_parity_test.go: 5 parity tests against the live db —
  ListAll field equality (skipping the renamed squatter slugs),
  spot-check resolves, caldav-list link round-trip, gitea-repo link
  round-trip, AllTags union, NotFound consistency. All 5 GREEN.
- cmd/projax-remap-views/main.go: one-shot tool to rewrite
  projax.views.filter_json.project_id from old projax uuids to new
  mBrian uuids using the audit map mBrian dropped (head will relay
  the path). Dry-run default; --apply commits. Idempotent.
- docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the
  remediation path. Must run remap BEFORE slice E drops projax.items.

CHANGES
- store/adapter.go: kept the ItemReader interface + *Store assertion;
  removed the prep stub (replaced by mbrian.go).
- web/server.go: Server.Items store.ItemReader field. web.New defaults
  Items to the concrete *Store (legacy path). main.go overrides to
  MBrianReader when PROJAX_BACKEND=mbrian.
- All read-path call sites in web/ swapped from s.Store.<readMethod>(
  to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools
  unchanged (separate scope; can pivot in a follow-up). Writes still
  flow through s.Store.
- cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default)
  and "mbrian" values. Logs the choice at startup. Unknown value
  refuses to start.

SMOKE
- go build ./... green; go vet green.
- go test ./store/ -count=1 — all parity tests pass against live data.
- Local server boot with PROJAX_BACKEND=mbrian — backs binding logs
  "backend=mbrian (read path via store.MBrianReader)" and serves
  /views/tree (auth wall protects deeper smoke; parity tests cover
  that surface).

PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter
tests in web/ already failed on main (legacy /timeline URL hits the
Phase 5j 301 redirect to /views/timeline). No diff vs main in those
test files; out of scope for slice B.

OUT OF SCOPE FOR SLICE B (deferred):
- MCP read tools migration to ItemReader (separate diff, low risk).
- Aggregator's LinkLister wired to ItemReader (currently consumes
  *Store directly through Server.Aggregator()).
- views.filter_json.project_id remap RUN — tool ships here, run waits
  on the head's relay of the audit-map path.
- Slice C write-path. Slice D mai-bridge worker. Slice E drop.
2026-05-31 22:20:38 +02:00

579 lines
18 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
const refTypeCalDAV = "caldav-list"
// CalDAVDeps is the optional CalDAV integration. When nil, the /admin/caldav
// page renders a "not configured" notice and the detail page hides the Tasks
// section. main.go sets it from DAV_URL / DAV_USER / DAV_PASSWORD env.
type CalDAVDeps struct {
Client *caldav.Client
}
// Suggestion pairs one calendar with its best-match projax item, if any.
type Suggestion struct {
Calendar caldav.Calendar
Item *store.Item // nil = no auto-match
AlreadyLink *store.ItemLink
}
// CalDAVOverview is rendered by /admin/caldav.
type CalDAVOverview struct {
Suggestions []Suggestion
Items []*store.Item // for the manual-link selector
}
// buildCalDAVOverview fetches the calendar list, looks up existing
// caldav-list links, and pairs each calendar with the best matching projax
// item by case-insensitive title/slug.
func (s *Server) buildCalDAVOverview(ctx context.Context) (*CalDAVOverview, error) {
cals, err := s.CalDAV.Client.ListCalendars(ctx)
if err != nil {
return nil, fmt.Errorf("caldav list: %w", err)
}
items, err := s.Items.ListAll(ctx)
if err != nil {
return nil, err
}
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
if err != nil {
return nil, err
}
// Map calendar URL → existing link
byURL := map[string]*store.ItemLink{}
for _, l := range links {
byURL[l.RefID] = l
}
// Lower-case lookup over title+slug for the heuristic.
byKey := map[string]*store.Item{}
for _, it := range items {
byKey[strings.ToLower(it.Slug)] = it
byKey[strings.ToLower(it.Title)] = it
}
sort.Slice(cals, func(i, j int) bool { return cals[i].DisplayName < cals[j].DisplayName })
overview := &CalDAVOverview{Items: items}
for _, c := range cals {
s := Suggestion{Calendar: c}
if l, ok := byURL[c.URL]; ok {
s.AlreadyLink = l
// surface the linked item
for _, it := range items {
if it.ID == l.ItemID {
s.Item = it
break
}
}
} else {
key := strings.ToLower(c.DisplayName)
if it, ok := byKey[key]; ok {
s.Item = it
}
}
overview.Suggestions = append(overview.Suggestions, s)
}
return overview, nil
}
func (s *Server) handleCalDAVAdmin(w http.ResponseWriter, r *http.Request) {
if s.CalDAV == nil {
s.render(w, r, "caldav_disabled", map[string]any{"Title": "caldav"})
return
}
ov, err := s.buildCalDAVOverview(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "caldav_admin", map[string]any{
"Title": "caldav",
"Suggestions": ov.Suggestions,
"Items": ov.Items,
})
}
func (s *Server) handleCalDAVLink(w http.ResponseWriter, r *http.Request) {
if s.CalDAV == nil {
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
itemID := strings.TrimSpace(r.FormValue("item_id"))
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
note := strings.TrimSpace(r.FormValue("display_name"))
color := strings.TrimSpace(r.FormValue("color"))
if itemID == "" || calURL == "" {
http.Error(w, "item_id + calendar_url required", http.StatusBadRequest)
return
}
meta := map[string]any{
"display_name": note,
"calendar_color": color,
"linked_at": time.Now().UTC().Format(time.RFC3339),
}
if _, err := s.Store.AddLink(r.Context(), itemID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther)
}
func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
linkID := strings.TrimSpace(r.FormValue("link_id"))
if linkID == "" {
http.Error(w, "link_id required", http.StatusBadRequest)
return
}
if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
s.fail(w, r, err)
return
}
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.Items.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) {
if s.CalDAV == nil {
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Items.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
slug := safeCalendarSlug(it.Slug)
calURL := s.CalDAV.Client.BaseURL + slug + "/"
displayName := it.Title
if displayName == "" {
displayName = it.Slug
}
if err := s.CalDAV.Client.CreateCalendar(r.Context(), calURL, displayName, ""); err != nil {
if errors.Is(err, caldav.ErrCalendarExists) {
// Existing calendar — link instead.
meta := map[string]any{"display_name": displayName, "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)
return
}
s.fail(w, r, err)
return
}
meta := map[string]any{
"display_name": displayName,
"created_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)
}
// safeCalendarSlug normalises a projax slug for use in a CalDAV URL segment.
// Slugs are already lowercase + no dots per the projax invariant, but we
// re-escape to be safe.
func safeCalendarSlug(slug string) string {
return url.PathEscape(strings.ToLower(strings.TrimSpace(slug)))
}
// detailTodos pulls open + recently-completed VTODOs for the item by iterating
// every caldav-list link. Errors per-calendar are logged and skipped so one
// down calendar doesn't blank the whole section.
type calendarTasks struct {
CalendarURL string
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) {
if s.CalDAV == nil {
return nil, nil
}
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
if err != nil {
return nil, err
}
cutoff := time.Now().AddDate(0, 0, -30)
var out []calendarTasks
for _, l := range links {
todos, err := s.CalDAV.Client.ListTodos(ctx, l.RefID)
if err != nil {
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),
}
for _, td := range todos {
if td.Status == "COMPLETED" || td.Status == "CANCELLED" {
if td.LastModified == nil || td.LastModified.After(cutoff) {
ct.DoneRecent = append(ct.DoneRecent, td)
}
continue
}
ct.Open = append(ct.Open, td)
}
out = append(out, ct)
}
return out, nil
}
func linkDisplay(l *store.ItemLink) string {
if v, ok := l.Metadata["display_name"].(string); ok && v != "" {
return v
}
if l.Note != nil && *l.Note != "" {
return *l.Note
}
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.Items.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.Items.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
}
// 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
}
}
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
}
// Writeback may move a task on or off the timeline, so bust both caches.
if s.dashboard != nil {
s.dashboard.InvalidateAll()
}
if s.timeline != nil {
s.timeline.InvalidateAll()
}
// 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
}
// 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.Items.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,
"AvailableCalendars": available,
"CalDAVOn": s.CalDAV != nil,
"Banner": banner,
}
s.render(w, r, "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
}