github.com/blend/go-sdk@v1.20220411.3/cron/parse_schedule.go (about)

     1  /*
     2  
     3  Copyright (c) 2022 - Present. Blend Labs, Inc. All rights reserved
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file.
     5  
     6  */
     7  
     8  package cron
     9  
    10  import (
    11  	"fmt"
    12  	"sort"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/blend/go-sdk/ex"
    18  )
    19  
    20  /*ParseSchedule parses a cron formatted string into a schedule.
    21  
    22  The string must be at least 5 components, whitespace separated.
    23  If the string has 5 components a 0 will be prepended for the seconds component, and a * appended for the year component.
    24  If the string has 6 components a * appended for the year component.
    25  
    26  The components are (in short form / 5 component):
    27  	(minutes) (hours) (day of month) (month) (day of week)
    28  
    29  The components are (in medium form / 6 component):
    30  	(seconds) (hours) (day of month) (month) (day of week)
    31  
    32  The components are (in long form / 7 component):
    33  	(seconds) (minutes) (hours) (day of month) (month) (day of week) (year)
    34  
    35  The full list of possible field values:
    36  
    37  	Field name     Mandatory?   Allowed values    Allowed special characters
    38  	----------     ----------   --------------    --------------------------
    39  	Seconds        No           0-59              * / , -
    40  	Minutes        Yes          0-59              * / , -
    41  	Hours          Yes          0-23              * / , -
    42  	Day of month   Yes          1-31              * / , - L W
    43  	Month          Yes          1-12 or JAN-DEC   * / , -
    44  	Day of week    Yes          0-6 or SUN-SAT    * / , - L #
    45  	Year           No           1970–2099         * / , -
    46  
    47  You can also use shorthands:
    48  
    49  	"@yearly" is equivalent to "0 0 0 1 1 * *"
    50  	"@monthly" is equivalent to "0 0 0 1 * * *"
    51  	"@weekly" is equivalent to "0 0 0 * * 0 *"
    52  	"@daily" is equivalent to "0 0 0 * * * *"
    53  	"@hourly" is equivalent to "0 0 * * * * *"
    54  	"@every 500ms" is equivalent to "cron.Every(500 * time.Millisecond)""
    55  	"@immediately-then @every 500ms" is equivalent to "cron.Immediately().Then(cron.Every(500*time.Millisecond))"
    56  	"@once-at 2021-06-05 13:04" is "cron.OnceAtUTC(time.Date(...))"
    57  	"@never" is equivalent to an unset schedule (i.e., only on demand) to avoid defaults
    58  
    59  */
    60  func ParseSchedule(cronString string) (schedule Schedule, err error) {
    61  	cronString = strings.TrimSpace(cronString)
    62  
    63  	// check for "@never"
    64  	if cronString == StringScheduleNever {
    65  		schedule = Never()
    66  		return
    67  	}
    68  
    69  	// check for "@immediately"
    70  	if cronString == StringScheduleImmediately {
    71  		schedule = Immediately()
    72  		return
    73  	}
    74  
    75  	// check for "@once-at"
    76  	if strings.HasPrefix(cronString, StringScheduleOnceAt) {
    77  		cronString = strings.TrimPrefix(cronString, StringScheduleOnceAt)
    78  		cronString = strings.TrimSpace(cronString)
    79  
    80  		onceAtUTC, errOnceAtUTC := time.Parse(time.RFC3339, cronString)
    81  		if errOnceAtUTC != nil {
    82  			err = ex.New(ErrStringScheduleInvalid, ex.OptInner(errOnceAtUTC))
    83  			return
    84  		}
    85  		schedule = OnceAtUTC(onceAtUTC.UTC())
    86  		return
    87  	}
    88  
    89  	// here we assume the rest of the schedule is either
    90  	// 	@immediately-then ...
    91  	//  @delay ...
    92  	// 	@immediately-then @delay ...
    93  	// etc.
    94  
    95  	// pull the @immediately-then off the beginning of
    96  	// the schedule if it's present
    97  	var immediately bool
    98  	if strings.HasPrefix(cronString, StringScheduleImmediatelyThen) {
    99  		immediately = true
   100  		cronString = strings.TrimPrefix(cronString, StringScheduleImmediatelyThen)
   101  		cronString = strings.TrimSpace(cronString)
   102  	}
   103  
   104  	var delay time.Duration
   105  	if strings.HasPrefix(cronString, StringScheduleDelay) {
   106  		cronString = strings.TrimPrefix(cronString, StringScheduleDelay)
   107  		cronString = strings.TrimSpace(cronString)
   108  
   109  		remainingFields := strings.Fields(cronString)
   110  		if len(remainingFields) < 2 {
   111  			err = ex.New(ErrStringScheduleInvalid, ex.OptInner(ex.Class("@delay must be in the form `@delay <DURATION> <THEN>`")))
   112  			return
   113  		}
   114  		durationPart := remainingFields[0]
   115  		cronString = strings.TrimPrefix(cronString, durationPart)
   116  		cronString = strings.TrimSpace(cronString)
   117  		delay, err = time.ParseDuration(durationPart)
   118  		if err != nil {
   119  			err = ex.New(ErrStringScheduleInvalid, ex.OptInner(err))
   120  			return
   121  		}
   122  	}
   123  
   124  	// DEFER / FINALLY BLOCK
   125  	// we add the optional immediately or delay
   126  	// directives into the final schedule.
   127  	defer func() {
   128  		if schedule != nil {
   129  			if delay > 0 {
   130  				schedule = Delay(delay, schedule)
   131  			}
   132  			if immediately {
   133  				schedule = Immediately().Then(schedule)
   134  			}
   135  		}
   136  	}()
   137  
   138  	// at this point, the rest of the cron string is considered
   139  	// a complete, single representation of a schedule
   140  
   141  	// handle the specific @every string representation
   142  	if strings.HasPrefix(cronString, StringScheduleEvery) {
   143  		cronString = strings.TrimPrefix(cronString, StringScheduleEvery)
   144  		cronString = strings.TrimSpace(cronString)
   145  		duration, durationErr := time.ParseDuration(cronString)
   146  		if durationErr != nil {
   147  			err = ex.New(ErrStringScheduleInvalid, ex.OptInner(durationErr))
   148  			return
   149  		}
   150  		schedule = Every(duration)
   151  		return
   152  	}
   153  
   154  	// we now assume the string is a 'cron-like' string
   155  	// that is in the form "* * * * *" etc.
   156  
   157  	// check for shorthands, replace the shorthand with a proper cron-like string
   158  	if shorthand, ok := StringScheduleShorthands[cronString]; ok {
   159  		cronString = shorthand
   160  	}
   161  
   162  	parts := strings.Fields(cronString)
   163  	if len(parts) < 5 || len(parts) > 7 {
   164  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(ErrStringScheduleComponents), ex.OptMessagef("provided string; %s", cronString))
   165  	}
   166  	// fill in optional components
   167  	if len(parts) == 5 {
   168  		parts = append([]string{"0"}, parts...)
   169  		parts = append(parts, "*")
   170  	} else if len(parts) == 6 {
   171  		parts = append(parts, "*")
   172  	}
   173  
   174  	seconds, err := parsePart(parts[0], parseInt, below(60))
   175  	if err != nil {
   176  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("seconds invalid"))
   177  	}
   178  
   179  	minutes, err := parsePart(parts[1], parseInt, below(60))
   180  	if err != nil {
   181  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("minutes invalid"))
   182  	}
   183  
   184  	hours, err := parsePart(parts[2], parseInt, below(24))
   185  	if err != nil {
   186  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("hours invalid"))
   187  	}
   188  
   189  	days, err := parsePart(parts[3], parseInt, between(1, 32))
   190  	if err != nil {
   191  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("days invalid"))
   192  	}
   193  
   194  	months, err := parsePart(parts[4], parseMonth, between(1, 13))
   195  	if err != nil {
   196  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("months invalid"))
   197  	}
   198  
   199  	daysOfWeek, err := parsePart(parts[5], parseDayOfWeek, between(0, 7))
   200  	if err != nil {
   201  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("days of week invalid"))
   202  	}
   203  
   204  	years, err := parsePart(parts[6], parseInt, between(1970, 2100))
   205  	if err != nil {
   206  		return nil, ex.New(ErrStringScheduleInvalid, ex.OptInner(err), ex.OptMessage("years invalid"))
   207  	}
   208  
   209  	schedule = &StringSchedule{
   210  		Original:    cronString,
   211  		Seconds:     seconds,
   212  		Minutes:     minutes,
   213  		Hours:       hours,
   214  		DaysOfMonth: days,
   215  		Months:      months,
   216  		DaysOfWeek:  daysOfWeek,
   217  		Years:       years,
   218  	}
   219  	return
   220  }
   221  
   222  // Error Constants
   223  const (
   224  	ErrStringScheduleInvalid         ex.Class = "cron: schedule string invalid"
   225  	ErrStringScheduleComponents      ex.Class = "cron: must have at least (5) components space delimited; ex: '0 0 * * * * *'"
   226  	ErrStringScheduleValueOutOfRange ex.Class = "cron: string schedule part out of range"
   227  	ErrStringScheduleInvalidRange    ex.Class = "cron: range (from-to) invalid"
   228  )
   229  
   230  // String schedule constants
   231  const (
   232  	StringScheduleImmediately     = "@immediately"
   233  	StringScheduleDelay           = "@delay"
   234  	StringScheduleImmediatelyThen = "@immediately-then"
   235  	StringScheduleEvery           = "@every"
   236  	StringScheduleOnceAt          = "@once-at"
   237  	StringScheduleNever           = "@never"
   238  )
   239  
   240  // String schedule shorthands labels
   241  const (
   242  	StringScheduleShorthandAnnually = "@annually"
   243  	StringScheduleShorthandYearly   = "@yearly"
   244  	StringScheduleShorthandMonthly  = "@monthly"
   245  	StringScheduleShorthandWeekly   = "@weekly"
   246  	StringScheduleShorthandDaily    = "@daily"
   247  	StringScheduleShorthandHourly   = "@hourly"
   248  )
   249  
   250  // String schedule shorthand values
   251  var (
   252  	StringScheduleShorthands = map[string]string{
   253  		StringScheduleShorthandAnnually: "0 0 0 1 1 * *",
   254  		StringScheduleShorthandYearly:   "0 0 0 1 1 * *",
   255  		StringScheduleShorthandMonthly:  "0 0 0 1 * * *",
   256  		StringScheduleShorthandDaily:    "0 0 0 * * * *",
   257  		StringScheduleShorthandHourly:   "0 0 * * * * *",
   258  	}
   259  )
   260  
   261  // Interface assertions.
   262  var (
   263  	_ Schedule     = (*StringSchedule)(nil)
   264  	_ fmt.Stringer = (*StringSchedule)(nil)
   265  )
   266  
   267  // StringSchedule is a schedule generated from a cron string.
   268  type StringSchedule struct {
   269  	Original string
   270  
   271  	Seconds     []int
   272  	Minutes     []int
   273  	Hours       []int
   274  	DaysOfMonth []int
   275  	Months      []int
   276  	DaysOfWeek  []int
   277  	Years       []int
   278  }
   279  
   280  // String returns the original string schedule.
   281  func (ss *StringSchedule) String() string {
   282  	return ss.Original
   283  }
   284  
   285  // FullString returns a fully formed string representation of the schedule's components.
   286  // It shows fields as expanded.
   287  func (ss *StringSchedule) FullString() string {
   288  	fields := []string{
   289  		csvOfInts(ss.Seconds, "*"),
   290  		csvOfInts(ss.Minutes, "*"),
   291  		csvOfInts(ss.Hours, "*"),
   292  		csvOfInts(ss.DaysOfMonth, "*"),
   293  		csvOfInts(ss.Months, "*"),
   294  		csvOfInts(ss.DaysOfWeek, "*"),
   295  		csvOfInts(ss.Years, "*"),
   296  	}
   297  	return strings.Join(fields, " ")
   298  }
   299  
   300  // Next implements cron.Schedule.
   301  func (ss *StringSchedule) Next(after time.Time) time.Time {
   302  	working := after
   303  	if after.IsZero() {
   304  		working = Now()
   305  	}
   306  	original := working
   307  
   308  	if len(ss.Years) > 0 {
   309  		for _, year := range ss.Years {
   310  			if year >= working.Year() {
   311  				working = advanceYearTo(working, year)
   312  				break
   313  			}
   314  		}
   315  	}
   316  
   317  	if len(ss.Months) > 0 {
   318  		var didSet bool
   319  		for _, month := range ss.Months {
   320  			if time.Month(month) == working.Month() && working.After(original) {
   321  				didSet = true
   322  				break
   323  			}
   324  			if time.Month(month) > working.Month() {
   325  				working = advanceMonthTo(working, time.Month(month))
   326  				didSet = true
   327  				break
   328  			}
   329  		}
   330  		if !didSet {
   331  			working = advanceYear(working)
   332  			for _, month := range ss.Months {
   333  				if time.Month(month) >= working.Month() {
   334  					working = advanceMonthTo(working, time.Month(month))
   335  					break
   336  				}
   337  			}
   338  		}
   339  	}
   340  
   341  	if len(ss.DaysOfMonth) > 0 {
   342  		var didSet bool
   343  		for _, day := range ss.DaysOfMonth {
   344  			if day == working.Day() && working.After(original) {
   345  				didSet = true
   346  				break
   347  			}
   348  			if day > working.Day() {
   349  				working = advanceDayTo(working, day)
   350  				didSet = true
   351  				break
   352  			}
   353  		}
   354  		if !didSet {
   355  			working = advanceMonth(working)
   356  			for _, day := range ss.DaysOfMonth {
   357  				if day >= working.Day() {
   358  					working = advanceDayTo(working, day)
   359  					break
   360  				}
   361  			}
   362  		}
   363  	}
   364  
   365  	if len(ss.DaysOfWeek) > 0 {
   366  		var didSet bool
   367  		for _, dow := range ss.DaysOfWeek {
   368  			if dow == int(working.Weekday()) && working.After(original) {
   369  				didSet = true
   370  				break
   371  			}
   372  			if dow > int(working.Weekday()) {
   373  				working = advanceDayBy(working, (dow - int(working.Weekday())))
   374  				didSet = true
   375  				break
   376  			}
   377  		}
   378  		if !didSet {
   379  			working = advanceToNextSunday(working)
   380  			for _, dow := range ss.DaysOfWeek {
   381  				if dow >= int(working.Weekday()) {
   382  					working = advanceDayBy(working, (dow - int(working.Weekday())))
   383  					break
   384  				}
   385  			}
   386  		}
   387  	}
   388  
   389  	if len(ss.Hours) > 0 {
   390  		var didSet bool
   391  		for _, hour := range ss.Hours {
   392  			if hour == working.Hour() && working.After(original) {
   393  				didSet = true
   394  				break
   395  			}
   396  			if hour > working.Hour() {
   397  				working = advanceHourTo(working, hour)
   398  				didSet = true
   399  				break
   400  			}
   401  		}
   402  		if !didSet {
   403  			working = advanceDay(working)
   404  			for _, hour := range ss.Hours {
   405  				if hour >= working.Hour() {
   406  					working = advanceHourTo(working, hour)
   407  					break
   408  				}
   409  			}
   410  		}
   411  	}
   412  
   413  	if len(ss.Minutes) > 0 {
   414  		var didSet bool
   415  		for _, minute := range ss.Minutes {
   416  			if minute == working.Minute() && working.After(original) {
   417  				didSet = true
   418  				break
   419  			}
   420  			if minute > working.Minute() {
   421  				working = advanceMinuteTo(working, minute)
   422  				didSet = true
   423  				break
   424  			}
   425  		}
   426  		if !didSet {
   427  			working = advanceHour(working)
   428  			for _, minute := range ss.Minutes {
   429  				if minute >= working.Minute() {
   430  					working = advanceMinuteTo(working, minute)
   431  					break
   432  				}
   433  			}
   434  		}
   435  	}
   436  
   437  	if len(ss.Seconds) > 0 {
   438  		var didSet bool
   439  		for _, second := range ss.Seconds {
   440  			if second == working.Second() && working.After(original) {
   441  				didSet = true
   442  				break
   443  			}
   444  			if second > working.Second() {
   445  				working = advanceSecondTo(working, second)
   446  				didSet = true
   447  				break
   448  			}
   449  		}
   450  		if !didSet {
   451  			working = advanceMinute(working)
   452  			for _, second := range ss.Hours {
   453  				if second >= working.Second() {
   454  					working = advanceSecondTo(working, second)
   455  					break
   456  				}
   457  			}
   458  		}
   459  	}
   460  
   461  	return working
   462  }
   463  
   464  func parsePart(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) {
   465  	if values == string(cronSpecialStar) {
   466  		return nil, nil
   467  	}
   468  
   469  	// check if we need to expand an "every" pattern
   470  	if strings.HasPrefix(values, cronSpecialEvery) {
   471  		return parseEvery(values, parseInt, validator)
   472  	}
   473  
   474  	components := strings.Split(values, string(cronSpecialComma))
   475  
   476  	output := map[int]bool{}
   477  	var component string
   478  	for x := 0; x < len(components); x++ {
   479  		component = components[x]
   480  		if strings.Contains(component, string(cronSpecialDash)) {
   481  			rangeValues, err := parseRange(values, parser, validator)
   482  			if err != nil {
   483  				return nil, err
   484  			}
   485  
   486  			for _, value := range rangeValues {
   487  				output[value] = true
   488  			}
   489  			continue
   490  		}
   491  
   492  		part, err := parser(component)
   493  		if err != nil {
   494  			return nil, ex.New(err)
   495  		}
   496  		if validator != nil && !validator(part) {
   497  			return nil, ex.New(err)
   498  		}
   499  		output[part] = true
   500  	}
   501  	return mapKeysToArray(output), nil
   502  }
   503  
   504  func parseEvery(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) {
   505  	every, err := parser(strings.TrimPrefix(values, "*/"))
   506  	if err != nil {
   507  		return nil, ex.New(err)
   508  	}
   509  	if validator != nil && !validator(every) {
   510  		return nil, ex.New(ErrStringScheduleValueOutOfRange)
   511  	}
   512  
   513  	var output []int
   514  	for x := 0; x < 60; x += every {
   515  		output = append(output, x)
   516  	}
   517  	return output, nil
   518  }
   519  
   520  func parseRange(values string, parser func(string) (int, error), validator func(int) bool) ([]int, error) {
   521  	parts := strings.Split(values, string(cronSpecialDash))
   522  
   523  	if len(parts) != 2 {
   524  		return nil, ex.New(ErrStringScheduleInvalidRange, ex.OptMessagef("invalid range: %s", values))
   525  	}
   526  
   527  	from, err := parser(parts[0])
   528  	if err != nil {
   529  		return nil, ex.New(err)
   530  	}
   531  	to, err := parser(parts[1])
   532  	if err != nil {
   533  		return nil, ex.New(err)
   534  	}
   535  
   536  	if validator != nil && !validator(from) {
   537  		return nil, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessage("invalid range from"))
   538  	}
   539  	if validator != nil && !validator(to) {
   540  		return nil, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessage("invalid range to"))
   541  	}
   542  
   543  	if from >= to {
   544  		return nil, ex.New(ErrStringScheduleInvalidRange, ex.OptMessage("invalid range; from greater than to"))
   545  	}
   546  
   547  	var output []int
   548  	for x := from; x <= to; x++ {
   549  		output = append(output, x)
   550  	}
   551  	return output, nil
   552  }
   553  
   554  func parseInt(s string) (int, error) {
   555  	return strconv.Atoi(s)
   556  }
   557  
   558  func parseMonth(s string) (int, error) {
   559  	if value, ok := validMonths[s]; ok {
   560  		return value, nil
   561  	}
   562  	value, err := strconv.Atoi(s)
   563  	if err != nil {
   564  		return 0, ex.New(err, ex.OptMessage("month not a valid integer"))
   565  	}
   566  	if value < 1 || value > 12 {
   567  		return 0, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessagef("month out of range (1-12): %s", s))
   568  	}
   569  	return value, nil
   570  }
   571  
   572  func parseDayOfWeek(s string) (int, error) {
   573  	if value, ok := validDaysOfWeek[s]; ok {
   574  		return value, nil
   575  	}
   576  	value, err := strconv.Atoi(s)
   577  	if err != nil {
   578  		return 0, ex.New(err, ex.OptMessage("day of week not a valid integer"))
   579  	}
   580  	if value < 0 || value > 6 {
   581  		return 0, ex.New(ErrStringScheduleValueOutOfRange, ex.OptMessagef("day of week out of range (0-6): %s", s))
   582  	}
   583  	return value, nil
   584  }
   585  
   586  func below(max int) func(int) bool {
   587  	return between(0, max)
   588  }
   589  
   590  func between(min, max int) func(int) bool {
   591  	return func(value int) bool {
   592  		return value >= min && value < max
   593  	}
   594  }
   595  
   596  func mapKeysToArray(values map[int]bool) []int {
   597  	output := make([]int, len(values))
   598  	var index int
   599  	for key := range values {
   600  		output[index] = key
   601  		index++
   602  	}
   603  	sort.Ints(output)
   604  	return output
   605  }
   606  
   607  //
   608  // time helpers
   609  //
   610  
   611  func advanceYear(t time.Time) time.Time {
   612  	return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location()).AddDate(1, 0, 0)
   613  }
   614  
   615  func advanceYearTo(t time.Time, year int) time.Time {
   616  	return time.Date(year, 1, 1, 0, 0, 0, 0, t.Location())
   617  }
   618  
   619  func advanceMonth(t time.Time) time.Time {
   620  	return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
   621  }
   622  
   623  func advanceMonthTo(t time.Time, month time.Month) time.Time {
   624  	return time.Date(t.Year(), month, 1, 0, 0, 0, 0, t.Location())
   625  }
   626  
   627  func advanceDayTo(t time.Time, day int) time.Time {
   628  	return time.Date(t.Year(), t.Month(), day, 0, 0, 0, 0, t.Location())
   629  }
   630  
   631  func advanceToNextSunday(t time.Time) time.Time {
   632  	daysUntilSunday := 7 - int(t.Weekday())
   633  	return t.AddDate(0, 0, daysUntilSunday)
   634  }
   635  
   636  func advanceDay(t time.Time) time.Time {
   637  	return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
   638  }
   639  
   640  func advanceDayBy(t time.Time, days int) time.Time {
   641  	return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, days)
   642  }
   643  
   644  func advanceHour(t time.Time) time.Time {
   645  	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour)
   646  }
   647  
   648  func advanceHourTo(t time.Time, hour int) time.Time {
   649  	return time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, t.Location())
   650  }
   651  
   652  func advanceMinute(t time.Time) time.Time {
   653  	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, t.Location()).Add(time.Minute)
   654  }
   655  
   656  func advanceMinuteTo(t time.Time, minute int) time.Time {
   657  	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), minute, 0, 0, t.Location())
   658  }
   659  
   660  func advanceSecondTo(t time.Time, second int) time.Time {
   661  	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), second, 0, t.Location())
   662  }
   663  
   664  func csvOfInts(values []int, placeholder string) string {
   665  	if len(values) == 0 {
   666  		return placeholder
   667  	}
   668  	valueStrings := make([]string, len(values))
   669  	for x := 0; x < len(values); x++ {
   670  		valueStrings[x] = strconv.Itoa(values[x])
   671  	}
   672  	return strings.Join(valueStrings, ",")
   673  }
   674  
   675  // these are special characters
   676  const (
   677  	cronSpecialComma = ',' //
   678  	cronSpecialDash  = '-'
   679  	cronSpecialStar  = '*'
   680  
   681  	// these are unused
   682  	// cronSpecialSlash = '/'
   683  	// cronSpecialQuestion = '?' // sometimes used as the startup time, sometimes as a *
   684  
   685  	// cronSpecialLast       = 'L'
   686  	// cronSpecialWeekday    = 'W' // nearest weekday to the given day of the month
   687  	// cronSpecialDayOfMonth = '#' //
   688  
   689  	cronSpecialEvery = "*/"
   690  )
   691  
   692  var (
   693  	validMonths = map[string]int{
   694  		"JAN": 1,
   695  		"FEB": 2,
   696  		"MAR": 3,
   697  		"APR": 4,
   698  		"MAY": 5,
   699  		"JUN": 6,
   700  		"JUL": 7,
   701  		"AUG": 8,
   702  		"SEP": 9,
   703  		"OCT": 10,
   704  		"NOV": 11,
   705  		"DEC": 12,
   706  	}
   707  
   708  	validDaysOfWeek = map[string]int{
   709  		"SUN": 0,
   710  		"MON": 1,
   711  		"TUE": 2,
   712  		"WED": 3,
   713  		"THU": 4,
   714  		"FRI": 5,
   715  		"SAT": 6,
   716  	}
   717  )