diff --git a/core/cron.go b/core/cron.go index 4287093..e6a017a 100644 --- a/core/cron.go +++ b/core/cron.go @@ -8,57 +8,95 @@ import ( "time" ) +type cronSpec interface { + // Get the next time matching this spec that is after the base time. + GetNextTime(base time.Time) (nextUpdate time.Time) +} + func GetNextSpecTime(lastUpdated time.Time, spec string) (nextUpdate time.Time, err error) { - var nextUpdates []time.Time - switch { - case strings.HasPrefix(spec, "every "): - 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: - // "HH:MM" is implicitly an at-spec "at HH:MM", so see if it's that - nextUpdates, err = parseAtSpec(lastUpdated, spec) - if err != nil { - return time.Time{}, fmt.Errorf("unknown spec format: %v", spec) - } - } + var cron cronSpec + cron, err = ParseCronSpec(spec) if err != nil { return time.Time{}, err } + return cron.GetNextTime(lastUpdated), nil +} + +func ParseCronSpec(spec string) (cron cronSpec, err error) { + switch { + case strings.HasPrefix(spec, "every "): + cron, err = parseEverySpec(spec[len("every "):]) + case strings.HasPrefix(spec, "at "): + cron, err = parseAtSpec(spec[len("at "):]) + case strings.HasPrefix(spec, "on "): + cron, err = parseOnSpec(spec[len("on "):]) + default: + // Interpret "HH:MM" as "at HH:MM" + cron, err = parseAtSpec(spec) + if err != nil { + return nil, fmt.Errorf("unknown spec format: %v", spec) + } + } + return +} + +// Return the time that follows the base time most closely. +func nextAfter(base time.Time, nextUpdates []time.Time) (nextUpdate time.Time) { for _, next := range nextUpdates { - if next.After(lastUpdated) && (nextUpdate.IsZero() || next.Before(nextUpdate)) { + if next.After(base) && (nextUpdate.IsZero() || next.Before(nextUpdate)) { nextUpdate = next } } return } -// Get the next instance of the every-spec after the base time. -// An every-spec is a Go duration string. -func parseEverySpec(base time.Time, everySpec string) (nextUpdates []time.Time, err error) { - var duration time.Duration - duration, err = time.ParseDuration(everySpec) - if err == nil { - next := base.Round(duration) - if !next.After(base) { - next = next.Add(duration) - } - nextUpdates = []time.Time{next} - } - return +// An every-spec is a Go duration string, e.g. "every 5m" +type everySpec struct { + duration time.Duration +} + +func parseEverySpec(spec string) (*everySpec, error) { + duration, err := time.ParseDuration(spec) + if err != nil { + return nil, err + } + return &everySpec{duration}, nil +} + +// Get the next time matching the every-spec that is after the base time. +func (spec *everySpec) GetNextTime(base time.Time) (nextUpdate time.Time) { + next := base.Round(spec.duration) + if !next.After(base) { + next = next.Add(spec.duration) + } + return next } -// 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) { - for _, timeSpec := range strings.Split(atSpec, ",") { +type atSpec struct { + hours []int + minutes []int +} + +func parseAtSpec(spec string) (*atSpec, error) { + var hours, minutes []int + for _, timeSpec := range strings.Split(spec, ",") { var hour, minute int - _, err = fmt.Sscanf(timeSpec, "%d:%d", &hour, &minute) + _, err := fmt.Sscanf(timeSpec, "%d:%d", &hour, &minute) if err != nil { - return nil, fmt.Errorf("could not parse %s: %v", timeSpec, err) + return nil, fmt.Errorf("could not parse at-spec %s: %w", timeSpec, err) } + hours = append(hours, hour) + minutes = append(minutes, minute) + } + return &atSpec{hours, minutes}, nil +} + +// Get the next time matching the at-spec that is after the base time. +func (spec *atSpec) GetNextTime(base time.Time) (nextUpdate time.Time) { + nextUpdates := []time.Time{} + for i, hour := range spec.hours { + minute := spec.minutes[i] // The time instance on the same day as the base time specOfDay := time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, base.Location()) // Bump it forward one day if it's before the base time @@ -67,7 +105,7 @@ func parseAtSpec(base time.Time, atSpec string) (nextUpdates []time.Time, err er } nextUpdates = append(nextUpdates, specOfDay) } - return + return nextAfter(base, nextUpdates) } var weekdays = map[string]time.Weekday{ @@ -131,62 +169,66 @@ func (date *onSpecMonthDay) getDates(base time.Time) (dates []ymd) { return } -// 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 Time struct { - Hour int - Minute int +type onSpec struct { + atSpec atSpec + dates []onSpecDate +} + +func parseOnSpec(spec string) (*onSpec, error) { + atPart := "00:00" + if at := strings.Index(spec, " at "); at > -1 { + atPart = spec[at+len(" at "):] + spec = spec[:at] + } + atSpec, err := parseAtSpec(atPart) + if err != nil { + return nil, fmt.Errorf("could not parse at-spec %v: %w", atSpec, err) } - 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 []ymd - for _, daySpec := range strings.Split(onSpec, ",") { - var date onSpecDate + var dates []onSpecDate + for _, daySpec := range strings.Split(spec, ",") { if weekday, ok := weekdays[daySpec]; ok { - date = &onSpecWeekday{weekday} + dates = append(dates, &onSpecWeekday{weekday}) } else if strings.HasPrefix(daySpec, "*/") { 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) } - date = &onSpecEveryMonth{day} + dates = append(dates, &onSpecEveryMonth{day}) } else { 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) } - date = &onSpecMonthDay{month, day} + dates = append(dates, &onSpecMonthDay{month, day}) } + } + return &onSpec{*atSpec, dates}, nil +} + +// Get the next time matching the on-spec that is after the base time. +func (spec *onSpec) GetNextTime(base time.Time) (nextUpdate time.Time) { + // Convert each type of on-spec date spec to a concrete YMD date + var dates []ymd + for _, date := range spec.dates { dates = append(dates, date.getDates(base)...) } - // Now, for each date, create a datetime based on the at-spec. + // Then, for each date, create a datetime for each at-spec. + nextUpdates := []time.Time{} 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())) + for i, atHour := range spec.atSpec.hours { + atMinute := spec.atSpec.minutes[i] + nextUpdates = append(nextUpdates, time.Date(date.Year, date.Month, date.Day, atHour, atMinute, 0, 0, base.Location())) } } - return + return nextAfter(base, nextUpdates) } type Schedule struct {