feat(phase 3l vevents): VEVENT support on dashboard — closes mgmt-parity gap
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.
This commit is contained in:
@@ -59,6 +59,86 @@ func parseVTodos(ics string) []Todo {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user