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:
@@ -55,7 +55,10 @@ func meetingNotification() calendar.Notification {
|
||||
Package: "com.google.android.gm",
|
||||
Title: "Планёрка",
|
||||
Text: "10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||
// Local, like a phone relaying from the box's own timezone: the fact
|
||||
// key and value are stamped on the owner's clock, so a UTC reading
|
||||
// here would only be testing the offset of the test machine.
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,15 +72,56 @@ type Event struct {
|
||||
// across polls so re-reading an unchanged calendar rewrites nothing.
|
||||
//
|
||||
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
||||
// by key prefix, not by a timestamp column.
|
||||
func FactKey(e Event) string {
|
||||
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
|
||||
// by key prefix, not by a timestamp column — and it is the OWNER's day, taken
|
||||
// on the box clock. An event carries the zone its server stated it in, so
|
||||
// keying off the event's own location would file a 21:00 Moscow meeting under
|
||||
// a different date than the day plan asks for.
|
||||
func FactKey(e Event) string { return FactKeyIn(e, time.Local) }
|
||||
|
||||
// FactKeyIn is FactKey against an explicit location.
|
||||
func FactKeyIn(e Event, loc *time.Location) string {
|
||||
return fmt.Sprintf("calendar_event_%s_%s", e.Start.In(loc).Format("20060102"), safeKey(e.Summary))
|
||||
}
|
||||
|
||||
// FactValue is the human-readable rendering stored as the fact value, and the
|
||||
// string the day plan and the query path read back.
|
||||
func FactValue(e Event) string {
|
||||
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
|
||||
// string the day plan and the query path read back. Times are the owner's wall
|
||||
// clock, for the same reason the key date is.
|
||||
func FactValue(e Event) string { return FactValueIn(e, time.Local) }
|
||||
|
||||
// FactValueIn is FactValue against an explicit location.
|
||||
func FactValueIn(e Event, loc *time.Location) string {
|
||||
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.In(loc).Format("15:04"), e.End.In(loc).Format("15:04"))
|
||||
}
|
||||
|
||||
// FactSummary strips the "@ HH:MM-HH:MM" tail FactValue appends, for a caller
|
||||
// that prints the time itself. The day plan does: without this it renders
|
||||
// "14:00 — Standup @ 14:00-14:30" and says the hour twice.
|
||||
func FactSummary(value string) string {
|
||||
i := strings.LastIndex(value, " @ ")
|
||||
if i < 0 {
|
||||
return value
|
||||
}
|
||||
tail := value[i+len(" @ "):]
|
||||
if len(tail) != len("15:04-15:04") {
|
||||
return value
|
||||
}
|
||||
for j, r := range tail {
|
||||
switch j {
|
||||
case 2, 8:
|
||||
if r != ':' {
|
||||
return value
|
||||
}
|
||||
case 5:
|
||||
if r != '-' {
|
||||
return value
|
||||
}
|
||||
default:
|
||||
if r < '0' || r > '9' {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return value[:i]
|
||||
}
|
||||
|
||||
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||
|
||||
@@ -78,11 +78,13 @@ func TestParseICalDayUsesOwnersDay(t *testing.T) {
|
||||
|
||||
func TestParseVEVENT(t *testing.T) {
|
||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||
e, ok := parseVEVENT(block)
|
||||
e, ok := parseVEVENT(block, time.UTC)
|
||||
if !ok {
|
||||
t.Fatal("expected a parsed event")
|
||||
}
|
||||
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
|
||||
// 13:00 Moscow is 10:00Z. Reading it as 13:00Z is the bug that put the
|
||||
// event three hours late in the day plan.
|
||||
if !e.Start.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("start = %v", e.Start)
|
||||
}
|
||||
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
|
||||
@@ -93,26 +95,34 @@ func TestParseVEVENT(t *testing.T) {
|
||||
}
|
||||
|
||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||
if _, ok := parseVEVENT(allDay); ok {
|
||||
if _, ok := parseVEVENT(allDay, time.UTC); ok {
|
||||
t.Error("all-day event should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDT(t *testing.T) {
|
||||
plus4 := time.FixedZone("+04", 4*60*60)
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
loc *time.Location
|
||||
want time.Time
|
||||
wantOK bool
|
||||
}{
|
||||
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
|
||||
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
|
||||
{"garbage", "DTSTART:garbage", time.Time{}, false},
|
||||
{"UTC", "DTEND:20260703T100000Z", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||
{"tzid", "DTSTART;TZID=Europe/Moscow:20260703T130000", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||
{"tzid quoted", `DTSTART;TZID="Europe/Moscow":20260703T130000`, plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||
{"tzid with other params", "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260703T130000", plus4, time.Date(2026, 7, 3, 4, 0, 0, 0, time.UTC), true},
|
||||
// An unloadable zone falls back to the reader's own clock, not to UTC.
|
||||
{"unknown tzid", "DTSTART;TZID=Mars/Olympus:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true},
|
||||
// Floating: no Z, no TZID. Local to whoever reads it.
|
||||
{"floating", "DTSTART:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true},
|
||||
{"all-day", "DTSTART;VALUE=DATE:20260703", plus4, time.Time{}, false},
|
||||
{"garbage", "DTSTART:garbage", plus4, time.Time{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseDT(tt.line)
|
||||
got, ok := parseDT(tt.line, tt.loc)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
@@ -143,20 +153,73 @@ func TestFactKeyAndValue(t *testing.T) {
|
||||
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
|
||||
}
|
||||
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
|
||||
if got, want := FactKeyIn(e, time.UTC), "calendar_event_20260703_Team-sync"; got != want {
|
||||
t.Errorf("FactKey = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
|
||||
if got, want := FactValueIn(e, time.UTC), "Team sync @ 14:00-15:00"; got != want {
|
||||
t.Errorf("FactValue = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
|
||||
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
|
||||
if !strings.HasPrefix(FactKeyIn(e, time.UTC), KeyPrefixForDay(e.Start)) {
|
||||
t.Error("FactKey must start with the day prefix the store range-scans on")
|
||||
}
|
||||
}
|
||||
|
||||
// The key date and the printed time are the owner's, not the calendar
|
||||
// server's. A 23:00 Moscow event read on a +04 box belongs to the next local
|
||||
// day, and filing it under the Moscow day would hide it from the day plan the
|
||||
// store range-scans for.
|
||||
func TestFactKeyAndValueUseTheOwnersClock(t *testing.T) {
|
||||
msk := time.FixedZone("MSK", 3*60*60)
|
||||
plus4 := time.FixedZone("+04", 4*60*60)
|
||||
e := Event{
|
||||
Summary: "Late sync",
|
||||
Start: time.Date(2026, 7, 3, 23, 30, 0, 0, msk),
|
||||
End: time.Date(2026, 7, 4, 0, 30, 0, 0, msk),
|
||||
}
|
||||
if got, want := FactKeyIn(e, plus4), "calendar_event_20260704_Late-sync"; got != want {
|
||||
t.Errorf("FactKeyIn = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := FactValueIn(e, plus4), "Late sync @ 00:30-01:30"; got != want {
|
||||
t.Errorf("FactValueIn = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactSummaryDropsTheTimeTail(t *testing.T) {
|
||||
if got, want := FactSummary("Standup @ 14:00-14:30"), "Standup"; got != want {
|
||||
t.Errorf("FactSummary = %q, want %q", got, want)
|
||||
}
|
||||
// Nothing that is not the exact tail FactValue writes is touched.
|
||||
for _, in := range []string{"Coffee @ home", "Standup", "Standup @ 14:00-14:3", "Standup @ 1a:00-14:30"} {
|
||||
if got := FactSummary(in); got != in {
|
||||
t.Errorf("FactSummary(%q) = %q, want it unchanged", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the mirror image of the window bug: the window is local, so
|
||||
// the event must be a real instant too. A 22:00 event stated in the poller's
|
||||
// own zone used to parse as 22:00Z, which on a +03 box is past the end of the
|
||||
// local day, and the whole evening dropped out of both the busy gate and the
|
||||
// day plan.
|
||||
func TestParseICalDayKeepsTheEveningInAZonedCalendar(t *testing.T) {
|
||||
plus3 := time.FixedZone("+03", 3*60*60)
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, plus3)
|
||||
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
||||
"DTSTART;TZID=Europe/Moscow:20260801T220000\nDTEND;TZID=Europe/Moscow:20260801T230000\n" +
|
||||
"SUMMARY:Evening call\nEND:VEVENT\nEND:VCALENDAR")
|
||||
|
||||
events := ParseICalDay(body, now)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want the evening one", len(events))
|
||||
}
|
||||
if got := events[0].Start.In(plus3).Format("15:04"); got != "22:00" {
|
||||
t.Errorf("start reads %s locally, want 22:00", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyAndOverlapping(t *testing.T) {
|
||||
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
|
||||
+62
-19
@@ -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
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/stt
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/tts
|
||||
Reference in New Issue
Block a user