Files
projax/caldav/caldav.go
mAi d49ad219a4 feat(phase 3l vevents): VEVENT support on dashboard — closes mgmt-parity gap
caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
  Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
  REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
  splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
  expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam

web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
  fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
  rendering; empty-collapse with no links

design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
2026-05-16 00:57:52 +02:00

479 lines
14 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
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"`
}