calendar: resolve DTSTART against its own TZID

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.
This commit is contained in:
kami
2026-08-01 14:00:05 +04:00
parent 7f42cc73be
commit 4f012e350c
6 changed files with 189 additions and 37 deletions
+62 -19
View File
@@ -4,12 +4,22 @@ 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)
@@ -26,7 +36,7 @@ func ParseICal(body []byte, from, to time.Time) []Event {
block := text[:j]
text = text[j+len("END:VEVENT"):]
e, ok := parseVEVENT(block)
e, ok := parseVEVENT(block, from.Location())
if !ok {
continue
}
@@ -40,11 +50,12 @@ func ParseICal(body []byte, from, to time.Time) []Event {
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
// location — the window cmd/mavcaldav polls.
//
// The location matters. The old inline 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": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
// owner's day is the day the day plan and the busy gate mean.
// 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())
@@ -53,17 +64,17 @@ func ParseICalDay(body []byte, now time.Time) []Event {
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
// Reports false for all-day events and parse failures.
func parseVEVENT(block string) (Event, bool) {
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); ok {
if t, ok := parseDT(line, loc); ok {
e.Start = t
}
case strings.HasPrefix(line, "DTEND"):
if t, ok := parseDT(line); ok {
if t, ok := parseDT(line, loc); ok {
e.End = t
}
case strings.HasPrefix(line, "SUMMARY"):
@@ -85,16 +96,21 @@ func afterColon(line string) string {
return ""
}
// parseDT parses a DTSTART/DTEND value:
// parseDT parses a DTSTART/DTEND value into a real instant:
//
// - UTC: DTEND:20260703T100000Z
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
// - Zoned: DTSTART;TZID=Europe/Moscow:20260703T130000
// - Floating: DTSTART:20260703T130000 (read in loc)
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
//
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
// CalDAV server and the poller run in the same timezone, and the busy gate only
// needs busy/not-busy to be right.
func parseDT(line string) (time.Time, bool) {
// 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
}
@@ -102,12 +118,39 @@ func parseDT(line string) (time.Time, bool) {
if i < 0 {
return time.Time{}, false
}
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
t, err := time.Parse("20060102T150405", val)
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.UTC(), true
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