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