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.
This commit is contained in:
mAi
2026-05-15 16:57:43 +02:00
parent d23533e5ee
commit 96b61f7ed4
14 changed files with 1094 additions and 1 deletions

View File

@@ -27,6 +27,7 @@ type Server struct {
pages map[string]*template.Template
Logger *slog.Logger
Auth *AuthConfig // nil → no auth (local dev / tests)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
}
// New builds a Server. Each page is parsed alongside the layout into its own
@@ -72,7 +73,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
for _, name := range []string{"tree", "detail", "new", "classify", "caldav_admin", "caldav_disabled", "error"} {
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/"+name+".tmpl",
@@ -100,6 +101,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
@@ -172,11 +176,17 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
tasks, err := s.detailTodos(r.Context(), it)
if err != nil {
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
}
s.render(w, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"CalDAVOn": s.CalDAV != nil,
})
}
@@ -186,6 +196,10 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
s.handleReparent(w, r, base)
return
}
if base, ok := strings.CutSuffix(path, "/caldav/create"); ok {
s.handleCalDAVCreate(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)