hron 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +98 -0
- data/Rakefile +13 -0
- data/hron.gemspec +39 -0
- data/lib/hron/ast.rb +235 -0
- data/lib/hron/cron.rb +250 -0
- data/lib/hron/display.rb +166 -0
- data/lib/hron/error.rb +64 -0
- data/lib/hron/evaluator.rb +725 -0
- data/lib/hron/lexer.rb +253 -0
- data/lib/hron/parser.rb +617 -0
- data/lib/hron/schedule.rb +75 -0
- data/lib/hron/version.rb +5 -0
- data/lib/hron.rb +30 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ee599aa7c2d192e696b13aa9ce580646930f177d12fea11207c8a0364ce72098
|
|
4
|
+
data.tar.gz: 6c0ad8e2e53cad36ceb9e3cf6f93ea2d8a981d1201c739680b3d90fecc74fcc6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 160761a9f062f461cc93613fb4008279fac3200183b128079440c734a328e32a8f682c374692307420ce8c0e672523e8d9db0df5ee854bd096aef4f8b084ce5e
|
|
7
|
+
data.tar.gz: f216e50bc6d69ae4a6a6a22396eea71777b051e34e4af3ff21fd245f45a1936385f0a70999276f977c1fb9f12cc16b9a1ce9a594449d4d51b3d01e5799a2a179
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# hron
|
|
2
|
+
|
|
3
|
+
**Human-readable cron** — scheduling expressions that are a superset of what cron can express.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require 'hron'
|
|
7
|
+
|
|
8
|
+
schedule = Hron::Schedule.parse("every weekday at 9:00 in America/New_York")
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
gem install hron
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'hron'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require 'hron'
|
|
27
|
+
|
|
28
|
+
# Parse an expression
|
|
29
|
+
schedule = Hron::Schedule.parse("every weekday at 9:00 except dec 25, jan 1 in America/New_York")
|
|
30
|
+
|
|
31
|
+
# Get next occurrence
|
|
32
|
+
now = Time.now
|
|
33
|
+
next_time = schedule.next_from(now)
|
|
34
|
+
puts next_time
|
|
35
|
+
|
|
36
|
+
# Get next 5 occurrences
|
|
37
|
+
upcoming = schedule.next_n_from(now, 5)
|
|
38
|
+
upcoming.each { |t| puts t }
|
|
39
|
+
|
|
40
|
+
# Check if a time matches
|
|
41
|
+
schedule.matches(Time.new(2026, 2, 9, 9, 0, 0)) # true
|
|
42
|
+
|
|
43
|
+
# Convert to/from cron
|
|
44
|
+
simple = Hron::Schedule.parse("every day at 9:00")
|
|
45
|
+
puts simple.to_cron # "0 9 * * *"
|
|
46
|
+
|
|
47
|
+
from_cron = Hron::Schedule.from_cron("*/30 * * * *")
|
|
48
|
+
puts from_cron # "every 30 min from 00:00 to 23:59"
|
|
49
|
+
|
|
50
|
+
# Validate without exceptions
|
|
51
|
+
Hron::Schedule.validate("every day at 9:00") # true
|
|
52
|
+
Hron::Schedule.validate("invalid") # false
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Expression Syntax
|
|
56
|
+
|
|
57
|
+
See the full [expression reference](https://github.com/prasrvenkat/hron#expression-syntax).
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
### `Hron::Schedule.parse(input) -> Schedule`
|
|
62
|
+
Parse an hron expression string.
|
|
63
|
+
|
|
64
|
+
### `Hron::Schedule.from_cron(cron_expr) -> Schedule`
|
|
65
|
+
Convert a 5-field cron expression to a Schedule.
|
|
66
|
+
|
|
67
|
+
### `Hron::Schedule.validate(input) -> Boolean`
|
|
68
|
+
Check if an input string is a valid hron expression.
|
|
69
|
+
|
|
70
|
+
### `schedule.next_from(now) -> Time | nil`
|
|
71
|
+
Compute the next occurrence after `now`.
|
|
72
|
+
|
|
73
|
+
### `schedule.next_n_from(now, n) -> Array<Time>`
|
|
74
|
+
Compute the next `n` occurrences after `now`.
|
|
75
|
+
|
|
76
|
+
### `schedule.matches(time) -> Boolean`
|
|
77
|
+
Check if a time matches this schedule.
|
|
78
|
+
|
|
79
|
+
### `schedule.to_cron -> String`
|
|
80
|
+
Convert to a 5-field cron expression. Raises `Hron::HronError` if the schedule can't be expressed as cron.
|
|
81
|
+
|
|
82
|
+
### `schedule.to_s -> String`
|
|
83
|
+
Render as the canonical string form (roundtrip-safe).
|
|
84
|
+
|
|
85
|
+
### `schedule.timezone -> String | nil`
|
|
86
|
+
The timezone, if specified.
|
|
87
|
+
|
|
88
|
+
### `schedule.expression -> ScheduleExpr`
|
|
89
|
+
The underlying schedule expression AST.
|
|
90
|
+
|
|
91
|
+
## Requirements
|
|
92
|
+
|
|
93
|
+
- Ruby >= 3.2
|
|
94
|
+
- TZInfo gem for timezone support
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "standard/rake"
|
|
6
|
+
|
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
|
8
|
+
t.libs << "test"
|
|
9
|
+
t.libs << "lib"
|
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
task default: %i[test standard]
|
data/hron.gemspec
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/hron/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "hron"
|
|
7
|
+
spec.version = Hron::VERSION
|
|
8
|
+
spec.authors = ["Prasanna Venkataraman"]
|
|
9
|
+
spec.email = ["prasrvenkat@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Human-readable cron — a scheduling expression language that is a superset of cron"
|
|
12
|
+
spec.description = "HRON (Human Readable Object Notation for schedules) is a scheduling expression language " \
|
|
13
|
+
"that is designed to be easy to read, write, and understand. It is a superset of cron, " \
|
|
14
|
+
"meaning any valid cron expression can be converted to and from HRON."
|
|
15
|
+
spec.homepage = "https://github.com/prasrvenkat/hron"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 4.0.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = "https://github.com/prasrvenkat/hron"
|
|
21
|
+
spec.metadata["changelog_uri"] = "https://github.com/prasrvenkat/hron/blob/main/CHANGELOG.md"
|
|
22
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
26
|
+
(File.expand_path(f) == __FILE__) ||
|
|
27
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
spec.add_dependency "tzinfo", "~> 2.0"
|
|
35
|
+
|
|
36
|
+
spec.add_development_dependency "minitest", "~> 5.20"
|
|
37
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
38
|
+
spec.add_development_dependency "standard", "~> 1.43"
|
|
39
|
+
end
|
data/lib/hron/ast.rb
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hron
|
|
4
|
+
# Weekday enumeration (ISO 8601: Monday=1, Sunday=7)
|
|
5
|
+
module Weekday
|
|
6
|
+
MONDAY = :monday
|
|
7
|
+
TUESDAY = :tuesday
|
|
8
|
+
WEDNESDAY = :wednesday
|
|
9
|
+
THURSDAY = :thursday
|
|
10
|
+
FRIDAY = :friday
|
|
11
|
+
SATURDAY = :saturday
|
|
12
|
+
SUNDAY = :sunday
|
|
13
|
+
|
|
14
|
+
ALL = [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY].freeze
|
|
15
|
+
WEEKDAYS = [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY].freeze
|
|
16
|
+
WEEKEND = [SATURDAY, SUNDAY].freeze
|
|
17
|
+
|
|
18
|
+
NUMBERS = {
|
|
19
|
+
MONDAY => 1, TUESDAY => 2, WEDNESDAY => 3, THURSDAY => 4,
|
|
20
|
+
FRIDAY => 5, SATURDAY => 6, SUNDAY => 7
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
CRON_DOW = {
|
|
24
|
+
SUNDAY => 0, MONDAY => 1, TUESDAY => 2, WEDNESDAY => 3,
|
|
25
|
+
THURSDAY => 4, FRIDAY => 5, SATURDAY => 6
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
NUMBER_TO_WEEKDAY = NUMBERS.invert.freeze
|
|
29
|
+
|
|
30
|
+
PARSE_MAP = {
|
|
31
|
+
"monday" => MONDAY, "mon" => MONDAY,
|
|
32
|
+
"tuesday" => TUESDAY, "tue" => TUESDAY,
|
|
33
|
+
"wednesday" => WEDNESDAY, "wed" => WEDNESDAY,
|
|
34
|
+
"thursday" => THURSDAY, "thu" => THURSDAY,
|
|
35
|
+
"friday" => FRIDAY, "fri" => FRIDAY,
|
|
36
|
+
"saturday" => SATURDAY, "sat" => SATURDAY,
|
|
37
|
+
"sunday" => SUNDAY, "sun" => SUNDAY
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def self.number(day)
|
|
41
|
+
NUMBERS[day]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.cron_dow(day)
|
|
45
|
+
CRON_DOW[day]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.from_number(n)
|
|
49
|
+
NUMBER_TO_WEEKDAY[n]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.try_parse(s)
|
|
53
|
+
PARSE_MAP[s.downcase]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.to_s(day)
|
|
57
|
+
day.to_s
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Month name enumeration
|
|
62
|
+
module MonthName
|
|
63
|
+
JAN = :jan
|
|
64
|
+
FEB = :feb
|
|
65
|
+
MAR = :mar
|
|
66
|
+
APR = :apr
|
|
67
|
+
MAY = :may
|
|
68
|
+
JUN = :jun
|
|
69
|
+
JUL = :jul
|
|
70
|
+
AUG = :aug
|
|
71
|
+
SEP = :sep
|
|
72
|
+
OCT = :oct
|
|
73
|
+
NOV = :nov
|
|
74
|
+
DEC = :dec
|
|
75
|
+
|
|
76
|
+
ALL = [JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC].freeze
|
|
77
|
+
|
|
78
|
+
NUMBERS = {
|
|
79
|
+
JAN => 1, FEB => 2, MAR => 3, APR => 4, MAY => 5, JUN => 6,
|
|
80
|
+
JUL => 7, AUG => 8, SEP => 9, OCT => 10, NOV => 11, DEC => 12
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
NUMBER_TO_MONTH = NUMBERS.invert.freeze
|
|
84
|
+
|
|
85
|
+
PARSE_MAP = {
|
|
86
|
+
"january" => JAN, "jan" => JAN,
|
|
87
|
+
"february" => FEB, "feb" => FEB,
|
|
88
|
+
"march" => MAR, "mar" => MAR,
|
|
89
|
+
"april" => APR, "apr" => APR,
|
|
90
|
+
"may" => MAY,
|
|
91
|
+
"june" => JUN, "jun" => JUN,
|
|
92
|
+
"july" => JUL, "jul" => JUL,
|
|
93
|
+
"august" => AUG, "aug" => AUG,
|
|
94
|
+
"september" => SEP, "sep" => SEP,
|
|
95
|
+
"october" => OCT, "oct" => OCT,
|
|
96
|
+
"november" => NOV, "nov" => NOV,
|
|
97
|
+
"december" => DEC, "dec" => DEC
|
|
98
|
+
}.freeze
|
|
99
|
+
|
|
100
|
+
def self.number(month)
|
|
101
|
+
NUMBERS[month]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.from_number(n)
|
|
105
|
+
NUMBER_TO_MONTH[n]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.try_parse(s)
|
|
109
|
+
PARSE_MAP[s.downcase]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.to_s(month)
|
|
113
|
+
month.to_s
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Interval unit (minutes or hours)
|
|
118
|
+
module IntervalUnit
|
|
119
|
+
MIN = :min
|
|
120
|
+
HOURS = :hours
|
|
121
|
+
|
|
122
|
+
def self.to_s(unit)
|
|
123
|
+
unit.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Ordinal position (first, second, etc.)
|
|
128
|
+
module OrdinalPosition
|
|
129
|
+
FIRST = :first
|
|
130
|
+
SECOND = :second
|
|
131
|
+
THIRD = :third
|
|
132
|
+
FOURTH = :fourth
|
|
133
|
+
FIFTH = :fifth
|
|
134
|
+
LAST = :last
|
|
135
|
+
|
|
136
|
+
TO_N = {
|
|
137
|
+
FIRST => 1, SECOND => 2, THIRD => 3, FOURTH => 4, FIFTH => 5
|
|
138
|
+
}.freeze
|
|
139
|
+
|
|
140
|
+
def self.to_n(ord)
|
|
141
|
+
TO_N[ord]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.to_s(ord)
|
|
145
|
+
ord.to_s
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Time of day (hour and minute)
|
|
150
|
+
TimeOfDay = Data.define(:hour, :minute) do
|
|
151
|
+
def to_s
|
|
152
|
+
format("%02d:%02d", hour, minute)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- Day filter variants ---
|
|
157
|
+
|
|
158
|
+
DayFilterEvery = Data.define
|
|
159
|
+
DayFilterWeekday = Data.define
|
|
160
|
+
DayFilterWeekend = Data.define
|
|
161
|
+
DayFilterDays = Data.define(:days) # days: Array<Weekday>
|
|
162
|
+
|
|
163
|
+
# --- Day of month spec ---
|
|
164
|
+
|
|
165
|
+
SingleDay = Data.define(:day)
|
|
166
|
+
DayRange = Data.define(:start, :end_day) # end_day to avoid Ruby keyword
|
|
167
|
+
|
|
168
|
+
# --- Month target variants ---
|
|
169
|
+
|
|
170
|
+
DaysTarget = Data.define(:specs) # specs: Array<DayOfMonthSpec>
|
|
171
|
+
LastDayTarget = Data.define
|
|
172
|
+
LastWeekdayTarget = Data.define
|
|
173
|
+
|
|
174
|
+
# --- Year target variants ---
|
|
175
|
+
|
|
176
|
+
YearDateTarget = Data.define(:month, :day)
|
|
177
|
+
YearOrdinalWeekdayTarget = Data.define(:ordinal, :weekday, :month)
|
|
178
|
+
YearDayOfMonthTarget = Data.define(:day, :month)
|
|
179
|
+
YearLastWeekdayTarget = Data.define(:month)
|
|
180
|
+
|
|
181
|
+
# --- Date spec variants ---
|
|
182
|
+
|
|
183
|
+
NamedDate = Data.define(:month, :day)
|
|
184
|
+
IsoDate = Data.define(:date) # date: String (YYYY-MM-DD)
|
|
185
|
+
|
|
186
|
+
# --- Exception spec variants ---
|
|
187
|
+
|
|
188
|
+
NamedException = Data.define(:month, :day)
|
|
189
|
+
IsoException = Data.define(:date)
|
|
190
|
+
|
|
191
|
+
# --- Until spec variants ---
|
|
192
|
+
|
|
193
|
+
IsoUntil = Data.define(:date)
|
|
194
|
+
NamedUntil = Data.define(:month, :day)
|
|
195
|
+
|
|
196
|
+
# --- Schedule expression variants ---
|
|
197
|
+
|
|
198
|
+
IntervalRepeat = Data.define(:interval, :unit, :from_time, :to_time, :day_filter)
|
|
199
|
+
DayRepeat = Data.define(:interval, :days, :times)
|
|
200
|
+
WeekRepeat = Data.define(:interval, :days, :times)
|
|
201
|
+
MonthRepeat = Data.define(:interval, :target, :times)
|
|
202
|
+
OrdinalRepeat = Data.define(:interval, :ordinal, :day, :times)
|
|
203
|
+
SingleDateExpr = Data.define(:date, :times)
|
|
204
|
+
YearRepeat = Data.define(:interval, :target, :times)
|
|
205
|
+
|
|
206
|
+
# --- Schedule data (top-level) ---
|
|
207
|
+
|
|
208
|
+
ScheduleData = Data.define(:expr, :timezone, :except, :until, :anchor, :during) do
|
|
209
|
+
def initialize(expr:, timezone: nil, except: [], until: nil, anchor: nil, during: [])
|
|
210
|
+
super
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# --- Helper functions ---
|
|
215
|
+
|
|
216
|
+
def self.expand_day_spec(spec)
|
|
217
|
+
case spec
|
|
218
|
+
when SingleDay
|
|
219
|
+
[spec.day]
|
|
220
|
+
when DayRange
|
|
221
|
+
(spec.start..spec.end_day).to_a
|
|
222
|
+
else
|
|
223
|
+
[]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def self.expand_month_target(target)
|
|
228
|
+
case target
|
|
229
|
+
when DaysTarget
|
|
230
|
+
target.specs.flat_map { |spec| expand_day_spec(spec) }
|
|
231
|
+
else
|
|
232
|
+
[]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
data/lib/hron/cron.rb
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ast"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module Hron
|
|
7
|
+
# Cron conversion module
|
|
8
|
+
module Cron
|
|
9
|
+
CRON_DOW_MAP = {
|
|
10
|
+
0 => Weekday::SUNDAY,
|
|
11
|
+
1 => Weekday::MONDAY,
|
|
12
|
+
2 => Weekday::TUESDAY,
|
|
13
|
+
3 => Weekday::WEDNESDAY,
|
|
14
|
+
4 => Weekday::THURSDAY,
|
|
15
|
+
5 => Weekday::FRIDAY,
|
|
16
|
+
6 => Weekday::SATURDAY,
|
|
17
|
+
7 => Weekday::SUNDAY
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.to_cron(schedule)
|
|
21
|
+
raise HronError.cron("not expressible as cron (except clauses not supported)") unless schedule.except.empty?
|
|
22
|
+
|
|
23
|
+
raise HronError.cron("not expressible as cron (until clauses not supported)") if schedule.until
|
|
24
|
+
|
|
25
|
+
raise HronError.cron("not expressible as cron (during clauses not supported)") unless schedule.during.empty?
|
|
26
|
+
|
|
27
|
+
expr = schedule.expr
|
|
28
|
+
|
|
29
|
+
case expr
|
|
30
|
+
when DayRepeat
|
|
31
|
+
raise HronError.cron("not expressible as cron (multi-day intervals not supported)") if expr.interval > 1
|
|
32
|
+
raise HronError.cron("not expressible as cron (multiple times not supported)") if expr.times.length != 1
|
|
33
|
+
|
|
34
|
+
time = expr.times[0]
|
|
35
|
+
dow = day_filter_to_cron_dow(expr.days)
|
|
36
|
+
"#{time.minute} #{time.hour} * * #{dow}"
|
|
37
|
+
|
|
38
|
+
when IntervalRepeat
|
|
39
|
+
full_day = expr.from_time.hour.zero? && expr.from_time.minute.zero? &&
|
|
40
|
+
expr.to_time.hour == 23 && expr.to_time.minute == 59
|
|
41
|
+
raise HronError.cron("not expressible as cron (partial-day interval windows not supported)") unless full_day
|
|
42
|
+
|
|
43
|
+
raise HronError.cron("not expressible as cron (interval with day filter not supported)") if expr.day_filter
|
|
44
|
+
|
|
45
|
+
if expr.unit == IntervalUnit::MIN
|
|
46
|
+
if (60 % expr.interval) != 0
|
|
47
|
+
raise HronError.cron("not expressible as cron (*/#{expr.interval} breaks at hour boundaries)")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
"*/#{expr.interval} * * * *"
|
|
51
|
+
else
|
|
52
|
+
"0 */#{expr.interval} * * *"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
when WeekRepeat
|
|
56
|
+
raise HronError.cron("not expressible as cron (multi-week intervals not supported)")
|
|
57
|
+
|
|
58
|
+
when MonthRepeat
|
|
59
|
+
raise HronError.cron("not expressible as cron (multi-month intervals not supported)") if expr.interval > 1
|
|
60
|
+
raise HronError.cron("not expressible as cron (multiple times not supported)") if expr.times.length != 1
|
|
61
|
+
|
|
62
|
+
time = expr.times[0]
|
|
63
|
+
case expr.target
|
|
64
|
+
when DaysTarget
|
|
65
|
+
expanded = []
|
|
66
|
+
expr.target.specs.each do |s|
|
|
67
|
+
case s
|
|
68
|
+
when SingleDay
|
|
69
|
+
expanded << s.day
|
|
70
|
+
when DayRange
|
|
71
|
+
(s.start..s.end_day).each { |d| expanded << d }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
dom = expanded.join(",")
|
|
75
|
+
"#{time.minute} #{time.hour} #{dom} * *"
|
|
76
|
+
when LastDayTarget
|
|
77
|
+
raise HronError.cron("not expressible as cron (last day of month not supported)")
|
|
78
|
+
else
|
|
79
|
+
raise HronError.cron("not expressible as cron (last weekday of month not supported)")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
when OrdinalRepeat
|
|
83
|
+
raise HronError.cron("not expressible as cron (ordinal weekday of month not supported)")
|
|
84
|
+
|
|
85
|
+
when SingleDateExpr
|
|
86
|
+
raise HronError.cron("not expressible as cron (single dates are not repeating)")
|
|
87
|
+
|
|
88
|
+
when YearRepeat
|
|
89
|
+
raise HronError.cron("not expressible as cron (yearly schedules not supported in 5-field cron)")
|
|
90
|
+
|
|
91
|
+
else
|
|
92
|
+
raise HronError.cron("unknown expression type: #{expr.class}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.day_filter_to_cron_dow(filter)
|
|
97
|
+
case filter
|
|
98
|
+
when DayFilterEvery
|
|
99
|
+
"*"
|
|
100
|
+
when DayFilterWeekday
|
|
101
|
+
"1-5"
|
|
102
|
+
when DayFilterWeekend
|
|
103
|
+
"0,6"
|
|
104
|
+
when DayFilterDays
|
|
105
|
+
nums = filter.days.map { |d| Weekday.cron_dow(d) }.sort
|
|
106
|
+
nums.join(",")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.from_cron(cron_str)
|
|
111
|
+
fields = cron_str.strip.split
|
|
112
|
+
raise HronError.cron("expected 5 cron fields, got #{fields.length}") if fields.length != 5
|
|
113
|
+
|
|
114
|
+
minute_field, hour_field, dom_field, _month_field, dow_field = fields
|
|
115
|
+
|
|
116
|
+
# Minute interval: */N
|
|
117
|
+
if minute_field.start_with?("*/")
|
|
118
|
+
interval_str = minute_field[2..]
|
|
119
|
+
begin
|
|
120
|
+
interval = Integer(interval_str)
|
|
121
|
+
rescue ArgumentError
|
|
122
|
+
raise HronError.cron("invalid minute interval")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
from_hour = 0
|
|
126
|
+
to_hour = 23
|
|
127
|
+
|
|
128
|
+
if hour_field == "*"
|
|
129
|
+
# full day
|
|
130
|
+
elsif hour_field.include?("-")
|
|
131
|
+
parts = hour_field.split("-")
|
|
132
|
+
begin
|
|
133
|
+
from_hour = Integer(parts[0])
|
|
134
|
+
to_hour = Integer(parts[1])
|
|
135
|
+
rescue ArgumentError, IndexError
|
|
136
|
+
raise HronError.cron("invalid hour range")
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
begin
|
|
140
|
+
h = Integer(hour_field)
|
|
141
|
+
rescue ArgumentError
|
|
142
|
+
raise HronError.cron("invalid hour")
|
|
143
|
+
end
|
|
144
|
+
from_hour = h
|
|
145
|
+
to_hour = h
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
day_filter = (dow_field == "*") ? nil : parse_cron_dow(dow_field)
|
|
149
|
+
|
|
150
|
+
if dom_field == "*"
|
|
151
|
+
return ScheduleData.new(
|
|
152
|
+
expr: IntervalRepeat.new(
|
|
153
|
+
interval,
|
|
154
|
+
IntervalUnit::MIN,
|
|
155
|
+
TimeOfDay.new(from_hour, 0),
|
|
156
|
+
TimeOfDay.new(to_hour, (to_hour == 23) ? 59 : 0),
|
|
157
|
+
day_filter
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Hour interval: 0 */N
|
|
164
|
+
if hour_field.start_with?("*/") && minute_field == "0"
|
|
165
|
+
interval_str = hour_field[2..]
|
|
166
|
+
begin
|
|
167
|
+
interval = Integer(interval_str)
|
|
168
|
+
rescue ArgumentError
|
|
169
|
+
raise HronError.cron("invalid hour interval")
|
|
170
|
+
end
|
|
171
|
+
if dom_field == "*" && dow_field == "*"
|
|
172
|
+
return ScheduleData.new(
|
|
173
|
+
expr: IntervalRepeat.new(
|
|
174
|
+
interval,
|
|
175
|
+
IntervalUnit::HOURS,
|
|
176
|
+
TimeOfDay.new(0, 0),
|
|
177
|
+
TimeOfDay.new(23, 59),
|
|
178
|
+
nil
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Standard time-based cron
|
|
185
|
+
begin
|
|
186
|
+
minute = Integer(minute_field)
|
|
187
|
+
rescue ArgumentError
|
|
188
|
+
raise HronError.cron("invalid minute field: #{minute_field}")
|
|
189
|
+
end
|
|
190
|
+
begin
|
|
191
|
+
hour = Integer(hour_field)
|
|
192
|
+
rescue ArgumentError
|
|
193
|
+
raise HronError.cron("invalid hour field: #{hour_field}")
|
|
194
|
+
end
|
|
195
|
+
t = TimeOfDay.new(hour, minute)
|
|
196
|
+
|
|
197
|
+
# DOM-based (monthly)
|
|
198
|
+
if dom_field != "*" && dow_field == "*"
|
|
199
|
+
raise HronError.cron("DOM ranges not supported: #{dom_field}") if dom_field.include?("-")
|
|
200
|
+
|
|
201
|
+
day_nums = []
|
|
202
|
+
dom_field.split(",").each do |s|
|
|
203
|
+
begin
|
|
204
|
+
n = Integer(s)
|
|
205
|
+
rescue ArgumentError
|
|
206
|
+
raise HronError.cron("invalid DOM field: #{dom_field}")
|
|
207
|
+
end
|
|
208
|
+
day_nums << n
|
|
209
|
+
end
|
|
210
|
+
specs = day_nums.map { |d| SingleDay.new(d) }
|
|
211
|
+
return ScheduleData.new(
|
|
212
|
+
expr: MonthRepeat.new(1, DaysTarget.new(specs), [t])
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# DOW-based (day repeat)
|
|
217
|
+
days = parse_cron_dow(dow_field)
|
|
218
|
+
expr = DayRepeat.new(1, days, [t])
|
|
219
|
+
ScheduleData.new(expr: expr)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.parse_cron_dow(field)
|
|
223
|
+
return DayFilterEvery.new if field == "*"
|
|
224
|
+
return DayFilterWeekday.new if field == "1-5"
|
|
225
|
+
return DayFilterWeekend.new if ["0,6", "6,0"].include?(field)
|
|
226
|
+
|
|
227
|
+
raise HronError.cron("DOW ranges not supported: #{field}") if field.include?("-")
|
|
228
|
+
|
|
229
|
+
nums = []
|
|
230
|
+
field.split(",").each do |s|
|
|
231
|
+
begin
|
|
232
|
+
n = Integer(s)
|
|
233
|
+
rescue ArgumentError
|
|
234
|
+
raise HronError.cron("invalid DOW field: #{field}")
|
|
235
|
+
end
|
|
236
|
+
nums << n
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
days = nums.map { |n| cron_dow_to_weekday(n) }
|
|
240
|
+
DayFilterDays.new(days)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.cron_dow_to_weekday(n)
|
|
244
|
+
result = CRON_DOW_MAP[n]
|
|
245
|
+
raise HronError.cron("invalid DOW number: #{n}") unless result
|
|
246
|
+
|
|
247
|
+
result
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|