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.
124 lines
2.8 KiB
Go
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
|
|
}
|