From a55b8bfaecd4dbd16274a9c42a288d7da4d48ce7 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Tue, 15 Oct 2024 22:58:43 +1100 Subject: [PATCH] Parse time. Pending merge with https://github.com/arran4/golang-ical/issues/99 for resolution --- calendar.go | 1 + components.go | 158 ++++++++++++++++------------- components_test.go | 136 +++++++++++++++++++++++++ errors.go | 5 +- property.go | 245 +++++++++++++++++++++++++++++++++++++++++++++ property_test.go | 84 ++++++++++++++++ 6 files changed, 560 insertions(+), 69 deletions(-) diff --git a/calendar.go b/calendar.go index cf2c3c3..ce4deb7 100644 --- a/calendar.go +++ b/calendar.go @@ -61,6 +61,7 @@ const ( ComponentPropertyTzid = ComponentProperty(PropertyTzid) ComponentPropertyComment = ComponentProperty(PropertyComment) ComponentPropertyRelatedTo = ComponentProperty(PropertyRelatedTo) + ComponentPropertyDuration = ComponentProperty(PropertyDuration) ) type Property string diff --git a/components.go b/components.go index 3f6f639..8cc6e09 100644 --- a/components.go +++ b/components.go @@ -163,17 +163,17 @@ func (cb *ComponentBase) SetAllDayEndAt(t time.Time, params ...PropertyParameter } // SetDuration updates the duration of an event. -// This function will set either the end or start time of an event depending what is already given. -// The duration defines the length of a event relative to start or end time. +// This function will set either the end or start time of an event depending on what is already given. +// The duration defines the length of an event relative to start or end time. // // Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. func (cb *ComponentBase) SetDuration(d time.Duration) error { startProp := cb.GetProperty(ComponentPropertyDtStart) if startProp != nil { - t, err := cb.GetStartAt() - if err == nil { + t, allDay, err := startProp.ParseTime(false) + if t != nil && err == nil { v, _ := startProp.parameterValue(ParameterValue) - if v == string(ValueDataTypeDate) { + if v == string(ValueDataTypeDate) || allDay { cb.SetAllDayEndAt(t.Add(d)) } else { cb.SetEndAt(t.Add(d)) @@ -183,10 +183,10 @@ func (cb *ComponentBase) SetDuration(d time.Duration) error { } endProp := cb.GetProperty(ComponentPropertyDtEnd) if endProp != nil { - t, err := cb.GetEndAt() - if err == nil { + t, allDay, err := endProp.ParseTime(false) + if t != nil && err == nil { v, _ := endProp.parameterValue(ParameterValue) - if v == string(ValueDataTypeDate) { + if v == string(ValueDataTypeDate) || allDay { cb.SetAllDayStartAt(t.Add(-d)) } else { cb.SetStartAt(t.Add(-d)) @@ -197,76 +197,89 @@ func (cb *ComponentBase) SetDuration(d time.Duration) error { return errors.New("start or end not yet defined") } -func (cb *ComponentBase) GetEndAt() (time.Time, error) { - return cb.getTimeProp(ComponentPropertyDtEnd, false) -} - -func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { - timeProp := cb.GetProperty(componentProperty) - if timeProp == nil { - return time.Time{}, fmt.Errorf("%w: %s", ErrorPropertyNotFound, componentProperty) - } - - timeVal := timeProp.BaseProperty.Value - matched := timeStampVariations.FindStringSubmatch(timeVal) - if matched == nil { - return time.Time{}, fmt.Errorf("time value not matched, got '%s'", timeVal) - } - tOrZGrp := matched[2] - zGrp := matched[4] - grp1len := len(matched[1]) - grp3len := len(matched[3]) - - tzId, tzIdOk := timeProp.ICalParameters["TZID"] - var propLoc *time.Location - if tzIdOk { - if len(tzId) != 1 { - return time.Time{}, errors.New("expected only one TZID") +func (cb *ComponentBase) IsDuring(point time.Time) (bool, error) { + var effectiveStartTime *time.Time + var effectiveEndTime *time.Time + var durations []Duration + var startAllDay bool + var endAllDay bool + var err error + startProp := cb.GetProperty(ComponentPropertyDtStart) + if startProp != nil { + effectiveStartTime, startAllDay, err = startProp.ParseTime(false) + if err != nil { + return false, fmt.Errorf("start time: %w", err) } - var tzErr error - propLoc, tzErr = time.LoadLocation(tzId[0]) - if tzErr != nil { - return time.Time{}, tzErr + } + endProp := cb.GetProperty(ComponentPropertyDtEnd) + if endProp != nil { + effectiveEndTime, endAllDay, err = endProp.ParseTime(false) + if err != nil { + return false, fmt.Errorf("start time: %w", err) } } - dateStr := matched[1] - - if expectAllDay { - if grp1len > 0 { - if tOrZGrp == "Z" || zGrp == "Z" { - return time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC) - } else { - if propLoc == nil { - return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local) - } else { - return time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc) - } - } + durationProp := cb.GetProperty(ComponentPropertyDuration) + if durationProp != nil { + durations, err = durationProp.ParseDurations() + if err != nil { + return false, fmt.Errorf("start time: %w", err) } - - return time.Time{}, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal) } - switch { - case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z": - return time.ParseInLocation(icalTimestampFormatUtc, timeVal, time.UTC) - case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "": - if propLoc == nil { - return time.ParseInLocation(icalTimestampFormatLocal, timeVal, time.Local) - } else { - return time.ParseInLocation(icalTimestampFormatLocal, timeVal, propLoc) + case len(durations) == 1 && effectiveStartTime == nil && effectiveEndTime != nil: + d := durations[0].Duration + days := durations[0].Days + // TODO clarify expected behavior + if durations[0].Positive { + d = -d + days = -days } - case grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "": - return time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC) - case grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "": - if propLoc == nil { - return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local) - } else { - return time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc) + t := effectiveEndTime.Add(d).AddDate(0, 0, days) + effectiveStartTime = &t + case len(durations) == 1 && effectiveStartTime != nil && effectiveEndTime == nil: + d := durations[0].Duration + days := durations[0].Days + // TODO clarify expected behavior + if !durations[0].Positive { + d = -d + days = -days } + t := effectiveStartTime.Add(d).AddDate(0, 0, days+1).Truncate(24 * time.Hour).Add(-1) + effectiveEndTime = &t + case effectiveStartTime == nil && effectiveEndTime == nil: + return false, ErrStartAndEndDateNotDefined + } + if startAllDay && effectiveStartTime != nil { + t := effectiveStartTime.Truncate(24 * time.Hour) + effectiveStartTime = &t + } + if endAllDay && effectiveEndTime != nil { + t := effectiveEndTime.AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(-1) + effectiveEndTime = &t } + switch { + case effectiveStartTime == nil && effectiveEndTime == nil: + return false, nil + case effectiveStartTime != nil && effectiveEndTime != nil: + return (point.Equal(*effectiveStartTime) || point.After(*effectiveStartTime)) && (point.Equal(*effectiveEndTime) || point.Before(*effectiveEndTime)), nil + } + return false, fmt.Errorf("unsupported state") +} - return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal) +func (cb *ComponentBase) GetEndAt() (time.Time, error) { + return cb.getTimeProp(ComponentPropertyDtEnd, false) +} + +func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { + timeProp := cb.GetProperty(componentProperty) + if timeProp == nil { + return time.Time{}, fmt.Errorf("%w: %s", ErrorPropertyNotFound, componentProperty) + } + t, _, err := timeProp.ParseTime(expectAllDay) + if t == nil { + return time.Time{}, err + } + return *t, err } func (cb *ComponentBase) GetStartAt() (time.Time, error) { @@ -454,6 +467,15 @@ func (cb *ComponentBase) alarms() []*VAlarm { return r } +func (cb *ComponentBase) SetDurationStr(duration string) error { + _, err := ParseDuration(duration) + if err != nil { + return err + } + cb.SetProperty(ComponentPropertyDuration, duration) + return nil +} + type VEvent struct { ComponentBase } diff --git a/components_test.go b/components_test.go index daa7831..b64eb94 100644 --- a/components_test.go +++ b/components_test.go @@ -1,6 +1,7 @@ package ics import ( + "errors" "strings" "testing" "time" @@ -190,3 +191,138 @@ END:VTODO }) } } + +// Helper function to create a *time.Time from a string +func MustNewTime(value string) *time.Time { + t, err := time.ParseInLocation(time.RFC3339, value, time.UTC) + if err != nil { + return nil + } + return &t +} + +func TestIsDuring(t *testing.T) { + tests := []struct { + name string + startTime *time.Time + endTime *time.Time + duration string + pointInTime time.Time + expectedResult bool + expectedError error + allDayStart bool + allDayEnd bool + }{ + { + name: "Valid start and end time", + startTime: MustNewTime("2024-10-15T09:00:00Z"), + endTime: MustNewTime("2024-10-15T17:00:00Z"), + pointInTime: time.Date(2024, 10, 15, 10, 0, 0, 0, time.UTC), + expectedResult: true, + expectedError: nil, + }, + { + name: "Valid start time, no end, duration", + startTime: MustNewTime("2024-10-15T09:00:00Z"), + duration: "P2H", + pointInTime: time.Date(2024, 10, 15, 11, 0, 0, 0, time.UTC), + expectedResult: true, + expectedError: nil, + }, + { + name: "No start or end time", + pointInTime: time.Date(2024, 10, 15, 10, 0, 0, 0, time.UTC), + expectedResult: false, + expectedError: ErrStartAndEndDateNotDefined, + }, + { + name: "All-day event", + startTime: MustNewTime("2024-10-15T00:00:00Z"), + endTime: MustNewTime("2024-10-15T23:59:59Z"), + pointInTime: time.Date(2024, 10, 15, 12, 0, 0, 0, time.UTC), + expectedResult: true, + expectedError: nil, + allDayStart: true, + allDayEnd: true, + }, + { + name: "Point outside event duration", + startTime: MustNewTime("2024-10-15T09:00:00Z"), + endTime: MustNewTime("2024-10-15T17:00:00Z"), + pointInTime: time.Date(2024, 10, 15, 18, 0, 0, 0, time.UTC), + expectedResult: false, + expectedError: nil, + }, + { + name: "All-day start with valid end time", + startTime: MustNewTime("2024-10-15T00:00:00Z"), + endTime: MustNewTime("2024-10-15T17:00:00Z"), + pointInTime: time.Date(2024, 10, 15, 12, 0, 0, 0, time.UTC), + expectedResult: true, + expectedError: nil, + allDayStart: true, + }, + { + name: "All-day end with valid start time", + startTime: MustNewTime("2024-10-15T09:00:00Z"), + endTime: MustNewTime("2024-10-15T23:59:59Z"), + pointInTime: time.Date(2024, 10, 15, 22, 0, 0, 0, time.UTC), + expectedResult: true, + expectedError: nil, + allDayEnd: true, + }, + { + name: "Duration 1 day, point within event", + startTime: MustNewTime("2024-10-15T09:00:00Z"), + duration: "P1D", + pointInTime: time.Date(2024, 10, 16, 10, 0, 0, 0, time.UTC), + expectedResult: true, + expectedError: nil, + }, + { + name: "Duration 2 hours, point after event", + startTime: MustNewTime("2024-10-15T09:00:00Z"), + duration: "P2H", + pointInTime: time.Date(2024, 10, 15, 12, 0, 0, 0, time.UTC), + expectedResult: false, + expectedError: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cb := &ComponentBase{} + if tt.startTime != nil { + if tt.allDayStart { + cb.SetAllDayStartAt(*tt.startTime) + } else { + cb.SetStartAt(*tt.startTime) + } + } + if tt.endTime != nil { + if tt.allDayEnd { + cb.SetAllDayEndAt(*tt.endTime) + } else { + cb.SetEndAt(*tt.endTime) + } + } + if tt.duration != "" { + err := cb.SetDurationStr(tt.duration) + if err != nil { + t.Fatalf("Duration parse failed: %s", err) + } + } + // Call the IsDuring method + result, err := cb.IsDuring(tt.pointInTime) + + if err != nil || tt.expectedError != nil { + if !errors.Is(err, tt.expectedError) { + t.Fatalf("expected error: %v, got: %v", tt.expectedError, err) + } + } + + if result != tt.expectedResult { + t.Errorf("expected result: %v, got: %v", tt.expectedResult, result) + } + }) + } +} diff --git a/errors.go b/errors.go index dfb34ca..f4ffe05 100644 --- a/errors.go +++ b/errors.go @@ -1,8 +1,11 @@ package ics -import "errors" +import ( + "errors" +) var ( + ErrStartAndEndDateNotDefined = errors.New("start time and end time not defined") // ErrorPropertyNotFound is the error returned if the requested valid // property is not set. ErrorPropertyNotFound = errors.New("property not found") diff --git a/property.go b/property.go index 3e7291d..f89ad95 100644 --- a/property.go +++ b/property.go @@ -10,6 +10,8 @@ import ( "sort" "strconv" "strings" + "time" + "unicode" "unicode/utf8" ) @@ -200,6 +202,249 @@ type IANAProperty struct { BaseProperty } +// ParseTime Parses the time, all day is if we should treat the value as an all day event. +// Returns the time if parsable, if it is an all day time, and an error if there is one +func (p IANAProperty) ParseTime(expectAllDay bool) (*time.Time, bool, error) { + timeVal := p.BaseProperty.Value + matched := timeStampVariations.FindStringSubmatch(timeVal) + if matched == nil { + return nil, false, fmt.Errorf("time value not matched, got '%s'", timeVal) + } + tOrZGrp := matched[2] + zGrp := matched[4] + grp1len := len(matched[1]) + grp3len := len(matched[3]) + + tzId, tzIdOk := p.ICalParameters["TZID"] + var propLoc *time.Location + if tzIdOk { + if len(tzId) != 1 { + return nil, false, errors.New("expected only one TZID") + } + var tzErr error + propLoc, tzErr = time.LoadLocation(tzId[0]) + if tzErr != nil { + return nil, false, tzErr + } + } + dateStr := matched[1] + + if expectAllDay { + if grp1len > 0 { + if tOrZGrp == "Z" || zGrp == "Z" { + t, err := time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC) + return &t, true, err + } else { + if propLoc == nil { + t, err := time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local) + return &t, true, err + } else { + t, err := time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc) + return &t, true, err + } + } + } + return nil, false, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal) + } + + switch { + case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z": + t, err := time.ParseInLocation(icalTimestampFormatUtc, timeVal, time.UTC) + return &t, false, err + case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "": + if propLoc == nil { + t, err := time.ParseInLocation(icalTimestampFormatLocal, timeVal, time.Local) + return &t, false, err + } else { + t, err := time.ParseInLocation(icalTimestampFormatLocal, timeVal, propLoc) + return &t, false, err + } + case grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "": + t, err := time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC) + return &t, true, err + case grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "": + if propLoc == nil { + t, err := time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local) + return &t, true, err + } else { + t, err := time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc) + return &t, true, err + } + } + + return nil, false, fmt.Errorf("time value matched but not supported, got '%s'", timeVal) +} + +// ParseDurations assumes the value is a duration and tries to parse it +// +// Value Name: DURATION +// +// Purpose: This value type is used to identify properties that contain +// a duration of time. +// +// Format Definition: This value type is defined by the following +// notation: +// +// dur-value = (["+"] / "-") "P" (dur-date / dur-time / dur-week) +// +// dur-date = dur-day [dur-time] +// dur-time = "T" (dur-hour / dur-minute / dur-second) +// dur-week = 1*DIGIT "W" +// dur-hour = 1*DIGIT "H" [dur-minute] +// dur-minute = 1*DIGIT "M" [dur-second] +// dur-second = 1*DIGIT "S" +// dur-day = 1*DIGIT "D" +// +// Description: If the property permits, multiple "duration" values are +// specified by a COMMA-separated list of values. The format is +// based on the [ISO.8601.2004] complete representation basic format +// with designators for the duration of time. The format can +// represent nominal durations (weeks and days) and accurate +// durations (hours, minutes, and seconds). Note that unlike +// [ISO.8601.2004], this value type doesn't support the "Y" and "M" +// designators to specify durations in terms of years and months. +// +// Desruisseaux Standards Track [Page 35] +// +// # RFC 5545 iCalendar September 2009 +// +// The duration of a week or a day depends on its position in the +// calendar. In the case of discontinuities in the time scale, such +// as the change from standard time to daylight time and back, the +// computation of the exact duration requires the subtraction or +// addition of the change of duration of the discontinuity. Leap +// seconds MUST NOT be considered when computing an exact duration. +// When computing an exact duration, the greatest order time +// components MUST be added first, that is, the number of days MUST +// be added first, followed by the number of hours, number of +// minutes, and number of seconds. +// +// Negative durations are typically used to schedule an alarm to +// trigger before an associated time (see Section 3.8.6.3). +// +// No additional content value encoding (i.e., BACKSLASH character +// encoding, see Section 3.3.11) are defined for this value type. +// +// Example: A duration of 15 days, 5 hours, and 20 seconds would be: +// +// P15DT5H0M20S +// +// A duration of 7 weeks would be: +// +// P7W +func (p IANAProperty) ParseDurations() ([]Duration, error) { + var result []Duration + br := bytes.NewReader([]byte(strings.ToUpper(p.Value))) + for { + value, err := ParseDurationReader(br) + if err != nil && err != io.EOF { + return nil, fmt.Errorf("%w: '%s'", err, p.Value) + } + if value != nil { + result = append(result, *value) + } + if err == io.EOF { + return result, nil + } + } +} + +type DurationOrder struct { + Key rune + Value *Duration + Required bool +} + +var order = []DurationOrder{ + {Key: 'P', Value: nil, Required: true}, + {Key: 'W', Value: &Duration{Duration: 0, Days: 7}}, + {Key: 'D', Value: &Duration{Duration: 0, Days: 1}}, + {Key: 'T', Value: nil}, + {Key: 'H', Value: &Duration{Duration: time.Hour, Days: 0}}, + {Key: 'M', Value: &Duration{Duration: time.Minute, Days: 0}}, + {Key: 'S', Value: &Duration{Duration: time.Second, Days: 0}}, +} + +func ParseDuration(s string) (*Duration, error) { + return ParseDurationReader(strings.NewReader(strings.ToUpper(s))) +} + +type ReaderRuneBuffer interface { + ReadRune() (rune, int, error) + UnreadRune() error +} + +func ParseDurationReader(br ReaderRuneBuffer) (*Duration, error) { + var value = Duration{ + Positive: true, + } + pos := 0 + for pos != 1 { + b, _, err := br.ReadRune() + if err == io.EOF { + return nil, err + } + if err != nil { + return nil, fmt.Errorf("failed to parse duration") + } + switch b { + case '-': + value.Positive = false + case '+': + case 'P': + pos = 1 + default: + return nil, fmt.Errorf("missing p initializer got %c", b) + } + } + for pos < len(order) { + var number int + var b rune + var err error + for { + b, _, err = br.ReadRune() + if err == io.EOF || b == ',' { + break + } + if err != nil { + return nil, fmt.Errorf("failed to parse duration") + } + if unicode.IsSpace(b) { + continue + } + if unicode.IsDigit(b) { + number = number*10 + int(b-'0') + } else { + break + } + } + if err == io.EOF || b == ',' { + break + } + for ; pos < len(order) && order[pos].Key != b; pos++ { + } + if pos >= len(order) { + err := br.UnreadRune() + if err != nil { + return nil, fmt.Errorf("unread rune error '%w'", err) + } + break + } + selected := order[pos] + if selected.Value != nil { + value.Days += selected.Value.Days * number + value.Duration += selected.Value.Duration * time.Duration(number) + } + } + return &value, nil +} + +type Duration struct { + Positive bool + Duration time.Duration + Days int +} + var ( propertyIanaTokenReg *regexp.Regexp propertyParamNameReg *regexp.Regexp diff --git a/property_test.go b/property_test.go index 84e2d04..266fa2f 100644 --- a/property_test.go +++ b/property_test.go @@ -2,6 +2,7 @@ package ics import ( "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -185,3 +186,86 @@ func Test_parsePropertyParamValue(t *testing.T) { }) } } + +func TestParseDurations(t *testing.T) { + tests := []struct { + name string + value string + expected []Duration + hasError bool + }{ + { + name: "Valid duration with days, hours, and seconds", + value: "P15DT5H0M20S", + expected: []Duration{ + {Positive: true, Duration: 5*time.Hour + 20*time.Second, Days: 15}, + }, + hasError: false, + }, + { + name: "Valid duration with weeks", + value: "P7W", + expected: []Duration{ + {Positive: true, Duration: 0, Days: 7 * 7}, // 7 weeks + }, + hasError: false, + }, + { + name: "Valid negative duration", + value: "-P1DT3H", + expected: []Duration{ + {Positive: false, Duration: 3 * time.Hour, Days: 1}, + }, + hasError: false, + }, + { + name: "Invalid duration missing 'P'", + value: "15DT5H0M20S", + expected: nil, + hasError: true, + }, + { + name: "Invalid input format with random string", + value: "INVALID", + expected: nil, + hasError: true, + }, + { + name: "Multiple durations in comma-separated list", + value: "P1DT5H,P2DT3H", + expected: []Duration{ + {Positive: true, Duration: 5 * time.Hour, Days: 1}, + {Positive: true, Duration: 3 * time.Hour, Days: 2}, + }, + hasError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prop := IANAProperty{BaseProperty{Value: tt.value}} + durations, err := prop.ParseDurations() + + if (err != nil) != tt.hasError { + t.Fatalf("expected error: %v, got: %v", tt.hasError, err) + } + + if !tt.hasError && !equalDurations(durations, tt.expected) { + t.Errorf("expected durations: %v, got: %v", tt.expected, durations) + } + }) + } +} + +// Helper function to compare two slices of Duration +func equalDurations(a, b []Duration) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}