`. If the URL is already in use (SabreDAV returns 405), the binary links to the existing calendar instead and surfaces a one-line notice.
+- **Multi-parent items** keep ONE list per item — the URL is derived from the slug, not the path. `paliad` gets `/dav/calendars/m/paliad/` whether it lives at `work.paliad`, `dev.paliad`, or both.
+- **Out of scope for v1**: editing VTODOs from projax, two-way creation, background sync, calendar colour/icon editing. Phase 2.b will layer write semantics; phase 2.c may add a TTL'd cache table if live REPORT-querying gets slow.
+
+Env contract: `DAV_URL` (default `https://dav.msbls.de/dav/calendars/m/`), `DAV_USER`, `DAV_PASSWORD`. All three live in Dokploy secrets; missing → `/admin/caldav` renders a "not configured" notice and the detail page hides the Tasks section.
+
## 8. Open questions (post-PRD)
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.
diff --git a/store/store.go b/store/store.go
index 47e3923..39cb41b 100644
--- a/store/store.go
+++ b/store/store.go
@@ -324,6 +324,100 @@ func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, erro
return s.GetByID(ctx, id)
}
+// ItemLink mirrors a projax.item_links row — external pointer attached to
+// an item (calendar URL, gitea repo, mai project id, …).
+type ItemLink struct {
+ ID string
+ ItemID string
+ RefType string
+ RefID string
+ Rel string
+ Note *string
+ Metadata map[string]any
+ CreatedAt time.Time
+}
+
+// LinksByType returns every item_link of the given ref_type for one item.
+func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
+ rows, err := s.Pool.Query(ctx, `
+ select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
+ from projax.item_links
+ where item_id = $1 and ref_type = $2
+ order by created_at`, itemID, refType)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*ItemLink
+ for rows.Next() {
+ var l ItemLink
+ if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, &l)
+ }
+ return out, rows.Err()
+}
+
+// LinksByRefType returns every item_link of the given ref_type across the
+// whole schema. Used by /admin/caldav to find already-linked calendars.
+func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
+ rows, err := s.Pool.Query(ctx, `
+ select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
+ from projax.item_links
+ where ref_type = $1
+ order by created_at`, refType)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*ItemLink
+ for rows.Next() {
+ var l ItemLink
+ if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, &l)
+ }
+ return out, rows.Err()
+}
+
+// AddLink inserts an item_link. ON CONFLICT (item_id, ref_type, ref_id, rel)
+// the existing row is returned untouched.
+func (s *Store) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
+ if rel == "" {
+ rel = "contains"
+ }
+ if metadata == nil {
+ metadata = map[string]any{}
+ }
+ var id string
+ err := s.Pool.QueryRow(ctx, `
+ insert into projax.item_links (item_id, ref_type, ref_id, rel, metadata)
+ values ($1, $2, $3, $4, $5)
+ on conflict (item_id, ref_type, ref_id, rel) do update set metadata = excluded.metadata
+ returning id`,
+ itemID, refType, refID, rel, metadata,
+ ).Scan(&id)
+ if err != nil {
+ return nil, fmt.Errorf("add link: %w", err)
+ }
+ row := s.Pool.QueryRow(ctx, `
+ select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
+ from projax.item_links where id = $1`, id)
+ var l ItemLink
+ if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
+ return nil, err
+ }
+ return &l, nil
+}
+
+// DeleteLink removes a single item_link by id.
+func (s *Store) DeleteLink(ctx context.Context, id string) error {
+ _, err := s.Pool.Exec(ctx, `delete from projax.item_links where id = $1`, id)
+ return err
+}
+
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
// Used by the tree page filter chips.
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
diff --git a/web/caldav.go b/web/caldav.go
new file mode 100644
index 0000000..0bb1efc
--- /dev/null
+++ b/web/caldav.go
@@ -0,0 +1,256 @@
+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
+}
diff --git a/web/server.go b/web/server.go
index 5e8d649..7b53d0d 100644
--- a/web/server.go
+++ b/web/server.go
@@ -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)
diff --git a/web/templates/caldav_admin.tmpl b/web/templates/caldav_admin.tmpl
new file mode 100644
index 0000000..8d4fe8e
--- /dev/null
+++ b/web/templates/caldav_admin.tmpl
@@ -0,0 +1,53 @@
+{{define "content"}}
+CalDAV calendars
+{{len .Suggestions}} calendars discovered on dav.msbls.de. Auto-match is case-insensitive on display name vs projax title/slug. Confirm or override; link rows live in projax.item_links with ref_type=caldav-list.
+
+
+
+ | Calendar | Suggested / linked projax item | Action |
+
+
+ {{range .Suggestions}}
+
+
+ {{.Calendar.DisplayName}}
+ {{.Calendar.URL}}
+ |
+
+ {{if .AlreadyLink}}
+ linked → {{.Item.Title}}
+ {{else if .Item}}
+ suggested → {{.Item.Title}}
+ ({{.Item.PrimaryPath}})
+ {{else}}
+ no match — pick manually
+ {{end}}
+ |
+
+ {{if .AlreadyLink}}
+
+ {{else}}
+
+ {{end}}
+ |
+
+ {{else}}
+ | No calendars discovered. |
+ {{end}}
+
+
+{{end}}
diff --git a/web/templates/caldav_disabled.tmpl b/web/templates/caldav_disabled.tmpl
new file mode 100644
index 0000000..be6cfad
--- /dev/null
+++ b/web/templates/caldav_disabled.tmpl
@@ -0,0 +1,5 @@
+{{define "content"}}
+CalDAV
+CalDAV integration is not configured on this deploy.
+Set the DAV_URL, DAV_USER and DAV_PASSWORD environment variables, then redeploy.
+{{end}}
diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl
index e21b958..b6d6e0e 100644
--- a/web/templates/detail.tmpl
+++ b/web/templates/detail.tmpl
@@ -13,6 +13,45 @@
Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}{{$p}}{{end}}
{{end}}
+{{if .CalDAVOn}}
+
+ Tasks
+ {{if .Tasks}}
+ {{range .Tasks}}
+
+
{{.DisplayName}}
+ {{if .Open}}
+
+ {{range .Open}}
+ -
+ {{.Status}}
+ {{.Summary}}
+ {{if .Due}}due {{.Due.Format "2006-01-02"}}{{end}}
+
+ {{end}}
+
+ {{else}}
+
No open tasks.
+ {{end}}
+ {{if .DoneRecent}}
+
+ {{len .DoneRecent}} completed in last 30 days
+
+ {{range .DoneRecent}}- {{.Summary}}
{{end}}
+
+
+ {{end}}
+
+ {{end}}
+ {{else}}
+ No CalDAV list linked.
+
+ {{end}}
+
+{{end}}
+