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.
484 lines
15 KiB
Go
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"`
|
|
}
|