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.
257 lines
7.1 KiB
Go
257 lines
7.1 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, "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
|
|
}
|