Skip to content

Commit

Permalink
Merge pull request #19 from egorbunov/master
Browse files Browse the repository at this point in the history
Be more careful parsing local times in rules
  • Loading branch information
zensh committed Oct 20, 2018
2 parents a1ff18d + 655c146 commit c53b0d0
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 33 deletions.
69 changes: 38 additions & 31 deletions str.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ func timeToDtStartStr(time time.Time) string {
return fmt.Sprintf("TZID=%s:%s", time.Location().String(), time.Format(LocalDateTimeFormat))
}

func strToTime(str string) (time.Time, error) {
return strToTimeInLoc(str, time.UTC)
}

func strToTimeInLoc(str string, loc *time.Location) (time.Time, error) {
if len(str) == len(DateFormat) {
return time.ParseInLocation(DateFormat, str, loc)
Expand Down Expand Up @@ -258,7 +254,19 @@ func StrToRRuleSet(s string) (*Set, error) {
}

// StrSliceToRRuleSet converts given str slice to RRuleSet
// In case there is a time met in any rule without specified time zone, when
// it is parsed in UTC (see StrSliceToRRuleSetInLoc)
func StrSliceToRRuleSet(ss []string) (*Set, error) {
return StrSliceToRRuleSetInLoc(ss, time.UTC)
}

// StrSliceToRRuleSetInLoc is same as StrSliceToRRuleSet, but by default parses local times
// in specified default location
func StrSliceToRRuleSetInLoc(ss []string, defaultLoc *time.Location) (*Set, error) {
if len(ss) == 0 {
return &Set{}, nil
}

set := Set{}

// According to RFC DTSTART is always the first line.
Expand All @@ -268,40 +276,37 @@ func StrSliceToRRuleSet(ss []string) (*Set, error) {
}

if firstName == "DTSTART" {
nameLen := strings.IndexAny(ss[0], ";:")
dt, err := strToDtStart(ss[0][nameLen+1:])
dt, err := strToDtStart(ss[0][len(firstName)+1:], defaultLoc)
if err != nil {
return nil, fmt.Errorf("strToDtStart failed: %v", err)
}
// default location should be taken from DTSTART property to correctly
// parse local times met in RDATE,EXDATE and other rules
defaultLoc = dt.Location()
set.DTStart(dt)
// We've processed the first one
ss = ss[1:]
}

for _, line := range ss {

name, err := processRRuleName(line)
if err != nil {
return nil, err
}

nameLen := len(name)
rule := line[len(name)+1:]

switch name {
case "RRULE", "EXRULE":
r, err := StrToRRule(line[nameLen+1:])

rOpt, err := StrToROption(rule)
if err != nil {
return nil, fmt.Errorf("strToRRule failed: %v", err)
return nil, fmt.Errorf("StrToROption failed: %v", err)
}

if !set.GetDTStart().IsZero() {
opt := r.OrigOptions
opt.Dtstart = set.GetDTStart()
r, err = NewRRule(opt)
if err != nil {
return nil, fmt.Errorf("could not add dtstart to rule: %v", r)
}
rOpt.Dtstart = set.GetDTStart()
}
r, err := NewRRule(*rOpt)
if err != nil {
return nil, fmt.Errorf("NewRRule failed: %v", r)
}

if name == "RRULE" {
Expand All @@ -310,7 +315,7 @@ func StrSliceToRRuleSet(ss []string) (*Set, error) {
set.ExRule(r)
}
case "RDATE", "EXDATE":
ts, err := StrToDates(line[nameLen+1:])
ts, err := StrToDatesInLoc(rule, defaultLoc)
if err != nil {
return nil, fmt.Errorf("strToDates failed: %v", err)
}
Expand All @@ -329,22 +334,29 @@ func StrSliceToRRuleSet(ss []string) (*Set, error) {
return &set, nil
}

// StrToDates is inteded to parse RDATE and EXDATE properties supporting only
// StrToDates is intended to parse RDATE and EXDATE properties supporting only
// VALUE=DATE-TIME (DATE and PERIOD are not supported).
// Accepts string with format: "VALUE=DATE-TIME;[TZID=...]:{time},{time},...,{time}"
// or simply "{time},{time},...{time}" and parses it to array of dates
// In case no time zone specified in str, when all dates are parsed in UTC
func StrToDates(str string) (ts []time.Time, err error) {
return StrToDatesInLoc(str, time.UTC)
}

// StrToDatesInLoc same as StrToDates but it consideres default location to parse dates in
// in case no location specified with TZID parameter
func StrToDatesInLoc(str string, defaultLoc *time.Location) (ts []time.Time, err error) {
tmp := strings.Split(str, ":")
if len(tmp) > 2 {
return nil, fmt.Errorf("bad format")
}
var loc *time.Location
loc := defaultLoc
if len(tmp) == 2 {
params := strings.Split(tmp[0], ";")
for _, param := range params {
if strings.HasPrefix(param, "TZID=") {
loc, err = parseTZID(param)
} else if param != "VALUE=DATE-TIME" {
} else if param != "VALUE=DATE-TIME" && param != "VALUE=DATE" {
err = fmt.Errorf("unsupported: %v", param)
}
if err != nil {
Expand All @@ -354,12 +366,7 @@ func StrToDates(str string) (ts []time.Time, err error) {
tmp = tmp[1:]
}
for _, datestr := range strings.Split(tmp[0], ",") {
var t time.Time
if loc == nil {
t, err = strToTime(datestr)
} else {
t, err = strToTimeInLoc(datestr, loc)
}
t, err := strToTimeInLoc(datestr, loc)
if err != nil {
return nil, fmt.Errorf("strToTime failed: %v", err)
}
Expand Down Expand Up @@ -390,7 +397,7 @@ func processRRuleName(line string) (string, error) {

// strToDtStart accepts string with format: "(TZID={timezone}:)?{time}" and parses it to a date
// may be used to parse DTSTART rules, without the DTSTART; part.
func strToDtStart(str string) (time.Time, error) {
func strToDtStart(str string, defaultLoc *time.Location) (time.Time, error) {
tmp := strings.Split(str, ":")
if len(tmp) > 2 || len(tmp) == 0 {
return time.Time{}, fmt.Errorf("bad format")
Expand All @@ -405,7 +412,7 @@ func strToDtStart(str string) (time.Time, error) {
return strToTimeInLoc(tmp[1], loc)
}
// no tzid, len == 1
return strToTime(tmp[0])
return strToTimeInLoc(tmp[0], defaultLoc)
}

func parseTZID(s string) (*time.Location, error) {
Expand Down
82 changes: 80 additions & 2 deletions str_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,13 @@ func TestStrToDtStart(t *testing.T) {
}

for _, item := range validCases {
if _, e := strToDtStart(item); e != nil {
if _, e := strToDtStart(item, time.UTC); e != nil {
t.Errorf("strToDtStart(%q) error = %s, want nil", item, e.Error())
}
}

for _, item := range invalidCases {
if _, e := strToDtStart(item); e == nil {
if _, e := strToDtStart(item, time.UTC); e == nil {
t.Errorf("strToDtStart(%q) err = nil, want not nil", item)
}
}
Expand All @@ -133,6 +133,7 @@ func TestStrToDates(t *testing.T) {
"19970714T173000Z",
"VALUE=DATE-TIME:19970714T133000,19980714T133000,19980714T133000",
"VALUE=DATE-TIME;TZID=America/New_York:19970714T133000,19980714T133000,19980714T133000",
"VALUE=DATE:19970714T133000,19980714T133000,19980714T133000",
}

invalidCases := []string{
Expand All @@ -141,18 +142,25 @@ func TestStrToDates(t *testing.T) {
" ",
"",
"VALUE=DATE-TIME;TZID=:19970714T133000",
"VALUE=PERIOD:19970714T133000Z/19980714T133000Z",
}

for _, item := range validCases {
if _, e := StrToDates(item); e != nil {
t.Errorf("StrToDates(%q) error = %s, want nil", item, e.Error())
}
if _, e := StrToDatesInLoc(item, time.Local); e != nil {
t.Errorf("StrToDates(%q) error = %s, want nil", item, e.Error())
}
}

for _, item := range invalidCases {
if _, e := StrToDates(item); e == nil {
t.Errorf("StrToDates(%q) err = nil, want not nil", item)
}
if _, e := StrToDatesInLoc(item, time.Local); e == nil {
t.Errorf("StrToDates(%q) err = nil, want not nil", item)
}
}
}

Expand Down Expand Up @@ -288,6 +296,76 @@ func TestRFCSetStr(t *testing.T) {
}
}

func TestSetParseLocalTimes(t *testing.T) {
moscow, _ := time.LoadLocation("Europe/Moscow")

t.Run("DtstartTimeZoneIsUsed", func(t *testing.T) {
input := []string{
"DTSTART;TZID=Europe/Moscow:20180220T090000",
"RDATE;VALUE=DATE-TIME:20180223T100000",
}
s, err := StrSliceToRRuleSet(input)
if err != nil {
t.Error(err)
}
d := s.GetRDate()[0]
if !d.Equal(time.Date(2018, 02, 23, 10, 0, 0, 0, moscow)) {
t.Error("Bad time parsed: ", d)
}
})

t.Run("SpecifiedDefaultZoneIsUsed", func(t *testing.T) {
input := []string{
"RDATE;VALUE=DATE-TIME:20180223T100000",
}
s, err := StrSliceToRRuleSetInLoc(input, moscow)
if err != nil {
t.Error(err)
}
d := s.GetRDate()[0]
if !d.Equal(time.Date(2018, 02, 23, 10, 0, 0, 0, moscow)) {
t.Error("Bad time parsed: ", d)
}
})
}

func TestRDateValueDateStr(t *testing.T) {
input := []string{
"RDATE;VALUE=DATE:20180223",
}
s, err := StrSliceToRRuleSet(input)
if err != nil {
t.Error(err)
}
d := s.GetRDate()[0]
if !d.Equal(time.Date(2018, 02, 23, 0, 0, 0, 0, time.UTC)) {
t.Error("Bad time parsed: ", d)
}
}

func TestStrSetEmptySliceParse(t *testing.T) {
s, err := StrSliceToRRuleSet([]string{})
if err != nil {
t.Error(err)
}
if s == nil {
t.Error("Empty set should not be nil")
}
}

func TestStrSetParseErrors(t *testing.T) {
inputs := [][]string{
{"RULE:XXX"},
{"RDATE;TZD=X:1"},
}

for _, ss := range inputs {
if _, err := StrSliceToRRuleSet(ss); err == nil {
t.Error("Expected parse error for rules: ", ss)
}
}
}

// Helper for TestRFCSetStr and TestSetStr
func assertRulesMatch(set *Set, t *testing.T) {
// matching parsed RRules
Expand Down

0 comments on commit c53b0d0

Please sign in to comment.