4f012e350c
parseDT stamped a zoned or floating DTSTART as UTC while every window around it is built in local time, so the two sides of every comparison were in different frames. On a +03 box a 22:00 local event parsed as 22:00Z, past the end of the local day, and the whole evening dropped out of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04 box was recited at 17:00 next to its own printed 13:00. DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from the zone database, and a floating value is read in the caller's location. tzdata is embedded because the deploy image carries none, and a silent fallback to the box offset is the bug being fixed. FactKey and FactValue stamp the owner's clock, so the key date the store range-scans is the same day the plan asks for. FactSummary drops the time tail for callers that print the hour themselves. Found in review of #56 and #58.
189 lines
6.2 KiB
Go
189 lines
6.2 KiB
Go
package calendar
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
// The TZID of a DTSTART names an IANA zone, and resolving it needs the zone
|
|
// database. The deploy image has no system tzdata, so embed it: without it
|
|
// every zoned event would silently fall back to the box's own offset, which
|
|
// is the bug this package had before.
|
|
_ "time/tzdata"
|
|
)
|
|
|
|
// ParseICal scans iCal text for VEVENT components and returns the events
|
|
// overlapping [from, to). All-day events are skipped: parseDT reports no time
|
|
// for a VALUE=DATE value, and an event with no clock reading answers neither
|
|
// the busy gate nor the day plan.
|
|
//
|
|
// from's location is the fallback zone for a floating DTSTART — one with
|
|
// neither a Z suffix nor a TZID. RFC 5545 says a floating time is local to
|
|
// wherever it is read, and here that is the box the poller runs on.
|
|
func ParseICal(body []byte, from, to time.Time) []Event {
|
|
var events []Event
|
|
text := string(body)
|
|
for {
|
|
i := strings.Index(text, "BEGIN:VEVENT")
|
|
if i < 0 {
|
|
break
|
|
}
|
|
text = text[i+len("BEGIN:VEVENT"):]
|
|
j := strings.Index(text, "END:VEVENT")
|
|
if j < 0 {
|
|
break
|
|
}
|
|
block := text[:j]
|
|
text = text[j+len("END:VEVENT"):]
|
|
|
|
e, ok := parseVEVENT(block, from.Location())
|
|
if !ok {
|
|
continue
|
|
}
|
|
if e.End.After(from) && e.Start.Before(to) {
|
|
events = append(events, e)
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
|
|
// location — the window cmd/mavcaldav polls.
|
|
//
|
|
// The location matters, on both sides of the comparison. An older version took
|
|
// the day number off a local clock reading but built the boundaries in UTC, so
|
|
// east of Greenwich the window was shifted by the offset and part of the
|
|
// evening fell outside "today". Building the window locally is only half the
|
|
// fix: parseDT used to stamp a zoned DTSTART as UTC, which lost the mirror
|
|
// image of the same evening. Both sides are real instants now.
|
|
func ParseICalDay(body []byte, now time.Time) []Event {
|
|
y, m, d := now.Date()
|
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
return ParseICal(body, start, start.AddDate(0, 0, 1))
|
|
}
|
|
|
|
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
|
|
// Reports false for all-day events and parse failures.
|
|
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
|
var e Event
|
|
for _, line := range strings.Split(block, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "DTSTART"):
|
|
if t, ok := parseDT(line, loc); ok {
|
|
e.Start = t
|
|
}
|
|
case strings.HasPrefix(line, "DTEND"):
|
|
if t, ok := parseDT(line, loc); ok {
|
|
e.End = t
|
|
}
|
|
case strings.HasPrefix(line, "SUMMARY"):
|
|
e.Summary = afterColon(line)
|
|
case strings.HasPrefix(line, "UID"):
|
|
e.UID = afterColon(line)
|
|
}
|
|
}
|
|
if e.Start.IsZero() || e.End.IsZero() {
|
|
return Event{}, false
|
|
}
|
|
return e, true
|
|
}
|
|
|
|
func afterColon(line string) string {
|
|
if i := strings.Index(line, ":"); i >= 0 {
|
|
return strings.TrimSpace(line[i+1:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseDT parses a DTSTART/DTEND value into a real instant:
|
|
//
|
|
// - UTC: DTEND:20260703T100000Z
|
|
// - Zoned: DTSTART;TZID=Europe/Moscow:20260703T130000
|
|
// - Floating: DTSTART:20260703T130000 (read in loc)
|
|
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
|
|
//
|
|
// A zoned value is resolved against its own TZID, not stamped as UTC. The old
|
|
// behaviour was "the server and the poller share a timezone, and the busy gate
|
|
// only needs busy/not-busy to be right", and that stopped being enough when the
|
|
// day plan started reciting the wall clock: a 13:00 Moscow meeting read as
|
|
// 13:00Z was recited at 17:00 on a +04 box, and a 21:00 one fell out of the day
|
|
// altogether. An unknown or unloadable TZID falls back to loc, which is the
|
|
// closest thing to the reader's own wall clock we have.
|
|
func parseDT(line string, loc *time.Location) (time.Time, bool) {
|
|
if strings.Contains(line, "VALUE=DATE:") {
|
|
return time.Time{}, false
|
|
}
|
|
i := strings.LastIndex(line, ":")
|
|
if i < 0 {
|
|
return time.Time{}, false
|
|
}
|
|
if loc == nil {
|
|
loc = time.UTC
|
|
}
|
|
raw := strings.TrimSpace(line[i+1:])
|
|
if strings.HasSuffix(raw, "Z") {
|
|
t, err := time.ParseInLocation("20060102T150405", strings.TrimSuffix(raw, "Z"), time.UTC)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t, true
|
|
}
|
|
if tz := tzidOf(line[:i]); tz != "" {
|
|
if l, err := time.LoadLocation(tz); err == nil {
|
|
loc = l
|
|
}
|
|
}
|
|
t, err := time.ParseInLocation("20060102T150405", raw, loc)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// tzidOf pulls the TZID out of a property's parameter list ("DTSTART;TZID=..."
|
|
// up to the value colon). The value may be quoted, per RFC 5545 param syntax.
|
|
func tzidOf(params string) string {
|
|
for _, p := range strings.Split(params, ";")[1:] {
|
|
if !strings.HasPrefix(strings.ToUpper(p), "TZID=") {
|
|
continue
|
|
}
|
|
return strings.Trim(strings.TrimSpace(p[len("TZID="):]), `"`)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
|
|
// collection. One event per file is the CalDAV convention, so callers normally
|
|
// pass a single event.
|
|
//
|
|
// This is the write half of #127 and it only ever renders: the canonical state
|
|
// is sqlite, the calendar is a view of it. Nothing reads a rendered file back.
|
|
func RenderICal(events []Event) string {
|
|
var b strings.Builder
|
|
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n")
|
|
for _, e := range events {
|
|
b.WriteString("BEGIN:VEVENT\r\n")
|
|
fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID))
|
|
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
|
fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
|
fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z"))
|
|
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary))
|
|
b.WriteString("END:VEVENT\r\n")
|
|
}
|
|
b.WriteString("END:VCALENDAR\r\n")
|
|
return b.String()
|
|
}
|
|
|
|
// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that
|
|
// would otherwise let a reminder payload inject iCal properties.
|
|
func escapeText(s string) string {
|
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
|
s = strings.ReplaceAll(s, ";", "\\;")
|
|
s = strings.ReplaceAll(s, ",", "\\,")
|
|
s = strings.ReplaceAll(s, "\r\n", "\\n")
|
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
|
s = strings.ReplaceAll(s, "\r", "\\n")
|
|
return s
|
|
}
|