hron 1.0.0 → 2.1.0
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 +4 -4
- data/README.md +126 -21
- data/hron.gemspec +7 -11
- data/lib/hron/ast.rb +28 -54
- data/lib/hron/cron.rb +369 -652
- data/lib/hron/display.rb +6 -4
- data/lib/hron/error.rb +54 -17
- data/lib/hron/eval/calendar.rb +120 -0
- data/lib/hron/eval/wall_clock.rb +65 -0
- data/lib/hron/evaluator.rb +339 -1010
- data/lib/hron/lexer.rb +84 -94
- data/lib/hron/parser.rb +369 -502
- data/lib/hron/parts.rb +257 -0
- data/lib/hron/schedule.rb +81 -19
- data/lib/hron/version.rb +1 -1
- data/lib/hron.rb +13 -3
- metadata +14 -53
data/lib/hron/cron.rb
CHANGED
|
@@ -2,746 +2,463 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "ast"
|
|
4
4
|
require_relative "error"
|
|
5
|
+
require_relative "evaluator"
|
|
5
6
|
|
|
6
7
|
module Hron
|
|
7
|
-
# Cron conversion module
|
|
8
8
|
module Cron
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
MAX_LISTED_TIMES = 24
|
|
10
|
+
BOTH_DAYS_RESTRICTED =
|
|
11
|
+
"not expressible in hron: cron fires on either the day of month or the day of week"
|
|
12
|
+
INTERVAL_DAYS =
|
|
13
|
+
"not expressible in hron: an interval runs only on every day, weekdays, the weekend or listed days"
|
|
14
|
+
MINUTES_PER_DAY = 24 * 60
|
|
15
|
+
MIDNIGHT = TimeOfDay.new(0, 0)
|
|
16
|
+
END_OF_DAY = TimeOfDay.new(23, 59)
|
|
17
|
+
|
|
18
|
+
# Digit strings may be of any length. Every number at or above this cap is out
|
|
19
|
+
# of every field's range and steps past every range's end, so saturating at it
|
|
20
|
+
# keeps each comparison exact.
|
|
21
|
+
NUMBER_CAP = 1000
|
|
22
|
+
|
|
23
|
+
MONTH_NAMES = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
|
|
24
|
+
DAY_NAMES = %w[sun mon tue wed thu fri sat].freeze
|
|
25
|
+
WEEKDAYS = [
|
|
26
|
+
Weekday::SUNDAY, Weekday::MONDAY, Weekday::TUESDAY, Weekday::WEDNESDAY,
|
|
27
|
+
Weekday::THURSDAY, Weekday::FRIDAY, Weekday::SATURDAY
|
|
28
|
+
].freeze
|
|
29
|
+
ORDINALS = [
|
|
30
|
+
OrdinalPosition::FIRST, OrdinalPosition::SECOND, OrdinalPosition::THIRD,
|
|
31
|
+
OrdinalPosition::FOURTH, OrdinalPosition::FIFTH
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
SHORTCUTS = {
|
|
35
|
+
"@yearly" => "0 0 1 1 *",
|
|
36
|
+
"@annually" => "0 0 1 1 *",
|
|
37
|
+
"@monthly" => "0 0 1 * *",
|
|
38
|
+
"@weekly" => "0 0 * * 0",
|
|
39
|
+
"@daily" => "0 0 * * *",
|
|
40
|
+
"@midnight" => "0 0 * * *",
|
|
41
|
+
"@hourly" => "0 * * * *"
|
|
18
42
|
}.freeze
|
|
19
43
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
Field = Data.define(:name, :min, :max, :star_end, :names)
|
|
45
|
+
MINUTE = Field.new("minute", 0, 59, 59, [])
|
|
46
|
+
HOUR = Field.new("hour", 0, 23, 23, [])
|
|
47
|
+
DAY_OF_MONTH = Field.new("day of month", 1, 31, 31, [])
|
|
48
|
+
MONTH = Field.new("month", 1, 12, 12, MONTH_NAMES)
|
|
49
|
+
# 7 is Sunday only where written: `*` and `a/n` end at 6.
|
|
50
|
+
DAY_OF_WEEK = Field.new("day of week", 0, 7, 6, DAY_NAMES)
|
|
51
|
+
|
|
52
|
+
Item = Data.define(:bounds, :step)
|
|
53
|
+
|
|
54
|
+
private_constant :MAX_LISTED_TIMES, :BOTH_DAYS_RESTRICTED, :INTERVAL_DAYS, :MINUTES_PER_DAY,
|
|
55
|
+
:MIDNIGHT, :END_OF_DAY, :NUMBER_CAP, :MONTH_NAMES, :DAY_NAMES, :WEEKDAYS, :ORDINALS,
|
|
56
|
+
:SHORTCUTS, :Field, :MINUTE, :HOUR, :DAY_OF_MONTH, :MONTH, :DAY_OF_WEEK, :Item
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
def from_cron(input)
|
|
60
|
+
text = trim(utf8(input))
|
|
61
|
+
text = shortcut(text) if text.start_with?("@")
|
|
62
|
+
fields = text.split(/[ \t]+/)
|
|
63
|
+
raise HronError.cron("expected 5 cron fields, got #{fields.length}") unless fields.length == 5
|
|
64
|
+
|
|
65
|
+
minute, hour, day_of_month, month, day_of_week = fields
|
|
66
|
+
minutes = values(minute, MINUTE).sort
|
|
67
|
+
hours = values(hour, HOUR).sort
|
|
68
|
+
month_days = parse_day_of_month(day_of_month)
|
|
69
|
+
months = values(month, MONTH).sort
|
|
70
|
+
week_days = parse_day_of_week(day_of_week)
|
|
71
|
+
days = day_expression(month_days, week_days)
|
|
72
|
+
times = hours.flat_map { |h| minutes.map { |m| TimeOfDay.new(h, m) } }
|
|
73
|
+
|
|
74
|
+
gap = equal_gap(times)
|
|
75
|
+
expr = if days.key?(:of_week) && gap
|
|
76
|
+
interval(times, gap, days[:of_week])
|
|
77
|
+
elsif times.length > MAX_LISTED_TIMES
|
|
78
|
+
raise too_many_times(times.length, gap)
|
|
79
|
+
elsif (target = year_target(days, months))
|
|
80
|
+
YearRepeat.new(1, target, times)
|
|
81
|
+
elsif days.key?(:of_week)
|
|
82
|
+
DayRepeat.new(1, days[:of_week], times)
|
|
51
83
|
else
|
|
52
|
-
|
|
84
|
+
MonthRepeat.new(1, days[:of_month], times)
|
|
53
85
|
end
|
|
86
|
+
yearly = expr.is_a?(YearRepeat)
|
|
87
|
+
during = (!yearly && months.length < MonthName::ALL.length) ? months.map { |m| MonthName::ALL[m - 1] } : []
|
|
88
|
+
ScheduleData.new(expression: expr, during: during)
|
|
89
|
+
end
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
raise
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
91
|
+
def to_cron(schedule)
|
|
92
|
+
raise not_expressible("except clauses not supported") unless schedule.except.empty?
|
|
93
|
+
raise not_expressible("until clauses not supported") if schedule.until
|
|
94
|
+
raise not_expressible("starting clauses not supported") if schedule.starting
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
when LastWeekdayTarget
|
|
79
|
-
raise HronError.cron("not expressible as cron (last weekday of month not supported)")
|
|
80
|
-
when NearestWeekdayTarget
|
|
81
|
-
if expr.target.direction
|
|
82
|
-
raise HronError.cron("not expressible as cron (directional nearest weekday not supported)")
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
"#{time.minute} #{time.hour} #{expr.target.day}W * *"
|
|
86
|
-
when OrdinalWeekdayTarget
|
|
87
|
-
raise HronError.cron("not expressible as cron (ordinal weekday of month not supported)")
|
|
88
|
-
else
|
|
89
|
-
raise HronError.cron("not expressible as cron (unsupported month target)")
|
|
90
|
-
end
|
|
96
|
+
day_of_month, day_of_week = day_fields(schedule.expression)
|
|
97
|
+
month = month_field(schedule)
|
|
98
|
+
minute, hour = time_fields(schedule.expression)
|
|
99
|
+
"#{minute} #{hour} #{day_of_month} #{month} #{day_of_week}"
|
|
100
|
+
end
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
raise HronError.cron("not expressible as cron (single dates are not repeating)")
|
|
102
|
+
private
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
# Error messages echo the input, so they must be valid UTF-8. No byte outside ASCII
|
|
105
|
+
# is valid cron, so replacing one changes no result. BINARY text, and text in an
|
|
106
|
+
# encoding Ruby has no converter for, is read as UTF-8.
|
|
107
|
+
def utf8(input)
|
|
108
|
+
return as_utf8(input) if input.encoding == Encoding::BINARY
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
input.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
|
|
111
|
+
rescue Encoding::ConverterNotFoundError
|
|
112
|
+
as_utf8(input)
|
|
100
113
|
end
|
|
101
|
-
end
|
|
102
114
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
when DayFilterEvery
|
|
106
|
-
"*"
|
|
107
|
-
when DayFilterWeekday
|
|
108
|
-
"1-5"
|
|
109
|
-
when DayFilterWeekend
|
|
110
|
-
"0,6"
|
|
111
|
-
when DayFilterDays
|
|
112
|
-
nums = filter.days.map { |d| Weekday.cron_dow(d) }.sort
|
|
113
|
-
nums.join(",")
|
|
115
|
+
def as_utf8(input)
|
|
116
|
+
input.dup.force_encoding(Encoding::UTF_8).scrub
|
|
114
117
|
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
DOW_NAME_MAP = {
|
|
118
|
-
"SUN" => 0, "MON" => 1, "TUE" => 2, "WED" => 3,
|
|
119
|
-
"THU" => 4, "FRI" => 5, "SAT" => 6
|
|
120
|
-
}.freeze
|
|
121
118
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def self.from_cron(cron_str)
|
|
129
|
-
cron_str = cron_str.strip
|
|
130
|
-
|
|
131
|
-
# Handle @ shortcuts first
|
|
132
|
-
return parse_cron_shortcut(cron_str) if cron_str.start_with?("@")
|
|
133
|
-
|
|
134
|
-
fields = cron_str.split
|
|
135
|
-
raise HronError.cron("expected 5 cron fields, got #{fields.length}") if fields.length != 5
|
|
136
|
-
|
|
137
|
-
minute_field, hour_field, dom_field, month_field, dow_field = fields
|
|
138
|
-
|
|
139
|
-
# Normalize ? to * (they're semantically equivalent for our purposes)
|
|
140
|
-
dom_field = "*" if dom_field == "?"
|
|
141
|
-
dow_field = "*" if dow_field == "?"
|
|
119
|
+
# String#strip would also strip NUL, vertical tab and form feed.
|
|
120
|
+
def trim(text)
|
|
121
|
+
first = text.index(/[^ \t\r\n]/)
|
|
122
|
+
first ? text[first..text.rindex(/[^ \t\r\n]/)] : ""
|
|
123
|
+
end
|
|
142
124
|
|
|
143
|
-
|
|
144
|
-
|
|
125
|
+
def shortcut(input)
|
|
126
|
+
SHORTCUTS.fetch(input.downcase(:ascii)) do
|
|
127
|
+
raise HronError.cron("unknown cron shortcut: #{input}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
145
130
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
131
|
+
def parse_day_of_month(text)
|
|
132
|
+
return :any if text == "*" || text == "?"
|
|
133
|
+
return :last if text.downcase(:ascii) == "l"
|
|
134
|
+
return :last_weekday if text.downcase(:ascii) == "lw"
|
|
149
135
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return result if result
|
|
136
|
+
day = text[0...-1]
|
|
137
|
+
return {nearest: field_value(day, DAY_OF_MONTH)} if text.end_with?("W", "w") && number?(day)
|
|
153
138
|
|
|
154
|
-
|
|
155
|
-
if dom_field.end_with?("W") && dom_field != "LW"
|
|
156
|
-
result = try_parse_nearest_weekday(minute_field, hour_field, dom_field, dow_field, during)
|
|
157
|
-
return result if result
|
|
139
|
+
{days: values(text, DAY_OF_MONTH)}
|
|
158
140
|
end
|
|
159
141
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return result if result
|
|
163
|
-
|
|
164
|
-
# Standard time-based cron
|
|
165
|
-
minute = parse_single_value(minute_field, "minute", 0, 59)
|
|
166
|
-
hour = parse_single_value(hour_field, "hour", 0, 23)
|
|
167
|
-
time = TimeOfDay.new(hour, minute)
|
|
142
|
+
def parse_day_of_week(text)
|
|
143
|
+
return :any if text == "*" || text == "?"
|
|
168
144
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
during: during
|
|
175
|
-
)
|
|
176
|
-
end
|
|
145
|
+
day, hash, nth = text.partition("#")
|
|
146
|
+
if !hash.empty? && value?(day, DAY_OF_WEEK) && number?(nth)
|
|
147
|
+
weekday = WEEKDAYS[field_value(day, DAY_OF_WEEK) % 7]
|
|
148
|
+
n = number(nth)
|
|
149
|
+
raise HronError.cron("day of week ordinal must be 1-5, got #{nth}") unless n.between?(1, 5)
|
|
177
150
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
during: during
|
|
183
|
-
)
|
|
184
|
-
end
|
|
151
|
+
return {nth: n, weekday: weekday}
|
|
152
|
+
end
|
|
153
|
+
day = text[0...-1]
|
|
154
|
+
return {last: WEEKDAYS[field_value(day, DAY_OF_WEEK) % 7]} if text.end_with?("L", "l") && value?(day, DAY_OF_WEEK)
|
|
185
155
|
|
|
186
|
-
|
|
187
|
-
def self.parse_cron_shortcut(cron_str)
|
|
188
|
-
case cron_str.downcase
|
|
189
|
-
when "@yearly", "@annually"
|
|
190
|
-
ScheduleData.new(
|
|
191
|
-
expr: YearRepeat.new(
|
|
192
|
-
1,
|
|
193
|
-
YearDateTarget.new(MonthName::JAN, 1),
|
|
194
|
-
[TimeOfDay.new(0, 0)]
|
|
195
|
-
)
|
|
196
|
-
)
|
|
197
|
-
when "@monthly"
|
|
198
|
-
ScheduleData.new(
|
|
199
|
-
expr: MonthRepeat.new(
|
|
200
|
-
1,
|
|
201
|
-
DaysTarget.new([SingleDay.new(1)]),
|
|
202
|
-
[TimeOfDay.new(0, 0)]
|
|
203
|
-
)
|
|
204
|
-
)
|
|
205
|
-
when "@weekly"
|
|
206
|
-
ScheduleData.new(
|
|
207
|
-
expr: DayRepeat.new(
|
|
208
|
-
1,
|
|
209
|
-
DayFilterDays.new([Weekday::SUNDAY]),
|
|
210
|
-
[TimeOfDay.new(0, 0)]
|
|
211
|
-
)
|
|
212
|
-
)
|
|
213
|
-
when "@daily", "@midnight"
|
|
214
|
-
ScheduleData.new(
|
|
215
|
-
expr: DayRepeat.new(
|
|
216
|
-
1,
|
|
217
|
-
DayFilterEvery.new,
|
|
218
|
-
[TimeOfDay.new(0, 0)]
|
|
219
|
-
)
|
|
220
|
-
)
|
|
221
|
-
when "@hourly"
|
|
222
|
-
ScheduleData.new(
|
|
223
|
-
expr: IntervalRepeat.new(
|
|
224
|
-
1,
|
|
225
|
-
IntervalUnit::HOURS,
|
|
226
|
-
TimeOfDay.new(0, 0),
|
|
227
|
-
TimeOfDay.new(23, 59),
|
|
228
|
-
nil
|
|
229
|
-
)
|
|
230
|
-
)
|
|
231
|
-
else
|
|
232
|
-
raise HronError.cron("unknown @ shortcut: #{cron_str}")
|
|
156
|
+
{days: values(text, DAY_OF_WEEK)}
|
|
233
157
|
end
|
|
234
|
-
end
|
|
235
158
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
raise HronError.cron("
|
|
159
|
+
# Keeps the order of first appearance, in which from_cron lists days of the week.
|
|
160
|
+
def values(text, field)
|
|
161
|
+
items = items(text, field)
|
|
162
|
+
raise HronError.cron("invalid #{field.name}: #{text}") unless items
|
|
163
|
+
|
|
164
|
+
values = []
|
|
165
|
+
items.each do |item|
|
|
166
|
+
first, last = case item.bounds
|
|
167
|
+
in :star
|
|
168
|
+
[field.min, field.star_end]
|
|
169
|
+
in [a]
|
|
170
|
+
first = field_value(a, field)
|
|
171
|
+
# `7/n` starts past the end of `*`, so it is Sunday alone.
|
|
172
|
+
[first, item.step ? [first, field.star_end].max : first]
|
|
173
|
+
in [a, b]
|
|
174
|
+
first = field_value(a, field)
|
|
175
|
+
last = field_value(b, field)
|
|
176
|
+
raise HronError.cron("#{field.name} range must not run backwards: #{a}-#{b}") if first > last
|
|
177
|
+
|
|
178
|
+
[first, last]
|
|
254
179
|
end
|
|
180
|
+
step = item.step ? number(item.step) : 1
|
|
181
|
+
raise HronError.cron("#{field.name} step must be at least 1") if step.zero?
|
|
255
182
|
|
|
256
|
-
step
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
raise(HronError.cron("invalid month step value: #{step_str}"))
|
|
183
|
+
first.step(last, step) do |value|
|
|
184
|
+
value %= 7 if field == DAY_OF_WEEK
|
|
185
|
+
values << value unless values.include?(value)
|
|
260
186
|
end
|
|
261
|
-
|
|
187
|
+
end
|
|
188
|
+
values
|
|
189
|
+
end
|
|
262
190
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
191
|
+
def items(text, field)
|
|
192
|
+
text.split(",", -1).map do |item|
|
|
193
|
+
range, slash, step = item.partition("/")
|
|
194
|
+
step = nil if slash.empty?
|
|
195
|
+
first, dash, last = range.partition("-")
|
|
196
|
+
bounds = if range == "*"
|
|
197
|
+
:star
|
|
198
|
+
elsif dash.empty?
|
|
199
|
+
[range]
|
|
200
|
+
else
|
|
201
|
+
[first, last]
|
|
267
202
|
end
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
end_month = parse_month_value(end_str)
|
|
273
|
-
start_num = MonthName.number(start_month)
|
|
274
|
-
end_num = MonthName.number(end_month)
|
|
275
|
-
raise HronError.cron("invalid month range: #{start_str} > #{end_str}") if start_num > end_num
|
|
276
|
-
|
|
277
|
-
(start_num..end_num).each { |n| months << month_from_number(n) }
|
|
278
|
-
else
|
|
279
|
-
# Single month
|
|
280
|
-
months << parse_month_value(part)
|
|
203
|
+
valid = (step.nil? || number?(step)) && (bounds == :star || bounds.all? { |value| value?(value, field) })
|
|
204
|
+
return nil unless valid
|
|
205
|
+
|
|
206
|
+
Item.new(bounds, step)
|
|
281
207
|
end
|
|
282
208
|
end
|
|
283
209
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
# Parse a single month value (number 1-12 or name JAN-DEC).
|
|
288
|
-
def self.parse_month_value(s)
|
|
289
|
-
# Try as number first
|
|
290
|
-
if /^\d+$/.match?(s)
|
|
291
|
-
n = Integer(s)
|
|
292
|
-
return month_from_number(n)
|
|
210
|
+
def number?(text)
|
|
211
|
+
text.match?(/\A[0-9]+\z/)
|
|
293
212
|
end
|
|
294
|
-
# Try as name
|
|
295
|
-
n = MONTH_NAME_MAP[s.upcase]
|
|
296
|
-
raise HronError.cron("invalid month: #{s}") unless n
|
|
297
213
|
|
|
298
|
-
|
|
299
|
-
|
|
214
|
+
def value?(text, field)
|
|
215
|
+
number?(text) || !name_value(text, field).nil?
|
|
216
|
+
end
|
|
300
217
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
218
|
+
def name_value(text, field)
|
|
219
|
+
index = field.names.index(text.downcase(:ascii))
|
|
220
|
+
index && index + field.min
|
|
221
|
+
end
|
|
304
222
|
|
|
305
|
-
|
|
306
|
-
|
|
223
|
+
def number(digits)
|
|
224
|
+
digits.each_byte.reduce(0) { |n, digit| [n * 10 + digit - "0".ord, NUMBER_CAP].min }
|
|
225
|
+
end
|
|
307
226
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
dow_str, nth_str = dow_field.split("#", 2)
|
|
313
|
-
dow_num = parse_dow_value(dow_str)
|
|
314
|
-
weekday = cron_dow_to_weekday(dow_num)
|
|
315
|
-
nth = begin
|
|
316
|
-
Integer(nth_str)
|
|
317
|
-
rescue
|
|
318
|
-
raise(HronError.cron("invalid nth value: #{nth_str}"))
|
|
319
|
-
end
|
|
320
|
-
raise HronError.cron("nth must be 1-5, got #{nth}") if nth < 1 || nth > 5
|
|
321
|
-
|
|
322
|
-
ordinal = case nth
|
|
323
|
-
when 1 then OrdinalPosition::FIRST
|
|
324
|
-
when 2 then OrdinalPosition::SECOND
|
|
325
|
-
when 3 then OrdinalPosition::THIRD
|
|
326
|
-
when 4 then OrdinalPosition::FOURTH
|
|
327
|
-
when 5 then OrdinalPosition::FIFTH
|
|
227
|
+
def field_value(text, field)
|
|
228
|
+
value = name_value(text, field) || number(text)
|
|
229
|
+
unless value.between?(field.min, field.max)
|
|
230
|
+
raise HronError.cron("#{field.name} must be #{field.min}-#{field.max}, got #{text}")
|
|
328
231
|
end
|
|
329
232
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
minute = parse_single_value(minute_field, "minute", 0, 59)
|
|
333
|
-
hour = parse_single_value(hour_field, "hour", 0, 23)
|
|
334
|
-
|
|
335
|
-
return ScheduleData.new(
|
|
336
|
-
expr: MonthRepeat.new(1, OrdinalWeekdayTarget.new(ordinal, weekday), [TimeOfDay.new(hour, minute)]),
|
|
337
|
-
during: during
|
|
338
|
-
)
|
|
233
|
+
value
|
|
339
234
|
end
|
|
340
235
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
236
|
+
def day_expression(month_days, week_days)
|
|
237
|
+
case [month_days, week_days]
|
|
238
|
+
in [:any, :any]
|
|
239
|
+
{of_week: DayFilterEvery.new}
|
|
240
|
+
in [:any, {days:}]
|
|
241
|
+
{of_week: weekday_filter(days)}
|
|
242
|
+
in [:any, {nth:, weekday:}]
|
|
243
|
+
{of_month: OrdinalWeekdayTarget.new(ORDINALS[nth - 1], weekday)}
|
|
244
|
+
in [:any, {last:}]
|
|
245
|
+
{of_month: OrdinalWeekdayTarget.new(OrdinalPosition::LAST, last)}
|
|
246
|
+
in [{days:}, :any] if days.length == 31
|
|
247
|
+
{of_week: DayFilterEvery.new}
|
|
248
|
+
in [{days:}, :any]
|
|
249
|
+
specs = runs(days.sort).map { |first, last| (first == last) ? SingleDay.new(first) : DayRange.new(first, last) }
|
|
250
|
+
{of_month: DaysTarget.new(specs)}
|
|
251
|
+
in [:last, :any]
|
|
252
|
+
{of_month: LastDayTarget.new}
|
|
253
|
+
in [:last_weekday, :any]
|
|
254
|
+
{of_month: LastWeekdayTarget.new}
|
|
255
|
+
in [{nearest:}, :any]
|
|
256
|
+
{of_month: NearestWeekdayTarget.new(nearest, nil)}
|
|
257
|
+
else
|
|
258
|
+
raise HronError.cron(BOTH_DAYS_RESTRICTED)
|
|
259
|
+
end
|
|
356
260
|
end
|
|
357
261
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
minute = parse_single_value(minute_field, "minute", 0, 59)
|
|
368
|
-
hour = parse_single_value(hour_field, "hour", 0, 23)
|
|
369
|
-
|
|
370
|
-
target = (dom_field == "LW") ? LastWeekdayTarget.new : LastDayTarget.new
|
|
371
|
-
|
|
372
|
-
ScheduleData.new(
|
|
373
|
-
expr: MonthRepeat.new(1, target, [TimeOfDay.new(hour, minute)]),
|
|
374
|
-
during: during
|
|
375
|
-
)
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
# Try to parse W (nearest weekday) patterns: 15W, 1W, etc.
|
|
379
|
-
def self.try_parse_nearest_weekday(minute_field, hour_field, dom_field, dow_field, during)
|
|
380
|
-
return nil unless dom_field.end_with?("W") && dom_field != "LW"
|
|
262
|
+
def weekday_filter(days)
|
|
263
|
+
case days.sort
|
|
264
|
+
in [0, 1, 2, 3, 4, 5, 6] then DayFilterEvery.new
|
|
265
|
+
in [1, 2, 3, 4, 5] then DayFilterWeekday.new
|
|
266
|
+
in [0, 6] then DayFilterWeekend.new
|
|
267
|
+
else DayFilterDays.new(days.map { |d| WEEKDAYS[d] })
|
|
268
|
+
end
|
|
269
|
+
end
|
|
381
270
|
|
|
382
|
-
|
|
271
|
+
def equal_gap(times)
|
|
272
|
+
return nil if times.length < 2
|
|
383
273
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
raise(HronError.cron("invalid W day: #{day_str}"))
|
|
274
|
+
minutes = times.map { |t| minute_of_day(t) }
|
|
275
|
+
gap = minutes[1] - minutes[0]
|
|
276
|
+
equal = minutes.length >= 3 && minutes.each_cons(2).all? { |a, b| b - a == gap }
|
|
277
|
+
equal ? gap : nil
|
|
389
278
|
end
|
|
390
|
-
raise HronError.cron("W day must be 1-31, got #{day}") if day < 1 || day > 31
|
|
391
279
|
|
|
392
|
-
|
|
393
|
-
|
|
280
|
+
def interval(times, gap, days)
|
|
281
|
+
from = times.first
|
|
282
|
+
last = times.last
|
|
283
|
+
to = (from == MIDNIGHT && minute_of_day(last) + gap >= MINUTES_PER_DAY) ? END_OF_DAY : last
|
|
284
|
+
interval, unit = (gap % 60).zero? ? [gap / 60, IntervalUnit::HOURS] : [gap, IntervalUnit::MIN]
|
|
285
|
+
IntervalRepeat.new(interval, unit, from, to, days.is_a?(DayFilterEvery) ? nil : days)
|
|
286
|
+
end
|
|
394
287
|
|
|
395
|
-
|
|
288
|
+
def too_many_times(count, gap)
|
|
289
|
+
return HronError.cron(INTERVAL_DAYS) if gap
|
|
396
290
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
during: during
|
|
400
|
-
)
|
|
401
|
-
end
|
|
291
|
+
HronError.cron("not expressible in hron: #{count} times a day are too many to list")
|
|
292
|
+
end
|
|
402
293
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if range_part == "*"
|
|
416
|
-
from_minute = 0
|
|
417
|
-
to_minute = 59
|
|
418
|
-
elsif range_part.include?("-")
|
|
419
|
-
s, e = range_part.split("-", 2)
|
|
420
|
-
from_minute = begin
|
|
421
|
-
Integer(s)
|
|
422
|
-
rescue
|
|
423
|
-
raise(HronError.cron("invalid minute range"))
|
|
424
|
-
end
|
|
425
|
-
to_minute = begin
|
|
426
|
-
Integer(e)
|
|
427
|
-
rescue
|
|
428
|
-
raise(HronError.cron("invalid minute range"))
|
|
429
|
-
end
|
|
430
|
-
raise HronError.cron("range start must be <= end: #{s}-#{e}") if from_minute > to_minute
|
|
294
|
+
def year_target(days, months)
|
|
295
|
+
target = days[:of_month]
|
|
296
|
+
return nil unless target && months.length == 1
|
|
297
|
+
|
|
298
|
+
month = MonthName::ALL[months[0] - 1]
|
|
299
|
+
case target
|
|
300
|
+
in DaysTarget(specs: [SingleDay(day:)]) if day <= MonthName.max_day(month)
|
|
301
|
+
YearDateTarget.new(month, day)
|
|
302
|
+
in LastWeekdayTarget
|
|
303
|
+
YearLastWeekdayTarget.new(month)
|
|
304
|
+
in OrdinalWeekdayTarget(ordinal:, weekday:)
|
|
305
|
+
YearOrdinalWeekdayTarget.new(ordinal, weekday, month)
|
|
431
306
|
else
|
|
432
|
-
|
|
433
|
-
from_minute = begin
|
|
434
|
-
Integer(range_part)
|
|
435
|
-
rescue
|
|
436
|
-
raise(HronError.cron("invalid minute value"))
|
|
437
|
-
end
|
|
438
|
-
to_minute = 59
|
|
307
|
+
nil
|
|
439
308
|
end
|
|
309
|
+
end
|
|
440
310
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
to_hour = 23
|
|
445
|
-
elsif hour_field.include?("-")
|
|
446
|
-
s, e = hour_field.split("-", 2)
|
|
447
|
-
from_hour = begin
|
|
448
|
-
Integer(s)
|
|
449
|
-
rescue
|
|
450
|
-
raise(HronError.cron("invalid hour range"))
|
|
451
|
-
end
|
|
452
|
-
to_hour = begin
|
|
453
|
-
Integer(e)
|
|
454
|
-
rescue
|
|
455
|
-
raise(HronError.cron("invalid hour range"))
|
|
456
|
-
end
|
|
457
|
-
elsif hour_field.include?("/")
|
|
458
|
-
# Hour also has step - this is complex, handle as hour interval
|
|
459
|
-
return nil
|
|
460
|
-
else
|
|
461
|
-
h = begin
|
|
462
|
-
Integer(hour_field)
|
|
463
|
-
rescue
|
|
464
|
-
raise(HronError.cron("invalid hour"))
|
|
465
|
-
end
|
|
466
|
-
from_hour = h
|
|
467
|
-
to_hour = h
|
|
468
|
-
end
|
|
311
|
+
def not_expressible(reason)
|
|
312
|
+
HronError.cron("not expressible as cron: #{reason}")
|
|
313
|
+
end
|
|
469
314
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if dom_field == "*" || dom_field == "?"
|
|
474
|
-
# Determine the end minute based on context
|
|
475
|
-
end_minute = if from_minute == 0 && to_minute == 59 && to_hour == 23
|
|
476
|
-
# Full day: 00:00 to 23:59
|
|
477
|
-
59
|
|
478
|
-
elsif from_minute == 0 && to_minute == 59
|
|
479
|
-
# Partial day with full minutes range: use :00 for cleaner output
|
|
480
|
-
0
|
|
481
|
-
else
|
|
482
|
-
to_minute
|
|
483
|
-
end
|
|
315
|
+
def repeats_once(interval, unit)
|
|
316
|
+
raise not_expressible("multi-#{unit} repeats not supported") if interval > 1
|
|
317
|
+
end
|
|
484
318
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
)
|
|
319
|
+
def day_fields(expr)
|
|
320
|
+
case expr
|
|
321
|
+
when IntervalRepeat
|
|
322
|
+
["*", expr.day_filter ? filter_field(expr.day_filter) : "*"]
|
|
323
|
+
when DayRepeat
|
|
324
|
+
repeats_once(expr.interval, "day")
|
|
325
|
+
["*", filter_field(expr.days)]
|
|
326
|
+
when WeekRepeat
|
|
327
|
+
repeats_once(expr.interval, "week")
|
|
328
|
+
["*", weekdays_field(expr.days)]
|
|
329
|
+
when MonthRepeat
|
|
330
|
+
repeats_once(expr.interval, "month")
|
|
331
|
+
month_target_fields(expr.target)
|
|
332
|
+
when YearRepeat
|
|
333
|
+
repeats_once(expr.interval, "year")
|
|
334
|
+
year_target_fields(expr.target)
|
|
335
|
+
when SingleDateExpr
|
|
336
|
+
raise not_expressible("ISO dates do not repeat") if expr.date.is_a?(IsoDate)
|
|
337
|
+
|
|
338
|
+
[expr.date.day.to_s, "*"]
|
|
495
339
|
end
|
|
496
340
|
end
|
|
497
341
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if range_part == "*"
|
|
509
|
-
from_hour = 0
|
|
510
|
-
to_hour = 23
|
|
511
|
-
elsif range_part.include?("-")
|
|
512
|
-
s, e = range_part.split("-", 2)
|
|
513
|
-
from_hour = begin
|
|
514
|
-
Integer(s)
|
|
515
|
-
rescue
|
|
516
|
-
raise(HronError.cron("invalid hour range"))
|
|
517
|
-
end
|
|
518
|
-
to_hour = begin
|
|
519
|
-
Integer(e)
|
|
520
|
-
rescue
|
|
521
|
-
raise(HronError.cron("invalid hour range"))
|
|
522
|
-
end
|
|
523
|
-
raise HronError.cron("range start must be <= end: #{s}-#{e}") if from_hour > to_hour
|
|
524
|
-
else
|
|
525
|
-
from_hour = begin
|
|
526
|
-
Integer(range_part)
|
|
527
|
-
rescue
|
|
528
|
-
raise(HronError.cron("invalid hour value"))
|
|
529
|
-
end
|
|
530
|
-
to_hour = 23
|
|
531
|
-
end
|
|
342
|
+
def month_target_fields(target)
|
|
343
|
+
case target
|
|
344
|
+
when DaysTarget
|
|
345
|
+
[list_field(Evaluator.days_of(target).sort.uniq, 31), "*"]
|
|
346
|
+
when LastDayTarget
|
|
347
|
+
["L", "*"]
|
|
348
|
+
when LastWeekdayTarget
|
|
349
|
+
["LW", "*"]
|
|
350
|
+
when NearestWeekdayTarget
|
|
351
|
+
raise not_expressible("directional nearest weekday not supported") if target.direction
|
|
532
352
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return ScheduleData.new(
|
|
538
|
-
expr: IntervalRepeat.new(
|
|
539
|
-
interval,
|
|
540
|
-
IntervalUnit::HOURS,
|
|
541
|
-
TimeOfDay.new(from_hour, 0),
|
|
542
|
-
TimeOfDay.new(to_hour, end_minute),
|
|
543
|
-
nil
|
|
544
|
-
),
|
|
545
|
-
during: during
|
|
546
|
-
)
|
|
353
|
+
["#{target.day}W", "*"]
|
|
354
|
+
when OrdinalWeekdayTarget
|
|
355
|
+
["*", ordinal_field(target.ordinal, target.weekday)]
|
|
547
356
|
end
|
|
548
357
|
end
|
|
549
358
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
range_part, step_str = part.split("/", 2)
|
|
561
|
-
if range_part == "*"
|
|
562
|
-
start_day = 1
|
|
563
|
-
end_day = 31
|
|
564
|
-
elsif range_part.include?("-")
|
|
565
|
-
s, e = range_part.split("-", 2)
|
|
566
|
-
start_day = begin
|
|
567
|
-
Integer(s)
|
|
568
|
-
rescue
|
|
569
|
-
raise(HronError.cron("invalid DOM range start: #{s}"))
|
|
570
|
-
end
|
|
571
|
-
end_day = begin
|
|
572
|
-
Integer(e)
|
|
573
|
-
rescue
|
|
574
|
-
raise(HronError.cron("invalid DOM range end: #{e}"))
|
|
575
|
-
end
|
|
576
|
-
raise HronError.cron("range start must be <= end: #{start_day}-#{end_day}") if start_day > end_day
|
|
577
|
-
else
|
|
578
|
-
start_day = begin
|
|
579
|
-
Integer(range_part)
|
|
580
|
-
rescue
|
|
581
|
-
raise(HronError.cron("invalid DOM value: #{range_part}"))
|
|
582
|
-
end
|
|
583
|
-
end_day = 31
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
step = begin
|
|
587
|
-
Integer(step_str)
|
|
588
|
-
rescue
|
|
589
|
-
raise(HronError.cron("invalid DOM step: #{step_str}"))
|
|
590
|
-
end
|
|
591
|
-
raise HronError.cron("step cannot be 0") if step == 0
|
|
359
|
+
def year_target_fields(target)
|
|
360
|
+
case target
|
|
361
|
+
when YearDateTarget, YearDayOfMonthTarget
|
|
362
|
+
[target.day.to_s, "*"]
|
|
363
|
+
when YearOrdinalWeekdayTarget
|
|
364
|
+
["*", ordinal_field(target.ordinal, target.weekday)]
|
|
365
|
+
when YearLastWeekdayTarget
|
|
366
|
+
["LW", "*"]
|
|
367
|
+
end
|
|
368
|
+
end
|
|
592
369
|
|
|
593
|
-
|
|
594
|
-
|
|
370
|
+
def month_field(schedule)
|
|
371
|
+
during = schedule.during
|
|
372
|
+
month = own_month(schedule.expression)
|
|
373
|
+
if month
|
|
374
|
+
raise not_expressible("during excludes the schedule's month") unless during.empty? || during.include?(month)
|
|
595
375
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
d += step
|
|
600
|
-
end
|
|
601
|
-
elsif part.include?("-")
|
|
602
|
-
# Range: 1-5
|
|
603
|
-
start_str, end_str = part.split("-", 2)
|
|
604
|
-
start_day = begin
|
|
605
|
-
Integer(start_str)
|
|
606
|
-
rescue
|
|
607
|
-
raise(HronError.cron("invalid DOM range start: #{start_str}"))
|
|
608
|
-
end
|
|
609
|
-
end_day = begin
|
|
610
|
-
Integer(end_str)
|
|
611
|
-
rescue
|
|
612
|
-
raise(HronError.cron("invalid DOM range end: #{end_str}"))
|
|
613
|
-
end
|
|
614
|
-
raise HronError.cron("range start must be <= end: #{start_day}-#{end_day}") if start_day > end_day
|
|
615
|
-
validate_dom(start_day)
|
|
616
|
-
validate_dom(end_day)
|
|
617
|
-
specs << DayRange.new(start_day, end_day)
|
|
376
|
+
MonthName.number(month).to_s
|
|
377
|
+
elsif during.empty?
|
|
378
|
+
"*"
|
|
618
379
|
else
|
|
619
|
-
|
|
620
|
-
day = begin
|
|
621
|
-
Integer(part)
|
|
622
|
-
rescue
|
|
623
|
-
raise(HronError.cron("invalid DOM value: #{part}"))
|
|
624
|
-
end
|
|
625
|
-
validate_dom(day)
|
|
626
|
-
specs << SingleDay.new(day)
|
|
380
|
+
list_field(during.map { |m| MonthName.number(m) }.sort.uniq, 12)
|
|
627
381
|
end
|
|
628
382
|
end
|
|
629
383
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
384
|
+
def own_month(expr)
|
|
385
|
+
case expr
|
|
386
|
+
when YearRepeat then expr.target.month
|
|
387
|
+
when SingleDateExpr then expr.date.is_a?(NamedDate) ? expr.date.month : nil
|
|
388
|
+
end
|
|
389
|
+
end
|
|
636
390
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if part.include?("/")
|
|
645
|
-
# Step value: 0-6/2 or */2
|
|
646
|
-
range_part, step_str = part.split("/", 2)
|
|
647
|
-
if range_part == "*"
|
|
648
|
-
start_dow = 0
|
|
649
|
-
end_dow = 6
|
|
650
|
-
elsif range_part.include?("-")
|
|
651
|
-
s, e = range_part.split("-", 2)
|
|
652
|
-
start_dow = parse_dow_value_raw(s)
|
|
653
|
-
end_dow = parse_dow_value_raw(e)
|
|
654
|
-
raise HronError.cron("range start must be <= end: #{s}-#{e}") if start_dow > end_dow
|
|
655
|
-
else
|
|
656
|
-
start_dow = parse_dow_value_raw(range_part)
|
|
657
|
-
end_dow = 6
|
|
658
|
-
end
|
|
391
|
+
def time_fields(expr)
|
|
392
|
+
times = daily_times(expr)
|
|
393
|
+
minutes = times.map { |t| t % 60 }.sort.uniq
|
|
394
|
+
hours = times.map { |t| t / 60 }.sort.uniq
|
|
395
|
+
if minutes.length * hours.length != times.length
|
|
396
|
+
raise not_expressible("times are not every combination of their minutes and hours")
|
|
397
|
+
end
|
|
659
398
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
rescue
|
|
663
|
-
raise(HronError.cron("invalid DOW step: #{step_str}"))
|
|
664
|
-
end
|
|
665
|
-
raise HronError.cron("step cannot be 0") if step == 0
|
|
399
|
+
[step_field(minutes, 60), step_field(hours, 24)]
|
|
400
|
+
end
|
|
666
401
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
d += step
|
|
671
|
-
end
|
|
672
|
-
elsif part.include?("-")
|
|
673
|
-
# Range: 1-5 or MON-FRI
|
|
674
|
-
# Parse without normalizing 7 to 0 for range purposes
|
|
675
|
-
start_str, end_str = part.split("-", 2)
|
|
676
|
-
start_dow = parse_dow_value_raw(start_str)
|
|
677
|
-
end_dow = parse_dow_value_raw(end_str)
|
|
678
|
-
raise HronError.cron("range start must be <= end: #{start_str}-#{end_str}") if start_dow > end_dow
|
|
679
|
-
|
|
680
|
-
(start_dow..end_dow).each do |d|
|
|
681
|
-
# Normalize 7 to 0 (Sunday) when converting to weekday
|
|
682
|
-
normalized = (d == 7) ? 0 : d
|
|
683
|
-
days << cron_dow_to_weekday(normalized)
|
|
684
|
-
end
|
|
402
|
+
def daily_times(expr)
|
|
403
|
+
times = if expr.is_a?(IntervalRepeat)
|
|
404
|
+
Evaluator.interval_slots(expr.interval, expr.unit, expr.from_time, expr.to_time)
|
|
685
405
|
else
|
|
686
|
-
|
|
687
|
-
dow = parse_dow_value(part)
|
|
688
|
-
days << cron_dow_to_weekday(dow)
|
|
406
|
+
expr.times.map { |t| minute_of_day(t) }
|
|
689
407
|
end
|
|
408
|
+
times.sort.uniq
|
|
690
409
|
end
|
|
691
410
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
return DayFilterWeekend.new if sorted == Weekday::WEEKEND
|
|
411
|
+
def filter_field(filter)
|
|
412
|
+
case filter
|
|
413
|
+
when DayFilterEvery then "*"
|
|
414
|
+
when DayFilterWeekday then weekdays_field(Weekday::WEEKDAYS)
|
|
415
|
+
when DayFilterWeekend then weekdays_field(Weekday::WEEKEND)
|
|
416
|
+
when DayFilterDays then weekdays_field(filter.days)
|
|
417
|
+
end
|
|
700
418
|
end
|
|
701
419
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
# Parse a DOW value (number 0-7 or name SUN-SAT), normalizing 7 to 0.
|
|
706
|
-
def self.parse_dow_value(s)
|
|
707
|
-
raw = parse_dow_value_raw(s)
|
|
708
|
-
# Normalize 7 to 0 (both mean Sunday)
|
|
709
|
-
(raw == 7) ? 0 : raw
|
|
710
|
-
end
|
|
711
|
-
|
|
712
|
-
# Parse a DOW value without normalizing 7 to 0 (for range checking).
|
|
713
|
-
def self.parse_dow_value_raw(s)
|
|
714
|
-
# Try as number first
|
|
715
|
-
if /^\d+$/.match?(s)
|
|
716
|
-
n = Integer(s)
|
|
717
|
-
raise HronError.cron("DOW must be 0-7, got #{n}") if n > 7
|
|
420
|
+
def weekdays_field(days)
|
|
421
|
+
list_field(days.map { |d| Weekday.cron_dow(d) }.sort.uniq, 7)
|
|
422
|
+
end
|
|
718
423
|
|
|
719
|
-
|
|
424
|
+
def ordinal_field(ordinal, weekday)
|
|
425
|
+
day = Weekday.cron_dow(weekday)
|
|
426
|
+
(ordinal == OrdinalPosition::LAST) ? "#{day}L" : "#{day}##{OrdinalPosition.to_n(ordinal)}"
|
|
720
427
|
end
|
|
721
|
-
# Try as name
|
|
722
|
-
n = DOW_NAME_MAP[s.upcase]
|
|
723
|
-
raise HronError.cron("invalid DOW: #{s}") unless n
|
|
724
428
|
|
|
725
|
-
|
|
726
|
-
|
|
429
|
+
def step_field(values, size)
|
|
430
|
+
first = values.first
|
|
431
|
+
last = values.last
|
|
432
|
+
gap = (values.length > 1) ? values[1] - first : nil
|
|
433
|
+
equal_gaps = !gap.nil? && values.each_cons(2).all? { |a, b| b - a == gap }
|
|
434
|
+
if values.length == size
|
|
435
|
+
"*"
|
|
436
|
+
elsif gap.nil?
|
|
437
|
+
first.to_s
|
|
438
|
+
elsif equal_gaps && first.zero? && last + gap == size
|
|
439
|
+
"*/#{gap}"
|
|
440
|
+
elsif equal_gaps && gap == 1
|
|
441
|
+
"#{first}-#{last}"
|
|
442
|
+
elsif equal_gaps && values.length >= 3
|
|
443
|
+
"#{first}-#{last}/#{gap}"
|
|
444
|
+
else
|
|
445
|
+
list_field(values, size)
|
|
446
|
+
end
|
|
447
|
+
end
|
|
727
448
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
raise HronError.cron("invalid DOW number: #{n}") unless result
|
|
449
|
+
def list_field(values, size)
|
|
450
|
+
return "*" if values.length == size
|
|
731
451
|
|
|
732
|
-
|
|
733
|
-
|
|
452
|
+
runs(values).map { |first, last| (first == last) ? first.to_s : "#{first}-#{last}" }.join(",")
|
|
453
|
+
end
|
|
734
454
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
value = begin
|
|
738
|
-
Integer(field)
|
|
739
|
-
rescue
|
|
740
|
-
raise(HronError.cron("invalid #{name} field: #{field}"))
|
|
455
|
+
def runs(sorted_values)
|
|
456
|
+
sorted_values.slice_when { |a, b| b != a + 1 }.map { |run| [run.first, run.last] }
|
|
741
457
|
end
|
|
742
|
-
raise HronError.cron("#{name} must be #{min}-#{max}, got #{value}") if value < min || value > max
|
|
743
458
|
|
|
744
|
-
|
|
459
|
+
def minute_of_day(time)
|
|
460
|
+
time.hour * 60 + time.minute
|
|
461
|
+
end
|
|
745
462
|
end
|
|
746
463
|
end
|
|
747
464
|
end
|