Files
projax/caldav/parse.go
mAi 96b61f7ed4 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.
2026-05-15 16:57:43 +02:00

124 lines
2.8 KiB
Go

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
}