Files
projax/caldav/caldav.go
mAi 311cf943bc 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.
2026-05-27 14:16:04 +02:00

484 lines
15 KiB
Go

// Package caldav is a minimal client for the slice of CalDAV that projax
// needs: list calendar collections, fetch open VTODOs from one, create a new
// calendar. SabreDAV-flavoured XML. Basic auth only — single-user, single-host.
package caldav
import (
"bytes"
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client wraps a base URL + Basic credentials.
type Client struct {
BaseURL string // e.g. https://dav.msbls.de/dav/calendars/m/
User string
Password string
HTTPClient *http.Client
}
// New builds a client with a sensible default timeout. base must end in '/'.
func New(base, user, password string) *Client {
if !strings.HasSuffix(base, "/") {
base += "/"
}
return &Client{
BaseURL: base,
User: user,
Password: password,
HTTPClient: &http.Client{Timeout: 8 * time.Second},
}
}
// Calendar is one collection returned by ListCalendars.
type Calendar struct {
URL string // absolute, e.g. https://dav.msbls.de/dav/calendars/m/Work/
HRef string // server-relative path, useful for routing
DisplayName string
Color string
}
// Todo is one VTODO returned by ListTodos. URL, ETag and Raw are populated by
// ListTodos and required by PutTodo / DeleteTodo for optimistic-concurrency
// roundtrips.
type Todo struct {
UID string
Summary string
Status string // NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED
Due *time.Time
Priority int
LastModified *time.Time
// 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
// writeback. RRULE is flagged (Recurring=true) but NOT expanded — the first
// DTSTART instance is what the UI shows; m clicks through to his calendar
// app for the recurring picture.
type Event struct {
UID string
Summary string
Start time.Time
End time.Time
AllDay bool // DTSTART was VALUE=DATE rather than DATE-TIME
Location string
Description string
Recurring bool // RRULE property present
URL string // absolute URL of the .ics resource
}
// ListEventsOpts narrows ListEvents. Both bounds are required (server-side
// time-range filter). UTC is assumed.
type ListEventsOpts struct {
TimeMin time.Time
TimeMax time.Time
}
func (c *Client) do(ctx context.Context, method, urlStr string, headers map[string]string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, urlStr, bytes.NewReader(body))
if err != nil {
return nil, err
}
if c.User != "" || c.Password != "" {
req.SetBasicAuth(c.User, c.Password)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
// ListCalendars sends PROPFIND Depth: 1 against BaseURL and returns every
// child collection tagged with <cal:calendar/>. Non-calendar collections
// (default/, inbox/, outbox/) are filtered out.
func (c *Client) ListCalendars(ctx context.Context) ([]Calendar, error) {
body := []byte(`<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<cs:getctag/>
<cal:calendar-color/>
</d:prop>
</d:propfind>`)
resp, err := c.do(ctx, "PROPFIND", c.BaseURL, map[string]string{
"Depth": "1",
"Content-Type": "application/xml; charset=utf-8",
}, body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("caldav PROPFIND: %d %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var ms multistatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, fmt.Errorf("caldav PROPFIND decode: %w", err)
}
base, err := url.Parse(c.BaseURL)
if err != nil {
return nil, err
}
var out []Calendar
for _, r := range ms.Responses {
// We only care about responses that are calendar collections AND have
// a 200 status on the prop block. Filter inbox/outbox out — they are
// <cs:schedule-inbox/> / <cs:schedule-outbox/>, not <cal:calendar/>.
isCalendar := false
display := ""
color := ""
for _, ps := range r.PropStat {
if ps.Status == "" || strings.Contains(ps.Status, "200") {
if ps.Prop.ResourceType.Calendar != nil {
isCalendar = true
}
if ps.Prop.DisplayName != "" {
display = ps.Prop.DisplayName
}
if ps.Prop.CalColor != "" {
color = ps.Prop.CalColor
}
}
}
if !isCalendar {
continue
}
// Resolve href against the base URL host so URL is absolute.
abs := *base
hrefURL, err := url.Parse(r.Href)
if err != nil {
continue
}
if hrefURL.IsAbs() {
abs = *hrefURL
} else {
abs.Path = hrefURL.Path
abs.RawQuery = ""
abs.Fragment = ""
}
out = append(out, Calendar{
URL: abs.String(),
HRef: r.Href,
DisplayName: display,
Color: color,
})
}
return out, nil
}
// ListTodos issues a REPORT calendar-query against a single calendar URL and
// returns parsed VTODOs.
func (c *Client) ListTodos(ctx context.Context, calendarURL string) ([]Todo, error) {
body := []byte(`<?xml version="1.0"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VTODO"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>`)
resp, err := c.do(ctx, "REPORT", calendarURL, map[string]string{
"Depth": "1",
"Content-Type": "application/xml; charset=utf-8",
}, body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("caldav REPORT: %d %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var ms multistatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, fmt.Errorf("caldav REPORT decode: %w", err)
}
base, err := url.Parse(calendarURL)
if err != nil {
return nil, err
}
var out []Todo
for _, r := range ms.Responses {
hrefURL, err := url.Parse(r.Href)
if err != nil {
continue
}
abs := *base
if hrefURL.IsAbs() {
abs = *hrefURL
} else {
abs.Path = hrefURL.Path
abs.RawQuery = ""
abs.Fragment = ""
}
for _, ps := range r.PropStat {
if ps.Prop.CalendarData == "" {
continue
}
todos := parseVTodos(ps.Prop.CalendarData)
etag := strings.TrimSpace(ps.Prop.GetEtag)
raw := ps.Prop.CalendarData
for i := range todos {
todos[i].URL = abs.String()
todos[i].ETag = etag
todos[i].Raw = raw
}
out = append(out, todos...)
}
}
return out, nil
}
// ListEvents issues a REPORT calendar-query against a single calendar URL,
// restricted by a server-side time-range filter, and returns parsed VEVENTs.
// RRULE-bearing events surface with Recurring=true but are NOT expanded; only
// the literal DTSTART instance is returned. Recurring expansion is a v2 item
// — m has a calendar app for the full picture.
func (c *Client) ListEvents(ctx context.Context, calendarURL string, opts ListEventsOpts) ([]Event, error) {
tmin := formatICalUTC(opts.TimeMin.UTC())
tmax := formatICalUTC(opts.TimeMax.UTC())
body := fmt.Appendf(nil, `<?xml version="1.0"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="%s" end="%s"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>`, tmin, tmax)
resp, err := c.do(ctx, "REPORT", calendarURL, map[string]string{
"Depth": "1",
"Content-Type": "application/xml; charset=utf-8",
}, body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("caldav REPORT VEVENT: %d %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var ms multistatus
if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
return nil, fmt.Errorf("caldav REPORT VEVENT decode: %w", err)
}
base, err := url.Parse(calendarURL)
if err != nil {
return nil, err
}
var out []Event
for _, r := range ms.Responses {
hrefURL, err := url.Parse(r.Href)
if err != nil {
continue
}
abs := *base
if hrefURL.IsAbs() {
abs = *hrefURL
} else {
abs.Path = hrefURL.Path
abs.RawQuery = ""
abs.Fragment = ""
}
for _, ps := range r.PropStat {
if ps.Prop.CalendarData == "" {
continue
}
events := parseVEvents(ps.Prop.CalendarData)
for i := range events {
events[i].URL = abs.String()
}
out = append(out, events...)
}
}
return out, nil
}
// CreateCalendar issues MKCALENDAR for the given absolute URL with the given
// display name. Returns ErrCalendarExists when the server reports the URL is
// already in use.
func (c *Client) CreateCalendar(ctx context.Context, calendarURL, displayName, color string) error {
colorEl := ""
if color != "" {
colorEl = fmt.Sprintf(`<cal:calendar-color xmlns:cal="urn:ietf:params:xml:ns:caldav">%s</cal:calendar-color>`, xmlEscape(color))
}
body := []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set>
<d:prop>
<d:displayname>%s</d:displayname>
%s
<c:supported-calendar-component-set>
<c:comp name="VTODO"/>
<c:comp name="VEVENT"/>
</c:supported-calendar-component-set>
</d:prop>
</d:set>
</c:mkcalendar>`, xmlEscape(displayName), colorEl))
resp, err := c.do(ctx, "MKCALENDAR", calendarURL, map[string]string{
"Content-Type": "application/xml; charset=utf-8",
}, body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated /* 201 */ {
return nil
}
if resp.StatusCode == http.StatusMethodNotAllowed /* 405 */ {
// SabreDAV returns 405 when the URL is already in use.
return ErrCalendarExists
}
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("caldav MKCALENDAR: %d %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
// ErrCalendarExists is returned when MKCALENDAR refuses because a collection
// already lives at the target URL.
var ErrCalendarExists = errors.New("caldav: calendar already exists")
// ErrPreconditionFailed is returned by PutTodo / DeleteTodo when the server
// responds 412 — the ETag the client supplied no longer matches the server's
// copy. The caller should refetch the resource and retry.
var ErrPreconditionFailed = errors.New("caldav: precondition failed (etag mismatch)")
// ErrNotFound is returned when the server reports 404 for a PUT/DELETE — most
// likely the resource was already removed.
var ErrNotFound = errors.New("caldav: resource not found")
// PutTodo writes the given ICS body to resourceURL. ifMatch, if non-empty, is
// sent as If-Match for optimistic-concurrency on edits. ifNoneMatch ("*")
// guards creation against accidental overwrite. The returned string is the new
// ETag from the response (may be empty if the server didn't issue one — caller
// should refetch via ListTodos to pick up the canonical ETag).
func (c *Client) PutTodo(ctx context.Context, resourceURL, ics, ifMatch, ifNoneMatch string) (string, error) {
headers := map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
}
if ifMatch != "" {
headers["If-Match"] = ifMatch
}
if ifNoneMatch != "" {
headers["If-None-Match"] = ifNoneMatch
}
resp, err := c.do(ctx, "PUT", resourceURL, headers, []byte(ics))
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated /* 201 */, http.StatusNoContent /* 204 */, http.StatusOK /* 200 */ :
return strings.TrimSpace(resp.Header.Get("ETag")), nil
case http.StatusPreconditionFailed /* 412 */ :
return "", ErrPreconditionFailed
case http.StatusNotFound /* 404 */ :
return "", ErrNotFound
default:
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("caldav PUT %s: %d %s", resourceURL, resp.StatusCode, strings.TrimSpace(string(raw)))
}
}
// DeleteTodo removes the resource at resourceURL. ifMatch is required so a
// concurrent edit on another client triggers a 412 rather than a silent loss.
func (c *Client) DeleteTodo(ctx context.Context, resourceURL, ifMatch string) error {
headers := map[string]string{}
if ifMatch != "" {
headers["If-Match"] = ifMatch
}
resp, err := c.do(ctx, "DELETE", resourceURL, headers, nil)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent /* 204 */, http.StatusOK /* 200 */ :
return nil
case http.StatusNotFound /* 404 */ :
// Treat as success — the resource is gone, which was the goal.
return nil
case http.StatusPreconditionFailed /* 412 */ :
return ErrPreconditionFailed
default:
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("caldav DELETE %s: %d %s", resourceURL, resp.StatusCode, strings.TrimSpace(string(raw)))
}
}
// TodoURLFor builds the conventional CalDAV resource URL for a fresh VTODO
// with the given UID under calendarURL (which must end in '/'). The .ics
// extension matches SabreDAV conventions; some servers ignore it but it
// shouldn't ever hurt.
func TodoURLFor(calendarURL, uid string) string {
if !strings.HasSuffix(calendarURL, "/") {
calendarURL += "/"
}
return calendarURL + url.PathEscape(uid) + ".ics"
}
func xmlEscape(s string) string {
var b bytes.Buffer
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
// --- XML response shapes ---
type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []davResponse `xml:"response"`
}
type davResponse struct {
Href string `xml:"href"`
PropStat []davPropStat `xml:"propstat"`
}
type davPropStat struct {
Prop davProp `xml:"prop"`
Status string `xml:"status"`
}
type davProp struct {
DisplayName string `xml:"displayname"`
CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
CalColor string `xml:"urn:ietf:params:xml:ns:caldav calendar-color"`
ResourceType davResourceType `xml:"resourcetype"`
GetEtag string `xml:"getetag"`
}
type davResourceType struct {
Collection *struct{} `xml:"collection"`
Calendar *struct{} `xml:"urn:ietf:params:xml:ns:caldav calendar"`
}