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.
279 lines
7.8 KiB
Go
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"`
|
|
}
|