caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam
web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
rendering; empty-collapse with no links
design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
464 lines
13 KiB
Go
464 lines
13 KiB
Go
package caldav
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
// parseVEvents extracts every VEVENT block from a calendar-data string.
|
|
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
|
|
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
|
|
// before splitLine drops params. RRULE presence flips Recurring=true; the
|
|
// rule itself is intentionally NOT parsed — projax surfaces the literal
|
|
// DTSTART occurrence and a recurring badge.
|
|
func parseVEvents(ics string) []Event {
|
|
ics = unfold(ics)
|
|
lines := strings.Split(ics, "\n")
|
|
var out []Event
|
|
var inEvent bool
|
|
var cur Event
|
|
for _, ln := range lines {
|
|
ln = strings.TrimRight(ln, "\r")
|
|
if ln == "BEGIN:VEVENT" {
|
|
inEvent = true
|
|
cur = Event{}
|
|
continue
|
|
}
|
|
if ln == "END:VEVENT" {
|
|
if cur.UID != "" {
|
|
out = append(out, cur)
|
|
}
|
|
inEvent = false
|
|
continue
|
|
}
|
|
if !inEvent {
|
|
continue
|
|
}
|
|
key, val := splitLine(ln)
|
|
switch key {
|
|
case "UID":
|
|
cur.UID = val
|
|
case "SUMMARY":
|
|
cur.Summary = unescapeText(val)
|
|
case "DESCRIPTION":
|
|
cur.Description = unescapeText(val)
|
|
case "LOCATION":
|
|
cur.Location = unescapeText(val)
|
|
case "DTSTART":
|
|
if t, ok := parseICalTime(val); ok {
|
|
cur.Start = t
|
|
}
|
|
if hasDateOnlyParam(ln) {
|
|
cur.AllDay = true
|
|
}
|
|
case "DTEND":
|
|
if t, ok := parseICalTime(val); ok {
|
|
cur.End = t
|
|
}
|
|
case "RRULE":
|
|
cur.Recurring = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasDateOnlyParam reports whether the property line carried VALUE=DATE
|
|
// (rather than DATE-TIME) before the value separator. This matters because
|
|
// splitLine throws params away, so the caller has to inspect the raw line
|
|
// to know if the date is all-day or has a clock component.
|
|
func hasDateOnlyParam(ln string) bool {
|
|
colon := strings.Index(ln, ":")
|
|
if colon < 0 {
|
|
return false
|
|
}
|
|
head := ln[:colon]
|
|
semi := strings.Index(head, ";")
|
|
if semi < 0 {
|
|
return false
|
|
}
|
|
params := strings.ToUpper(head[semi+1:])
|
|
for _, p := range strings.Split(params, ";") {
|
|
if p == "VALUE=DATE" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// escapeText applies RFC 5545 §3.3.11 escaping. CR/LF become \n; backslash,
|
|
// comma, and semicolon are backslash-escaped. Everything else passes through.
|
|
func escapeText(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, "\r\n", `\n`)
|
|
s = strings.ReplaceAll(s, "\n", `\n`)
|
|
s = strings.ReplaceAll(s, "\r", `\n`)
|
|
s = strings.ReplaceAll(s, `,`, `\,`)
|
|
s = strings.ReplaceAll(s, `;`, `\;`)
|
|
return s
|
|
}
|
|
|
|
// foldLine wraps a single logical iCal line so no physical line exceeds 75
|
|
// octets, prepending a single space to each continuation line as per RFC 5545
|
|
// §3.1. Folding is octet-based, not rune-based — but we keep care not to split
|
|
// in the middle of a UTF-8 sequence.
|
|
func foldLine(line string) string {
|
|
const limit = 75
|
|
if len(line) <= limit {
|
|
return line
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(line); {
|
|
end := i + limit
|
|
if i > 0 {
|
|
// Continuation lines reserve one octet for the leading space.
|
|
end = i + (limit - 1)
|
|
}
|
|
if end > len(line) {
|
|
end = len(line)
|
|
}
|
|
// Back off so we don't split inside a multi-byte UTF-8 sequence.
|
|
for end < len(line) && end > i && (line[end]&0xC0) == 0x80 {
|
|
end--
|
|
}
|
|
chunk := line[i:end]
|
|
if i > 0 {
|
|
b.WriteString("\r\n ")
|
|
}
|
|
b.WriteString(chunk)
|
|
i = end
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// joinICS folds each logical line and joins them with CRLF terminators
|
|
// (RFC 5545 §3.1 — content lines MUST be terminated with CRLF).
|
|
func joinICS(lines []string) string {
|
|
var b strings.Builder
|
|
for _, ln := range lines {
|
|
b.WriteString(foldLine(ln))
|
|
b.WriteString("\r\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// formatICalUTC formats t in `YYYYMMDDTHHMMSSZ` form (RFC 5545 UTC date-time).
|
|
func formatICalUTC(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
|
|
|
// formatICalDate formats t in `YYYYMMDD` form (RFC 5545 DATE).
|
|
func formatICalDate(t time.Time) string { return t.UTC().Format("20060102") }
|
|
|
|
// NewUID generates an RFC 4122 v4 UUID rendered as a hyphenated lowercase
|
|
// string. The "@projax" suffix that some clients append is intentionally
|
|
// omitted — the UID is opaque to projax and we treat it as such.
|
|
func NewUID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand failure on Linux is extraordinary; fall back to time-based
|
|
// so projax keeps functioning, with the trade-off that the new UID is
|
|
// less random.
|
|
now := time.Now().UnixNano()
|
|
for i := 0; i < 16; i++ {
|
|
b[i] = byte(now >> (i * 4))
|
|
}
|
|
}
|
|
b[6] = (b[6] & 0x0F) | 0x40 // version 4
|
|
b[8] = (b[8] & 0x3F) | 0x80 // variant RFC 4122
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
// VTodoEdit describes a partial update to an existing VTODO. Fields left nil
|
|
// are not changed in the stored ICS. To clear a field, supply a pointer to an
|
|
// empty value where allowed; the writer will emit the bare key with no value.
|
|
// Use ClearDue to clear DUE explicitly.
|
|
type VTodoEdit struct {
|
|
Summary *string
|
|
Status *string
|
|
Completed *time.Time // sets COMPLETED; pass time.Time{} via ClearCompleted to remove the line
|
|
Due *time.Time
|
|
ClearDue bool
|
|
Priority *int
|
|
}
|
|
|
|
// BuildVTodoICS serialises a fresh VTODO as a complete VCALENDAR document,
|
|
// suitable for PUT to a CalDAV server. UID is the only required input; the
|
|
// other VTodoEdit fields populate optional properties. DTSTAMP is set to now.
|
|
func BuildVTodoICS(uid string, e VTodoEdit) string {
|
|
now := time.Now().UTC()
|
|
lines := []string{
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//projax//caldav writeback//EN",
|
|
"CALSCALE:GREGORIAN",
|
|
"BEGIN:VTODO",
|
|
"UID:" + uid,
|
|
"DTSTAMP:" + formatICalUTC(now),
|
|
"CREATED:" + formatICalUTC(now),
|
|
"LAST-MODIFIED:" + formatICalUTC(now),
|
|
}
|
|
if e.Summary != nil {
|
|
lines = append(lines, "SUMMARY:"+escapeText(*e.Summary))
|
|
}
|
|
status := "NEEDS-ACTION"
|
|
if e.Status != nil && *e.Status != "" {
|
|
status = strings.ToUpper(*e.Status)
|
|
}
|
|
lines = append(lines, "STATUS:"+status)
|
|
if status == "COMPLETED" {
|
|
ct := now
|
|
if e.Completed != nil && !e.Completed.IsZero() {
|
|
ct = *e.Completed
|
|
}
|
|
lines = append(lines, "COMPLETED:"+formatICalUTC(ct))
|
|
lines = append(lines, "PERCENT-COMPLETE:100")
|
|
}
|
|
if e.Due != nil && !e.Due.IsZero() {
|
|
lines = append(lines, dueLine(*e.Due))
|
|
}
|
|
if e.Priority != nil {
|
|
lines = append(lines, fmt.Sprintf("PRIORITY:%d", *e.Priority))
|
|
}
|
|
lines = append(lines, "END:VTODO", "END:VCALENDAR")
|
|
return joinICS(lines)
|
|
}
|
|
|
|
// dueLine emits a DUE property. If the time has no clock component (00:00:00),
|
|
// it is encoded as DATE (`DUE;VALUE=DATE:YYYYMMDD`); otherwise as UTC
|
|
// date-time. Single-user, single-timezone — no VTIMEZONE acrobatics required.
|
|
func dueLine(t time.Time) string {
|
|
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
|
|
return "DUE;VALUE=DATE:" + formatICalDate(t)
|
|
}
|
|
return "DUE:" + formatICalUTC(t)
|
|
}
|
|
|
|
// ApplyVTodoEdit returns a new ICS document derived from the existing one with
|
|
// the supplied edits applied. Unknown properties (DESCRIPTION, CATEGORIES,
|
|
// ATTENDEE, X-*) are preserved — only the changed keys are rewritten. Folded
|
|
// lines are normalised on read. LAST-MODIFIED is always bumped to now.
|
|
func ApplyVTodoEdit(ics string, e VTodoEdit) string {
|
|
now := time.Now().UTC()
|
|
lines := strings.Split(unfold(ics), "\n")
|
|
// Helper to locate the VTODO segment so we don't touch VCALENDAR-level keys.
|
|
vtStart, vtEnd := -1, -1
|
|
for i, raw := range lines {
|
|
ln := strings.TrimRight(raw, "\r")
|
|
if ln == "BEGIN:VTODO" {
|
|
vtStart = i
|
|
}
|
|
if ln == "END:VTODO" {
|
|
vtEnd = i
|
|
break
|
|
}
|
|
}
|
|
if vtStart < 0 || vtEnd < 0 {
|
|
// Malformed; fall back to a from-scratch build with a fresh UID so the
|
|
// caller gets *something* well-formed back rather than silent garbage.
|
|
return BuildVTodoICS(NewUID(), e)
|
|
}
|
|
|
|
// Build a set of keys we plan to overwrite. We also drop COMPLETED when
|
|
// status flips away from COMPLETED.
|
|
overwrite := map[string]string{}
|
|
if e.Summary != nil {
|
|
overwrite["SUMMARY"] = "SUMMARY:" + escapeText(*e.Summary)
|
|
}
|
|
if e.Status != nil && *e.Status != "" {
|
|
s := strings.ToUpper(*e.Status)
|
|
overwrite["STATUS"] = "STATUS:" + s
|
|
switch s {
|
|
case "COMPLETED":
|
|
ct := now
|
|
if e.Completed != nil && !e.Completed.IsZero() {
|
|
ct = *e.Completed
|
|
}
|
|
overwrite["COMPLETED"] = "COMPLETED:" + formatICalUTC(ct)
|
|
overwrite["PERCENT-COMPLETE"] = "PERCENT-COMPLETE:100"
|
|
default:
|
|
// reopen / cancel: clear COMPLETED and PERCENT-COMPLETE if present.
|
|
overwrite["COMPLETED"] = ""
|
|
overwrite["PERCENT-COMPLETE"] = ""
|
|
}
|
|
}
|
|
if e.Due != nil && !e.Due.IsZero() {
|
|
overwrite["DUE"] = dueLine(*e.Due)
|
|
}
|
|
if e.ClearDue {
|
|
overwrite["DUE"] = ""
|
|
}
|
|
if e.Priority != nil {
|
|
overwrite["PRIORITY"] = fmt.Sprintf("PRIORITY:%d", *e.Priority)
|
|
}
|
|
// LAST-MODIFIED always bumps.
|
|
overwrite["LAST-MODIFIED"] = "LAST-MODIFIED:" + formatICalUTC(now)
|
|
// DTSTAMP per RFC 5545 also reflects last sync; safe to bump.
|
|
overwrite["DTSTAMP"] = "DTSTAMP:" + formatICalUTC(now)
|
|
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(lines))
|
|
for i, raw := range lines {
|
|
ln := strings.TrimRight(raw, "\r")
|
|
// Pass everything outside the VTODO block through verbatim.
|
|
if i <= vtStart || i >= vtEnd {
|
|
out = append(out, ln)
|
|
continue
|
|
}
|
|
key, _ := splitLine(ln)
|
|
key = strings.ToUpper(key)
|
|
if repl, ok := overwrite[key]; ok {
|
|
seen[key] = true
|
|
if repl == "" {
|
|
continue // explicit clear
|
|
}
|
|
out = append(out, repl)
|
|
continue
|
|
}
|
|
out = append(out, ln)
|
|
}
|
|
// Insert any overwrite keys that weren't present in the source ICS just
|
|
// before END:VTODO. Skip explicit clears (repl == "") so we don't append
|
|
// empty lines.
|
|
endIdx := -1
|
|
for i, ln := range out {
|
|
if strings.TrimRight(ln, "\r") == "END:VTODO" {
|
|
endIdx = i
|
|
break
|
|
}
|
|
}
|
|
if endIdx >= 0 {
|
|
extras := []string{}
|
|
for key, repl := range overwrite {
|
|
if seen[key] || repl == "" {
|
|
continue
|
|
}
|
|
extras = append(extras, repl)
|
|
}
|
|
if len(extras) > 0 {
|
|
before := append([]string{}, out[:endIdx]...)
|
|
before = append(before, extras...)
|
|
before = append(before, out[endIdx:]...)
|
|
out = before
|
|
}
|
|
}
|
|
// joinICS handles fold + CRLF on each logical line.
|
|
return joinICS(out)
|
|
}
|