Skip to content

Commit

Permalink
Merge pull request #44 from shaohme/time-parsing
Browse files Browse the repository at this point in the history
Extended time parsing
  • Loading branch information
arran4 authored Mar 10, 2022
2 parents c519bf0 + 705a150 commit a5af9a2
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 17 deletions.
2 changes: 1 addition & 1 deletion calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ func (calendar *Calendar) SetDescription(s string, props ...PropertyParameter) {
}

func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParameter) {
calendar.setProperty(PropertyLastModified, t.UTC().Format(icalTimeFormat), props...)
calendar.setProperty(PropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...)
}

func (calendar *Calendar) SetRefreshInterval(s string, props ...PropertyParameter) {
Expand Down
90 changes: 89 additions & 1 deletion calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,98 @@ import (
"strings"
"testing"
"unicode/utf8"

"time"
"github.com/stretchr/testify/assert"
)

func TestTimeParsing(t *testing.T) {
calFile, err := os.OpenFile("./testdata/timeparsing.ics", os.O_RDONLY, 0400)
if err != nil {
t.Errorf("read file: %v", err)
}
cal, err := ParseCalendar(calFile)
if err != nil {
t.Errorf("parse calendar: %v", err)
}

cphLoc, locErr := time.LoadLocation("Europe/Copenhagen")
if locErr != nil {
t.Errorf("could not load location")
}

var tests = []struct {
uid string
start time.Time
end time.Time
allDayStart time.Time
allDayEnd time.Time
}{
// FORM 1
{"be7c9690-d42a-40ef-b82f-1634dc5033b4",
time.Date(1998, 1, 18, 23, 0, 0, 0, time.Local),
time.Date(1998, 1, 19, 23, 0, 0, 0, time.Local),
time.Date(1998, 1, 18, 0, 0, 0, 0, time.Local),
time.Date(1998, 1, 19, 0, 0, 0, 0, time.Local)},
// FORM 2
{"53634aed-1b7d-4d85-aa38-ede76a2e4fe3",
time.Date(2022, 1, 22, 17, 0, 0, 0, time.UTC),
time.Date(2022, 1, 22, 20, 0, 0, 0, time.UTC),
time.Date(2022, 1, 22, 0, 0, 0, 0, time.UTC),
time.Date(2022, 1, 22, 0, 0, 0, 0, time.UTC)},
// FORM 3
{"269cf715-4e14-4a10-8753-f2feeb9d060e",
time.Date(2021, 12, 7, 14, 0, 0, 0, cphLoc),
time.Date(2021, 12, 7, 15, 0, 0, 0, cphLoc),
time.Date(2021, 12, 7, 0, 0, 0, 0, cphLoc),
time.Date(2021, 12, 7, 0, 0, 0, 0, cphLoc)},
// Unknown local date, with 'VALUE'
{"fb54680e-7f69-46d3-9632-00aed2469f7b",
time.Date(2021, 6, 27, 0, 0, 0, 0, time.Local),
time.Date(2021, 6, 28, 0, 0, 0, 0, time.Local),
time.Date(2021, 6, 27, 0, 0, 0, 0, time.Local),
time.Date(2021, 6, 28, 0, 0, 0, 0, time.Local)},
// Unknown UTC date
{"62475ad0-a76c-4fab-8e68-f99209afcca6",
time.Date(2021, 5, 27, 0, 0, 0, 0, time.UTC),
time.Date(2021, 5, 28, 0, 0, 0, 0, time.UTC),
time.Date(2021, 5, 27, 0, 0, 0, 0, time.UTC),
time.Date(2021, 5, 28, 0, 0, 0, 0, time.UTC)},
}

assertTime := func(evtUid string, exp time.Time, timeFunc func() (given time.Time, err error)) {
given, err := timeFunc()
if err == nil {
if !exp.Equal(given) {
t.Errorf("no match on '%s', expected=%v != given=%v", evtUid, exp, given)
}
} else {
t.Errorf("get time on uid '%s', %v", evtUid, err)
}
}
evts := cal.Events()

for _, tt := range tests {
t.Run(tt.uid, func(t *testing.T) {
var evt *VEvent
for _, e := range evts {
if strings.EqualFold(e.Id(), tt.uid) {
evt = e
}
}

if evt == nil {
t.Errorf("event UID not found, %s", tt.uid)
return
}

assertTime(tt.uid, tt.start, evt.GetStartAt)
assertTime(tt.uid, tt.end, evt.GetEndAt)
assertTime(tt.uid, tt.allDayStart, evt.GetAllDayStartAt)
assertTime(tt.uid, tt.allDayEnd, evt.GetAllDayEndAt)
})
}
}

func TestCalendarStream(t *testing.T) {
i := `
ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:
Expand Down
95 changes: 80 additions & 15 deletions components.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -55,40 +56,46 @@ func (c *VEvent) Serialize() string {
}

const (
icalTimeFormat = "20060102T150405Z"
icalAllDayTimeFormat = "20060102"
icalTimestampFormatUtc = "20060102T150405Z"
icalTimestampFormatLocal = "20060102T150405"
icalDateFormatUtc = "20060102Z"
icalDateFormatLocal = "20060102"
)

var (
timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$")
)

func (event *VEvent) SetCreatedTime(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimeFormat), props...)
event.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), props...)
}

func (event *VEvent) SetDtStampTime(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimeFormat), props...)
event.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), props...)
}

func (event *VEvent) SetModifiedAt(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimeFormat), props...)
event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...)
}

func (event *VEvent) SetSequence(seq int, props ...PropertyParameter) {
event.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...)
}

func (event *VEvent) SetStartAt(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimeFormat), props...)
event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...)
}

func (event *VEvent) SetAllDayStartAt(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalAllDayTimeFormat), props...)
event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalDateFormatUtc), props...)
}

func (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimeFormat), props...)
event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...)
}

func (event *VEvent) SetAllDayEndAt(t time.Time, props ...PropertyParameter) {
event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalAllDayTimeFormat), props...)
event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalDateFormatUtc), props...)
}

// SetDuration updates the duration of an event.
Expand All @@ -111,29 +118,87 @@ func (event *VEvent) SetDuration(d time.Duration) error {
return errors.New("start or end not yet defined")
}

func (event *VEvent) getTimeProp(componentProperty ComponentProperty, tFormat string) (time.Time, error) {
func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) {
timeProp := event.GetProperty(componentProperty)
if timeProp == nil {
return time.Time{}, errors.New("property not found")
}

timeVal := timeProp.BaseProperty.Value
return time.Parse(tFormat, timeVal)
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")
}
var tzErr error
propLoc, tzErr = time.LoadLocation(tzId[0])
if tzErr != nil {
return time.Time{}, tzErr
}
}
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)
}
}
}

return time.Time{}, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal)
}

if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z" {
return time.ParseInLocation(icalTimestampFormatUtc, timeVal, time.UTC)
} else if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "" {
if propLoc == nil {
return time.ParseInLocation(icalTimestampFormatLocal, timeVal, time.Local)
} else {
return time.ParseInLocation(icalTimestampFormatLocal, timeVal, propLoc)
}
} else if grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "" {
return time.ParseInLocation(icalDateFormatUtc, dateStr + "Z", time.UTC)
} else if grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "" {
if propLoc == nil {
return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local)
} else {
return time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc)
}
}

return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal)
}

func (event *VEvent) GetStartAt() (time.Time, error) {
return event.getTimeProp(ComponentPropertyDtStart, icalTimeFormat)
return event.getTimeProp(ComponentPropertyDtStart, false)
}

func (event *VEvent) GetEndAt() (time.Time, error) {
return event.getTimeProp(ComponentPropertyDtEnd, icalTimeFormat)
return event.getTimeProp(ComponentPropertyDtEnd, false)
}

func (event *VEvent) GetAllDayStartAt() (time.Time, error) {
return event.getTimeProp(ComponentPropertyDtStart, icalAllDayTimeFormat)
return event.getTimeProp(ComponentPropertyDtStart, true)
}

func (event *VEvent) GetAllDayEndAt() (time.Time, error) {
return event.getTimeProp(ComponentPropertyDtEnd, icalAllDayTimeFormat)
return event.getTimeProp(ComponentPropertyDtEnd, true)
}

type TimeTransparency string
Expand Down
53 changes: 53 additions & 0 deletions testdata/timeparsing.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.0-gplay ical4j/3.1.0
BEGIN:VTIMEZONE
TZID:Europe/Copenhagen
LAST-MODIFIED:20201010T011803Z
BEGIN:STANDARD
DTSTART:19961027T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:269cf715-4e14-4a10-8753-f2feeb9d060e
DTSTART;TZID=Europe/Copenhagen:20211207T140000
DTEND;TZID=Europe/Copenhagen:20211207T150000
SUMMARY:Form 3
END:VEVENT
BEGIN:VEVENT
UID:53634aed-1b7d-4d85-aa38-ede76a2e4fe3
DTSTART:20220122T170000Z
DTEND:20220122T200000Z
SUMMARY:Form #2
END:VEVENT
BEGIN:VEVENT
UID:be7c9690-d42a-40ef-b82f-1634dc5033b4
DTSTART:19980118T230000
DTEND:19980119T230000
DTSTAMP:20210624T080748Z
SUMMARY:Form #1
END:VEVENT
BEGIN:VEVENT
UID:fb54680e-7f69-46d3-9632-00aed2469f7b
DTSTART;VALUE=DATE:20210627
DTEND;VALUE=DATE:20210628
SUMMARY:Unknown local date, with 'VALUE'
END:VEVENT
BEGIN:VEVENT
UID:62475ad0-a76c-4fab-8e68-f99209afcca6
DTSTART:20210527Z
DTEND:20210528Z
SUMMARY:Unknown UTC date
END:VEVENT
END:VCALENDAR

0 comments on commit a5af9a2

Please sign in to comment.