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 } case "CATEGORIES": // CATEGORIES is comma-separated per RFC 5545. Some clients emit // multiple CATEGORIES lines; we merge by appending. The unescape // is per-entry because commas inside a category value MUST be // escaped (`\,`), so we split on bare commas only after unescape. for _, raw := range strings.Split(val, ",") { t := strings.TrimSpace(unescapeText(raw)) if t == "" { continue } cur.Categories = append(cur.Categories, t) } } } return out } // ProjaxCategoryFor returns the projax-namespaced CATEGORIES entry for // the given primary-path (e.g. "projax:admin.vacations.greece"). Used by // both the write side (tag-on-create) and the read side (per-item filter). func ProjaxCategoryFor(primaryPath string) string { return "projax:" + primaryPath } // HasProjaxTag reports whether the VTODO carries any `projax:` category. // Used to decide whether the per-item filter kicks in: a list with at // least one projax: tag is "managed" by projax and the detail page only // shows todos matching THIS item's path; a list with zero projax: tags // is a legacy/unmanaged list and the detail page shows everything. func HasProjaxTag(t Todo) bool { for _, c := range t.Categories { if strings.HasPrefix(c, "projax:") { return true } } return false } // HasProjaxTagFor reports whether the VTODO carries the specific // `projax:` category. A todo can carry multiple projax: tags // (when it belongs to multiple projax items) — any match returns true. func HasProjaxTagFor(t Todo, primaryPath string) bool { want := ProjaxCategoryFor(primaryPath) for _, c := range t.Categories { if c == want { return true } } return false } // AnyTodoHasProjaxTag reports whether the slice contains at least one // projax-tagged VTODO. The detail page uses this to decide between the // projax-managed filter (show only matching) and the legacy unmanaged // path (show all). func AnyTodoHasProjaxTag(todos []Todo) bool { for _, t := range todos { if HasProjaxTag(t) { return true } } return false } // 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 // Categories: optional CATEGORIES list. BuildVTodoICS writes them // directly on a fresh VTODO. ApplyVTodoEdit intentionally ignores // this field — existing categories pass through unchanged via the // unknown-property preserve path, which is what every edit/complete/ // delete flow wants. Tag-on-create is the only write path that // uses it. Categories []string } // 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)) } if len(e.Categories) > 0 { // RFC 5545 CATEGORIES — comma-separated, single line. Escape commas // inside individual entries so the round-trip survives parseVTodos. escaped := make([]string, 0, len(e.Categories)) for _, c := range e.Categories { escaped = append(escaped, escapeText(c)) } lines = append(lines, "CATEGORIES:"+strings.Join(escaped, ",")) } 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) }