Skip to content

Commit

Permalink
Parse time. Pending merge with #99 for resolution
Browse files Browse the repository at this point in the history
  • Loading branch information
arran4 committed Oct 15, 2024
1 parent 937429d commit a55b8bf
Show file tree
Hide file tree
Showing 6 changed files with 560 additions and 69 deletions.
1 change: 1 addition & 0 deletions calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const (
ComponentPropertyTzid = ComponentProperty(PropertyTzid)
ComponentPropertyComment = ComponentProperty(PropertyComment)
ComponentPropertyRelatedTo = ComponentProperty(PropertyRelatedTo)
ComponentPropertyDuration = ComponentProperty(PropertyDuration)
)

type Property string
Expand Down
158 changes: 90 additions & 68 deletions components.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
136 changes: 136 additions & 0 deletions components_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ics

import (
"errors"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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)
}
})
}
}
5 changes: 4 additions & 1 deletion errors.go
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
Loading

0 comments on commit a55b8bf

Please sign in to comment.