m's ask: per-item CalDAV linking should support existing lists, not
just create-new. Athena's design update extended it: also tag VTODOs
on create so multiple projax items can SHARE one CalDAV list, with
projax doing tag-based slicing on read.
Three layers, one branch:
## 1. Link-existing picker (the original ask)
- New POST /i/{path}/caldav/link-existing handler validates the
submitted calendar_url is in the discoverable PROPFIND set (defence
against crafted forms pointing at arbitrary HTTP servers), then
inserts the item_link row with display_name + color metadata
preserved from the discovery payload.
- handleDetail + renderTasksSection pre-load
availableCalendarsForItem(ctx, links) — calendars from
s.CalDAV.Client.ListCalendars MINUS the ones already linked to this
item. Errors degrade to an empty picker (non-fatal).
- tasks_section.tmpl gains a .caldav-actions block rendering the
picker (<select> of available calendars) when AvailableCalendars
is non-empty AND the Create-new button (when the item has no
linked list yet). Same surface serves both the "first link" flow
and the "+ link another" flow per athena's brief.
## 2. Tag-on-create (CATEGORIES carries projax:<path>)
- caldav package gains Categories []string on Todo + the same on
VTodoEdit. BuildVTodoICS emits a CATEGORIES line when non-empty;
parseVTodos parses CATEGORIES comma-list into the slice with per-
entry unescape per RFC 5545.
- handleCalDAVTodoAction action="todo-create" passes
`Categories: []{ProjaxCategoryFor(it.PrimaryPath())}` into
VTodoEdit so every per-item Add submits a tagged VTODO.
- ApplyVTodoEdit intentionally ignores the Categories field —
edit/complete/delete paths preserve existing CATEGORIES via the
unknown-property pass-through that's been tested since Phase 5
(TestApplyVTodoEditPreservesUnknown).
## 3. Per-item filter (managed-vs-legacy)
- detailTodos now calls caldav.AnyTodoHasProjaxTag(todos) to decide
whether the linked list is projax-managed (any projax: tag
anywhere) or legacy/unmanaged (zero projax: tags).
- Managed → filter to VTODOs whose CATEGORIES include this
item's projax:<path>. Multiple projax: tags are AND-of-OR — a
VTODO with two projax tags appears on both items per athena's
multi-tag contract.
- Legacy → show every VTODO untouched. Existing pre-5j users with
untagged lists keep seeing everything; the detail page doesn't
suddenly hide their tasks.
## Helpers (caldav package, exported)
- ProjaxCategoryFor(primaryPath) → "projax:<path>" string
- HasProjaxTag(t) bool → any projax: prefix
- HasProjaxTagFor(t, primaryPath) bool → exact projax:<path>
- AnyTodoHasProjaxTag(todos) bool → list-level signal
## Tests
caldav unit (caldav/projax_tags_test.go):
- TestProjaxCategoryFor / TestHasProjaxTagAndFor /
TestAnyTodoHasProjaxTag / TestBuildVTodoICSEmitsCategories /
TestParseVTodosMultiCategory.
web integration (web/caldav_link_existing_test.go) — single fake
CalDAV server (httptest) answering PROPFIND + REPORT + PUT, then
four end-to-end probes:
- TestDetailLinkExistingCalendar — three calendars discoverable,
picker renders, POST link-existing creates the link, second GET
drops the linked URL from the picker.
- TestVTodoCreateAttachesProjaxCategory — Add-task POST writes a
VTODO whose CATEGORIES contains projax:<path>.
- TestDetailFilterByProjaxCategory — one calendar shared between
Trip A and Trip B with three tagged VTODOs; A sees A+shared,
B sees B+shared, neither sees the other's tagged-only VTODO.
- TestDetailUntaggedListShowsAll — linked list with zero projax
tags renders ALL VTODOs (legacy fallback).
Full web + caldav suites green. Pre-existing
db/TestBackfillTagsFromArea failure unchanged.
Net: +795 / -14.
579 lines
18 KiB
Go
579 lines
18 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, 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)
|
|
}
|
|
|
|
// availableCalendarsForItem returns the discoverable CalDAV calendars
|
|
// minus the ones already linked to this item — feeds the per-item
|
|
// "Link existing list" picker on the detail page. Errors during
|
|
// discovery (network, auth, parse) are surfaced to the caller; callers
|
|
// downgrade to an empty list so the rest of the page still renders.
|
|
//
|
|
// "Already linked" is computed by the caller's `links` slice rather
|
|
// than a fresh fetch, since handleDetail/renderTasksSection already
|
|
// loaded the per-item caldav-list links inside detailTodos and we
|
|
// avoid a second LinksByType round-trip.
|
|
func (s *Server) availableCalendarsForItem(ctx context.Context, links []*store.ItemLink) ([]caldav.Calendar, error) {
|
|
if s.CalDAV == nil {
|
|
return nil, nil
|
|
}
|
|
cals, err := s.CalDAV.Client.ListCalendars(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
linkedURLs := map[string]struct{}{}
|
|
for _, l := range links {
|
|
linkedURLs[l.RefID] = struct{}{}
|
|
}
|
|
out := make([]caldav.Calendar, 0, len(cals))
|
|
for _, c := range cals {
|
|
if _, already := linkedURLs[c.URL]; already {
|
|
continue
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName })
|
|
return out, nil
|
|
}
|
|
|
|
// handleCalDAVLinkExisting handles POST /i/{path}/caldav/link-existing —
|
|
// the per-item picker for sharing an existing CalDAV list across
|
|
// multiple projax items. Re-runs ListCalendars to validate that the
|
|
// submitted URL is genuinely discoverable (defence against a crafted
|
|
// form pointing at an arbitrary URL), then inserts the item_link.
|
|
func (s *Server) handleCalDAVLinkExisting(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
|
|
}
|
|
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
|
|
}
|
|
// Validate the URL is in the discoverable set — a malicious form must
|
|
// not be able to seed an item_link pointing at arbitrary HTTP servers.
|
|
cals, err := s.CalDAV.Client.ListCalendars(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
var matched *caldav.Calendar
|
|
for i := range cals {
|
|
if cals[i].URL == calURL {
|
|
matched = &cals[i]
|
|
break
|
|
}
|
|
}
|
|
if matched == nil {
|
|
http.Error(w, "calendar not in discoverable set", http.StatusBadRequest)
|
|
return
|
|
}
|
|
meta := map[string]any{
|
|
"display_name": matched.DisplayName,
|
|
"calendar_color": matched.Color,
|
|
"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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Phase 5j per-item filter: when the linked list contains ANY
|
|
// projax-tagged VTODO it's a managed list — narrow to entries
|
|
// carrying this item's `projax:<path>` tag. A list with zero
|
|
// projax tags is a legacy/unmanaged list and renders unfiltered
|
|
// (existing pre-5j behaviour, untouched). The cutoff still
|
|
// applies to DoneRecent on the post-filter slice.
|
|
if caldav.AnyTodoHasProjaxTag(todos) {
|
|
want := item.PrimaryPath()
|
|
filtered := todos[:0:0]
|
|
for _, td := range todos {
|
|
if caldav.HasProjaxTagFor(td, want) {
|
|
filtered = append(filtered, td)
|
|
}
|
|
}
|
|
todos = filtered
|
|
}
|
|
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
|
|
}
|
|
// Phase 5j tag-on-create: every VTODO created from a per-item Add
|
|
// form gets `projax:<primary-path>` in CATEGORIES so multiple
|
|
// projax items can share one CalDAV list and the per-item filter
|
|
// only surfaces the right ones.
|
|
edit := caldav.VTodoEdit{
|
|
Summary: &summary,
|
|
Categories: []string{caldav.ProjaxCategoryFor(it.PrimaryPath())},
|
|
}
|
|
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
|
|
}
|
|
// HTMX swaps re-render the section in place; the picker needs the same
|
|
// AvailableCalendars data the full /i/{path} render computes. Errors
|
|
// here are non-fatal — degrade to an empty picker.
|
|
var available []caldav.Calendar
|
|
if s.CalDAV != nil {
|
|
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
|
if lerr != nil {
|
|
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
|
|
}
|
|
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
|
|
if aerr != nil {
|
|
s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr)
|
|
}
|
|
available = acs
|
|
}
|
|
data := map[string]any{
|
|
"Item": it,
|
|
"Tasks": tasks,
|
|
"AvailableCalendars": available,
|
|
"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
|
|
}
|