diff --git a/README.md b/README.md index 54477f0..39af5e6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Defaults: - `PROJAX_LISTEN_ADDR=:8080` - `PROJAX_AUTO_MIGRATE=on` (set to `off` to skip on-start migration apply) - `SUPABASE_URL` + `SUPABASE_ANON_KEY` enable projax's own `/login`. Same Supabase backend as the rest of the m/* fleet, but every tool runs its own login page and scopes cookies per-host. Leave both unset for local dev — every request is anonymous. +- `DAV_URL` + `DAV_USER` + `DAV_PASSWORD` enable the CalDAV integration: `/admin/caldav` discovery, the Tasks section on item detail pages, and the "Create CalDAV list" action. Leave unset to disable (admin page shows a "not configured" notice). Visit `http://localhost:8080/`. Routes: diff --git a/caldav/caldav.go b/caldav/caldav.go new file mode 100644 index 0000000..07a2946 --- /dev/null +++ b/caldav/caldav.go @@ -0,0 +1,278 @@ +// Package caldav is a minimal client for the slice of CalDAV that projax +// needs: list calendar collections, fetch open VTODOs from one, create a new +// calendar. SabreDAV-flavoured XML. Basic auth only — single-user, single-host. +package caldav + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client wraps a base URL + Basic credentials. +type Client struct { + BaseURL string // e.g. https://dav.msbls.de/dav/calendars/m/ + User string + Password string + HTTPClient *http.Client +} + +// New builds a client with a sensible default timeout. base must end in '/'. +func New(base, user, password string) *Client { + if !strings.HasSuffix(base, "/") { + base += "/" + } + return &Client{ + BaseURL: base, + User: user, + Password: password, + HTTPClient: &http.Client{Timeout: 8 * time.Second}, + } +} + +// Calendar is one collection returned by ListCalendars. +type Calendar struct { + URL string // absolute, e.g. https://dav.msbls.de/dav/calendars/m/Work/ + HRef string // server-relative path, useful for routing + DisplayName string + Color string +} + +// Todo is one VTODO returned by ListTodos. +type Todo struct { + UID string + Summary string + Status string // NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED + Due *time.Time + Priority int + LastModified *time.Time +} + +func (c *Client) do(ctx context.Context, method, urlStr string, headers map[string]string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, urlStr, bytes.NewReader(body)) + if err != nil { + return nil, err + } + if c.User != "" || c.Password != "" { + req.SetBasicAuth(c.User, c.Password) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +// ListCalendars sends PROPFIND Depth: 1 against BaseURL and returns every +// child collection tagged with . Non-calendar collections +// (default/, inbox/, outbox/) are filtered out. +func (c *Client) ListCalendars(ctx context.Context) ([]Calendar, error) { + body := []byte(` + + + + + + + +`) + resp, err := c.do(ctx, "PROPFIND", c.BaseURL, map[string]string{ + "Depth": "1", + "Content-Type": "application/xml; charset=utf-8", + }, body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 207 { + raw, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("caldav PROPFIND: %d %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + + var ms multistatus + if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { + return nil, fmt.Errorf("caldav PROPFIND decode: %w", err) + } + base, err := url.Parse(c.BaseURL) + if err != nil { + return nil, err + } + var out []Calendar + for _, r := range ms.Responses { + // We only care about responses that are calendar collections AND have + // a 200 status on the prop block. Filter inbox/outbox out — they are + // / , not . + isCalendar := false + display := "" + color := "" + for _, ps := range r.PropStat { + if ps.Status == "" || strings.Contains(ps.Status, "200") { + if ps.Prop.ResourceType.Calendar != nil { + isCalendar = true + } + if ps.Prop.DisplayName != "" { + display = ps.Prop.DisplayName + } + if ps.Prop.CalColor != "" { + color = ps.Prop.CalColor + } + } + } + if !isCalendar { + continue + } + // Resolve href against the base URL host so URL is absolute. + abs := *base + hrefURL, err := url.Parse(r.Href) + if err != nil { + continue + } + if hrefURL.IsAbs() { + abs = *hrefURL + } else { + abs.Path = hrefURL.Path + abs.RawQuery = "" + abs.Fragment = "" + } + out = append(out, Calendar{ + URL: abs.String(), + HRef: r.Href, + DisplayName: display, + Color: color, + }) + } + return out, nil +} + +// ListTodos issues a REPORT calendar-query against a single calendar URL and +// returns parsed VTODOs. +func (c *Client) ListTodos(ctx context.Context, calendarURL string) ([]Todo, error) { + body := []byte(` + + + + + + + + + + +`) + resp, err := c.do(ctx, "REPORT", calendarURL, map[string]string{ + "Depth": "1", + "Content-Type": "application/xml; charset=utf-8", + }, body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 207 { + raw, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("caldav REPORT: %d %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var ms multistatus + if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { + return nil, fmt.Errorf("caldav REPORT decode: %w", err) + } + var out []Todo + for _, r := range ms.Responses { + for _, ps := range r.PropStat { + if ps.Prop.CalendarData == "" { + continue + } + todos := parseVTodos(ps.Prop.CalendarData) + out = append(out, todos...) + } + } + return out, nil +} + +// CreateCalendar issues MKCALENDAR for the given absolute URL with the given +// display name. Returns ErrCalendarExists when the server reports the URL is +// already in use. +func (c *Client) CreateCalendar(ctx context.Context, calendarURL, displayName, color string) error { + colorEl := "" + if color != "" { + colorEl = fmt.Sprintf(`%s`, xmlEscape(color)) + } + body := []byte(fmt.Sprintf(` + + + + %s + %s + + + + + + +`, xmlEscape(displayName), colorEl)) + resp, err := c.do(ctx, "MKCALENDAR", calendarURL, map[string]string{ + "Content-Type": "application/xml; charset=utf-8", + }, body) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusCreated /* 201 */ { + return nil + } + if resp.StatusCode == http.StatusMethodNotAllowed /* 405 */ { + // SabreDAV returns 405 when the URL is already in use. + return ErrCalendarExists + } + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("caldav MKCALENDAR: %d %s", resp.StatusCode, strings.TrimSpace(string(raw))) +} + +// ErrCalendarExists is returned when MKCALENDAR refuses because a collection +// already lives at the target URL. +var ErrCalendarExists = errors.New("caldav: calendar already exists") + +func xmlEscape(s string) string { + var b bytes.Buffer + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} + +// --- XML response shapes --- + +type multistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []davResponse `xml:"response"` +} + +type davResponse struct { + Href string `xml:"href"` + PropStat []davPropStat `xml:"propstat"` +} + +type davPropStat struct { + Prop davProp `xml:"prop"` + Status string `xml:"status"` +} + +type davProp struct { + DisplayName string `xml:"displayname"` + CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"` + CalColor string `xml:"urn:ietf:params:xml:ns:caldav calendar-color"` + ResourceType davResourceType `xml:"resourcetype"` + GetEtag string `xml:"getetag"` +} + +type davResourceType struct { + Collection *struct{} `xml:"collection"` + Calendar *struct{} `xml:"urn:ietf:params:xml:ns:caldav calendar"` +} diff --git a/caldav/caldav_test.go b/caldav/caldav_test.go new file mode 100644 index 0000000..515b134 --- /dev/null +++ b/caldav/caldav_test.go @@ -0,0 +1,198 @@ +package caldav + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newFakeServer stubs the slice of CalDAV that the client exercises. Each +// handler asserts the method and returns a canned XML body so the parser can +// be exercised end-to-end without a real DAV server. +func newFakeServer(t *testing.T) (*Client, *httptest.Server) { + t.Helper() + mux := http.NewServeMux() + + mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PROPFIND" { + t.Errorf("unexpected method %q on collection", r.Method) + } + w.WriteHeader(207) + _, _ = io.WriteString(w, propfindBody) + }) + + mux.HandleFunc("/dav/calendars/m/Work/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "REPORT": + w.WriteHeader(207) + _, _ = io.WriteString(w, reportBody) + case "MKCALENDAR": + w.WriteHeader(http.StatusMethodNotAllowed) + default: + t.Errorf("unexpected method %q on Work/", r.Method) + } + }) + + mux.HandleFunc("/dav/calendars/m/new-list/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "MKCALENDAR" { + t.Errorf("unexpected method %q on new-list/", r.Method) + } + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "Paliad") { + t.Errorf("MKCALENDAR body missing display name: %s", body) + } + w.WriteHeader(http.StatusCreated) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return New(srv.URL+"/dav/calendars/m/", "u", "p"), srv +} + +func TestListCalendars(t *testing.T) { + c, _ := newFakeServer(t) + cals, err := c.ListCalendars(context.Background()) + if err != nil { + t.Fatalf("ListCalendars: %v", err) + } + want := map[string]bool{"mCalendar": true, "Birthdays": true, "Work": true} + got := map[string]bool{} + for _, cal := range cals { + got[cal.DisplayName] = true + } + for k := range want { + if !got[k] { + t.Errorf("expected calendar %q in result, got %v", k, got) + } + } +} + +func TestListTodos(t *testing.T) { + c, _ := newFakeServer(t) + todos, err := c.ListTodos(context.Background(), c.BaseURL+"Work/") + if err != nil { + t.Fatalf("ListTodos: %v", err) + } + if len(todos) != 2 { + t.Fatalf("expected 2 todos, got %d (%v)", len(todos), todos) + } + if todos[0].UID != "todo-1@example" { + t.Errorf("todos[0].UID = %q", todos[0].UID) + } + if todos[0].Summary != "Pick up bread" { + t.Errorf("todos[0].Summary = %q", todos[0].Summary) + } + if todos[0].Status != "NEEDS-ACTION" { + t.Errorf("todos[0].Status = %q", todos[0].Status) + } + if todos[1].Status != "COMPLETED" { + t.Errorf("todos[1].Status = %q", todos[1].Status) + } +} + +func TestCreateCalendar(t *testing.T) { + c, _ := newFakeServer(t) + if err := c.CreateCalendar(context.Background(), c.BaseURL+"new-list/", "Paliad", "#bff355"); err != nil { + t.Fatalf("CreateCalendar: %v", err) + } +} + +func TestCreateCalendarExists(t *testing.T) { + c, _ := newFakeServer(t) + err := c.CreateCalendar(context.Background(), c.BaseURL+"Work/", "Work", "") + if err != ErrCalendarExists { + t.Fatalf("expected ErrCalendarExists, got %v", err) + } +} + +const propfindBody = ` + + + /dav/calendars/m/ + + + HTTP/1.1 200 OK + + + + /dav/calendars/m/default/ + + + mCalendar + + + HTTP/1.1 200 OK + + + + /dav/calendars/m/birthday-calendar/ + + + Birthdays + + + HTTP/1.1 200 OK + + + + /dav/calendars/m/Work/ + + + Work + + + HTTP/1.1 200 OK + + + + /dav/calendars/m/inbox/ + + + inbox + + + HTTP/1.1 200 OK + + +` + +const reportBody = ` + + + /dav/calendars/m/Work/todo-1.ics + + + "abc" + BEGIN:VCALENDAR +BEGIN:VTODO +UID:todo-1@example +SUMMARY:Pick up bread +STATUS:NEEDS-ACTION +PRIORITY:5 +DUE:20260601T120000Z +END:VTODO +END:VCALENDAR + + HTTP/1.1 200 OK + + + + /dav/calendars/m/Work/todo-2.ics + + + "def" + BEGIN:VCALENDAR +BEGIN:VTODO +UID:todo-2@example +SUMMARY:Filed paperwork +STATUS:COMPLETED +END:VTODO +END:VCALENDAR + + HTTP/1.1 200 OK + + +` diff --git a/caldav/parse.go b/caldav/parse.go new file mode 100644 index 0000000..029bc0a --- /dev/null +++ b/caldav/parse.go @@ -0,0 +1,123 @@ +package caldav + +import ( + "strconv" + "strings" + "time" +) + +// parseVTodos extracts every VTODO block from a calendar-data string. Hand- +// rolled because importing a full iCalendar parser for the half-dozen fields +// projax cares about is overkill. Tolerates folded lines per RFC 5545 §3.1. +func parseVTodos(ics string) []Todo { + ics = unfold(ics) + lines := strings.Split(ics, "\n") + var out []Todo + var inTodo bool + var cur Todo + for _, ln := range lines { + ln = strings.TrimRight(ln, "\r") + if ln == "BEGIN:VTODO" { + inTodo = true + cur = Todo{Status: "NEEDS-ACTION"} + continue + } + if ln == "END:VTODO" { + if cur.UID != "" { + out = append(out, cur) + } + inTodo = false + continue + } + if !inTodo { + continue + } + key, val := splitLine(ln) + switch key { + case "UID": + cur.UID = val + case "SUMMARY": + cur.Summary = unescapeText(val) + case "STATUS": + cur.Status = strings.ToUpper(val) + case "PRIORITY": + if n, err := strconv.Atoi(val); err == nil { + cur.Priority = n + } + case "DUE": + if t, ok := parseICalTime(val); ok { + cur.Due = &t + } + case "LAST-MODIFIED": + if t, ok := parseICalTime(val); ok { + cur.LastModified = &t + } + } + } + return out +} + +// unfold collapses RFC 5545 line continuations (a CRLF followed by a single +// SP or HT continues the previous line). +func unfold(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + var b strings.Builder + lines := strings.Split(s, "\n") + for i, ln := range lines { + if i > 0 && len(ln) > 0 && (ln[0] == ' ' || ln[0] == '\t') { + b.WriteString(ln[1:]) + continue + } + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(ln) + } + return b.String() +} + +// splitLine separates "KEY;PARAMS:VALUE" into ("KEY", "VALUE"). Params dropped +// — we don't need TZID etc. for v1. +func splitLine(ln string) (string, string) { + colon := strings.Index(ln, ":") + if colon < 0 { + return "", "" + } + head := ln[:colon] + val := ln[colon+1:] + if semi := strings.Index(head, ";"); semi >= 0 { + head = head[:semi] + } + return head, val +} + +// parseICalTime recognises both `YYYYMMDDTHHMMSSZ` (UTC) and bare `YYYYMMDD`. +// Floating local-time forms are coerced to UTC for ranking — single user, no +// tz acrobatics needed at v1. +func parseICalTime(v string) (time.Time, bool) { + v = strings.TrimSpace(v) + if len(v) == 8 { + if t, err := time.Parse("20060102", v); err == nil { + return t, true + } + } + if len(v) >= 15 { + layouts := []string{"20060102T150405Z", "20060102T150405"} + for _, l := range layouts { + if t, err := time.Parse(l, v); err == nil { + return t, true + } + } + } + return time.Time{}, false +} + +// unescapeText reverses RFC 5545 §3.3.11 text encoding. +func unescapeText(s string) string { + s = strings.ReplaceAll(s, `\n`, "\n") + s = strings.ReplaceAll(s, `\N`, "\n") + s = strings.ReplaceAll(s, `\,`, ",") + s = strings.ReplaceAll(s, `\;`, ";") + s = strings.ReplaceAll(s, `\\`, `\`) + return s +} diff --git a/cmd/projax/main.go b/cmd/projax/main.go index 32d8c24..89f5d98 100644 --- a/cmd/projax/main.go +++ b/cmd/projax/main.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "github.com/m/projax/caldav" "github.com/m/projax/db" "github.com/m/projax/store" "github.com/m/projax/web" @@ -76,6 +77,19 @@ func main() { logger.Warn("auth: disabled — SUPABASE_URL not set, every request is anonymous") } + if davURL := os.Getenv("DAV_URL"); davURL != "" { + davUser := os.Getenv("DAV_USER") + davPass := os.Getenv("DAV_PASSWORD") + if davUser == "" || davPass == "" { + logger.Error("DAV_URL set but DAV_USER / DAV_PASSWORD missing — refusing to start") + os.Exit(1) + } + srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(davURL, davUser, davPass)} + logger.Info("caldav: enabled", "base_url", davURL) + } else { + logger.Info("caldav: disabled — DAV_URL not set") + } + httpServer := &http.Server{ Addr: listen, Handler: srv.Routes(), diff --git a/deploy/dokploy.yaml b/deploy/dokploy.yaml index e433c90..f393b9f 100644 --- a/deploy/dokploy.yaml +++ b/deploy/dokploy.yaml @@ -38,6 +38,9 @@ env: - PROJAX_LISTEN_ADDR=:8080 - PROJAX_AUTO_MIGRATE=on - SUPABASE_URL=https://supa.flexsiebels.de + - DAV_URL=https://dav.msbls.de/dav/calendars/m/ secrets: - PROJAX_DB_URL - SUPABASE_ANON_KEY + - DAV_USER + - DAV_PASSWORD diff --git a/docs/design.md b/docs/design.md index 4769ead..4a1db1a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -233,6 +233,20 @@ After 1c, m can use the system. Test rows in mai.projects either stay as orphans - Bidirectional CalDAV/Gitea sync in v1 (read-only first) - Real-time collaboration features +## 5. CalDAV integration (Phase 2, v1: read-only + create-on-demand) + +m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth via `DAV_USER`/`DAV_PASSWORD`). projax v1 wires a small slice: + +- **Link model**: a `projax.item_links` row with `ref_type='caldav-list'`, `ref_id=`, `metadata={display_name, calendar_color, linked_at, …}`. Same item_links row pattern as `mai-project` / `gitea-repo`. An item can be linked to multiple calendars; a calendar can be linked to multiple items (rare in practice). +- **Discovery** (`GET /admin/caldav`): the binary PROPFINDs Depth: 1 against the base URL, filters out non-calendar collections (`inbox`/`outbox`), and pairs each discovered calendar with the projax item whose lowercased title or slug matches the calendar's display name. m confirms or overrides each suggestion. +- **Linking** (`POST /admin/caldav/link` / `/admin/caldav/unlink`): single-row CRUD on item_links. No background sync. +- **Task aggregation** (item detail page): for each linked calendar, the binary REPORTs `calendar-query` for VTODOs and renders open + recent-completed tasks. Errors per-calendar are logged and skipped — one bad list does not blank the section. +- **Create on demand** (`POST /i/{path}/caldav/create`): MKCALENDAR at `//` with display name ``. 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.

+ + + + + + + {{range .Suggestions}} + + + + + + {{else}} + + {{end}} + +
CalendarSuggested / linked projax itemAction
+ {{.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}} +
No calendars discovered.
+{{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}} +
diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl index cfc60ff..5da70e7 100644 --- a/web/templates/layout.tmpl +++ b/web/templates/layout.tmpl @@ -11,6 +11,7 @@