Files
projax/web/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

257 lines
7.1 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/store"
)
const refTypeCalDAV = "caldav-list"
// CalDAVDeps is the optional CalDAV integration. When nil, the /admin/caldav
// page renders a "not configured" notice and the detail page hides the Tasks
// section. main.go sets it from DAV_URL / DAV_USER / DAV_PASSWORD env.
type CalDAVDeps struct {
Client *caldav.Client
}
// Suggestion pairs one calendar with its best-match projax item, if any.
type Suggestion struct {
Calendar caldav.Calendar
Item *store.Item // nil = no auto-match
AlreadyLink *store.ItemLink
}
// CalDAVOverview is rendered by /admin/caldav.
type CalDAVOverview struct {
Suggestions []Suggestion
Items []*store.Item // for the manual-link selector
}
// buildCalDAVOverview fetches the calendar list, looks up existing
// caldav-list links, and pairs each calendar with the best matching projax
// item by case-insensitive title/slug.
func (s *Server) buildCalDAVOverview(ctx context.Context) (*CalDAVOverview, error) {
cals, err := s.CalDAV.Client.ListCalendars(ctx)
if err != nil {
return nil, fmt.Errorf("caldav list: %w", err)
}
items, err := s.Store.ListAll(ctx)
if err != nil {
return nil, err
}
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
if err != nil {
return nil, err
}
// Map calendar URL → existing link
byURL := map[string]*store.ItemLink{}
for _, l := range links {
byURL[l.RefID] = l
}
// Lower-case lookup over title+slug for the heuristic.
byKey := map[string]*store.Item{}
for _, it := range items {
byKey[strings.ToLower(it.Slug)] = it
byKey[strings.ToLower(it.Title)] = it
}
sort.Slice(cals, func(i, j int) bool { return cals[i].DisplayName < cals[j].DisplayName })
overview := &CalDAVOverview{Items: items}
for _, c := range cals {
s := Suggestion{Calendar: c}
if l, ok := byURL[c.URL]; ok {
s.AlreadyLink = l
// surface the linked item
for _, it := range items {
if it.ID == l.ItemID {
s.Item = it
break
}
}
} else {
key := strings.ToLower(c.DisplayName)
if it, ok := byKey[key]; ok {
s.Item = it
}
}
overview.Suggestions = append(overview.Suggestions, s)
}
return overview, nil
}
func (s *Server) handleCalDAVAdmin(w http.ResponseWriter, r *http.Request) {
if s.CalDAV == nil {
s.render(w, "caldav_disabled", map[string]any{"Title": "caldav"})
return
}
ov, err := s.buildCalDAVOverview(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, "caldav_admin", map[string]any{
"Title": "caldav",
"Suggestions": ov.Suggestions,
"Items": ov.Items,
})
}
func (s *Server) handleCalDAVLink(w http.ResponseWriter, r *http.Request) {
if s.CalDAV == nil {
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
itemID := strings.TrimSpace(r.FormValue("item_id"))
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
note := strings.TrimSpace(r.FormValue("display_name"))
color := strings.TrimSpace(r.FormValue("color"))
if itemID == "" || calURL == "" {
http.Error(w, "item_id + calendar_url required", http.StatusBadRequest)
return
}
meta := map[string]any{
"display_name": note,
"calendar_color": color,
"linked_at": time.Now().UTC().Format(time.RFC3339),
}
if _, err := s.Store.AddLink(r.Context(), itemID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther)
}
func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
linkID := strings.TrimSpace(r.FormValue("link_id"))
if linkID == "" {
http.Error(w, "link_id required", http.StatusBadRequest)
return
}
if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther)
}
// handleCalDAVCreate handles POST /i/{path}/caldav/create — MKCALENDAR on
// dav.msbls.de derived from the item slug, then the item_link insert.
func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path string) {
if s.CalDAV == nil {
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
slug := safeCalendarSlug(it.Slug)
calURL := s.CalDAV.Client.BaseURL + slug + "/"
displayName := it.Title
if displayName == "" {
displayName = it.Slug
}
if err := s.CalDAV.Client.CreateCalendar(r.Context(), calURL, displayName, ""); err != nil {
if errors.Is(err, caldav.ErrCalendarExists) {
// Existing calendar — link instead.
meta := map[string]any{"display_name": displayName, "linked_at": time.Now().UTC().Format(time.RFC3339)}
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
return
}
s.fail(w, r, err)
return
}
meta := map[string]any{
"display_name": displayName,
"created_at": time.Now().UTC().Format(time.RFC3339),
}
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
}
// safeCalendarSlug normalises a projax slug for use in a CalDAV URL segment.
// Slugs are already lowercase + no dots per the projax invariant, but we
// re-escape to be safe.
func safeCalendarSlug(slug string) string {
return url.PathEscape(strings.ToLower(strings.TrimSpace(slug)))
}
// detailTodos pulls open + recently-completed VTODOs for the item by iterating
// every caldav-list link. Errors per-calendar are logged and skipped so one
// down calendar doesn't blank the whole section.
type calendarTasks struct {
CalendarURL string
DisplayName string
Open []caldav.Todo
DoneRecent []caldav.Todo
}
func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarTasks, error) {
if s.CalDAV == nil {
return nil, nil
}
links, err := s.Store.LinksByType(ctx, item.ID, refTypeCalDAV)
if err != nil {
return nil, err
}
cutoff := time.Now().AddDate(0, 0, -30)
var out []calendarTasks
for _, l := range links {
todos, err := s.CalDAV.Client.ListTodos(ctx, l.RefID)
if err != nil {
s.Logger.Warn("caldav todos", "calendar", l.RefID, "err", err)
continue
}
ct := calendarTasks{
CalendarURL: l.RefID,
DisplayName: linkDisplay(l),
}
for _, td := range todos {
if td.Status == "COMPLETED" || td.Status == "CANCELLED" {
if td.LastModified == nil || td.LastModified.After(cutoff) {
ct.DoneRecent = append(ct.DoneRecent, td)
}
continue
}
ct.Open = append(ct.Open, td)
}
out = append(out, ct)
}
return out, nil
}
func linkDisplay(l *store.ItemLink) string {
if v, ok := l.Metadata["display_name"].(string); ok && v != "" {
return v
}
if l.Note != nil && *l.Note != "" {
return *l.Note
}
return l.RefID
}