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.
This commit is contained in:
@@ -15,6 +15,7 @@ Defaults:
|
|||||||
- `PROJAX_LISTEN_ADDR=:8080`
|
- `PROJAX_LISTEN_ADDR=:8080`
|
||||||
- `PROJAX_AUTO_MIGRATE=on` (set to `off` to skip on-start migration apply)
|
- `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.
|
- `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:
|
Visit `http://localhost:8080/`. Routes:
|
||||||
|
|
||||||
|
|||||||
278
caldav/caldav.go
Normal file
278
caldav/caldav.go
Normal file
@@ -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 <cal:calendar/>. Non-calendar collections
|
||||||
|
// (default/, inbox/, outbox/) are filtered out.
|
||||||
|
func (c *Client) ListCalendars(ctx context.Context) ([]Calendar, error) {
|
||||||
|
body := []byte(`<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<cs:getctag/>
|
||||||
|
<cal:calendar-color/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`)
|
||||||
|
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
|
||||||
|
// <cs:schedule-inbox/> / <cs:schedule-outbox/>, not <cal:calendar/>.
|
||||||
|
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(`<?xml version="1.0"?>
|
||||||
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<c:calendar-data/>
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VTODO"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>`)
|
||||||
|
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(`<cal:calendar-color xmlns:cal="urn:ietf:params:xml:ns:caldav">%s</cal:calendar-color>`, xmlEscape(color))
|
||||||
|
}
|
||||||
|
body := []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>%s</d:displayname>
|
||||||
|
%s
|
||||||
|
<c:supported-calendar-component-set>
|
||||||
|
<c:comp name="VTODO"/>
|
||||||
|
<c:comp name="VEVENT"/>
|
||||||
|
</c:supported-calendar-component-set>
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</c:mkcalendar>`, 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"`
|
||||||
|
}
|
||||||
198
caldav/caldav_test.go
Normal file
198
caldav/caldav_test.go
Normal file
@@ -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), "<d:displayname>Paliad</d:displayname>") {
|
||||||
|
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 = `<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/default/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>mCalendar</d:displayname>
|
||||||
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/birthday-calendar/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>Birthdays</d:displayname>
|
||||||
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/Work/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>Work</d:displayname>
|
||||||
|
<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/inbox/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>inbox</d:displayname>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>`
|
||||||
|
|
||||||
|
const reportBody = `<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/Work/todo-1.ics</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag>"abc"</d:getetag>
|
||||||
|
<cal:calendar-data>BEGIN:VCALENDAR
|
||||||
|
BEGIN:VTODO
|
||||||
|
UID:todo-1@example
|
||||||
|
SUMMARY:Pick up bread
|
||||||
|
STATUS:NEEDS-ACTION
|
||||||
|
PRIORITY:5
|
||||||
|
DUE:20260601T120000Z
|
||||||
|
END:VTODO
|
||||||
|
END:VCALENDAR</cal:calendar-data>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/dav/calendars/m/Work/todo-2.ics</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag>"def"</d:getetag>
|
||||||
|
<cal:calendar-data>BEGIN:VCALENDAR
|
||||||
|
BEGIN:VTODO
|
||||||
|
UID:todo-2@example
|
||||||
|
SUMMARY:Filed paperwork
|
||||||
|
STATUS:COMPLETED
|
||||||
|
END:VTODO
|
||||||
|
END:VCALENDAR</cal:calendar-data>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>`
|
||||||
123
caldav/parse.go
Normal file
123
caldav/parse.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/m/projax/caldav"
|
||||||
"github.com/m/projax/db"
|
"github.com/m/projax/db"
|
||||||
"github.com/m/projax/store"
|
"github.com/m/projax/store"
|
||||||
"github.com/m/projax/web"
|
"github.com/m/projax/web"
|
||||||
@@ -76,6 +77,19 @@ func main() {
|
|||||||
logger.Warn("auth: disabled — SUPABASE_URL not set, every request is anonymous")
|
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{
|
httpServer := &http.Server{
|
||||||
Addr: listen,
|
Addr: listen,
|
||||||
Handler: srv.Routes(),
|
Handler: srv.Routes(),
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ env:
|
|||||||
- PROJAX_LISTEN_ADDR=:8080
|
- PROJAX_LISTEN_ADDR=:8080
|
||||||
- PROJAX_AUTO_MIGRATE=on
|
- PROJAX_AUTO_MIGRATE=on
|
||||||
- SUPABASE_URL=https://supa.flexsiebels.de
|
- SUPABASE_URL=https://supa.flexsiebels.de
|
||||||
|
- DAV_URL=https://dav.msbls.de/dav/calendars/m/
|
||||||
secrets:
|
secrets:
|
||||||
- PROJAX_DB_URL
|
- PROJAX_DB_URL
|
||||||
- SUPABASE_ANON_KEY
|
- SUPABASE_ANON_KEY
|
||||||
|
- DAV_USER
|
||||||
|
- DAV_PASSWORD
|
||||||
|
|||||||
@@ -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)
|
- Bidirectional CalDAV/Gitea sync in v1 (read-only first)
|
||||||
- Real-time collaboration features
|
- 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=<absolute calendar URL>`, `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 `<base>/<item.slug>/` with display name `<item.title>`. 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)
|
## 8. Open questions (post-PRD)
|
||||||
|
|
||||||
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.
|
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.
|
||||||
|
|||||||
@@ -324,6 +324,100 @@ func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, erro
|
|||||||
return s.GetByID(ctx, id)
|
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.
|
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
|
||||||
// Used by the tree page filter chips.
|
// Used by the tree page filter chips.
|
||||||
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
|
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
|
||||||
|
|||||||
256
web/caldav.go
Normal file
256
web/caldav.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ type Server struct {
|
|||||||
pages map[string]*template.Template
|
pages map[string]*template.Template
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
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
|
// 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{}
|
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,
|
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||||
"templates/layout.tmpl",
|
"templates/layout.tmpl",
|
||||||
"templates/"+name+".tmpl",
|
"templates/"+name+".tmpl",
|
||||||
@@ -100,6 +101,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /new", s.handleNewForm)
|
mux.HandleFunc("GET /new", s.handleNewForm)
|
||||||
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
||||||
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
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("GET /login", s.handleLoginForm)
|
||||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
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)
|
s.fail(w, r, err)
|
||||||
return
|
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{
|
s.render(w, "detail", map[string]any{
|
||||||
"Title": it.Title,
|
"Title": it.Title,
|
||||||
"Item": it,
|
"Item": it,
|
||||||
"ParentOptions": parents,
|
"ParentOptions": parents,
|
||||||
"StatusOptions": []string{"active", "done", "archived"},
|
"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)
|
s.handleReparent(w, r, base)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if base, ok := strings.CutSuffix(path, "/caldav/create"); ok {
|
||||||
|
s.handleCalDAVCreate(w, r, base)
|
||||||
|
return
|
||||||
|
}
|
||||||
it, err := s.Store.GetByPath(r.Context(), path)
|
it, err := s.Store.GetByPath(r.Context(), path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, r, err)
|
s.fail(w, r, err)
|
||||||
|
|||||||
53
web/templates/caldav_admin.tmpl
Normal file
53
web/templates/caldav_admin.tmpl
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>CalDAV calendars</h1>
|
||||||
|
<p class="muted">{{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 <code>projax.item_links</code> with <code>ref_type=caldav-list</code>.</p>
|
||||||
|
|
||||||
|
<table class="classify">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Calendar</th><th>Suggested / linked projax item</th><th>Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Suggestions}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{.Calendar.DisplayName}}</strong>
|
||||||
|
<br><small class="slug">{{.Calendar.URL}}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .AlreadyLink}}
|
||||||
|
<span class="muted">linked →</span> <a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
|
||||||
|
{{else if .Item}}
|
||||||
|
<span class="muted">suggested →</span> <a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
|
||||||
|
<small class="muted">({{.Item.PrimaryPath}})</small>
|
||||||
|
{{else}}
|
||||||
|
<em class="muted">no match — pick manually</em>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .AlreadyLink}}
|
||||||
|
<form method="post" action="/admin/caldav/unlink" class="inline">
|
||||||
|
<input type="hidden" name="link_id" value="{{.AlreadyLink.ID}}">
|
||||||
|
<button type="submit" class="cancel">unlink</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/admin/caldav/link" class="inline">
|
||||||
|
<input type="hidden" name="calendar_url" value="{{.Calendar.URL}}">
|
||||||
|
<input type="hidden" name="display_name" value="{{.Calendar.DisplayName}}">
|
||||||
|
<input type="hidden" name="color" value="{{.Calendar.Color}}">
|
||||||
|
<select name="item_id" required>
|
||||||
|
<option value="">— pick projax item —</option>
|
||||||
|
{{range $it := $.Items}}
|
||||||
|
<option value="{{$it.ID}}" {{if and $.Item (eq $it.ID $.Item.ID)}}selected{{end}}>{{$it.PrimaryPath}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit">link</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3"><em>No calendars discovered.</em></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
5
web/templates/caldav_disabled.tmpl
Normal file
5
web/templates/caldav_disabled.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>CalDAV</h1>
|
||||||
|
<p class="muted">CalDAV integration is not configured on this deploy.</p>
|
||||||
|
<p>Set the <code>DAV_URL</code>, <code>DAV_USER</code> and <code>DAV_PASSWORD</code> environment variables, then redeploy.</p>
|
||||||
|
{{end}}
|
||||||
@@ -13,6 +13,45 @@
|
|||||||
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
|
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CalDAVOn}}
|
||||||
|
<section class="tasks">
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
{{if .Tasks}}
|
||||||
|
{{range .Tasks}}
|
||||||
|
<div class="cal-block">
|
||||||
|
<h3>{{.DisplayName}}</h3>
|
||||||
|
{{if .Open}}
|
||||||
|
<ul class="todo open">
|
||||||
|
{{range .Open}}
|
||||||
|
<li>
|
||||||
|
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||||
|
{{.Summary}}
|
||||||
|
{{if .Due}}<small class="muted">due {{.Due.Format "2006-01-02"}}</small>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">No open tasks.</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .DoneRecent}}
|
||||||
|
<details>
|
||||||
|
<summary class="muted">{{len .DoneRecent}} completed in last 30 days</summary>
|
||||||
|
<ul class="todo done">
|
||||||
|
{{range .DoneRecent}}<li>{{.Summary}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">No CalDAV list linked.</p>
|
||||||
|
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||||
|
<button type="submit">Create CalDAV list</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="brand">projax</a>
|
<a href="/" class="brand">projax</a>
|
||||||
<a href="/admin/classify">classify orphans</a>
|
<a href="/admin/classify">classify orphans</a>
|
||||||
|
<a href="/admin/caldav">caldav</a>
|
||||||
<form method="post" action="/logout" class="logout-form">
|
<form method="post" action="/logout" class="logout-form">
|
||||||
<button type="submit" class="logout-btn">sign out</button>
|
<button type="submit" class="logout-btn">sign out</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user