Skip to content

Commit

Permalink
Merge pull request #19 from adhocore/17-support-seconds
Browse files Browse the repository at this point in the history
  • Loading branch information
adhocore authored Apr 6, 2023
2 parents ac7f869 + 01f9c55 commit 46e2160
Show file tree
Hide file tree
Showing 12 changed files with 257 additions and 111 deletions.
50 changes: 35 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![Support](https://img.shields.io/static/v1?label=Support&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/adhocore)
[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Lightweight+fast+and+deps+free+cron+expression+parser+for+Golang&url=https://github.com/adhocore/gronx&hashtags=go,golang,parser,cron,cronexpr,cronparser)

`gronx` is Golang cron expression parser ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) with task runner
`gronx` is Golang [cron expression](#cron-expression) parser ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) with task runner
and daemon that supports crontab like task list file. Use it programatically in Golang or as standalone binary instead of crond.

- Zero dependency.
Expand Down Expand Up @@ -121,6 +121,7 @@ func main() {
```

### Task Daemon

It can also be used as standalone task daemon instead of programmatic usage for Golang application.

First, just install tasker command:
Expand All @@ -139,6 +140,7 @@ tasker -file path/to/taskfile
> You can pass more options to control the behavior of task daemon, see below.
#### Tasker command options:

```txt
-file string <required>
The task file in crontab format
Expand Down Expand Up @@ -167,36 +169,48 @@ tasker -tz America/New_York -file path/to/taskfile -shell zsh # run all tasks us
> Same timezone applies for all tasks currently and it might support overriding timezone per task in future release.
#### Notes on Windows

In Windows if it doesn't find `bash.exe` or `git-bash.exe` it will use `powershell`.
`powershell` may not be compatible with Unix flavored commands. Also to note:
you can't do chaining with `cmd1 && cmd2` but rather `cmd1 ; cmd2`.

---
### Cron Expression

Cron expression normally consists of 5 segments viz:
A complete cron expression consists of 7 segments viz:
```
<second> <minute> <hour> <day> <month> <weekday> <year>
```

However only 5 will do and this is most commonly used. 5 segments are interpreted as:
```
<minute> <hour> <day> <month> <weekday>
```
and sometimes there can be 6th segment for `<year>` at the end.
in which case a default value of 0 is prepended for `<second>` position.

For each segments you can have multiple choices separated by comma:
> Eg: `0,30 * * * *` means either 0th or 30th minute.
In a 6 segments expression, if 6th segment matches `<year>` (i.e 4 digits at least) it will be interpreted as:
```
<minute> <hour> <day> <month> <weekday> <year>
```
and a default value of 0 is prepended for `<second>` position.

For each segments you can have **multiple choices** separated by comma:
> Eg: `0 0,30 * * * *` means either 0th or 30th minute.
To specify range of values you can use dash:
> Eg: `10-15 * * * *` means 10th, 11th, 12th, 13th, 14th and 15th minute.
To specify **range of values** you can use dash:
> Eg: `0 10-15 * * * *` means 10th, 11th, 12th, 13th, 14th and 15th minute.
To specify range of step you can combine a dash and slash:
> Eg: `10-15/2 * * * *` means every 2 minutes between 10 and 15 i.e 10th, 12th and 14th minute.
To specify **range of step** you can combine a dash and slash:
> Eg: `0 10-15/2 * * * *` means every 2 minutes between 10 and 15 i.e 10th, 12th and 14th minute.
For the 3rd and 5th segment, there are additional [modifiers](#modifiers) (optional).
For the `<day>` and `<weekday>` segment, there are additional [**modifiers**](#modifiers) (optional).

And if you want, you can mix them up:
> `5,12-20/4,55 * * * *` matches if any one of `5` or `12-20/4` or `55` matches the minute.
And if you want, you can mix the multiple choices, ranges and steps in a single expression:
> `0 5,12-20/4,55 * * * *` matches if any one of `5` or `12-20/4` or `55` matches the minute.
### Real Abbreviations

You can use real abbreviations for month and week days. eg: `JAN`, `dec`, `fri`, `SUN`
You can use real abbreviations (3 chars) for month and week days. eg: `JAN`, `dec`, `fri`, `SUN`

### Tags

Expand All @@ -212,19 +226,24 @@ Following tags are available and they are converted to real cron expressions bef
- *@15minutes* - every 15 minutes
- *@30minutes* - every 30 minutes
- *@always* - every minute
- *@everysecond* - every second

> For BC reasons, `@always` still means every minute for now, in future release it may mean every seconds instead.
```go
// Use tags like so:
gron.IsDue("@hourly")
gron.IsDue("@5minutes")
```

### Modifiers

Following modifiers supported

- *Day of Month / 3rd segment:*
- *Day of Month / 3rd of 5 segments / 4th of 6+ segments:*
- `L` stands for last day of month (eg: `L` could mean 29th for February in leap year)
- `W` stands for closest week day (eg: `10W` is closest week days (MON-FRI) to 10th date)
- *Day of Week / 5th segment:*
- *Day of Week / 5th of 5 segments / 6th of 6+ segments:*
- `L` stands for last weekday of month (eg: `2L` is last monday)
- `#` stands for nth day of week in the month (eg: `1#2` is second sunday)

Expand All @@ -240,6 +259,7 @@ release managed by [please](https://github.com/adhocore/please).

---
### Other projects

My other golang projects you might find interesting and useful:

- [**urlsh**](https://github.com/adhocore/urlsh) - URL shortener and bookmarker service with UI, API, Cache, Hits Counter and forwarder using postgres and redis in backend, bulma in frontend; has [web](https://urlssh.xyz) and cli client
Expand Down
56 changes: 29 additions & 27 deletions checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ func (c *SegmentChecker) SetRef(ref time.Time) {
func (c *SegmentChecker) CheckDue(segment string, pos int) (due bool, err error) {
ref, last := c.GetRef(), -1
val, loc := valueByPos(ref, pos), ref.Location()
isMonth, isWeekDay := pos == 3, pos == 5

for _, offset := range strings.Split(segment, ",") {
mod := (pos == 2 || pos == 4) && strings.ContainsAny(offset, "LW#")
mod := (isMonth || isWeekDay) && strings.ContainsAny(offset, "LW#")
if due, err = c.isOffsetDue(offset, val, pos); due || (!mod && err != nil) {
return
}
Expand All @@ -46,9 +47,9 @@ func (c *SegmentChecker) CheckDue(segment string, pos int) (due bool, err error)
if last == -1 {
last = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, 1, 0).Add(-time.Nanosecond).Day()
}
if pos == 2 {
if isMonth {
due, err = isValidMonthDay(offset, last, ref)
} else if pos == 4 {
} else if isWeekDay {
due, err = isValidWeekDay(offset, last, ref)
}
if due || err != nil {
Expand All @@ -64,18 +65,18 @@ func (c *SegmentChecker) isOffsetDue(offset string, val, pos int) (bool, error)
return true, nil
}

bounds := boundsByPos(pos)
bounds, isWeekDay := boundsByPos(pos), pos == 5
if strings.Contains(offset, "/") {
return inStep(val, offset, bounds)
}
if strings.Contains(offset, "-") {
if pos == 4 {
if isWeekDay {
offset = strings.Replace(offset, "7-", "0-", 1)
}
return inRange(val, offset, bounds)
}

if pos != 4 && (val == 0 || offset == "0") {
if !isWeekDay && (val == 0 || offset == "0") {
return offset == "0" && val == 0, nil
}

Expand All @@ -87,43 +88,44 @@ func (c *SegmentChecker) isOffsetDue(offset string, val, pos int) (bool, error)
return false, fmt.Errorf("segment#%d: '%s' out of bounds(%d, %d)", pos, offset, bounds[0], bounds[1])
}

return nval == val || (pos == 4 && nval == 7 && val == 0), nil
return nval == val || (isWeekDay && nval == 7 && val == 0), nil
}

func valueByPos(ref time.Time, pos int) int {
func valueByPos(ref time.Time, pos int) (val int) {
switch pos {
case 0:
return ref.Minute()
val = ref.Second()
case 1:
return ref.Hour()
val = ref.Minute()
case 2:
return ref.Day()
val = ref.Hour()
case 3:
return int(ref.Month())
val = ref.Day()
case 4:
return int(ref.Weekday())
val = int(ref.Month())
case 5:
return ref.Year()
val = int(ref.Weekday())
case 6:
val = ref.Year()
}

return 0
return
}

func boundsByPos(pos int) []int {
func boundsByPos(pos int) (bounds []int) {
bounds = []int{0, 0}
switch pos {
case 0:
return []int{0, 59}
case 1:
return []int{0, 23}
case 0, 1:
bounds = []int{0, 59}
case 2:
return []int{1, 31}
bounds = []int{0, 23}
case 3:
return []int{1, 12}
bounds = []int{1, 31}
case 4:
return []int{0, 7}
bounds = []int{1, 12}
case 5:
return []int{1, 9999}
bounds = []int{0, 7}
case 6:
bounds = []int{1, 9999}
}

return []int{0, 0}
return
}
33 changes: 27 additions & 6 deletions gronx.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ var expressions = map[string]string{
"@10minutes": "*/10 * * * *",
"@15minutes": "*/15 * * * *",
"@30minutes": "0,30 * * * *",

"@everysecond": "* * * * * *",
}

// SpaceRe is regex for whitespace.
var SpaceRe = regexp.MustCompile(`\s+`)
var yearRe = regexp.MustCompile(`\d{4}`)

func normalize(expr string) []string {
expr = strings.Trim(expr, " \t")
Expand Down Expand Up @@ -70,20 +73,28 @@ func (g *Gronx) IsDue(expr string, ref ...time.Time) (bool, error) {
}

// Segments splits expr into array array of cron parts.
// If expression contains 5 parts or 6th part is year like, it prepends a second.
// It returns array or error.
func Segments(expr string) ([]string, error) {
segs := normalize(expr)
if len(segs) < 5 || len(segs) > 6 {
return []string{}, errors.New("expr should contain 5-6 segments separated by space")
slen := len(segs)
if slen < 5 || slen > 7 {
return []string{}, errors.New("expr should contain 5-7 segments separated by space")
}

// Prepend second if required
prepend := slen == 5 || (slen == 6 && yearRe.MatchString(segs[5]))
if prepend {
segs = append([]string{"0"}, segs...)
}

return segs, nil
}

// SegmentsDue checks if all cron parts are due.
// It returns bool. You should use IsDue(expr) instead.
func (g *Gronx) SegmentsDue(segments []string) (bool, error) {
for pos, seg := range segments {
func (g *Gronx) SegmentsDue(segs []string) (bool, error) {
for pos, seg := range segs {
if seg == "*" || seg == "?" {
continue
}
Expand All @@ -99,7 +110,17 @@ func (g *Gronx) SegmentsDue(segments []string) (bool, error) {
// IsValid checks if cron expression is valid.
// It returns bool.
func (g *Gronx) IsValid(expr string) bool {
_, err := g.IsDue(expr)
segs, err := Segments(expr)
if err != nil {
return false
}

g.C.SetRef(time.Now())
for pos, seg := range segs {
if _, err := g.C.CheckDue(seg, pos); err != nil {
return false
}
}

return err == nil
return true
}
Loading

0 comments on commit 46e2160

Please sign in to comment.