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, r, "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, r, "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 // Error, when non-empty, surfaces a per-calendar problem (network, // upstream auth, parse) so the UI can show a banner instead of silently // blanking the calendar. Error string } 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 } // handleCalDAVTodoAction dispatches POST /i/{path}/caldav/todo/{action}. // action ∈ {complete, reopen, edit, delete, todo-create}. The handler reloads // the live VTODO (to pick up the freshest ETag), applies the requested edit, // PUTs / DELETEs against the server, then re-renders the tasks section so // HTMX can swap it in. 412 responses surface as a banner so m can retry. func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request, path, action 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 } if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } calURL := strings.TrimSpace(r.FormValue("calendar_url")) if calURL == "" { http.Error(w, "calendar_url required", http.StatusBadRequest) return } // Guard: the calendar URL must be linked to this item — otherwise a // crafted form could route writes to arbitrary calendars. links, err := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV) if err != nil { s.fail(w, r, err) return } var matchedLink *store.ItemLink for _, l := range links { if l.RefID == calURL { matchedLink = l break } } if matchedLink == nil { http.Error(w, "calendar not linked to this item", http.StatusForbidden) return } banner := "" switch action { case "todo-create": summary := strings.TrimSpace(r.FormValue("summary")) if summary == "" { banner = "Cannot create task with empty summary." break } edit := caldav.VTodoEdit{Summary: &summary} if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" { if t, ok := parseDueInput(dueStr); ok { edit.Due = &t } } uid := caldav.NewUID() ics := caldav.BuildVTodoICS(uid, edit) url := caldav.TodoURLFor(calURL, uid) if _, err := s.CalDAV.Client.PutTodo(r.Context(), url, ics, "", "*"); err != nil { banner = "Could not create task: " + err.Error() } case "complete", "reopen", "edit", "delete": uid := strings.TrimSpace(r.FormValue("uid")) if uid == "" { http.Error(w, "uid required", http.StatusBadRequest) return } // Refetch — ETags from the original page render may be stale, and we // also need the latest Raw ICS body for in-place edits that preserve // unknown fields. todos, err := s.CalDAV.Client.ListTodos(r.Context(), calURL) if err != nil { banner = "Could not reach calendar: " + err.Error() break } var current *caldav.Todo for i := range todos { if todos[i].UID == uid { current = &todos[i] break } } if current == nil { banner = "Task no longer exists on the server." break } switch action { case "complete": st := "COMPLETED" updated := caldav.ApplyVTodoEdit(current.Raw, caldav.VTodoEdit{Status: &st}) if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil { banner = caldavBanner("complete", err) } case "reopen": st := "NEEDS-ACTION" updated := caldav.ApplyVTodoEdit(current.Raw, caldav.VTodoEdit{Status: &st}) if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil { banner = caldavBanner("reopen", err) } case "edit": edit := caldav.VTodoEdit{} if v := r.FormValue("summary"); v != "" { vv := strings.TrimSpace(v) edit.Summary = &vv } if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" { if t, ok := parseDueInput(dueStr); ok { edit.Due = &t } } else if _, present := r.Form["due"]; present { // Field submitted but blank → user cleared it. edit.ClearDue = true } updated := caldav.ApplyVTodoEdit(current.Raw, edit) if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil { banner = caldavBanner("edit", err) } case "delete": if err := s.CalDAV.Client.DeleteTodo(r.Context(), current.URL, current.ETag); err != nil { banner = caldavBanner("delete", err) } } default: http.Error(w, "unknown action: "+action, http.StatusBadRequest) return } // Writeback may move a task on or off the timeline, so bust both caches. if s.dashboard != nil { s.dashboard.InvalidateAll() } if s.timeline != nil { s.timeline.InvalidateAll() } // Always re-render the tasks section so HTMX (or a plain redirect for // non-HTMX clients) sees the post-write state. if r.Header.Get("HX-Request") == "true" { s.renderTasksSection(w, r, it, banner) return } http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther) } // caldavBanner formats an HTMX-banner string from a write error, distinguishing // the 412-mismatch case ("task changed elsewhere") from generic upstream // failures so m sees something actionable. func caldavBanner(action string, err error) string { if errors.Is(err, caldav.ErrPreconditionFailed) { return "Task changed elsewhere since this page was loaded — refresh and retry the " + action + "." } if errors.Is(err, caldav.ErrNotFound) { return "Task is gone on the server. The list below is current." } return "Could not " + action + " task: " + err.Error() } // renderTasksSection re-runs detailTodos for the item and renders the // tasks-section template fragment with an optional banner. Used by HTMX // responses so swap operations stay in-place. func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) { tasks, err := s.detailTodos(r.Context(), it) if err != nil { s.fail(w, r, err) return } data := map[string]any{ "Item": it, "Tasks": tasks, "CalDAVOn": s.CalDAV != nil, "Banner": banner, } s.render(w, r, "tasks_section", data) } // parseDueInput accepts an HTML5 date-input value (`YYYY-MM-DD`) or a // datetime-local value (`YYYY-MM-DDTHH:MM`), returning the corresponding UTC // time. Dates with no clock component round-trip to a DUE;VALUE=DATE line. func parseDueInput(s string) (time.Time, bool) { s = strings.TrimSpace(s) if s == "" { return time.Time{}, false } for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02"} { if t, err := time.Parse(layout, s); err == nil { return t, true } } return time.Time{}, false }