diff --git a/core/cron.go b/core/cron.go index 0f5474f..c9275a4 100644 --- a/core/cron.go +++ b/core/cron.go @@ -13,6 +13,8 @@ func GetNextUpdate(lastUpdated time.Time, spec string) (nextUpdate time.Time, er nextUpdates, err = parseEverySpec(lastUpdated, spec[len("every "):]) case strings.HasPrefix(spec, "at "): nextUpdates, err = parseAtSpec(lastUpdated, spec[len("at "):]) + case strings.HasPrefix(spec, "on "): + nextUpdates, err = parseOnSpec(lastUpdated, spec[len("on "):]) default: return time.Time{}, fmt.Errorf("unknown spec format: %v", spec) } @@ -45,8 +47,7 @@ func parseEverySpec(base time.Time, everySpec string) (nextUpdates []time.Time, // Get the next instances of the at-spec times after the base time. // An at-spec is in the patterm HH:MM[,HH:MM,[...]]. func parseAtSpec(base time.Time, atSpec string) (nextUpdates []time.Time, err error) { - timeSpecs := strings.Split(atSpec, ",") - for _, timeSpec := range timeSpecs { + for _, timeSpec := range strings.Split(atSpec, ",") { var hour, minute int _, err = fmt.Sscanf(timeSpec, "%d:%d", &hour, &minute) if err != nil { @@ -62,3 +63,87 @@ func parseAtSpec(base time.Time, atSpec string) (nextUpdates []time.Time, err er } return } + +var weekdays = map[string]time.Weekday{ + "Sun": time.Sunday, + "Mon": time.Monday, + "Tue": time.Tuesday, + "Wed": time.Wednesday, + "Thu": time.Thursday, + "Fri": time.Friday, + "Sat": time.Saturday, +} + +// Get the next instances of the on-spec times after the base time. +// An on-spec is in the pattern DOW[,DOW[...]] where DOW is an abbreviated weekday +// or M/D[,M/D[...]] where M/D is a month and day. +// As a special case, "*/N" matches the Nth day of every month. +// An on-spec may be followed by an at-spec; otherwise, "at 00:00" is implied. +func parseOnSpec(base time.Time, onSpec string) (nextUpdates []time.Time, err error) { + type Date struct { + Year int + Month time.Month + Day int + } + type Time struct { + Hour int + Minute int + } + + atSpec := "00:00" + if on := strings.Index(onSpec, " at "); on > -1 { + atSpec = onSpec[on+len(" at "):] + onSpec = onSpec[:on] + } + var atTimes []Time + for _, timeSpec := range strings.Split(atSpec, ",") { + var hour, minute int + _, err := fmt.Sscanf(timeSpec, "%d:%d", &hour, &minute) + if err != nil { + return nil, fmt.Errorf("could not parse at-spec %s: %v", timeSpec, err) + } + atTimes = append(atTimes, Time{hour, minute}) + } + + var dates []Date + for _, daySpec := range strings.Split(onSpec, ",") { + if weekday, ok := weekdays[daySpec]; ok { + // For a weekday, add the next of that weekday 0-6 days ahead and 7-13 days ahead. + // The first date ensures that we don't miss multiple updates on the same day of the week + // (e.g. "on Sun at 06:00,18:00") and the second date ensures that we don't get stuck with + // only a date in the past (e.g. "on Sun at 02:00" when base is Sun 03:00). + daysForward := (int(weekday) - int(base.Weekday()) + 7) % 7 // value in [0,6] + day0_6 := base.AddDate(0, 0, daysForward) + dates = append(dates, Date{day0_6.Year(), day0_6.Month(), day0_6.Day()}) + day7_13 := base.AddDate(0, 0, daysForward+7) + dates = append(dates, Date{day7_13.Year(), day7_13.Month(), day7_13.Day()}) + } else if strings.HasPrefix(daySpec, "*/") { + // For every-month, add the date for the current month and the date for the next month. + var day int + _, err := fmt.Sscanf(daySpec, "*/%d", &day) + if err != nil { + return nil, fmt.Errorf("could not parse month/day %s: %v", daySpec, err) + } + dates = append(dates, Date{base.Year(), base.Month(), day}) + nextMonth := base.AddDate(0, 1, 0) + dates = append(dates, Date{nextMonth.Year(), nextMonth.Month(), day}) + } else { + // For month/day, add the date for the base year and the next year. + var month, day int + _, err := fmt.Sscanf(daySpec, "%d/%d", &month, &day) + if err != nil { + return nil, fmt.Errorf("could not parse month/day %s: %v", daySpec, err) + } + dates = append(dates, Date{base.Year(), time.Month(month), day}) + dates = append(dates, Date{base.Year() + 1, time.Month(month), day}) + } + } + + // Now, for each date, create a datetime based on the at-spec. + for _, date := range dates { + for _, atTime := range atTimes { + nextUpdates = append(nextUpdates, time.Date(date.Year, date.Month, date.Day, atTime.Hour, atTime.Minute, 0, 0, base.Location())) + } + } + return +} diff --git a/core/cron_test.go b/core/cron_test.go index 6f56aca..c95d511 100644 --- a/core/cron_test.go +++ b/core/cron_test.go @@ -7,103 +7,163 @@ import ( ) func TestGetNextUpdate(t *testing.T) { - tests := []struct { - lastUpdated time.Time - spec string - expected time.Time - expectErr bool - }{ - { - lastUpdated: time.Date(2020, 10, 22, 12, 0, 0, 0, time.UTC), - spec: "every 1h#1", - expected: time.Date(2020, 10, 22, 13, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 12, 0, 5, 0, time.UTC), - spec: "every 1h#2", - expected: time.Date(2020, 10, 22, 13, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 11, 0, 0, 0, time.UTC), - spec: "every 2h30m#1", - expected: time.Date(2020, 10, 22, 11, 30, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 11, 30, 0, 0, time.UTC), - spec: "every 2h30m#2", - expected: time.Date(2020, 10, 22, 14, 00, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 11, 30, 0, 0, time.UTC), - spec: "every 3", - expected: time.Time{}, - expectErr: true, - }, - { - lastUpdated: time.Date(2020, 10, 22, 12, 0, 0, 0, time.UTC), - spec: "at 14:00#1", - expected: time.Date(2020, 10, 22, 14, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 14, 0, 0, 0, time.UTC), - spec: "at 14:00#2", - expected: time.Date(2020, 10, 23, 14, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 15, 0, 0, 0, time.UTC), - spec: "at 14:00#3", - expected: time.Date(2020, 10, 23, 14, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 9, 0, 0, 0, time.UTC), - spec: "at 10:00,15:00#1", - expected: time.Date(2020, 10, 22, 10, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 12, 0, 0, 0, time.UTC), - spec: "at 10:00,15:00#2", - expected: time.Date(2020, 10, 22, 15, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 15, 0, 0, 0, time.UTC), - spec: "at 10:00,15:00#3", - expected: time.Date(2020, 10, 23, 10, 0, 0, 0, time.UTC), - expectErr: false, - }, - { - lastUpdated: time.Date(2020, 10, 22, 16, 0, 0, 0, time.UTC), - spec: "at 10:00,15:00#4", - expected: time.Date(2020, 10, 23, 10, 0, 0, 0, time.UTC), - expectErr: false, - }, + date := func(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, time.UTC) } - for _, test := range tests { - t.Run(test.spec, func(t *testing.T) { - spec := test.spec + expect := func(lastUpdated time.Time, spec string, expected time.Time) { + t.Run(spec, func(t *testing.T) { if comment := strings.Index(spec, "#"); comment > -1 { spec = spec[:comment] } - nextUpdate, err := GetNextUpdate(test.lastUpdated, spec) - if test.expectErr && err == nil { - t.Error("test did not fail as expected") + nextUpdate, err := GetNextUpdate(lastUpdated, spec) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if !test.expectErr && err != nil { - t.Errorf("error: %v", err) - } - if !nextUpdate.Equal(test.expected) { - t.Errorf("\nexpected: %v\n got: %v", test.expected, nextUpdate) + if !nextUpdate.Equal(expected) { + t.Errorf("\nexpected: %v\n got: %v", expected, nextUpdate) } }) } + expectFail := func(lastUpdated time.Time, spec string) { + t.Run(spec, func(t *testing.T) { + if comment := strings.Index(spec, "#"); comment > -1 { + spec = spec[:comment] + } + _, err := GetNextUpdate(lastUpdated, spec) + if err == nil { + t.Error("expected error") + } + }) + } + + expect( + date(2020, 10, 22, 12, 0), + "every 1h#1", + date(2020, 10, 22, 13, 0), + ) + expect( + date(2020, 10, 22, 12, 1), + "every 1h#2", + date(2020, 10, 22, 13, 0), + ) + + expect( + date(2020, 10, 22, 11, 0), + "every 2h30m#1", + date(2020, 10, 22, 11, 30), + ) + expect( + date(2020, 10, 22, 11, 30), + "every 2h30m#2", + date(2020, 10, 22, 14, 0), + ) + + expectFail( + date(2020, 10, 22, 11, 30), + "every 3", + ) + + expect( + date(2020, 10, 22, 12, 0), + "at 14:00#1", + date(2020, 10, 22, 14, 0), + ) + expect( + date(2020, 10, 22, 14, 0), + "at 14:00#2", + date(2020, 10, 23, 14, 0), + ) + expect( + date(2020, 10, 22, 15, 0), + "at 14:00#3", + date(2020, 10, 23, 14, 0), + ) + + expect( + date(2020, 10, 22, 9, 0), + "at 10:00,15:00#1", + date(2020, 10, 22, 10, 0), + ) + expect( + date(2020, 10, 22, 12, 0), + "at 10:00,15:00#2", + date(2020, 10, 22, 15, 0), + ) + expect( + date(2020, 10, 22, 15, 0), + "at 10:00,15:00#3", + date(2020, 10, 23, 10, 0), + ) + expect( + date(2020, 10, 22, 16, 0), + "at 10:00,15:00#4", + date(2020, 10, 23, 10, 0), + ) + + expect( + date(2020, 10, 22, 6, 0), + "on Sun#1", + date(2020, 10, 25, 0, 0), + ) + expect( + date(2020, 10, 25, 0, 0), + "on Sun#2", + date(2020, 11, 1, 0, 0), + ) + + expect( + date(2020, 10, 19, 0, 0), + "on Tue,Thu at 04:00#1", + date(2020, 10, 20, 4, 0), + ) + expect( + date(2020, 10, 20, 3, 0), + "on Tue,Thu at 04:00#2", + date(2020, 10, 20, 4, 0), + ) + expect( + date(2020, 10, 20, 4, 0), + "on Tue,Thu at 04:00#3", + date(2020, 10, 22, 4, 0), + ) + expect( + date(2020, 10, 21, 4, 0), + "on Tue,Thu at 04:00#4", + date(2020, 10, 22, 4, 0), + ) + expect( + date(2020, 10, 22, 4, 0), + "on Tue,Thu at 04:00#5", + date(2020, 10, 27, 4, 0), + ) + + expect( + date(2020, 10, 21, 12, 0), + "on 10/22 at 12:00#1", + date(2020, 10, 22, 12, 0), + ) + expect( + date(2020, 10, 22, 10, 0), + "on 10/22 at 12:00#2", + date(2020, 10, 22, 12, 0), + ) + expect( + date(2020, 10, 22, 12, 0), + "on 10/22 at 12:00#3", + date(2021, 10, 22, 12, 0), + ) + + expect( + date(2020, 10, 21, 12, 0), + "on */22#1", + date(2020, 10, 22, 0, 0), + ) + expect( + date(2020, 10, 22, 0, 0), + "on */22#2", + date(2020, 11, 22, 0, 0), + ) + }