Files
projax/caldav/caldav.go
mAi 96b61f7ed4 feat(phase 2 caldav): list + link + create CalDAV calendars
m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.

New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
  parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
  line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
  instead" branch)
- httptest-stubbed tests cover all four paths.

Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
  AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
  same calendar is idempotent.

Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
  linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
  AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
  collapsed completed (30d window). Errors per calendar logged and
  skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.

main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
  off (admin page renders "not configured" notice). DAV_URL set but
  user/pass missing → fail fast at boot.

docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.

Phase 2.b (writeback / two-way / background sync) is parked.
2026-05-15 16:57:43 +02:00

279 lines
7.8 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.
type Todo struct {
UID string
Summary string
Status string // NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED
Due *time.Time
Priority int
LastModified *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)
}
var out []Todo
for _, r := range ms.Responses {
for _, ps := range r.PropStat {
if ps.Prop.CalendarData == "" {
continue
}
todos := parseVTodos(ps.Prop.CalendarData)
out = append(out, todos...)
}
}
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")
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"`
}