hron 0.5.1 → 0.6.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/hron.gemspec +3 -3
- data/lib/hron/ast.rb +9 -1
- data/lib/hron/cron.rb +579 -82
- data/lib/hron/display.rb +26 -8
- data/lib/hron/evaluator.rb +496 -71
- data/lib/hron/lexer.rb +6 -0
- data/lib/hron/parser.rb +100 -36
- data/lib/hron/schedule.rb +15 -0
- data/lib/hron/version.rb +1 -1
- metadata +5 -5
data/lib/hron/cron.rb
CHANGED
|
@@ -75,12 +75,19 @@ module Hron
|
|
|
75
75
|
"#{time.minute} #{time.hour} #{dom} * *"
|
|
76
76
|
when LastDayTarget
|
|
77
77
|
raise HronError.cron("not expressible as cron (last day of month not supported)")
|
|
78
|
-
|
|
78
|
+
when LastWeekdayTarget
|
|
79
79
|
raise HronError.cron("not expressible as cron (last weekday of month not supported)")
|
|
80
|
-
|
|
80
|
+
when NearestWeekdayTarget
|
|
81
|
+
if expr.target.direction
|
|
82
|
+
raise HronError.cron("not expressible as cron (directional nearest weekday not supported)")
|
|
83
|
+
end
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
84
91
|
|
|
85
92
|
when SingleDateExpr
|
|
86
93
|
raise HronError.cron("not expressible as cron (single dates are not repeating)")
|
|
@@ -107,144 +114,634 @@ module Hron
|
|
|
107
114
|
end
|
|
108
115
|
end
|
|
109
116
|
|
|
117
|
+
DOW_NAME_MAP = {
|
|
118
|
+
"SUN" => 0, "MON" => 1, "TUE" => 2, "WED" => 3,
|
|
119
|
+
"THU" => 4, "FRI" => 5, "SAT" => 6
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
MONTH_NAME_MAP = {
|
|
123
|
+
"JAN" => 1, "FEB" => 2, "MAR" => 3, "APR" => 4,
|
|
124
|
+
"MAY" => 5, "JUN" => 6, "JUL" => 7, "AUG" => 8,
|
|
125
|
+
"SEP" => 9, "OCT" => 10, "NOV" => 11, "DEC" => 12
|
|
126
|
+
}.freeze
|
|
127
|
+
|
|
110
128
|
def self.from_cron(cron_str)
|
|
111
|
-
|
|
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
|
|
112
135
|
raise HronError.cron("expected 5 cron fields, got #{fields.length}") if fields.length != 5
|
|
113
136
|
|
|
114
|
-
minute_field, hour_field, dom_field,
|
|
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 == "?"
|
|
142
|
+
|
|
143
|
+
# Parse month field into during clause
|
|
144
|
+
during = parse_month_field(month_field)
|
|
145
|
+
|
|
146
|
+
# Check for special DOW patterns: nth weekday (#), last weekday (5L)
|
|
147
|
+
result = try_parse_nth_weekday(minute_field, hour_field, dom_field, dow_field, during)
|
|
148
|
+
return result if result
|
|
149
|
+
|
|
150
|
+
# Check for L (last day) or LW (last weekday) in DOM
|
|
151
|
+
result = try_parse_last_day(minute_field, hour_field, dom_field, dow_field, during)
|
|
152
|
+
return result if result
|
|
153
|
+
|
|
154
|
+
# Check for W (nearest weekday): e.g., 15W
|
|
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
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check for interval patterns: */N or range/N
|
|
161
|
+
result = try_parse_interval(minute_field, hour_field, dom_field, dow_field, during)
|
|
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)
|
|
168
|
+
|
|
169
|
+
# DOM-based (monthly) - when DOM is specified and DOW is *
|
|
170
|
+
if dom_field != "*" && dow_field == "*"
|
|
171
|
+
target = parse_dom_field(dom_field)
|
|
172
|
+
return ScheduleData.new(
|
|
173
|
+
expr: MonthRepeat.new(1, target, [time]),
|
|
174
|
+
during: during
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# DOW-based (day repeat)
|
|
179
|
+
days = parse_cron_dow(dow_field)
|
|
180
|
+
ScheduleData.new(
|
|
181
|
+
expr: DayRepeat.new(1, days, [time]),
|
|
182
|
+
during: during
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Parse @ shortcuts like @daily, @hourly, etc.
|
|
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}")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Parse month field into a Vec<MonthName> for the `during` clause.
|
|
237
|
+
def self.parse_month_field(field)
|
|
238
|
+
return [] if field == "*"
|
|
239
|
+
|
|
240
|
+
months = []
|
|
241
|
+
field.split(",").each do |part|
|
|
242
|
+
# Check for step values FIRST (e.g., 1-12/3 or */3)
|
|
243
|
+
if part.include?("/")
|
|
244
|
+
range_part, step_str = part.split("/", 2)
|
|
245
|
+
if range_part == "*"
|
|
246
|
+
start_num = 1
|
|
247
|
+
end_num = 12
|
|
248
|
+
elsif range_part.include?("-")
|
|
249
|
+
s, e = range_part.split("-", 2)
|
|
250
|
+
start_num = MonthName.number(parse_month_value(s))
|
|
251
|
+
end_num = MonthName.number(parse_month_value(e))
|
|
252
|
+
else
|
|
253
|
+
raise HronError.cron("invalid month step expression: #{part}")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
step = begin
|
|
257
|
+
Integer(step_str)
|
|
258
|
+
rescue
|
|
259
|
+
raise(HronError.cron("invalid month step value: #{step_str}"))
|
|
260
|
+
end
|
|
261
|
+
raise HronError.cron("step cannot be 0") if step == 0
|
|
262
|
+
|
|
263
|
+
n = start_num
|
|
264
|
+
while n <= end_num
|
|
265
|
+
months << month_from_number(n)
|
|
266
|
+
n += step
|
|
267
|
+
end
|
|
268
|
+
elsif part.include?("-")
|
|
269
|
+
# Range like 1-3 or JAN-MAR
|
|
270
|
+
start_str, end_str = part.split("-", 2)
|
|
271
|
+
start_month = parse_month_value(start_str)
|
|
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)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
months
|
|
285
|
+
end
|
|
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)
|
|
293
|
+
end
|
|
294
|
+
# Try as name
|
|
295
|
+
n = MONTH_NAME_MAP[s.upcase]
|
|
296
|
+
raise HronError.cron("invalid month: #{s}") unless n
|
|
297
|
+
|
|
298
|
+
month_from_number(n)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def self.month_from_number(n)
|
|
302
|
+
month = MonthName.from_number(n)
|
|
303
|
+
raise HronError.cron("invalid month number: #{n}") unless month
|
|
115
304
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
305
|
+
month
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Try to parse nth weekday patterns like 1#1 (first Monday) or 5L (last Friday).
|
|
309
|
+
def self.try_parse_nth_weekday(minute_field, hour_field, dom_field, dow_field, during)
|
|
310
|
+
# Check for # pattern (nth weekday of month)
|
|
311
|
+
if dow_field.include?("#")
|
|
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
|
|
123
328
|
end
|
|
124
329
|
|
|
125
|
-
|
|
126
|
-
|
|
330
|
+
raise HronError.cron("DOM must be * when using # for nth weekday") if dom_field != "*" && dom_field != "?"
|
|
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
|
+
)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Check for nL pattern (last weekday of month, e.g., 5L = last Friday)
|
|
342
|
+
if dow_field.end_with?("L") && dow_field.length > 1
|
|
343
|
+
dow_str = dow_field[0..-2]
|
|
344
|
+
dow_num = parse_dow_value(dow_str)
|
|
345
|
+
weekday = cron_dow_to_weekday(dow_num)
|
|
346
|
+
|
|
347
|
+
raise HronError.cron("DOM must be * when using nL for last weekday") if dom_field != "*" && dom_field != "?"
|
|
348
|
+
|
|
349
|
+
minute = parse_single_value(minute_field, "minute", 0, 59)
|
|
350
|
+
hour = parse_single_value(hour_field, "hour", 0, 23)
|
|
351
|
+
|
|
352
|
+
return ScheduleData.new(
|
|
353
|
+
expr: MonthRepeat.new(1, OrdinalWeekdayTarget.new(OrdinalPosition::LAST, weekday), [TimeOfDay.new(hour, minute)]),
|
|
354
|
+
during: during
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
127
360
|
|
|
361
|
+
# Try to parse L (last day) or LW (last weekday) patterns.
|
|
362
|
+
def self.try_parse_last_day(minute_field, hour_field, dom_field, dow_field, during)
|
|
363
|
+
return nil unless dom_field == "L" || dom_field == "LW"
|
|
364
|
+
|
|
365
|
+
raise HronError.cron("DOW must be * when using L or LW in DOM") if dow_field != "*" && dow_field != "?"
|
|
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"
|
|
381
|
+
|
|
382
|
+
raise HronError.cron("DOW must be * when using W in DOM") if dow_field != "*" && dow_field != "?"
|
|
383
|
+
|
|
384
|
+
day_str = dom_field[0..-2]
|
|
385
|
+
day = begin
|
|
386
|
+
Integer(day_str)
|
|
387
|
+
rescue
|
|
388
|
+
raise(HronError.cron("invalid W day: #{day_str}"))
|
|
389
|
+
end
|
|
390
|
+
raise HronError.cron("W day must be 1-31, got #{day}") if day < 1 || day > 31
|
|
391
|
+
|
|
392
|
+
minute = parse_single_value(minute_field, "minute", 0, 59)
|
|
393
|
+
hour = parse_single_value(hour_field, "hour", 0, 23)
|
|
394
|
+
|
|
395
|
+
target = NearestWeekdayTarget.new(day, nil)
|
|
396
|
+
|
|
397
|
+
ScheduleData.new(
|
|
398
|
+
expr: MonthRepeat.new(1, target, [TimeOfDay.new(hour, minute)]),
|
|
399
|
+
during: during
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Try to parse interval patterns: */N, range/N in minute or hour fields.
|
|
404
|
+
def self.try_parse_interval(minute_field, hour_field, dom_field, dow_field, during)
|
|
405
|
+
# Minute interval: */N or range/N
|
|
406
|
+
if minute_field.include?("/")
|
|
407
|
+
range_part, step_str = minute_field.split("/", 2)
|
|
408
|
+
interval = begin
|
|
409
|
+
Integer(step_str)
|
|
410
|
+
rescue
|
|
411
|
+
raise(HronError.cron("invalid minute interval value"))
|
|
412
|
+
end
|
|
413
|
+
raise HronError.cron("step cannot be 0") if interval == 0
|
|
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
|
|
431
|
+
else
|
|
432
|
+
# Single value with step (e.g., 0/15) - treat as starting point
|
|
433
|
+
from_minute = begin
|
|
434
|
+
Integer(range_part)
|
|
435
|
+
rescue
|
|
436
|
+
raise(HronError.cron("invalid minute value"))
|
|
437
|
+
end
|
|
438
|
+
to_minute = 59
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Determine the hour window
|
|
128
442
|
if hour_field == "*"
|
|
129
|
-
|
|
443
|
+
from_hour = 0
|
|
444
|
+
to_hour = 23
|
|
130
445
|
elsif hour_field.include?("-")
|
|
131
|
-
|
|
132
|
-
begin
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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"))
|
|
137
456
|
end
|
|
457
|
+
elsif hour_field.include?("/")
|
|
458
|
+
# Hour also has step - this is complex, handle as hour interval
|
|
459
|
+
return nil
|
|
138
460
|
else
|
|
139
|
-
begin
|
|
140
|
-
|
|
141
|
-
rescue
|
|
142
|
-
raise
|
|
461
|
+
h = begin
|
|
462
|
+
Integer(hour_field)
|
|
463
|
+
rescue
|
|
464
|
+
raise(HronError.cron("invalid hour"))
|
|
143
465
|
end
|
|
144
466
|
from_hour = h
|
|
145
467
|
to_hour = h
|
|
146
468
|
end
|
|
147
469
|
|
|
470
|
+
# Check if this should be a day filter
|
|
148
471
|
day_filter = (dow_field == "*") ? nil : parse_cron_dow(dow_field)
|
|
149
472
|
|
|
150
|
-
if dom_field == "*"
|
|
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
|
|
484
|
+
|
|
151
485
|
return ScheduleData.new(
|
|
152
486
|
expr: IntervalRepeat.new(
|
|
153
487
|
interval,
|
|
154
488
|
IntervalUnit::MIN,
|
|
155
|
-
TimeOfDay.new(from_hour,
|
|
156
|
-
TimeOfDay.new(to_hour,
|
|
489
|
+
TimeOfDay.new(from_hour, from_minute),
|
|
490
|
+
TimeOfDay.new(to_hour, end_minute),
|
|
157
491
|
day_filter
|
|
158
|
-
)
|
|
492
|
+
),
|
|
493
|
+
during: during
|
|
159
494
|
)
|
|
160
495
|
end
|
|
161
496
|
end
|
|
162
497
|
|
|
163
|
-
# Hour interval: 0 */N
|
|
164
|
-
if hour_field.
|
|
165
|
-
|
|
166
|
-
begin
|
|
167
|
-
|
|
168
|
-
rescue
|
|
169
|
-
raise
|
|
498
|
+
# Hour interval: 0 */N or 0 range/N
|
|
499
|
+
if hour_field.include?("/") && (minute_field == "0" || minute_field == "00")
|
|
500
|
+
range_part, step_str = hour_field.split("/", 2)
|
|
501
|
+
interval = begin
|
|
502
|
+
Integer(step_str)
|
|
503
|
+
rescue
|
|
504
|
+
raise(HronError.cron("invalid hour interval value"))
|
|
170
505
|
end
|
|
171
|
-
|
|
506
|
+
raise HronError.cron("step cannot be 0") if interval == 0
|
|
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
|
|
532
|
+
|
|
533
|
+
if (dom_field == "*" || dom_field == "?") && (dow_field == "*" || dow_field == "?")
|
|
534
|
+
# Use :59 only for full day (00:00 to 23:59), otherwise use :00
|
|
535
|
+
end_minute = (from_hour == 0 && to_hour == 23) ? 59 : 0
|
|
536
|
+
|
|
172
537
|
return ScheduleData.new(
|
|
173
538
|
expr: IntervalRepeat.new(
|
|
174
539
|
interval,
|
|
175
540
|
IntervalUnit::HOURS,
|
|
176
|
-
TimeOfDay.new(
|
|
177
|
-
TimeOfDay.new(
|
|
541
|
+
TimeOfDay.new(from_hour, 0),
|
|
542
|
+
TimeOfDay.new(to_hour, end_minute),
|
|
178
543
|
nil
|
|
179
|
-
)
|
|
544
|
+
),
|
|
545
|
+
during: during
|
|
180
546
|
)
|
|
181
547
|
end
|
|
182
548
|
end
|
|
183
549
|
|
|
184
|
-
|
|
185
|
-
|
|
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)
|
|
550
|
+
nil
|
|
551
|
+
end
|
|
196
552
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
553
|
+
# Parse a DOM field into a MonthTarget.
|
|
554
|
+
def self.parse_dom_field(field)
|
|
555
|
+
specs = []
|
|
556
|
+
|
|
557
|
+
field.split(",").each do |part|
|
|
558
|
+
if part.include?("/")
|
|
559
|
+
# Step value: 1-31/2 or */5
|
|
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
|
|
592
|
+
|
|
593
|
+
validate_dom(start_day)
|
|
594
|
+
validate_dom(end_day)
|
|
200
595
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
596
|
+
d = start_day
|
|
597
|
+
while d <= end_day
|
|
598
|
+
specs << SingleDay.new(d)
|
|
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}"))
|
|
207
608
|
end
|
|
208
|
-
|
|
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)
|
|
618
|
+
else
|
|
619
|
+
# Single: 15
|
|
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)
|
|
209
627
|
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
628
|
end
|
|
215
629
|
|
|
216
|
-
|
|
217
|
-
days = parse_cron_dow(dow_field)
|
|
218
|
-
expr = DayRepeat.new(1, days, [t])
|
|
219
|
-
ScheduleData.new(expr: expr)
|
|
630
|
+
DaysTarget.new(specs)
|
|
220
631
|
end
|
|
221
632
|
|
|
633
|
+
def self.validate_dom(day)
|
|
634
|
+
raise HronError.cron("DOM must be 1-31, got #{day}") if day < 1 || day > 31
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Parse a DOW field into a DayFilter.
|
|
222
638
|
def self.parse_cron_dow(field)
|
|
223
639
|
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
640
|
|
|
227
|
-
|
|
641
|
+
days = []
|
|
642
|
+
|
|
643
|
+
field.split(",").each do |part|
|
|
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
|
|
228
659
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
raise HronError.cron("
|
|
660
|
+
step = begin
|
|
661
|
+
Integer(step_str)
|
|
662
|
+
rescue
|
|
663
|
+
raise(HronError.cron("invalid DOW step: #{step_str}"))
|
|
664
|
+
end
|
|
665
|
+
raise HronError.cron("step cannot be 0") if step == 0
|
|
666
|
+
|
|
667
|
+
d = start_dow
|
|
668
|
+
while d <= end_dow
|
|
669
|
+
days << cron_dow_to_weekday(d)
|
|
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
|
|
685
|
+
else
|
|
686
|
+
# Single: 1 or MON
|
|
687
|
+
dow = parse_dow_value(part)
|
|
688
|
+
days << cron_dow_to_weekday(dow)
|
|
235
689
|
end
|
|
236
|
-
nums << n
|
|
237
690
|
end
|
|
238
691
|
|
|
239
|
-
|
|
692
|
+
# Check for special patterns
|
|
693
|
+
if days.length == 5
|
|
694
|
+
sorted = days.sort_by { |d| Weekday.number(d) }
|
|
695
|
+
return DayFilterWeekday.new if sorted == Weekday::WEEKDAYS
|
|
696
|
+
end
|
|
697
|
+
if days.length == 2
|
|
698
|
+
sorted = days.sort_by { |d| Weekday.number(d) }
|
|
699
|
+
return DayFilterWeekend.new if sorted == Weekday::WEEKEND
|
|
700
|
+
end
|
|
701
|
+
|
|
240
702
|
DayFilterDays.new(days)
|
|
241
703
|
end
|
|
242
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
|
|
718
|
+
|
|
719
|
+
return n
|
|
720
|
+
end
|
|
721
|
+
# Try as name
|
|
722
|
+
n = DOW_NAME_MAP[s.upcase]
|
|
723
|
+
raise HronError.cron("invalid DOW: #{s}") unless n
|
|
724
|
+
|
|
725
|
+
n
|
|
726
|
+
end
|
|
727
|
+
|
|
243
728
|
def self.cron_dow_to_weekday(n)
|
|
244
729
|
result = CRON_DOW_MAP[n]
|
|
245
730
|
raise HronError.cron("invalid DOW number: #{n}") unless result
|
|
246
731
|
|
|
247
732
|
result
|
|
248
733
|
end
|
|
734
|
+
|
|
735
|
+
# Parse a single numeric value with validation.
|
|
736
|
+
def self.parse_single_value(field, name, min, max)
|
|
737
|
+
value = begin
|
|
738
|
+
Integer(field)
|
|
739
|
+
rescue
|
|
740
|
+
raise(HronError.cron("invalid #{name} field: #{field}"))
|
|
741
|
+
end
|
|
742
|
+
raise HronError.cron("#{name} must be #{min}-#{max}, got #{value}") if value < min || value > max
|
|
743
|
+
|
|
744
|
+
value
|
|
745
|
+
end
|
|
249
746
|
end
|
|
250
747
|
end
|