pax_global_header00006660000000000000000000000064150167147320014520gustar00rootroot0000000000000052 comment=6ce0b1051b8763e7ef58f44581f3894c0b998d71 golang-github-adhocore-gronx-1.19.6/000077500000000000000000000000001501671473200172625ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/.editorconfig000066400000000000000000000002741501671473200217420ustar00rootroot00000000000000root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.go] indent_style = tab tab_width = 2 golang-github-adhocore-gronx-1.19.6/.github/000077500000000000000000000000001501671473200206225ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/.github/CODEOWNERS000066400000000000000000000000221501671473200222070ustar00rootroot00000000000000* @adhocore golang-github-adhocore-gronx-1.19.6/.github/FUNDING.yml000066400000000000000000000000641501671473200224370ustar00rootroot00000000000000github: adhocore custom: ['https://paypal.me/ji10'] golang-github-adhocore-gronx-1.19.6/.github/workflows/000077500000000000000000000000001501671473200226575ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/.github/workflows/codeql-analysis.yml000066400000000000000000000012761501671473200265000ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ main ] pull_request: branches: [ main ] schedule: - cron: '20 15 * * 6' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] steps: - name: Checkout repository uses: actions/checkout@v2 - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@v1 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 golang-github-adhocore-gronx-1.19.6/.github/workflows/lint-action.yml000066400000000000000000000004671501671473200256320ustar00rootroot00000000000000name: Lint on: [push] jobs: golint: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Lint id: golint uses: Jerome1337/go-action/lint@master - name: Lint Output run: echo "${{ steps.golint.outputs.golint-output }}" golang-github-adhocore-gronx-1.19.6/.github/workflows/release.yml000066400000000000000000000011101501671473200250130ustar00rootroot00000000000000name: Release on: push: tags: - '*' permissions: contents: write # packages: write # issues: write jobs: goreleaser: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - run: git fetch --force --tags - uses: actions/setup-go@v4 with: go-version: stable - uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} golang-github-adhocore-gronx-1.19.6/.github/workflows/test-action.yml000066400000000000000000000010631501671473200256340ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: test: strategy: matrix: go-version: [1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v2 - name: Install Go uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} - name: Test run: go test -cover -coverprofile=coverage.txt -covermode=atomic ./... - name: Cov run: bash <(curl -s https://codecov.io/bash) golang-github-adhocore-gronx-1.19.6/.gitignore000066400000000000000000000001101501671473200212420ustar00rootroot00000000000000.idea/ .DS_Store *~ *.out vendor/ dist/ .env bin/ *.php test/*.go *.txt golang-github-adhocore-gronx-1.19.6/.goreleaser.yml000066400000000000000000000022331501671473200222130ustar00rootroot00000000000000 # yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 project_name: tasker release: prerelease: auto name_template: "Version v{{.Version}}" # draft: true mode: "keep-existing" before: hooks: - go mod tidy builds: - id: macOS binary: bin/tasker main: ./cmd/tasker ldflags: - -X main.Version={{.Version}} env: - CGO_ENABLED=0 goos: [darwin] goarch: [amd64, arm64] - id: linux main: ./cmd/tasker goos: [linux] goarch: ["386", arm, amd64, arm64] - id: windows main: ./cmd/tasker goos: [windows] goarch: [amd64] archives: - id: nix builds: [macOS, linux] name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" wrap_in_directory: true format: tar.gz files: - LICENSE - id: windows builds: [windows] wrap_in_directory: false format: zip files: - LICENSE checksum: name_template: 'checksums.txt' algorithm: sha256 changelog: disable: true use: github sort: desc filters: exclude: - '^doc:' - '^dev:' - '^build:' - '^ci:' golang-github-adhocore-gronx-1.19.6/CHANGELOG.md000066400000000000000000000134301501671473200210740ustar00rootroot00000000000000## [v0.2.7](https://github.com/adhocore/gronx/releases/tag/v0.2.7) (2022-06-28) ### Miscellaneous - **Workflow**: Run tests on 1.18x (Jitendra) - Tests for go v1.17.x, add codecov (Jitendra) ## [v0.2.6](https://github.com/adhocore/gronx/releases/tag/v0.2.6) (2021-10-14) ### Miscellaneous - Fix 'with' languages (Jitendra Adhikari) [_a813b55_](https://github.com/adhocore/gronx/commit/a813b55) - Init/setup github codeql (Jitendra Adhikari) [_fe2aa5a_](https://github.com/adhocore/gronx/commit/fe2aa5a) ## [v0.2.5](https://github.com/adhocore/gronx/releases/tag/v0.2.5) (2021-07-25) ### Bug Fixes - **Tasker**: The clause should be using OR (Jitendra Adhikari) [_b813b85_](https://github.com/adhocore/gronx/commit/b813b85) ## [v0.2.4](https://github.com/adhocore/gronx/releases/tag/v0.2.4) (2021-05-05) ### Features - **Pkg.tasker**: Capture cmd output in tasker logger, error in stderr (Jitendra Adhikari) [_0da0aae_](https://github.com/adhocore/gronx/commit/0da0aae) ### Internal Refactors - **Cmd.tasker**: Taskify is now method of tasker (Jitendra Adhikari) [_8b1373b_](https://github.com/adhocore/gronx/commit/8b1373b) ## [v0.2.3](https://github.com/adhocore/gronx/releases/tag/v0.2.3) (2021-05-04) ### Bug Fixes - **Pkg.tasker**: Sleep 100ms so abort can be bailed asap, remove dup msg (Jitendra Adhikari) [_d868920_](https://github.com/adhocore/gronx/commit/d868920) ### Miscellaneous - Allow leeway period at the end (Jitendra Adhikari) [_5ebf923_](https://github.com/adhocore/gronx/commit/5ebf923) ## [v0.2.2](https://github.com/adhocore/gronx/releases/tag/v0.2.2) (2021-05-03) ### Bug Fixes - **Pkg.tasker**: DoRun checks if timed out before run (Jitendra Adhikari) [_f27a657_](https://github.com/adhocore/gronx/commit/f27a657) ### Internal Refactors - **Pkg.tasker**: Use dateFormat var, update final tick phrase (Jitendra Adhikari) [_fad0271_](https://github.com/adhocore/gronx/commit/fad0271) ## [v0.2.1](https://github.com/adhocore/gronx/releases/tag/v0.2.1) (2021-05-02) ### Bug Fixes - **Pkg.tasker**: Deprecate sleep dur if next tick timeout (Jitendra Adhikari) [_3de45a1_](https://github.com/adhocore/gronx/commit/3de45a1) ## [v0.2.0](https://github.com/adhocore/gronx/releases/tag/v0.2.0) (2021-05-02) ### Features - **Cmd.tasker**: Add tasker for standalone usage as task daemon (Jitendra Adhikari) [_0d99409_](https://github.com/adhocore/gronx/commit/0d99409) - **Pkg.tasker**: Add parser for tasker pkg (Jitendra Adhikari) [_e7f1811_](https://github.com/adhocore/gronx/commit/e7f1811) - **Pkg.tasker**: Add tasker pkg (Jitendra Adhikari) [_a57b1c4_](https://github.com/adhocore/gronx/commit/a57b1c4) ### Bug Fixes - **Pkg.tasker**: Use log.New() instead (Jitendra Adhikari) [_0cf2c07_](https://github.com/adhocore/gronx/commit/0cf2c07) - **Validator**: This check is not really required (Jitendra Adhikari) [_c3d75e3_](https://github.com/adhocore/gronx/commit/c3d75e3) ### Internal Refactors - **Gronx**: Add public methods for internal usage, expose spaceRe (Jitendra Adhikari) [_94eb20b_](https://github.com/adhocore/gronx/commit/94eb20b) ### Miscellaneous - **Pkg.tasker**: Use file perms as octal (Jitendra Adhikari) [_83f258d_](https://github.com/adhocore/gronx/commit/83f258d) - **Workflow**: Include all tests in action (Jitendra Adhikari) [_7328cbf_](https://github.com/adhocore/gronx/commit/7328cbf) ### Documentations - Add task mangager and tasker docs/usages (Jitendra Adhikari) [_e77aa5f_](https://github.com/adhocore/gronx/commit/e77aa5f) ## [v0.1.4](https://github.com/adhocore/gronx/releases/tag/v0.1.4) (2021-04-25) ### Miscellaneous - **Mod**: 1.13 is okay too (Jitendra Adhikari) [_6c328e7_](https://github.com/adhocore/gronx/commit/6c328e7) - Try go 1.13.x (Jitendra Adhikari) [_b017ec4_](https://github.com/adhocore/gronx/commit/b017ec4) ### Documentations - Practical usage (Jitendra Adhikari) [_9572e61_](https://github.com/adhocore/gronx/commit/9572e61) ## [v0.1.3](https://github.com/adhocore/gronx/releases/tag/v0.1.3) (2021-04-22) ### Internal Refactors - **Checker**: Preserve error, for pos 2 & 4 bail only on due or err (Jitendra Adhikari) [_39a9cd5_](https://github.com/adhocore/gronx/commit/39a9cd5) - **Validator**: Do not discard error from strconv (Jitendra Adhikari) [_3b0f444_](https://github.com/adhocore/gronx/commit/3b0f444) ## [v0.1.2](https://github.com/adhocore/gronx/releases/tag/v0.1.2) (2021-04-21) ### Features - Add IsValid() (Jitendra Adhikari) [_150687b_](https://github.com/adhocore/gronx/commit/150687b) ### Documentations - IsValid usage (Jitendra Adhikari) [_b747116_](https://github.com/adhocore/gronx/commit/b747116) ## [v0.1.1](https://github.com/adhocore/gronx/releases/tag/v0.1.1) (2021-04-21) ### Features - Add main gronx api (Jitendra Adhikari) [_1b3b108_](https://github.com/adhocore/gronx/commit/1b3b108) - Add cron segment checker (Jitendra Adhikari) [_a56be7c_](https://github.com/adhocore/gronx/commit/a56be7c) - Add validator (Jitendra Adhikari) [_455a024_](https://github.com/adhocore/gronx/commit/455a024) ### Miscellaneous - **Workflow**: Update actions (Jitendra Adhikari) [_8b54cc3_](https://github.com/adhocore/gronx/commit/8b54cc3) - Init module (Jitendra Adhikari) [_bada37d_](https://github.com/adhocore/gronx/commit/bada37d) - Add license (Jitendra Adhikari) [_5f20b96_](https://github.com/adhocore/gronx/commit/5f20b96) - **Gh**: Add meta files (Jitendra Adhikari) [_35a1310_](https://github.com/adhocore/gronx/commit/35a1310) - **Workflow**: Add lint/test actions (Jitendra Adhikari) [_884d5cb_](https://github.com/adhocore/gronx/commit/884d5cb) - Add editorconfig (Jitendra Adhikari) [_8b75494_](https://github.com/adhocore/gronx/commit/8b75494) ### Documentations - On cron expressions (Jitendra Adhikari) [_547fd72_](https://github.com/adhocore/gronx/commit/547fd72) - Add readme (Jitendra Adhikari) [_3955e88_](https://github.com/adhocore/gronx/commit/3955e88) golang-github-adhocore-gronx-1.19.6/LICENSE000066400000000000000000000020671501671473200202740ustar00rootroot00000000000000MIT License Copyright (c) 2021-2099 Jitendra Adhikari Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-adhocore-gronx-1.19.6/README.md000066400000000000000000000277551501671473200205610ustar00rootroot00000000000000# adhocore/gronx [![Latest Version](https://img.shields.io/github/release/adhocore/gronx.svg?style=flat-square)](https://github.com/adhocore/gronx/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Go Report](https://goreportcard.com/badge/github.com/adhocore/gronx)](https://goreportcard.com/report/github.com/adhocore/gronx) [![Test](https://github.com/adhocore/gronx/actions/workflows/test-action.yml/badge.svg)](https://github.com/adhocore/gronx/actions/workflows/test-action.yml) [![Lint](https://github.com/adhocore/gronx/actions/workflows/lint-action.yml/badge.svg)](https://github.com/adhocore/gronx/actions/workflows/lint-action.yml) [![Codecov](https://img.shields.io/codecov/c/github/adhocore/gronx/main.svg?style=flat-square)](https://codecov.io/gh/adhocore/gronx) [![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](#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. If that's not enough, you can use gronx to find the next (`NextTick()`) or previous (`PrevTick()`) run time of an expression from any arbitrary point of time. - Zero dependency. - Very **fast** because it bails early in case a segment doesn't match. - Built in crontab like daemon. - Supports time granularity of Seconds. Find gronx in [pkg.go.dev](https://pkg.go.dev/github.com/adhocore/gronx). ## Installation ```sh go get -u github.com/adhocore/gronx ``` ## Usage ```go import ( "time" "github.com/adhocore/gronx" ) gron := gronx.New() expr := "* * * * *" // check if expr is even valid, returns bool gron.IsValid(expr) // true // check if expr is due for current time, returns bool and error gron.IsDue(expr) // true|false, nil // check if expr is due for given time gron.IsDue(expr, time.Date(2021, time.April, 1, 1, 1, 0, 0, time.UTC)) // true|false, nil ``` > Validity can be checked without instantiation: ```go import "github.com/adhocore/gronx" gronx.IsValid("* * * * *") // true ``` ### Batch Due Check If you have multiple cron expressions to check due on same reference time use `BatchDue()`: ```go gron := gronx.New() exprs := []string{"* * * * *", "0 */5 * * * *"} // gives []gronx.Expr{} array, each item has Due flag and Err enountered. dues := gron.BatchDue(exprs) for _, expr := range dues { if expr.Err != nil { // Handle err } else if expr.Due { // Handle due } } // Or with given time ref := time.Now() gron.BatchDue(exprs, ref) ``` ### Next Tick To find out when is the cron due next (in near future): ```go allowCurrent = true // includes current time as well nextTime, err := gronx.NextTick(expr, allowCurrent) // gives time.Time, error // OR, next tick after certain reference time refTime = time.Date(2022, time.November, 1, 1, 1, 0, 0, time.UTC) allowCurrent = false // excludes the ref time nextTime, err := gronx.NextTickAfter(expr, refTime, allowCurrent) // gives time.Time, error ``` ### Prev Tick To find out when was the cron due previously (in near past): ```go allowCurrent = true // includes current time as well prevTime, err := gronx.PrevTick(expr, allowCurrent) // gives time.Time, error // OR, prev tick before certain reference time refTime = time.Date(2022, time.November, 1, 1, 1, 0, 0, time.UTC) allowCurrent = false // excludes the ref time nextTime, err := gronx.PrevTickBefore(expr, refTime, allowCurrent) // gives time.Time, error ``` > The working of `PrevTick*()` and `NextTick*()` are mostly the same except the direction. > They differ in lookback or lookahead. ### Standalone Daemon In a more practical level, you would use this tool to manage and invoke jobs in app itself and not mess around with `crontab` for each and every new tasks/jobs. In crontab just put one entry with `* * * * *` which points to your Go entry point that uses this tool. Then in that entry point you would invoke different tasks if the corresponding Cron expr is due. Simple map structure would work for this. Check the section below for more sophisticated way of managing tasks automatically using `gronx` daemon called `tasker`. --- ### Go Tasker Tasker is a task manager that can be programatically used in Golang applications. It runs as a daemon and invokes tasks scheduled with cron expression: ```go package main import ( "context" "time" "github.com/adhocore/gronx/pkg/tasker" ) func main() { taskr := tasker.New(tasker.Option{ Verbose: true, // optional: defaults to local Tz: "Asia/Bangkok", // optional: defaults to stderr log stream Out: "/full/path/to/output-file", }) // add task to run every minute taskr.Task("* * * * *", func(ctx context.Context) (int, error) { // do something ... // then return exit code and error, for eg: if everything okay return 0, nil }).Task("*/5 * * * *", func(ctx context.Context) (int, error) { // every 5 minutes // you can also log the output to Out file as configured in Option above: taskr.Log.Printf("done something in %d s", 2) return 0, nil }) // run task without overlap, set concurrent flag to false: concurrent := false taskr.Task("* * * * * *", , tasker.Taskify("sleep 2", tasker.Option{}), concurrent) // every 10 minute with arbitrary command taskr.Task("@10minutes", taskr.Taskify("command --option val -- args", tasker.Option{Shell: "/bin/sh -c"})) // ... add more tasks // optionally if you want tasker to stop after 2 hour, pass the duration with Until(): taskr.Until(2 * time.Hour) // finally run the tasker, it ticks sharply on every minute and runs all the tasks due on that time! // it exits gracefully when ctrl+c is received making sure pending tasks are completed. taskr.Run() } ``` #### Concurrency By default the tasks can run concurrently i.e if previous run is still not finished but it is now due again, it will run again. If you want to run only one instance of a task at a time, set concurrent flag to false: ```go taskr := tasker.New(tasker.Option{}) concurrent := false expr, task := "* * * * * *", tasker.Taskify("php -r 'sleep(2);'") taskr.Task(expr, task, concurrent) ``` ### Task Daemon It can also be used as standalone task daemon instead of programmatic usage for Golang application. First, just install tasker command: ```sh go install github.com/adhocore/gronx/cmd/tasker@latest ``` Or you can also download latest prebuilt binary from [release](https://github.com/adhocore/gronx/releases/latest) for platform of your choice. Then prepare a taskfile ([example](./tests/../test/taskfile.txt)) in crontab format (or can even point to existing crontab). > `user` is not supported: it is just cron expr followed by the command. Finally run the task daemon like so ``` 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 The task file in crontab format -out string The fullpath to file where output from tasks are sent to -shell string The shell to use for running tasks (default "/usr/bin/bash") -tz string The timezone to use for tasks (default "Local") -until int The timeout for task daemon in minutes -verbose The verbose mode outputs as much as possible ``` Examples: ```sh tasker -verbose -file path/to/taskfile -until 120 # run until next 120min (i.e 2hour) with all feedbacks echoed back tasker -verbose -file path/to/taskfile -out path/to/output # with all feedbacks echoed to the output file tasker -tz America/New_York -file path/to/taskfile -shell zsh # run all tasks using zsh shell based on NY timezone ``` > File extension of taskfile for (`-file` option) does not matter: can be any or none. > The directory for outfile (`-out` option) must exist, file is created by task daemon. > 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 A complete cron expression consists of 7 segments viz: ``` ``` However only 5 will do and this is most commonly used. 5 segments are interpreted as: ``` ``` in which case a default value of 0 is prepended for `` position. In a 6 segments expression, if 6th segment matches `` (i.e 4 digits at least) it will be interpreted as: ``` ``` and a default value of 0 is prepended for `` 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: `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: `0 10-15/2 * * * *` means every 2 minutes between 10 and 15 i.e 10th, 12th and 14th minute. For the `` and `` segment, there are additional [**modifiers**](#modifiers) (optional). 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 (3 chars) for month and week days. eg: `JAN`, `dec`, `fri`, `SUN` ### Tags Following tags are available and they are converted to real cron expressions before parsing: - *@yearly* or *@annually* - every year - *@monthly* - every month - *@daily* - every day - *@weekly* - every week - *@hourly* - every hour - *@5minutes* - every 5 minutes - *@10minutes* - every 10 minutes - *@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 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 of 5 segments / 6th of 6+ segments:* - `L` stands for last weekday of month (eg: `2L` is last tuesday) - `#` stands for nth day of week in the month (eg: `1#2` is second monday) --- ## License > © [MIT](./LICENSE) | 2021-2099, Jitendra Adhikari ## Credits This project is ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) and 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 - [**fast**](https://github.com/adhocore/fast) - Check your internet speed with ease and comfort right from the terminal - [**goic**](https://github.com/adhocore/goic) - Go Open ID Connect, is OpenID connect client library for Golang, supports the Authorization Code Flow of OpenID Connect specification. - [**chin**](https://github.com/adhocore/chin) - A Go lang command line tool to show a spinner as user waits for some long running jobs to finish. golang-github-adhocore-gronx-1.19.6/VERSION000066400000000000000000000000071501671473200203270ustar00rootroot00000000000000v0.2.7 golang-github-adhocore-gronx-1.19.6/batch.go000066400000000000000000000020011501671473200206630ustar00rootroot00000000000000package gronx import ( "strings" "time" ) // Expr represents an item in array for batch check type Expr struct { Err error Expr string Due bool } // BatchDue checks if multiple expressions are due for given time (or now). // It returns []Expr with filled in Due and Err values. func (g *Gronx) BatchDue(exprs []string, ref ...time.Time) []Expr { ref = append(ref, time.Now()) g.C.SetRef(ref[0]) var segs []string cache, batch := map[string]Expr{}, make([]Expr, len(exprs)) for i := range exprs { batch[i].Expr = exprs[i] segs, batch[i].Err = Segments(exprs[i]) key := strings.Join(segs, " ") if batch[i].Err != nil { cache[key] = batch[i] continue } if c, ok := cache[key]; ok { batch[i] = c batch[i].Expr = exprs[i] continue } due := true for pos, seg := range segs { if seg != "*" && seg != "?" { if due, batch[i].Err = g.C.CheckDue(seg, pos); !due || batch[i].Err != nil { break } } } batch[i].Due = due cache[key] = batch[i] } return batch } golang-github-adhocore-gronx-1.19.6/batch_test.go000066400000000000000000000017241501671473200217350ustar00rootroot00000000000000package gronx import ( "fmt" "testing" "time" ) func TestBatch(t *testing.T) { gron := New() t.Run("batch no error", func(t *testing.T) { ref := time.Now() exprs := []string{"@everysecond", "* * * * * *", "* * * * * *"} exprs = append(exprs, fmt.Sprintf("* %d * * * * %d", ref.Minute(), ref.Year())) exprs = append(exprs, fmt.Sprintf("* * * * * * %d-%d", ref.Year()-1, ref.Year()+1)) for _, expr := range gron.BatchDue(exprs) { if expr.Err != nil { t.Errorf("%s error: %#v", expr.Expr, expr.Err) } if !expr.Due { t.Errorf("%s must be due", expr.Expr) } } }) t.Run("batch error", func(t *testing.T) { exprs := []string{"* * * *", "A B C D E F"} ref, _ := time.Parse(FullDateFormat, "2022-02-02 02:02:02") for _, expr := range gron.BatchDue(exprs, ref) { if expr.Err == nil { t.Errorf("%s expected error", expr.Expr) } if expr.Due { t.Errorf("%s must not be due when there is error", expr.Expr) } } }) } golang-github-adhocore-gronx-1.19.6/checker.go000066400000000000000000000054771501671473200212320ustar00rootroot00000000000000package gronx import ( "fmt" "strconv" "strings" "time" ) // Checker is interface for cron segment due check. type Checker interface { GetRef() time.Time SetRef(ref time.Time) CheckDue(segment string, pos int) (bool, error) } // SegmentChecker is factory implementation of Checker. type SegmentChecker struct { ref time.Time } // GetRef returns the current reference time func (c *SegmentChecker) GetRef() time.Time { return c.ref } // SetRef sets the reference time for which to check if a cron expression is due. func (c *SegmentChecker) SetRef(ref time.Time) { c.ref = ref } // CheckDue checks if the cron segment at given position is due. // It returns bool or error if any. func (c *SegmentChecker) CheckDue(segment string, pos int) (due bool, err error) { ref, last := c.GetRef(), -1 val, loc := valueByPos(ref, pos), ref.Location() isMonthDay, isWeekDay := pos == 3, pos == 5 for _, offset := range strings.Split(segment, ",") { mod := (isMonthDay || isWeekDay) && strings.ContainsAny(offset, "LW#") if due, err = c.isOffsetDue(offset, val, pos); due || (!mod && err != nil) { return } if !mod { continue } if last == -1 { last = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, 1, 0).Add(-time.Second).Day() } if isMonthDay { due, err = isValidMonthDay(offset, last, ref) } else if isWeekDay { due, err = isValidWeekDay(offset, last, ref) } if due || err != nil { return due, err } } return false, nil } func (c *SegmentChecker) isOffsetDue(offset string, val, pos int) (bool, error) { if offset == "*" || offset == "?" { return true, nil } bounds, isWeekDay := boundsByPos(pos), pos == 5 if strings.Contains(offset, "/") { return inStep(val, offset, bounds) } if strings.Contains(offset, "-") { if isWeekDay { offset = strings.Replace(offset, "7-", "0-", 1) } return inRange(val, offset, bounds) } nval, err := strconv.Atoi(offset) if err != nil { return false, err } if nval < bounds[0] || nval > bounds[1] { return false, fmt.Errorf("segment#%d: '%s' out of bounds(%d, %d)", pos, offset, bounds[0], bounds[1]) } if !isWeekDay && (val == 0 || nval == 0) { return nval == 0 && val == 0, nil } return nval == val || (isWeekDay && nval == 7 && val == 0), nil } func valueByPos(ref time.Time, pos int) (val int) { switch pos { case 0: val = ref.Second() case 1: val = ref.Minute() case 2: val = ref.Hour() case 3: val = ref.Day() case 4: val = int(ref.Month()) case 5: val = int(ref.Weekday()) case 6: val = ref.Year() } return } func boundsByPos(pos int) (bounds []int) { bounds = []int{0, 0} switch pos { case 0, 1: bounds = []int{0, 59} case 2: bounds = []int{0, 23} case 3: bounds = []int{1, 31} case 4: bounds = []int{1, 12} case 5: bounds = []int{0, 7} case 6: bounds = []int{0, 9999} } return } golang-github-adhocore-gronx-1.19.6/cmd/000077500000000000000000000000001501671473200200255ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/cmd/tasker/000077500000000000000000000000001501671473200213165ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/cmd/tasker/main.go000066400000000000000000000025301501671473200225710ustar00rootroot00000000000000package main import ( "flag" "fmt" "log" "os" "time" "github.com/adhocore/gronx/pkg/tasker" ) var exit = os.Exit var tick = time.Minute var opt tasker.Option var v bool // Version of tasker, injected in build var Version = "n/a" func init() { flag.StringVar(&opt.File, "file", "", "The task file in crontab format (without user)") flag.StringVar(&opt.Tz, "tz", "Local", "The timezone to use for tasks") flag.StringVar(&opt.Shell, "shell", tasker.Shell()[0], "The shell to use for running tasks") flag.StringVar(&opt.Out, "out", "", "The fullpath to file where output from tasks are sent to") flag.BoolVar(&opt.Verbose, "verbose", false, "The verbose mode outputs as much as possible") flag.Int64Var(&opt.Until, "until", 0, "The timeout for task daemon in minutes") flag.BoolVar(&v, "v", false, "Show version") } func main() { mustParseOption() taskr := tasker.New(opt) for _, task := range tasker.MustParseTaskfile(opt) { taskr.Task(task.Expr, taskr.Taskify(task.Cmd, opt)) } if opt.Until > 0 { taskr.Until(time.Duration(opt.Until) * tick) } taskr.Run() } func mustParseOption() { opt = tasker.Option{} flag.Parse() if v { fmt.Printf("v%s\n", Version) exit(0) } if opt.File == "" { flag.Usage() exit(1) } if _, err := os.Stat(opt.File); err != nil { log.Printf("can't read taskfile: %s", opt.File) exit(1) } } golang-github-adhocore-gronx-1.19.6/cmd/tasker/main_test.go000066400000000000000000000022141501671473200236270ustar00rootroot00000000000000package main import ( "os" "testing" "time" "github.com/adhocore/gronx/pkg/tasker" ) func TestMustGetOption(t *testing.T) { old := os.Args exit = func (code int) {} t.Run("Main", func(t *testing.T) { expect := tasker.Option{File: "../../test/taskfile.txt", Out: "../../test/out.txt"} os.Args = append(old, "-verbose", "-file", expect.File, "-out", expect.Out) mustParseOption() if opt.File != expect.File { t.Errorf("file: expected %v, got %v", opt.File, expect.File) } if opt.Out != expect.Out { t.Errorf("out: expected %v, got %v", opt.Out, expect.Out) } t.Run("must parse option", func (t *testing.T) { os.Args = append(old, "-verbose", "-out", expect.Out) mustParseOption() if opt.File != "" { t.Error("opt.File must be empty "+opt.File) } os.Args = append(old, "-verbose", "-file", "invalid", "-out", expect.Out) mustParseOption() if opt.File != "invalid" { t.Error("opt.File must be invalid") } }) t.Run("run", func (t *testing.T) { tick = time.Second os.Args = append(old, "-verbose", "-file", expect.File, "-out", expect.Out, "-until", "2") main() }) os.Args = old }) } golang-github-adhocore-gronx-1.19.6/go.mod000066400000000000000000000000521501671473200203650ustar00rootroot00000000000000module github.com/adhocore/gronx go 1.13 golang-github-adhocore-gronx-1.19.6/gronx.go000066400000000000000000000115711501671473200207530ustar00rootroot00000000000000package gronx import ( "errors" "regexp" "strings" "time" ) var literals = strings.NewReplacer( "SUN", "0", "MON", "1", "TUE", "2", "WED", "3", "THU", "4", "FRI", "5", "SAT", "6", "JAN", "1", "FEB", "2", "MAR", "3", "APR", "4", "MAY", "5", "JUN", "6", "JUL", "7", "AUG", "8", "SEP", "9", "OCT", "10", "NOV", "11", "DEC", "12", ) var expressions = map[string]string{ "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@hourly": "0 * * * *", "@always": "* * * * *", "@5minutes": "*/5 * * * *", "@10minutes": "*/10 * * * *", "@15minutes": "*/15 * * * *", "@30minutes": "0,30 * * * *", "@everysecond": "* * * * * *", } // AddTag adds a new custom tag representing given expr func AddTag(tag, expr string) error { _, ok := expressions[tag] if ok { return errors.New("conflict tag") } segs, err := Segments(expr) if err != nil { return err } expr = strings.Join(segs, " ") expressions[tag] = expr return nil } // 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") if e, ok := expressions[strings.ToLower(expr)]; ok { expr = e } expr = SpaceRe.ReplaceAllString(expr, " ") expr = literals.Replace(strings.ToUpper(expr)) return strings.Split(strings.ReplaceAll(expr, " ", " "), " ") } // Gronx is the main program. type Gronx struct { C Checker } // New initializes Gronx with factory defaults. func New() *Gronx { return &Gronx{&SegmentChecker{}} } // IsDue checks if cron expression is due for given reference time (or now). // It returns bool or error if any. func (g *Gronx) IsDue(expr string, ref ...time.Time) (bool, error) { if len(ref) == 0 { ref = append(ref, time.Now()) } g.C.SetRef(ref[0]) segs, err := Segments(expr) if err != nil { return false, err } return g.SegmentsDue(segs) } func (g *Gronx) isDue(expr string, ref time.Time) bool { due, err := g.IsDue(expr, ref) return err == nil && due } // 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) 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(segs []string) (bool, error) { skipMonthDayCheck := false for i := 0; i < len(segs); i++ { pos := len(segs) - 1 - i seg := segs[pos] isMonthDay, isWeekday := pos == 3, pos == 5 if seg == "*" || seg == "?" { continue } if isMonthDay && skipMonthDayCheck { continue } if isWeekday { monthDaySeg := segs[3] intersect := strings.Index(seg, "*/") == 0 || strings.Index(monthDaySeg, "*") == 0 || monthDaySeg == "?" if !intersect { due, err := g.C.CheckDue(seg, pos) if err != nil { return false, err } monthDayDue, err := g.C.CheckDue(monthDaySeg, 3) if due || monthDayDue { skipMonthDayCheck = true continue } if err != nil { return false, err } } } if due, err := g.C.CheckDue(seg, pos); !due { return due, err } } return true, nil } // IsValid checks if cron expression is valid. // It returns bool. func (g *Gronx) IsValid(expr string) bool { return IsValid(expr) } // checker for validity var checker = &SegmentChecker{ref: time.Now()} // IsValid checks if cron expression is valid. // It returns bool. func IsValid(expr string) bool { segs, err := Segments(expr) if err != nil { return false } // First check syntax without time dependency if !isSyntaxValid(segs) { return false } // Then check with time dependency for pos, seg := range segs { if _, err := checker.CheckDue(seg, pos); err != nil { return false } } return true } // isSyntaxValid checks if the cron segments are syntactically valid without time dependency. // It returns bool. func isSyntaxValid(segs []string) bool { for _, seg := range segs { // Check for empty segments if seg == "" { return false } // Split by comma to check each part parts := strings.Split(seg, ",") for _, part := range parts { // Check for empty parts if part == "" { return false } // Check for invalid characters if strings.ContainsAny(part, "*/") { // If contains /, must have a number after it if strings.Contains(part, "/") { parts := strings.Split(part, "/") if len(parts) != 2 || parts[1] == "" { return false } } } } } return true } golang-github-adhocore-gronx-1.19.6/gronx_test.go000066400000000000000000000275431501671473200220200ustar00rootroot00000000000000package gronx import ( "fmt" "strings" "testing" "time" ) type Case struct { Expr string `json:"expr"` Ref string `json:"ref"` Expect bool `json:"expect"` Next string `json:"next"` } func (test Case) run(t *testing.T, gron *Gronx) (bool, error) { if test.Ref == "" { return gron.IsDue(test.Expr) } ref, err := time.Parse(FullDateFormat, test.Ref) if err != nil { t.Errorf("can't parse date: %s", test.Ref) t.Fail() } return gron.IsDue(test.Expr, ref) } func TestNormalize(t *testing.T) { tests := map[string]string{ "* * *\t*\n*": "* * * * *", "* * * * * 2021": "* * * * * 2021", "@hourly": "0 * * * *", "0 0 JAN,feb * sun,MON": "0 0 1,2 * 0,1", } for expr, expect := range tests { t.Run("normalize "+expr, func(t *testing.T) { actual := strings.Join(normalize(expr), " ") if expect != actual { t.Errorf("expected %v, got %v", expect, actual) } }) } } func TestIsValid(t *testing.T) { gron := New() t.Run("is valid", func(t *testing.T) { if !gron.IsValid("5,10-20/4,55 * * * *") { t.Errorf("expected true, got false") } if !gron.IsValid("00 * * * *") { t.Errorf("expected true, got false") } if !gron.IsValid("* 00 * * *") { t.Errorf("expected true, got false") } if expr := "* * * * *"; IsValid(expr) != gron.IsValid(expr) { t.Error("IsValid func and method must return same") } }) t.Run("is not valid", func(t *testing.T) { if gron.IsValid("A-B * * * *") { t.Errorf("expected false, got true") } if gron.IsValid("60 * * * *") { t.Errorf("expected false, got true") } if gron.IsValid("* 30 * * *") { t.Errorf("expected false, got true") } if gron.IsValid("* * 99 * *") { t.Errorf("expected false, got true") } if gron.IsValid("* * * 13 *") { t.Errorf("expected false, got true") } if gron.IsValid("* * * * 8") { t.Errorf("expected false, got true") } if gron.IsValid("60-65 * * * *") { t.Errorf("expected false, got true") } if gron.IsValid("* 24-28/2 * * *") { t.Errorf("expected false, got true") } if gron.IsValid("* * * *") { t.Errorf("expected false, got true") } if gron.IsValid("0-0/-005 * * * *") { t.Errorf("expected true, got false") } }) t.Run("sensitivity to reference time", func(t *testing.T) { originalRef := checker.ref defer func() { checker.ref = originalRef }() expr := "*/15, * * * *" moments := []time.Time{ time.Date(2025, 4, 29, 12, 13, 0, 0, time.UTC), time.Date(2025, 4, 29, 12, 14, 0, 0, time.UTC), time.Date(2025, 4, 29, 12, 15, 0, 0, time.UTC), time.Date(2025, 4, 29, 12, 16, 0, 0, time.UTC), time.Date(2025, 4, 29, 12, 17, 0, 0, time.UTC), } for _, moment := range moments { checker.ref = moment if gron.IsValid(expr) { t.Errorf("expected false, got true at %v", moment) } } }) } func TestAddTag(t *testing.T) { t.Run("add good tag", func(t *testing.T) { err := AddTag("@2s", "*/2 * * * * *") if err != nil { t.Error("expected nil, got err") } expr, ok := expressions["@2s"] if !ok { t.Error("expected true, got false") } if expr != "*/2 * * * * *" { t.Error("expected */2 * * * * *") } }) t.Run("add conflict tag", func(t *testing.T) { err := AddTag("@2s", "*/2 * * * * *") if err == nil { t.Error("expected err, got nil") } }) t.Run("add wrong tag", func(t *testing.T) { err := AddTag("@3s", "* * * *") if err == nil { t.Error("expected err, got nil") } }) } func TestIsDue(t *testing.T) { gron := New() t.Run("seconds precision", func(t *testing.T) { expr := "*/2 * * * * *" ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:04") due, _ := gron.IsDue(expr, ref) if !due { t.Errorf("%s should be due on %s", expr, ref) } due, _ = gron.IsDue(expr, ref.Add(time.Second)) if due { t.Errorf("%s should be due on %s", expr, ref) } }) for i, test := range testcases() { t.Run(fmt.Sprintf("is due #%d=%s", i, test.Expr), func(t *testing.T) { actual, _ := test.run(t, gron) if actual != test.Expect { t.Errorf("expected %v, got %v", test.Expect, actual) } }) } for i, test := range errcases() { t.Run(fmt.Sprintf("is due err #%d=%s", i, test.Expr), func(t *testing.T) { actual, err := test.run(t, gron) if actual != test.Expect { t.Errorf("expected %v, got %v", test.Expect, actual) } if err == nil { t.Errorf("expected error, got nil") } }) } } func TestValueByPos(t *testing.T) { t.Run("valueByPos 7", func(t *testing.T) { if actual := valueByPos(time.Now(), 7); actual != 0 { t.Errorf("expected 0, got %v", actual) } }) } func testcases() []Case { return []Case{ {"@always", "2021-04-19 12:54:00", true, "2021-04-19 12:55:00"}, {"* * * * * 2018", "2022-01-02 15:04:00", false, "err"}, {"* * * * * 2018", "2021-04-19 12:54:00", false, "err"}, {"@5minutes", "2017-05-10 02:30:00", true, "2017-05-10 02:35:00"}, {"* * 7W * *", "2017-10-15 20:00:00", false, "2017-11-07 00:00:00"}, {"*/2 */2 * * *", "2015-08-10 21:47:00", false, "2015-08-10 22:00:00"}, {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"* * * * * ", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"* 20,21,22 * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"* 20,22 * * *", "2015-08-10 21:50:00", false, "2015-08-10 22:00:00"}, {"* 5,21-22 * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, {"7-9 * */9 * *", "2015-08-10 22:02:00", false, "2015-08-10 22:07:00"}, {"7-9 * */9 * *", "2015-08-11 22:02:00", false, "2015-08-19 00:07:00"}, {"1 * * * 7", "2015-08-10 21:47:00", false, "2015-08-16 00:01:00"}, {"47 21 * * *", "2015-08-10 21:47:00", true, "2015-08-11 21:47:00"}, {"00 * * * *", "2023-07-21 12:30:00", false, "2023-07-21 13:00:00"}, {"0 00 * * *", "2023-07-21 12:30:00", false, "2023-07-22 00:00:00"}, {"0 000 * * *", "2023-07-21 12:30:00", false, "2023-07-22 00:00:00"}, {"* * * * 0", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"* * * * 7", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"* * * * 1", "2011-06-15 23:09:00", false, "2011-06-20 00:00:00"}, {"0 0 * * MON,SUN", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"0 0 * * 1,7", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"0 0 * * 0-4", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, {"0 0 * * 7-4", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, {"0 0 * * 4-7", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, {"0 0 * * 7-3", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"0 0 * * 3-7", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, {"0 0 * * 3-7", "2011-06-18 23:09:00", false, "2011-06-22 00:00:00"}, {"0 0 * * 2-7", "2011-06-20 23:09:00", false, "2011-06-21 00:00:00"}, {"0 0 * * 0,2-6", "2011-06-20 23:09:00", false, "2011-06-21 00:00:00"}, {"0 0 * * 2-7", "2011-06-18 23:09:00", false, "2011-06-21 00:00:00"}, {"0 0 * * 4-7", "2011-07-19 00:00:00", false, "2011-07-21 00:00:00"}, {"0-12/4 * * * *", "2011-06-20 12:04:00", true, "2011-06-20 12:08:00"}, {"0-10/2 * * * *", "2011-06-20 12:12:00", false, "2011-06-20 13:00:00"}, {"4-59/2 * * * *", "2011-06-20 12:04:00", true, "2011-06-20 12:06:00"}, {"4-59/2 * * * *", "2011-06-20 12:06:00", true, "2011-06-20 12:08:00"}, {"4-59/3 * * * *", "2011-06-20 12:06:00", false, "2011-06-20 12:07:00"}, {"0 0 * * 0,2-6", "2011-06-20 23:09:00", false, "2011-06-21 00:00:00"}, {"0 0 1 1 0", "2011-06-15 23:09:00", false, "2012-01-01 00:00:00"}, {"0 0 1 JAN 0", "2011-06-15 23:09:00", false, "2012-01-01 00:00:00"}, {"0 0 1 * 0", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"0 0 L * *", "2011-07-15 00:00:00", false, "2011-07-31 00:00:00"}, {"0 0 2W * *", "2011-07-01 00:00:00", true, "2011-08-02 00:00:00"}, {"0 0 1W * *", "2011-05-01 00:00:00", false, "2011-05-02 00:00:00"}, {"0 0 1W * *", "2011-07-01 00:00:00", true, "2011-08-01 00:00:00"}, {"0 0 3W * *", "2011-07-01 00:00:00", false, "2011-07-04 00:00:00"}, {"0 0 16W * *", "2011-07-01 00:00:00", false, "2011-07-15 00:00:00"}, {"0 0 28W * *", "2011-07-01 00:00:00", false, "2011-07-28 00:00:00"}, {"0 0 30W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"}, // {"0 0 31W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"}, {"* * * * * 2012", "2011-05-01 00:00:00", false, "2012-01-01 00:00:00"}, {"* * * * 5L", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"}, {"* * * * 6L", "2011-07-01 00:00:00", false, "2011-07-30 00:00:00"}, {"* * * * 7L", "2011-07-01 00:00:00", false, "2011-07-31 00:00:00"}, {"* * * * 1L", "2011-07-24 00:00:00", false, "2011-07-25 00:00:00"}, {"* * * * TUEL", "2011-07-24 00:00:00", false, "2011-07-26 00:00:00"}, {"* * * 1 5L", "2011-12-25 00:00:00", false, "2012-01-27 00:00:00"}, {"* * * * 5#2", "2011-07-01 00:00:00", false, "2011-07-08 00:00:00"}, {"* * * * 5#1", "2011-07-01 00:00:00", true, "2011-07-01 00:01:00"}, {"* * * * 3#4", "2011-07-01 00:00:00", false, "2011-07-27 00:00:00"}, {"0 0 * * 1#1", "2009-10-23 00:00:00", false, "2009-11-02 00:00:00"}, {"0 0 * * 1#1", "2009-11-23 00:00:00", false, "2009-12-07 00:00:00"}, {"5/0 * * * *", "2021-04-19 12:54:00", false, "2018-08-13 00:25:00"}, {"5/20 * * * *", "2018-08-13 00:24:00", false, "2018-08-13 00:25:00"}, {"5/20 * * * *", "2018-08-13 00:45:00", true, "2018-08-13 01:05:00"}, {"5-11/4 * * * *", "2018-08-13 00:03:00", false, "2018-08-13 00:05:00"}, {"0 0 L * 0", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, {"3-59/15 6-12 */15 1 2-5", "2017-01-08 00:00:00", false, "2017-01-31 06:03:00"}, {"* * * * MON-FRI", "2017-01-08 00:00:00", false, "2017-01-09 00:00:00"}, {"* * * * TUE", "2017-01-08 00:00:00", false, "2017-01-10 00:00:00"}, {"0 1 15 JUL mon,Wed,FRi", "2019-11-14 00:00:00", false, "2020-07-01 01:00:00"}, {"0 1 15 jul mon,Wed,FRi", "2019-11-14 00:00:00", false, "2020-07-01 01:00:00"}, {"1 * 2 7 5-7", "2020-07-02 00:00:00", false, "2020-07-02 00:01:00"}, {"@weekly", "2019-11-14 00:00:00", false, "2019-11-17 00:00:00"}, {"@weekly", "2019-11-14 00:00:00", false, "2019-11-17 00:00:00"}, {"@weekly", "2019-11-14 00:00:00", false, "2019-11-17 00:00:00"}, {"0 12 * * ?", "2020-08-20 00:00:00", false, "2020-08-20 12:00:00"}, {"0 12 ? * *", "2020-08-20 00:00:00", false, "2020-08-20 12:00:00"}, {"* ? * ? * *", "2020-08-20 00:00:00", true, "2020-08-20 00:00:01"}, {"* * ? * * * */2", "2021-08-20 00:00:00", false, "2022-01-01 00:00:00"}, {"* * * * * * *", "2021-08-20 00:00:00", true, "2021-08-20 00:00:01"}, {"* * * * * * 2023-2099", "2021-08-20 00:00:00", false, "2023-01-01 00:00:00"}, {"30 9 L */3 *", "2023-04-23 09:30:00", false, "2023-04-30 09:30:00"}, {"30 9 L */3 *", "2023-05-01 09:30:00", false, "2023-07-31 09:30:00"}, {"0 * * * * * */2", "2019-05-01 09:30:00", false, "2020-01-01 00:00:00"}, {"0/4 * * * *", "2019-05-01 09:31:00", false, "2019-05-01 09:32:00"}, } } func errcases() []Case { return []Case{ {"* * * *", "", false, ""}, {"* * * * * * * *", "", false, ""}, {"- * * * *", "2011-07-01 00:01:00", false, ""}, {"/ * * * *", "2011-07-01 00:01:00", false, ""}, {"Z/Z * * * *", "2011-07-01 00:01:00", false, ""}, {"Z/0 * * * *", "2011-07-01 00:01:00", false, ""}, {"Z-10 * * * *", "2011-07-01 00:01:00", false, ""}, {"1-Z * * * *", "2011-07-01 00:01:00", false, ""}, {"1-Z/2 * * * *", "2011-07-01 00:01:00", false, ""}, {"Z-Z/2 * * * *", "2011-07-01 00:01:00", false, ""}, {"* * 0 * *", "2011-07-01 00:01:00", false, ""}, {"* * * W * *", "", false, ""}, {"* * * ZW * *", "", false, ""}, {"* * * * 4W", "2011-07-01 00:00:00", false, ""}, {"* * * 1L *", "2011-07-01 00:00:00", false, ""}, {"* * * * * ZL", "", false, ""}, {"* * * * * Z#", "", false, ""}, {"* * * * * 1#Z", "", false, ""}, {"* * W * L", "", false, ""}, {"* * 15 * 1#Z", "", false, ""}, } } golang-github-adhocore-gronx-1.19.6/next.go000066400000000000000000000121451501671473200205720ustar00rootroot00000000000000package gronx import ( "errors" "fmt" "regexp" "strconv" "strings" "time" ) // CronDateFormat is Y-m-d H:i (seconds are not significant) const CronDateFormat = "2006-01-02 15:04" // FullDateFormat is Y-m-d H:i:s (with seconds) const FullDateFormat = "2006-01-02 15:04:05" // NextTick gives next run time from now func NextTick(expr string, inclRefTime bool) (time.Time, error) { return NextTickAfter(expr, time.Now(), inclRefTime) } // NextTickAfter gives next run time from the provided time.Time func NextTickAfter(expr string, start time.Time, inclRefTime bool) (time.Time, error) { gron, next := New(), start.Truncate(time.Second) due, err := gron.IsDue(expr, start) if err != nil || (due && inclRefTime) { return start, err } segments, _ := Segments(expr) if len(segments) > 6 && isUnreachableYear(segments[6], next, false) { return next, fmt.Errorf("unreachable year segment: %s", segments[6]) } next, err = loop(gron, segments, next, inclRefTime, false) // Ignore superfluous err if err != nil && gron.isDue(expr, next) { err = nil } return next, err } func loop(gron *Gronx, segments []string, start time.Time, incl bool, reverse bool) (next time.Time, err error) { iter, next, bumped := 500, start, false over: for iter > 0 { iter-- skipMonthDayForIter := false for i := 0; i < len(segments); i++ { pos := len(segments) - 1 - i seg := segments[pos] isMonthDay, isWeekday := pos == 3, pos == 5 if seg == "*" || seg == "?" { continue } if !isWeekday { if isMonthDay && skipMonthDayForIter { continue } if next, bumped, err = bumpUntilDue(gron.C, seg, pos, next, reverse); bumped { goto over } continue } // From here we process the weekday segment in case it is neither * nor ? monthDaySeg := segments[3] intersect := strings.Index(seg, "*/") == 0 || strings.Index(monthDaySeg, "*") == 0 || monthDaySeg == "?" nextForWeekDay := next nextForWeekDay, bumped, err = bumpUntilDue(gron.C, seg, pos, nextForWeekDay, reverse) if !bumped { // Weekday seg is specific and next is already at right weekday, so no need to process month day if union case next = nextForWeekDay if !intersect { skipMonthDayForIter = true } continue } // Weekday was bumped, so we need to check for month day if intersect { // We need intersection so we keep bumped weekday and go over next = nextForWeekDay goto over } // Month day seg is specific and a number/list/range, so we need to check and keep the closest to next nextForMonthDay := next nextForMonthDay, bumped, err = bumpUntilDue(gron.C, monthDaySeg, 3, nextForMonthDay, reverse) monthDayIsClosestToNextThanWeekDay := reverse && nextForMonthDay.After(nextForWeekDay) || !reverse && nextForMonthDay.Before(nextForWeekDay) if monthDayIsClosestToNextThanWeekDay { next = nextForMonthDay if !bumped { // Month day seg is specific and next is already at right month day, we can continue skipMonthDayForIter = true continue } } else { next = nextForWeekDay } goto over } if !incl && next.Format(FullDateFormat) == start.Format(FullDateFormat) { delta := time.Second if reverse { delta = -time.Second } next = next.Add(delta) continue } return } return start, errors.New("tried so hard") } var dashRe = regexp.MustCompile(`/.*$`) func isUnreachableYear(year string, ref time.Time, reverse bool) bool { if year == "*" || year == "?" { return false } edge := ref.Year() for _, offset := range strings.Split(year, ",") { if strings.Index(offset, "*/") == 0 || strings.Index(offset, "0/") == 0 { return false } for _, part := range strings.Split(dashRe.ReplaceAllString(offset, ""), "-") { val, err := strconv.Atoi(part) if err != nil || (!reverse && val >= edge) || (reverse && val <= edge) { return false } } } return true } var limit = map[int]int{0: 60, 1: 60, 2: 24, 3: 31, 4: 12, 5: 366, 6: 100} func bumpUntilDue(c Checker, segment string, pos int, ref time.Time, reverse bool) (time.Time, bool, error) { // iter := limit[pos] for iter > 0 { c.SetRef(ref) if ok, _ := c.CheckDue(segment, pos); ok { return ref, iter != limit[pos], nil } if reverse { ref = bumpReverse(ref, pos) } else { ref = bump(ref, pos) } iter-- } return ref, false, errors.New("tried so hard") } func bump(ref time.Time, pos int) time.Time { loc := ref.Location() switch pos { case 0: ref = ref.Add(time.Second) case 1: minTime := ref.Add(time.Minute) ref = time.Date(minTime.Year(), minTime.Month(), minTime.Day(), minTime.Hour(), minTime.Minute(), 0, 0, loc) case 2: hTime := ref.Add(time.Hour) ref = time.Date(hTime.Year(), hTime.Month(), hTime.Day(), hTime.Hour(), 0, 0, 0, loc) case 3, 5: dTime := ref.AddDate(0, 0, 1) ref = time.Date(dTime.Year(), dTime.Month(), dTime.Day(), 0, 0, 0, 0, loc) case 4: ref = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc) ref = ref.AddDate(0, 1, 0) case 6: yTime := ref.AddDate(1, 0, 0) ref = time.Date(yTime.Year(), 1, 1, 0, 0, 0, 0, loc) } return ref } golang-github-adhocore-gronx-1.19.6/next_test.go000066400000000000000000000115731501671473200216350ustar00rootroot00000000000000package gronx import ( "fmt" "strings" "testing" "time" ) func TestNextTick(t *testing.T) { exp := "* * * * * *" t.Run("next tick incl "+exp, func(t *testing.T) { now := time.Now().Format(FullDateFormat) next, _ := NextTick(exp, true) tick := next.Format(FullDateFormat) if now != tick { t.Errorf("expected %v, got %v", now, tick) } }) t.Run("next tick excl "+exp, func(t *testing.T) { expect := time.Now().Add(time.Second).Format(FullDateFormat) next, _ := NextTick(exp, false) tick := next.Format(FullDateFormat) if expect != tick { t.Errorf("expected %v, got %v", expect, tick) } }) } func TestNextTickAfter(t *testing.T) { t.Run("next run after", func(t *testing.T) { t.Run("seconds precision", func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") next, _ := NextTickAfter("*/5 * * * * *", ref, false) if next.Format(FullDateFormat) != "2020-02-02 02:02:05" { t.Errorf("2020-02-02 02:02:02 next tick should be 2020-02-02 02:02:05") } }) for i, test := range testcases() { t.Run(fmt.Sprintf("next run after incl #%d: %s", i, test.Expr), func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, test.Ref) if next, err := NextTickAfter(test.Expr, ref, true); err == nil { actual := next.Format(FullDateFormat) if test.Expect != (test.Ref == actual) { t.Errorf("[incl] expected %v, got %v", test.Ref, actual) } } }) } gron := New() for i, test := range testcases() { t.Run(fmt.Sprintf("next run after excl #%d: %s", i, test.Expr), func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, test.Ref) next, err := NextTickAfter(test.Expr, ref, false) if err == nil { expect := test.Next if expect == "" { expect = test.Ref } actual := next.Format(FullDateFormat) if due, _ := gron.IsDue(test.Expr, next); !due { t.Errorf("[%s][%s] should be due on %v", test.Expr, test.Ref, next.Format(FullDateFormat)) } if expect != actual { t.Errorf("[%s][%s] expected %v, got %v", test.Expr, test.Ref, expect, actual) } } else { fmt.Println(test.Expr+" failed", err) } }) } }) } func TestIsUnreachableYearPrevTickBefore(t *testing.T) { now := time.Date(2024, time.November, 8, 22, 18, 16, 0, time.UTC) tests := []struct { name string cronExpr string expectedTime time.Time expectError bool }{ { // https://github.com/adhocore/gronx/issues/51 name: "Current Year - Previous Tick", cronExpr: "30 15 4 11 * 2024", expectedTime: time.Date(2024, time.November, 4, 15, 30, 0, 0, time.UTC), expectError: false, }, { name: "Next Year - Previous Tick (Unreachable Year)", cronExpr: "30 15 4 11 * 2025", expectedTime: time.Time{}, // Error expected expectError: true, }, { name: "Previous Year - Previous Tick", cronExpr: "30 15 4 11 * 2023", expectedTime: time.Date(2023, time.November, 4, 15, 30, 0, 0, time.UTC), expectError: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualTime, err := PrevTickBefore(tc.cronExpr, now, true) if tc.expectError { if err == nil || !strings.Contains(err.Error(), "unreachable year segment") { t.Errorf("expected unreachable year error, got: %v", err) } } else { if err != nil { t.Errorf("unexpected error: %v", err) } else if !actualTime.Equal(tc.expectedTime) { t.Errorf("expected previous tick to be %v, got %v", tc.expectedTime, actualTime) } } }) } } func TestIsUnreachableYearNextTickAfter(t *testing.T) { now := time.Date(2024, time.November, 8, 22, 18, 16, 0, time.UTC) tests := []struct { name string cronExpr string expectedTime time.Time expectError bool }{ { // https://github.com/adhocore/gronx/issues/53 name: "Current Year - Next Tick", cronExpr: "30 15 31 12 * 2024", expectedTime: time.Date(2024, time.December, 31, 15, 30, 0, 0, time.UTC), expectError: false, }, { name: "Next Year - Next Tick", cronExpr: "30 15 31 12 * 2025", expectedTime: time.Date(2025, time.December, 31, 15, 30, 0, 0, time.UTC), expectError: false, }, { name: "Previous Year - Next Tick (Unreachable Year)", cronExpr: "30 15 31 12 * 2023", expectedTime: time.Time{}, // Error expected expectError: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actualTime, err := NextTickAfter(tc.cronExpr, now, false) if tc.expectError { if err == nil || !strings.Contains(err.Error(), "unreachable year segment") { t.Errorf("expected unreachable year error, got: %v", err) } } else { if err != nil { t.Errorf("unexpected error: %v", err) } else if !actualTime.Equal(tc.expectedTime) { t.Errorf("expected next tick to be %v, got %v", tc.expectedTime, actualTime) } } }) } } golang-github-adhocore-gronx-1.19.6/pkg/000077500000000000000000000000001501671473200200435ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/pkg/tasker/000077500000000000000000000000001501671473200213345ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/pkg/tasker/README.md000066400000000000000000000140471501671473200226210ustar00rootroot00000000000000# adhocore/gronx/pkg/tasker [![Latest Version](https://img.shields.io/github/release/adhocore/gronx.svg?style=flat-square)](https://github.com/adhocore/gronx/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Go Report](https://goreportcard.com/badge/github.com/adhocore/gronx)](https://goreportcard.com/report/github.com/adhocore/gronx) [![Test](https://github.com/adhocore/gronx/actions/workflows/test-action.yml/badge.svg)](https://github.com/adhocore/gronx/actions/workflows/test-action.yml) [![Donate](https://img.shields.io/badge/donate-paypal-blue.svg?style=flat-square)](https://www.paypal.me/ji10/50usd) [![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) `tasker` is cron expression based task scheduler and/or daemon for programamtic usage in Golang (tested on v1.13 and above) or independent standalone usage. ## Installation ```sh go get -u github.com/adhocore/gronx/cmd/tasker ``` --- ## Usage ### Go Tasker Tasker is a task manager that can be programatically used in Golang applications. It runs as a daemon and and invokes tasks scheduled with cron expression: ```go package main import ( "context" "time" "github.com/adhocore/gronx/pkg/tasker" ) func main() { taskr := tasker.New(tasker.Option{ Verbose: true, // optional: defaults to local Tz: "Asia/Bangkok", // optional: defaults to stderr log stream Out: "/full/path/to/output-file", }) // add task to run every minute taskr.Task("* * * * *", func(ctx context.Context) (int, error) { // do something ... // then return exit code and error, for eg: if everything okay return 0, nil }).Task("*/5 * * * *", func(ctx context.Context) (int, error) { // every 5 minutes // you can also log the output to Out file as configured in Option above: taskr.Log.Printf("done something in %d s", 2) return 0, nil }) // run task without overlap, set concurrent flag to false: concurrent := false taskr.Task("* * * * * *", , tasker.Taskify("sleep 2", tasker.Option{}), concurrent) // every 10 minute with arbitrary command taskr.Task("@10minutes", taskr.Taskify("command --option val -- args", tasker.Option{Shell: "/bin/sh -c"})) // ... add more tasks // optionally if you want tasker to stop after 2 hour, pass the duration with Until(): taskr.Until(2 * time.Hour) // finally run the tasker, it ticks sharply on every minute and runs all the tasks due on that time! // it exits gracefully when ctrl+c is received making sure pending tasks are completed. taskr.Run() } ``` #### Concurrency By default the tasks can run concurrently i.e if previous run is still not finished but it is now due again, it will run again. If you want to run only one instance of a task at a time, set concurrent flag to false: ```go taskr := tasker.New(tasker.Option{}) concurrent := false expr, task := "* * * * * *", tasker.Taskify("php -r 'sleep(2);'") taskr.Task(expr, task, concurrent) ``` ### Task Daemon It can also be used as standalone task daemon instead of programmatic usage for Golang application. First, just install tasker command: ```sh go install github.com/adhocore/gronx/cmd/tasker@latest ``` Or you can also download latest prebuilt binary from [release](https://github.com/adhocore/gronx/releases/latest) for platform of your choice. Then prepare a taskfile ([example](https://github.com/adhocore/gronx/blob/main/test/taskfile.txt)) in crontab format (or can even point to existing crontab). > `user` is not supported: it is just cron expr followed by the command. Finally run the task daemon like so ``` tasker -file path/to/taskfile ``` #### Version ```sh tasker -v ``` > You can pass more options to control the behavior of task daemon, see below. #### Tasker command options: ```txt -file string The task file in crontab format -out string The fullpath to file where output from tasks are sent to -shell string The shell to use for running tasks (default "/usr/bin/bash") -tz string The timezone to use for tasks (default "Local") -until int The timeout for task daemon in minutes -verbose The verbose mode outputs as much as possible ``` Examples: ```sh tasker -verbose -file path/to/taskfile -until 120 # run until next 120min (i.e 2hour) with all feedbacks echoed back tasker -verbose -file path/to/taskfile -out path/to/output # with all feedbacks echoed to the output file tasker -tz America/New_York -file path/to/taskfile -shell zsh # run all tasks using zsh shell based on NY timezone ``` > File extension of taskfile for (`-file` option) does not matter: can be any or none. > The directory for outfile (`-out` option) must exist, file is created by task daemon. > 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`. --- ## Understanding Cron Expression Checkout [gronx](https://github.com/adhocore/gronx#cron-expression) docs on cron expression. --- ## License > © [MIT](https://github.com/adhocore/gronx/blob/main/LICENSE) | 2021-2099, Jitendra Adhikari ## Credits This project is ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) and 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 - [**fast**](https://github.com/adhocore/fast) - Check your internet speed with ease and comfort right from the terminal golang-github-adhocore-gronx-1.19.6/pkg/tasker/parser.go000066400000000000000000000046501501671473200231640ustar00rootroot00000000000000package tasker import ( "bufio" "log" "os" "regexp" "strings" "github.com/adhocore/gronx" ) // MustParseTaskfile either parses taskfile from given Option. // It fails hard in case any error. func MustParseTaskfile(opts Option) []Task { file, err := os.Open(opts.File) if err != nil { log.Printf("[parser] can't open file: %s", opts.File) exit(1) } defer file.Close() lines := []string{} scan := bufio.NewScanner(file) for scan.Scan() { ln := strings.TrimLeft(scan.Text(), " \t") // Skip empty or comment if ln != "" && ln[0] != '#' { lines = append(lines, ln) } } if err := scan.Err(); err != nil { if len(lines) == 0 { log.Printf("[parser] error reading taskfile: %v", err) exit(1) } log.Println(err) } return linesToTasks(lines) } // var cronRe = regexp.MustCompile(`^((?:[^\s]+\s+){5,6}(?:\d{4})?)(?:\s+)?(.*)`) var aliasRe = regexp.MustCompile(`^(@(?:annually|yearly|monthly|weekly|daily|hourly|5minutes|10minutes|15minutes|30minutes|always|everysecond))(?:\s+)?(.*)`) var segRe = regexp.MustCompile(`(?i),|/\d+$|^\d+-\d+$|^([0-7]|sun|mon|tue|wed|thu|fri|sat)(L|W|#\d)?$|-([0-7]|sun|mon|tue|wed|thu|fri|sat)$|\d{4}`) func linesToTasks(lines []string) []Task { var tasks []Task gron := gronx.New() for _, line := range lines { var match []string if line[0] == '@' { match = aliasRe.FindStringSubmatch(line) } else { match = parseLine(line) } if len(match) > 2 && gron.IsValid(match[1]) { tasks = append(tasks, Task{strings.Trim(match[1], " \t"), match[2]}) continue } log.Printf("[parser] can't parse cron expr: %s", line) } return tasks } func parseLine(line string) (match []string) { wasWs, expr, cmd := false, "", "" i, nseg, llen := 0, 0, len(line)-1 match = append(match, line) for ; i < llen && nseg <= 7; i++ { isWs := strings.ContainsAny(line[i:i+1], "\t ") if nseg >= 5 { seg, ws := "", line[i-1:i] for i < llen && !strings.ContainsAny(line[i:i+1], "\t ") { i, seg = i+1, seg+line[i:i+1] } if isCronPart(seg) { expr, nseg = expr+ws+seg, nseg+1 } else if seg != "" { cmd += seg break } } else { expr += line[i : i+1] } if isWs && !wasWs { nseg++ } wasWs = isWs } cmd += line[i:] if nseg >= 5 && strings.TrimSpace(cmd) != "" { match = append(match, expr, cmd) } return } func isCronPart(seg string) bool { return seg != "" && seg[0] != '/' && (seg[0] == '*' || seg[0] == '?' || segRe.MatchString(seg)) } golang-github-adhocore-gronx-1.19.6/pkg/tasker/parser_test.go000066400000000000000000000022661501671473200242240ustar00rootroot00000000000000package tasker import ( "strings" "testing" ) func TestMustParseTaskfile(t *testing.T) { exit = func(code int) {} t.Run("MustParseTaskfile", func(t *testing.T) { tasks := MustParseTaskfile(Option{File: "../../test/taskfile.txt"}) if len(tasks) != 8 { t.Errorf("should have 8 tasks, got %d", len(tasks)) } if tasks[0].Expr != "*/1 0/1 * * *" { t.Errorf("expected '*/1 0/1 * * *', got %s", tasks[0].Expr) } if tasks[2].Cmd != "echo '[task 3] @always' > test/task3.out" { t.Errorf("expected `echo '[task 3] @always' > test/task3.out`, got %s", tasks[2].Cmd) } t.Run("complex file - seconds precision", func(t *testing.T) { tasks := MustParseTaskfile(Option{File: "../../test/taskfile-complex.txt"}) if len(tasks) != 13 { t.Errorf("should have 13 tasks, got %d", len(tasks)) } for i, task := range tasks { if !strings.HasPrefix(task.Cmd, `echo "`) { t.Errorf("invalid cmd at %d [%s]: %s", i, task.Expr, task.Cmd) } } }) t.Run("must parse - no file", func(t *testing.T) { tasks := MustParseTaskfile(Option{File: "../../test/taskfile.txtx"}) if len(tasks) != 0 { t.Errorf("should have 0 tasks, got %d", len(tasks)) } }) }) } golang-github-adhocore-gronx-1.19.6/pkg/tasker/tasker.go000066400000000000000000000174731501671473200231700ustar00rootroot00000000000000package tasker import ( "context" "fmt" "log" "os" "os/exec" "os/signal" "path/filepath" "reflect" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/adhocore/gronx" ) // Option is the config options for Tasker. type Option struct { File string Tz string Shell string Out string Until int64 Verbose bool } // TaskFunc is the actual task handler. type TaskFunc func(ctx context.Context) (int, error) // Task wraps a cron expr and its' command. type Task struct { Expr string Cmd string } // Tasker is the task manager. type Tasker struct { until time.Time ctx context.Context loc *time.Location gron *gronx.Gronx Log *log.Logger exprs map[string][]string tasks map[string]TaskFunc mutex map[string]*uint32 ctxCancel context.CancelFunc wg sync.WaitGroup verbose bool running bool timeout bool abort bool } type result struct { err error ref string code int } var exit = os.Exit // New inits a task manager. // It returns Tasker. func New(opt Option) *Tasker { gron := gronx.New() tasks := make(map[string]TaskFunc) exprs := make(map[string][]string) if opt.Tz == "" { opt.Tz = "Local" } loc, err := time.LoadLocation(opt.Tz) if err != nil { log.Printf("invalid tz location: %s", opt.Tz) exit(1) } logger := log.New(os.Stderr, "", log.LstdFlags) if opt.Out != "" { if _, err := os.Stat(filepath.Dir(opt.Out)); err != nil { log.Printf("output dir does not exist: %s", filepath.Base(opt.Out)) exit(1) } file, err := os.OpenFile(opt.Out, os.O_CREATE|os.O_WRONLY, 0777) if err != nil { log.Printf("can't open output file: %s", opt.Out) exit(1) } logger = log.New(file, "", log.LstdFlags) } ctx, cancel := context.WithCancel(context.Background()) return &Tasker{ Log: logger, loc: loc, gron: gron, exprs: exprs, tasks: tasks, verbose: opt.Verbose, ctx: ctx, ctxCancel: cancel, } } // WithContext adds a parent context to the Tasker struct // and begins the abort when Done is received func (t *Tasker) WithContext(ctx context.Context) *Tasker { t.ctx, t.ctxCancel = context.WithCancel(ctx) return t } // Shell gives a pair of shell and arg. // It returns array of string. func Shell(shell ...string) []string { if os.PathSeparator == '\\' { shell = append(shell, "git-bash.exe -c", "bash.exe -c", "powershell.exe -Command") } else { shell = append(shell, "bash -c", "sh -c", "zsh -c") } for _, sh := range shell { arg := "-c" cmd := strings.Split(sh, " -") if len(cmd) > 1 { arg = "-" + cmd[1] } if exc, err := exec.LookPath(cmd[0]); err == nil { return []string{exc, arg} } } return []string{"/bin/sh", "-c"} } const taskIDFormat = "[%s][#%d]" // Task appends new task handler for given cron expr. // It returns Tasker (itself) for fluency and bails if expr is invalid. func (t *Tasker) Task(expr string, task TaskFunc, concurrent ...bool) *Tasker { segs, err := gronx.Segments(expr) if err != nil { log.Fatalf("invalid cron expr: %+v", err) } concurrent = append(concurrent, true) old, expr := gronx.SpaceRe.ReplaceAllString(expr, " "), strings.Join(segs, " ") if _, ok := t.exprs[expr]; !ok { if !t.gron.IsValid(expr) { log.Fatalf("invalid cron expr: %+v", err) } t.exprs[expr] = []string{} } ref := fmt.Sprintf(taskIDFormat, old, len(t.exprs[expr])+1) t.exprs[expr] = append(t.exprs[expr], ref) t.tasks[ref] = task if !concurrent[0] { if len(t.mutex) == 0 { t.mutex = make(map[string]*uint32) } t.mutex[ref] = new(uint32) } return t } // Until sets the cutoff time until which the tasker runs. // It returns itself for fluency. func (t *Tasker) Until(until interface{}) *Tasker { switch until := until.(type) { case time.Duration: t.until = t.now().Add(until) case time.Time: t.until = until default: log.Printf("until must be time.Duration or time.Time, got: %v", reflect.TypeOf(until)) exit(1) } return t } func (t *Tasker) now() time.Time { return time.Now().In(t.loc) } // Run runs the task manager. func (t *Tasker) Run() { t.doSetup() t.running = true first := true for !t.abort && !t.timeout { ref, willTime := t.tickTimer(first) if t.timeout || t.abort { break } tasks := make(map[string]TaskFunc) t.gron.C.SetRef(ref) for expr, refs := range t.exprs { if due, _ := t.gron.SegmentsDue(strings.Split(expr, " ")); !due { continue } for _, ref := range refs { tasks[ref] = t.tasks[ref] } } if len(tasks) > 0 { t.runTasks(tasks) } first = false t.timeout = willTime } t.wait() t.running = false } // Running tells if tasker is up and running func (t *Tasker) Running() bool { return t.running && !t.abort && !t.timeout } // Stop the task manager. func (t *Tasker) Stop() { t.stop() } func (t *Tasker) stop() { t.ctxCancel() t.abort = true } var dateFormat = "2006/01/02 15:04:05" func (t *Tasker) doSetup() { if len(t.tasks) == 0 { t.Log.Fatal("[tasker] no tasks available") } if !t.until.IsZero() && t.verbose { if t.until.Before(t.now()) { log.Fatalf("[tasker] timeout must be in future") } t.Log.Printf("[tasker] final tick on or before %s", t.until.Format(dateFormat)) } // If we have seconds precision tickSec should be 1 for expr := range t.exprs { if expr[0:2] != "0 " { tickSec = 1 break } } sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) go func() { select { case <-sig: case <-t.ctx.Done(): if t.verbose { t.Log.Printf("[tasker] received signal on context.Done, aborting") } } t.stop() }() } var tickSec = 60 func (t *Tasker) tickTimer(first bool) (time.Time, bool) { now, timed, willTime := t.now(), !t.until.IsZero(), false if t.timeout || t.abort { return now, willTime } wait := tickSec - now.Second()%tickSec if !first && wait == 0 { wait = tickSec } if wait < 1 || wait > tickSec { return now, willTime } next := now.Add(time.Duration(wait) * time.Second) willTime = timed && next.After(t.until) if t.verbose && !willTime { t.Log.Printf("[tasker] next tick on %s", next.Format(dateFormat)) } if willTime { next = now.Add(time.Duration(tickSec) - now.Sub(t.until)) } for !t.abort && !t.timeout && t.now().Before(next) { time.Sleep(100 * time.Millisecond) } t.timeout = timed && next.After(t.until) return next, willTime } func (t *Tasker) runTasks(tasks map[string]TaskFunc) { if t.verbose { if t.abort { t.Log.Println("[tasker] completing pending tasks") } else { t.Log.Printf("[tasker] running %d due tasks\n", len(tasks)) } } ctx := context.Background() if t.ctx != nil { ctx = t.ctx } for ref, task := range tasks { if !t.canRun(ref) { continue } t.wg.Add(1) rc := make(chan result) go t.doRun(ctx, ref, task, rc) go t.doOut(rc) } } func (t *Tasker) canRun(ref string) bool { lock, ok := t.mutex[ref] return !ok || atomic.CompareAndSwapUint32(lock, 0, 1) } func (t *Tasker) doRun(ctx context.Context, ref string, task TaskFunc, rc chan result) { defer t.wg.Done() if t.abort || t.timeout { return } if t.verbose { t.Log.Printf("[tasker] task %s running\n", ref) } code, err := task(ctx) if lock, ok := t.mutex[ref]; ok { atomic.StoreUint32(lock, 0) } rc <- result{err, ref, code} } func (t *Tasker) doOut(rc chan result) { res := <-rc if res.err != nil { t.Log.Printf("[tasker] task %s errored %v", res.ref, res.err) } if t.verbose { if res.code == 0 { t.Log.Printf("[tasker] task %s ran successfully", res.ref) } else { t.Log.Printf("[tasker] task %s returned error code: %d", res.ref, res.code) } } } func (t *Tasker) wait() { if !t.abort { t.Log.Println("[tasker] timed out, waiting tasks to complete") } else { t.Log.Println("[tasker] interrupted, waiting tasks to complete") } t.wg.Wait() // Allow a leeway period time.Sleep(100 * time.Microsecond) } golang-github-adhocore-gronx-1.19.6/pkg/tasker/tasker_other.go000066400000000000000000000014631501671473200243610ustar00rootroot00000000000000//go:build !windows // +build !windows package tasker import ( "context" "log" "os/exec" "strings" "syscall" ) // Taskify creates TaskFunc out of plain command wrt given options. func (t *Tasker) Taskify(cmd string, opt Option) TaskFunc { sh := Shell(opt.Shell) return func(ctx context.Context) (int, error) { buf := strings.Builder{} exc := exec.Command(sh[0], sh[1], cmd) exc.Stderr = &buf exc.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if t.Log.Writer() != exc.Stderr { exc.Stdout = t.Log.Writer() } err := exc.Run() if err == nil { return 0, nil } for _, ln := range strings.Split(strings.TrimRight(buf.String(), "\r\n"), "\n") { log.Println(ln) } code := 1 if exErr, ok := err.(*exec.ExitError); ok { code = exErr.ExitCode() } return code, err } } golang-github-adhocore-gronx-1.19.6/pkg/tasker/tasker_test.go000066400000000000000000000132661501671473200242230ustar00rootroot00000000000000package tasker import ( "context" "fmt" "io/ioutil" "os" "strings" "testing" "time" ) func TestNew(t *testing.T) { exit = func(code int) {} t.Run("New invalid Tz", func(t *testing.T) { New(Option{Tz: "Local/Xyz"}) }) t.Run("New invalid Out", func(t *testing.T) { New(Option{Out: "/a/b/c/d/e/f/out.log"}) }) t.Run("Invalid Until", func(t *testing.T) { var zero time.Time taskr := New(Option{}) taskr.Until(time.Now().Add(time.Minute)) taskr.Until(zero) taskr.Until(1) if !taskr.until.IsZero() { t.Error("tasker.until should be zero") } }) } func TestRun(t *testing.T) { t.Run("Run", func(t *testing.T) { tickSec = 1 taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}) called := 0 taskr.Task("* * * * * *", func(_ context.Context) (int, error) { taskr.Log.Println("task [* * * * * *][#1] sleeping 1s") time.Sleep(time.Second) called++ return 0, nil }) // dummy task that will never execute taskr.Task("* * * * * 2022", func(_ context.Context) (int, error) { return 0, nil }) time.Sleep(time.Second - time.Duration(time.Now().Nanosecond())) dur := 2500 * time.Millisecond now := time.Now() taskr.Until(dur).Run() if called != 2 { t.Errorf("task should run 2 times, ran %d times", called) } wait := tickSec - now.Second()%tickSec tickDur := time.Duration(wait) * time.Second start := now.Format(dateFormat) end := now.Add(dur).Format(dateFormat) next1 := now.Add(tickDur).Format(dateFormat) fin1 := now.Add(tickDur + 2*time.Second).Format(dateFormat) next2 := now.Add(tickDur + time.Duration(tickSec)*time.Second).Format(dateFormat) fin2 := now.Add(tickDur + time.Duration(tickSec)*time.Second).Format(dateFormat) buffers := []string{ start + " [tasker] final tick on or before " + end, start + " [tasker] next tick on " + next1, next1 + " [tasker] running 1 due tasks", next1 + " [tasker] next tick on " + next2, next1 + " [tasker] task [* * * * * *][#1] running", next1 + " task [* * * * * *][#1] sleeping 1s", next2 + " [tasker] running 1 due tasks", next2 + " [tasker] task [* * * * * *][#1] running", next2 + " task [* * * * * *][#1] sleeping 1s", fin1 + " [tasker] task [* * * * * *][#1] ran successfully", end + " [tasker] timed out, waiting tasks to complete", fin2 + " [tasker] task [* * * * * *][#1] ran successfully", } buf, _ := ioutil.ReadFile("../../test/tasker.out") buffer := string(buf) fmt.Println(buffer) for _, expect := range buffers { if !strings.Contains(buffer, expect) { t.Errorf("buffer should contain %s", expect) } } }) } func TestTaskify(t *testing.T) { t.Run("Taskify", func(t *testing.T) { ctx := context.TODO() taskr := New(Option{}) code, err := taskr.Taskify("echo -n 'taskify' > ../../test/taskify.out; echo 'test' >> ../../test/taskify.out", Option{})(ctx) if code != 0 { t.Errorf("expected code 0, got %d", code) } if err != nil { t.Errorf("expected no error, got %v", err) } t.Run("Taskify err", func(t *testing.T) { ctx := context.TODO() taskr := New(Option{}) code, err := taskr.Taskify("false", Option{})(ctx) if code != 1 { t.Errorf("expected code 127, got %d", code) } if err == nil { t.Error("expected error") } }) }) } func TestWithContext(t *testing.T) { // tickSec = 2 t.Run("WithContext", func(t *testing.T) { os.Remove("../../test/tasker-ctx.out") ctx, cancel := context.WithCancel(context.Background()) taskr := New(Option{Verbose: true, Out: "../../test/tasker-ctx.out"}).WithContext(ctx) called := 0 taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { called++ ct := 0 Over: for { time.Sleep(300 * time.Millisecond) select { case <-ctx.Done(): break Over default: ct++ } } return 0, nil }) startCh := make(chan bool) go func() { <-startCh time.Sleep(2100 * time.Millisecond) cancel() }() startCh <- true taskr.Until(2200 * time.Millisecond).Run() if called != 2 { t.Errorf("task should run 2 times, ran %d times", called) } buf, _ := ioutil.ReadFile("../../test/tasker-ctx.out") fmt.Println(string(buf)) }) } func TestConcurrency(t *testing.T) { t.Run("Run", func(t *testing.T) { taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}) single := 0 taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { time.Sleep(2500 * time.Millisecond) single++ return 0, nil }, false) concurrent := 0 taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { time.Sleep(1 * time.Second) concurrent++ return 0, nil }, true) taskr.Until(3 * time.Second).Run() if single != 1 { t.Errorf("single task should run 1x, not %dx", single) } if concurrent != 2 { t.Errorf("concurrent task should run 2x, not %dx", concurrent) } }) } func TestStopTasker(t *testing.T) { t.Run("call stop()", func(t *testing.T) { taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}) var incr int taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { incr++ return 0, nil }, false) go func() { time.Sleep(2 * time.Second) taskr.Stop() }() taskr.Run() if incr != 1 { t.Errorf("the task should run 1x, not %dx", incr) } }) t.Run("cancel context", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}).WithContext(ctx) var incr int taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { incr++ return 0, nil }, false) go func() { time.Sleep(2 * time.Second) cancel() }() taskr.Run() if incr != 1 { t.Errorf("the task should run 1x, not %dx", incr) } }) } golang-github-adhocore-gronx-1.19.6/pkg/tasker/tasker_windows.go000066400000000000000000000015341501671473200247310ustar00rootroot00000000000000//go:build windows // +build windows package tasker import ( "context" "log" "os/exec" "strings" "syscall" ) // Taskify creates TaskFunc out of plain command wrt given options. func (t *Tasker) Taskify(cmd string, opt Option) TaskFunc { sh := Shell(opt.Shell) return func(ctx context.Context) (int, error) { buf := strings.Builder{} exc := exec.Command(sh[0], sh[1], cmd) exc.Stderr = &buf exc.SysProcAttr = &syscall.SysProcAttr{ CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, } if t.Log.Writer() != exc.Stderr { exc.Stdout = t.Log.Writer() } err := exc.Run() if err == nil { return 0, nil } for _, ln := range strings.Split(strings.TrimRight(buf.String(), "\r\n"), "\n") { log.Println(ln) } code := 1 if exErr, ok := err.(*exec.ExitError); ok { code = exErr.ExitCode() } return code, err } } golang-github-adhocore-gronx-1.19.6/prev.go000066400000000000000000000031311501671473200205630ustar00rootroot00000000000000package gronx import ( "fmt" "time" ) // PrevTick gives previous run time before now func PrevTick(expr string, inclRefTime bool) (time.Time, error) { return PrevTickBefore(expr, time.Now(), inclRefTime) } // PrevTickBefore gives previous run time before given reference time func PrevTickBefore(expr string, start time.Time, inclRefTime bool) (time.Time, error) { gron, prev := New(), start.Truncate(time.Second) due, err := gron.IsDue(expr, start) if err != nil || (due && inclRefTime) { return prev, err } segments, _ := Segments(expr) if len(segments) > 6 && isUnreachableYear(segments[6], prev, true) { return prev, fmt.Errorf("unreachable year segment: %s", segments[6]) } prev, err = loop(gron, segments, prev, inclRefTime, true) // Ignore superfluous err if err != nil && gron.isDue(expr, prev) { err = nil } return prev, err } func bumpReverse(ref time.Time, pos int) time.Time { loc := ref.Location() switch pos { case 0: ref = ref.Add(-time.Second) case 1: minTime := ref.Add(-time.Minute) ref = time.Date(minTime.Year(), minTime.Month(), minTime.Day(), minTime.Hour(), minTime.Minute(), 59, 0, loc) case 2: hTime := ref.Add(-time.Hour) ref = time.Date(hTime.Year(), hTime.Month(), hTime.Day(), hTime.Hour(), 59, 59, 0, loc) case 3, 5: dTime := ref.AddDate(0, 0, -1) ref = time.Date(dTime.Year(), dTime.Month(), dTime.Day(), 23, 59, 59, 0, loc) case 4: ref = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc) ref = ref.Add(-time.Second) case 6: yTime := ref.AddDate(-1, 0, 0) ref = time.Date(yTime.Year(), 12, 31, 23, 59, 59, 0, loc) } return ref } golang-github-adhocore-gronx-1.19.6/prev_test.go000066400000000000000000000044321501671473200216270ustar00rootroot00000000000000package gronx import ( "fmt" "strings" "testing" "time" ) func TestPrevTick(t *testing.T) { exp := "* * * * * *" t.Run("prev tick "+exp, func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") prev, _ := PrevTickBefore(exp, ref, true) if prev.Format(FullDateFormat) != "2020-02-02 02:02:02" { t.Errorf("[incl] expected %v, got %v", ref, prev) } expect := time.Now().Add(-time.Second).Format(FullDateFormat) prev, _ = PrevTick(exp, false) if expect != prev.Format(FullDateFormat) { t.Errorf("expected %v, got %v", expect, prev) } }) t.Run("prev tick excl "+exp, func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") prev, _ := PrevTickBefore(exp, ref, false) if prev.Format(FullDateFormat) != "2020-02-02 02:02:01" { t.Errorf("[excl] expected %v, got %v", ref, prev) } }) } func TestPrevTickBefore(t *testing.T) { t.Run("prev tick before", func(t *testing.T) { t.Run("seconds precision", func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") next, _ := NextTickAfter("*/5 * * * * *", ref, false) prev, _ := PrevTickBefore("*/5 * * * * *", next, false) if prev.Format(FullDateFormat) != "2020-02-02 02:02:00" { t.Errorf("next > prev should be %s, got %s", "2020-02-02 02:02:00", prev) } }) for i, test := range testcases() { t.Run(fmt.Sprintf("prev tick #%d: %s", i, test.Expr), func(t *testing.T) { ref, _ := time.Parse(FullDateFormat, test.Ref) next1, err := NextTickAfter(test.Expr, ref, false) if err != nil { return } prev1, err := PrevTickBefore(test.Expr, next1, true) if err != nil { if strings.HasPrefix(err.Error(), "unreachable year") { return } t.Errorf("%v", err) } if next1.Format(FullDateFormat) != prev1.Format(FullDateFormat) { t.Errorf("next->prev expect %s, got %s", next1, prev1) } next2, _ := NextTickAfter(test.Expr, next1, false) prev2, err := PrevTickBefore(test.Expr, next2, false) if err != nil { if strings.HasPrefix(err.Error(), "unreachable year") { return } t.Errorf("%s", err) } if next1.Format(FullDateFormat) != prev2.Format(FullDateFormat) { t.Errorf("next->next->prev expect %s, got %s", next1, prev2) } }) } }) } golang-github-adhocore-gronx-1.19.6/test/000077500000000000000000000000001501671473200202415ustar00rootroot00000000000000golang-github-adhocore-gronx-1.19.6/test/taskfile-complex.txt000066400000000000000000000012651501671473200242550ustar00rootroot00000000000000*/1 * * * * echo "1 $(date +'%Y-%m-%d %T')" */2 * ? * ? * echo "2 $(date +'%Y-%m-%d %T')" */3 * ? * ? 0 2000-2024/4 echo "3 $(date +'%Y-%m-%d %T')" */4 * ? * ? 0 2023 echo "4 $(date +'%Y-%m-%d %T')" */5 * ? * ? * 2020-2030 echo "5 $(date +'%Y-%m-%d %T')" */6 * ? * ? 1,2 echo "6 $(date +'%Y-%m-%d %T')" */7 * ? * ? 1#4 2003,2005 echo "7 $(date +'%Y-%m-%d %T')" */8 * ? * ? 2-7/3 echo "8 $(date +'%Y-%m-%d %T')" */9 * ? * ? 1-6 */5 echo "9 $(date +'%Y-%m-%d %T')" */10 * ? * ? sun-mon * echo "10 $(date +'%Y-%m-%d %T')" */11 * ? * ? sun 2020 echo "11 $(date +'%Y-%m-%d %T')" */12 * ? * ? mon echo "12 $(date +'%Y-%m-%d %T')" */13 * ? * ? 1#4 echo "13 $(date +'%Y-%m-%d %T')" golang-github-adhocore-gronx-1.19.6/test/taskfile.txt000066400000000000000000000010251501671473200226020ustar00rootroot00000000000000# this taskfile contains 5 tasks, lines starting with # are comments */1 0/1 * * * echo '[task 1] */1 0/1 * * *' > test/task1.out * * * * * 2021 echo '[task 2] * * * * * 2021' > test/task2.out # below three are equivalent @always echo '[task 3] @always' > test/task3.out @always echo '[task 4] @always' > test/task4.out * * * * * echo '[task 5] * * * * *' > test/task5.out * * * * * echo '[task 6] it should go to outfile' && invalid-cmd # failure tasks @always xgronx @always false # below are invalid @invalid * * * * * golang-github-adhocore-gronx-1.19.6/validator.go000066400000000000000000000061361501671473200216040ustar00rootroot00000000000000package gronx import ( "errors" "fmt" "strconv" "strings" "time" ) func inStep(val int, s string, bounds []int) (bool, error) { parts := strings.Split(s, "/") step, err := strconv.Atoi(parts[1]) if err != nil { return false, err } if step <= 0 { return false, errors.New("step can't be 0") } if strings.Index(s, "*/") == 0 { return (val-bounds[0])%step == 0, nil } if strings.Index(s, "0/") == 0 { return val%step == 0, nil } sub, end := strings.Split(parts[0], "-"), val start, err := strconv.Atoi(sub[0]) if err != nil { return false, err } if len(sub) > 1 { end, err = strconv.Atoi(sub[1]) if err != nil { return false, err } } if (len(sub) > 1 && end < start) || start < bounds[0] || end > bounds[1] { return false, fmt.Errorf("step '%s' out of bounds(%d, %d)", parts[0], bounds[0], bounds[1]) } return inStepRange(val, start, end, step), nil } func inRange(val int, s string, bounds []int) (bool, error) { parts := strings.Split(s, "-") start, err := strconv.Atoi(parts[0]) if err != nil { return false, err } end, err := strconv.Atoi(parts[1]) if err != nil { return false, err } if end < start || start < bounds[0] || end > bounds[1] { return false, fmt.Errorf("range '%s' out of bounds(%d, %d)", s, bounds[0], bounds[1]) } return start <= val && val <= end, nil } func inStepRange(val, start, end, step int) bool { for i := start; i <= end && i <= val; i += step { if i == val { return true } } return false } func isValidMonthDay(val string, last int, ref time.Time) (valid bool, err error) { day, loc := ref.Day(), ref.Location() if val == "L" { return day == last, nil } pos := strings.Index(val, "W") if pos < 1 { return false, errors.New("invalid offset value: " + val) } nval, err := strconv.Atoi(val[0:pos]) if err != nil { return false, err } for _, i := range []int{0, -1, 1, -2, 2} { incr := i + nval if incr > 0 && incr <= last { iref := time.Date(ref.Year(), ref.Month(), incr, ref.Hour(), ref.Minute(), ref.Second(), 0, loc) week := int(iref.Weekday()) if week > 0 && week < 6 && iref.Month() == ref.Month() { valid = day == iref.Day() break } } } return valid, nil } func isValidWeekDay(val string, last int, ref time.Time) (bool, error) { loc := ref.Location() if pos := strings.Index(val, "L"); pos > 0 { nval, err := strconv.Atoi(val[0:pos]) if err != nil { return false, err } for i := 0; i < 7; i++ { day := last - i dref := time.Date(ref.Year(), ref.Month(), day, ref.Hour(), ref.Minute(), ref.Second(), 0, loc) if int(dref.Weekday()) == nval%7 { return ref.Day() == day, nil } } } pos := strings.Index(val, "#") parts := strings.Split(strings.ReplaceAll(val, "7#", "0#"), "#") if pos < 1 || len(parts) < 2 { return false, errors.New("invalid offset value: " + val) } day, err := strconv.Atoi(parts[0]) if err != nil { return false, err } nth, err := strconv.Atoi(parts[1]) if err != nil { return false, err } if day < 0 || day > 7 || nth < 1 || nth > 5 || int(ref.Weekday()) != day { return false, nil } return (ref.Day()-1)/7 == nth-1, nil }