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.
199 lines
5.5 KiB
Go
199 lines
5.5 KiB
Go
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newFakeServer stubs the slice of CalDAV that the client exercises. Each
|
|
// handler asserts the method and returns a canned XML body so the parser can
|
|
// be exercised end-to-end without a real DAV server.
|
|
func newFakeServer(t *testing.T) (*Client, *httptest.Server) {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "PROPFIND" {
|
|
t.Errorf("unexpected method %q on collection", r.Method)
|
|
}
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, propfindBody)
|
|
})
|
|
|
|
mux.HandleFunc("/dav/calendars/m/Work/", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case "REPORT":
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, reportBody)
|
|
case "MKCALENDAR":
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
default:
|
|
t.Errorf("unexpected method %q on Work/", r.Method)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/dav/calendars/m/new-list/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "MKCALENDAR" {
|
|
t.Errorf("unexpected method %q on new-list/", r.Method)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
if !strings.Contains(string(body), "<d:displayname>Paliad</d:displayname>") {
|
|
t.Errorf("MKCALENDAR body missing display name: %s", body)
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return New(srv.URL+"/dav/calendars/m/", "u", "p"), srv
|
|
}
|
|
|
|
func TestListCalendars(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
cals, err := c.ListCalendars(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListCalendars: %v", err)
|
|
}
|
|
want := map[string]bool{"mCalendar": true, "Birthdays": true, "Work": true}
|
|
got := map[string]bool{}
|
|
for _, cal := range cals {
|
|
got[cal.DisplayName] = true
|
|
}
|
|
for k := range want {
|
|
if !got[k] {
|
|
t.Errorf("expected calendar %q in result, got %v", k, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListTodos(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
todos, err := c.ListTodos(context.Background(), c.BaseURL+"Work/")
|
|
if err != nil {
|
|
t.Fatalf("ListTodos: %v", err)
|
|
}
|
|
if len(todos) != 2 {
|
|
t.Fatalf("expected 2 todos, got %d (%v)", len(todos), todos)
|
|
}
|
|
if todos[0].UID != "todo-1@example" {
|
|
t.Errorf("todos[0].UID = %q", todos[0].UID)
|
|
}
|
|
if todos[0].Summary != "Pick up bread" {
|
|
t.Errorf("todos[0].Summary = %q", todos[0].Summary)
|
|
}
|
|
if todos[0].Status != "NEEDS-ACTION" {
|
|
t.Errorf("todos[0].Status = %q", todos[0].Status)
|
|
}
|
|
if todos[1].Status != "COMPLETED" {
|
|
t.Errorf("todos[1].Status = %q", todos[1].Status)
|
|
}
|
|
}
|
|
|
|
func TestCreateCalendar(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
if err := c.CreateCalendar(context.Background(), c.BaseURL+"new-list/", "Paliad", "#bff355"); err != nil {
|
|
t.Fatalf("CreateCalendar: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateCalendarExists(t *testing.T) {
|
|
c, _ := newFakeServer(t)
|
|
err := c.CreateCalendar(context.Background(), c.BaseURL+"Work/", "Work", "")
|
|
if err != ErrCalendarExists {
|
|
t.Fatalf("expected ErrCalendarExists, got %v", err)
|
|
}
|
|
}
|
|
|
|
const propfindBody = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/</d:href>
|
|
<d:propstat>
|
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/default/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>mCalendar</d:displayname>
|
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/birthday-calendar/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Birthdays</d:displayname>
|
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/Work/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>Work</d:displayname>
|
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/inbox/</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:displayname>inbox</d:displayname>
|
|
<d:resourcetype><d:collection/></d:resourcetype>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>`
|
|
|
|
const reportBody = `<?xml version="1.0"?>
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/Work/todo-1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"abc"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:todo-1@example
|
|
SUMMARY:Pick up bread
|
|
STATUS:NEEDS-ACTION
|
|
PRIORITY:5
|
|
DUE:20260601T120000Z
|
|
END:VTODO
|
|
END:VCALENDAR</cal:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
<d:response>
|
|
<d:href>/dav/calendars/m/Work/todo-2.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"def"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VTODO
|
|
UID:todo-2@example
|
|
SUMMARY:Filed paperwork
|
|
STATUS:COMPLETED
|
|
END:VTODO
|
|
END:VCALENDAR</cal:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>`
|