aws-cron-parser 0.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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/lib/aws_cron_parser/describer/cron_composer.rb +198 -0
- data/lib/aws_cron_parser/describer/day_composer.rb +74 -0
- data/lib/aws_cron_parser/describer/field_describers/base.rb +108 -0
- data/lib/aws_cron_parser/describer/field_describers/day_of_month.rb +88 -0
- data/lib/aws_cron_parser/describer/field_describers/day_of_week.rb +143 -0
- data/lib/aws_cron_parser/describer/field_describers/month.rb +92 -0
- data/lib/aws_cron_parser/describer/field_describers/time_field.rb +101 -0
- data/lib/aws_cron_parser/describer/month_composer.rb +29 -0
- data/lib/aws_cron_parser/describer/template.en.yml +183 -0
- data/lib/aws_cron_parser/describer/template.rb +87 -0
- data/lib/aws_cron_parser/describer/time_composer.rb +255 -0
- data/lib/aws_cron_parser/describer.rb +131 -0
- data/lib/aws_cron_parser/fugit_adapter.rb +64 -0
- data/lib/aws_cron_parser/helpers/calendar.rb +115 -0
- data/lib/aws_cron_parser/helpers/clock.rb +50 -0
- data/lib/aws_cron_parser/helpers/cron_expression.rb +277 -0
- data/lib/aws_cron_parser/helpers/numbers.rb +32 -0
- data/lib/aws_cron_parser/helpers.rb +17 -0
- data/lib/aws_cron_parser/parser.rb +152 -0
- data/lib/aws_cron_parser/parsers/aws_at.rb +32 -0
- data/lib/aws_cron_parser/parsers/aws_cron.rb +413 -0
- data/lib/aws_cron_parser/parsers/aws_rate.rb +33 -0
- data/lib/aws_cron_parser/parsers/base.rb +21 -0
- data/lib/aws_cron_parser/parsers/quartz_cron.rb +21 -0
- data/lib/aws_cron_parser/validator.rb +31 -0
- data/lib/aws_cron_parser/validators/aws_at_validator.rb +28 -0
- data/lib/aws_cron_parser/validators/aws_cron_validator.rb +212 -0
- data/lib/aws_cron_parser/validators/aws_macro_validator.rb +13 -0
- data/lib/aws_cron_parser/validators/aws_rate_validator.rb +17 -0
- data/lib/aws_cron_parser/validators/base.rb +23 -0
- data/lib/aws_cron_parser/validators/quartz_cron_validator.rb +132 -0
- data/lib/aws_cron_parser/version.rb +5 -0
- data/lib/aws_cron_parser.rb +48 -0
- metadata +127 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Helpers
|
|
5
|
+
module CronExpression
|
|
6
|
+
include AWSCronParser::Helpers::Calendar
|
|
7
|
+
|
|
8
|
+
# Expression type patterns
|
|
9
|
+
AWS_CRON_REGEX = /^cron\((?<expression>.+)\)$/i
|
|
10
|
+
RATE_REGEX = /^rate\((?<value>\d+)\s+(?<unit>minute|minutes|hour|hours|day|days)\)$/i
|
|
11
|
+
MACRO_REGEX = /^@(?<macro>yearly|monthly|weekly|daily|hourly)$/i
|
|
12
|
+
AT_REGEX = /^at\((?<datetime>.+)\)$/i
|
|
13
|
+
|
|
14
|
+
# Consolidated CRON field patterns
|
|
15
|
+
CRON_PATTERNS = {
|
|
16
|
+
digit_only: /^\d+$/,
|
|
17
|
+
step: %r{^\*/(\d+)$|^0/(\d+)$},
|
|
18
|
+
range: /^(\d+)-(\d+)$/,
|
|
19
|
+
step_range: %r{(\d+)/(\d+)},
|
|
20
|
+
nth_weekday_numeric: /^\d+#\d+$/,
|
|
21
|
+
last_weekday_numeric: /^\d+L$/,
|
|
22
|
+
nth_weekday: /^(?<weekday>\d)#(?<occurrence>\d)$/,
|
|
23
|
+
nth_weekday_name: /^(?<name>[A-Z]{3}|[A-Z]+)#(?<occurrence>\d+)$/i,
|
|
24
|
+
last_weekday: /^(?<weekday>\d)L$/,
|
|
25
|
+
last_weekday_name: /^(?<name>[A-Z]{3}|[A-Z]+)L$/i
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Backward compatibility - individual constants for existing code
|
|
29
|
+
DIGIT_ONLY_REGEX = CRON_PATTERNS[:digit_only]
|
|
30
|
+
RANGE_REGEX = CRON_PATTERNS[:range]
|
|
31
|
+
STEP_RANGE_REGEX = CRON_PATTERNS[:step_range]
|
|
32
|
+
NTH_WEEKDAY_REGEX = CRON_PATTERNS[:nth_weekday]
|
|
33
|
+
LAST_WEEKDAY_REGEX = CRON_PATTERNS[:last_weekday]
|
|
34
|
+
|
|
35
|
+
# Normalize expression by stripping cron() wrapper if present
|
|
36
|
+
def normalize_source(source)
|
|
37
|
+
return source if source.blank?
|
|
38
|
+
|
|
39
|
+
normalized = source.strip
|
|
40
|
+
if (match = normalized.match(AWS_CRON_REGEX))
|
|
41
|
+
content = match[:expression].strip
|
|
42
|
+
parts = content.split(/\s+/)
|
|
43
|
+
normalized = if parts.length == 7
|
|
44
|
+
parts[0..5].join(' ')
|
|
45
|
+
elsif parts.length == 6
|
|
46
|
+
parts[0..4].join(' ')
|
|
47
|
+
else
|
|
48
|
+
parts.join(' ')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
normalized
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Detect if a specific pattern character exists in the day-of-week field
|
|
56
|
+
def detect_pattern(source, pattern_char)
|
|
57
|
+
tokens = source.split(/\s+/)
|
|
58
|
+
return false if tokens.length < 5
|
|
59
|
+
|
|
60
|
+
dow_field = tokens.length == 5 ? tokens[4] : tokens[5]
|
|
61
|
+
dow_field.include?(pattern_char)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Detect if a specific pattern character exists in the day-of-month field
|
|
65
|
+
def detect_dom_pattern(source, pattern_char)
|
|
66
|
+
tokens = source.split(/\s+/)
|
|
67
|
+
return false if tokens.length < 5
|
|
68
|
+
|
|
69
|
+
dom_field = tokens.length == 5 ? tokens[2] : tokens[3]
|
|
70
|
+
dom_field.include?(pattern_char)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Detect if the CRON expression uses complex step patterns (with ranges or lists)
|
|
74
|
+
def detect_complex_step(source)
|
|
75
|
+
tokens = source.split(/\s+/)
|
|
76
|
+
return false if tokens.length < 5
|
|
77
|
+
|
|
78
|
+
tokens.each do |field|
|
|
79
|
+
return true if field.include?('/') && field.exclude?('#') && field.exclude?('L')
|
|
80
|
+
end
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if month field matches actual month
|
|
85
|
+
def month_only?(source)
|
|
86
|
+
tokens = source.split(/\s+/)
|
|
87
|
+
return false if tokens.length < 5
|
|
88
|
+
|
|
89
|
+
dom_idx = tokens.length == 5 ? 2 : 3
|
|
90
|
+
dow_idx = tokens.length == 5 ? 4 : 5
|
|
91
|
+
|
|
92
|
+
dom = tokens[dom_idx]
|
|
93
|
+
dow = tokens[dow_idx]
|
|
94
|
+
|
|
95
|
+
['*', '?'].include?(dom) && dow != '*' && dow != '?' && dow.exclude?('#') && dow.exclude?('L')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if day-of-month field is specified and day-of-week is '?'
|
|
99
|
+
def day_only?(source)
|
|
100
|
+
tokens = source.split(/\s+/)
|
|
101
|
+
return false if tokens.length < 5
|
|
102
|
+
|
|
103
|
+
dom_idx = tokens.length == 5 ? 2 : 3
|
|
104
|
+
dow_idx = tokens.length == 5 ? 4 : 5
|
|
105
|
+
|
|
106
|
+
dom = tokens[dom_idx]
|
|
107
|
+
dow = tokens[dow_idx]
|
|
108
|
+
|
|
109
|
+
dom != '?' && dow == '?'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Parse a simple cron field (minute, hour, day, month, or weekday)
|
|
113
|
+
def parse_simple_field(field, range)
|
|
114
|
+
return range.to_a if ['*', '?'].include?(field)
|
|
115
|
+
return [field.to_i] if field.match?(/^\d+$/)
|
|
116
|
+
|
|
117
|
+
if field.include?('-')
|
|
118
|
+
start_num, end_num = field.split('-').map(&:to_i)
|
|
119
|
+
(start_num..end_num).to_a
|
|
120
|
+
elsif field.include?(',')
|
|
121
|
+
field.split(',').map(&:to_i)
|
|
122
|
+
elsif field.include?('/')
|
|
123
|
+
start_str, step_str = field.split('/')
|
|
124
|
+
start = start_str == '*' ? range.first : start_str.to_i
|
|
125
|
+
step = step_str.to_i
|
|
126
|
+
result = []
|
|
127
|
+
current = start
|
|
128
|
+
while current <= range.last
|
|
129
|
+
result << current
|
|
130
|
+
current += step
|
|
131
|
+
end
|
|
132
|
+
result
|
|
133
|
+
else
|
|
134
|
+
[field.to_i]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Parse the day-of-week field (0 = Sunday, 1 = Monday, ..., 6 = Saturday)
|
|
139
|
+
def parse_dow_field(field)
|
|
140
|
+
return (0..6).to_a if ['*', '?'].include?(field)
|
|
141
|
+
|
|
142
|
+
result = []
|
|
143
|
+
field.split(',').each do |part|
|
|
144
|
+
part = part.strip
|
|
145
|
+
if part.include?('/')
|
|
146
|
+
result.concat(expand_dow_step(part))
|
|
147
|
+
elsif part.include?('-')
|
|
148
|
+
start_num, end_num = part.split('-')
|
|
149
|
+
start_num = AWS_DAY_NAMES[start_num.upcase] || start_num.to_i
|
|
150
|
+
end_num = AWS_DAY_NAMES[end_num.upcase] || end_num.to_i
|
|
151
|
+
(start_num..end_num).each { |n| result << n }
|
|
152
|
+
elsif part.match?(/^\d+$/)
|
|
153
|
+
result << part.to_i
|
|
154
|
+
elsif AWS_DAY_NAMES[part.upcase]
|
|
155
|
+
result << AWS_DAY_NAMES[part.upcase]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
result.sort.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Expands */2, MON/2 or 2-6/2 over the AWS weekday range (1=SUN..7=SAT).
|
|
162
|
+
# Without this the field yields no days at all and the caller searches a
|
|
163
|
+
# whole year before giving up.
|
|
164
|
+
def expand_dow_step(part)
|
|
165
|
+
base, step = part.split('/')
|
|
166
|
+
step = step.to_i
|
|
167
|
+
return [] if step <= 0
|
|
168
|
+
|
|
169
|
+
if base.include?('-')
|
|
170
|
+
start_num, end_num = base.split('-').map { |v| AWS_DAY_NAMES[v.upcase] || v.to_i }
|
|
171
|
+
elsif ['*', '?'].include?(base)
|
|
172
|
+
start_num = 1
|
|
173
|
+
end_num = 7
|
|
174
|
+
else
|
|
175
|
+
start_num = AWS_DAY_NAMES[base.upcase] || base.to_i
|
|
176
|
+
end_num = 7
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
(start_num..end_num).step(step).to_a
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Normalize weekday in nth spec (e.g., "MON#2" to "1#2")
|
|
183
|
+
def normalize_weekday_in_nth_spec(dow_spec, is_aws: false)
|
|
184
|
+
return dow_spec if dow_spec.match?(CRON_PATTERNS[:nth_weekday_numeric])
|
|
185
|
+
|
|
186
|
+
match = dow_spec.match(CRON_PATTERNS[:nth_weekday_name])
|
|
187
|
+
return dow_spec unless match
|
|
188
|
+
|
|
189
|
+
day_names = is_aws ? AWS_DAY_NAMES : DAY_NAMES
|
|
190
|
+
day_num = day_names[match[:name].upcase] || match[:name]
|
|
191
|
+
"#{day_num}##{match[:occurrence]}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Normalize weekday field (e.g., "mon", "tue-wed", "fri/2")
|
|
195
|
+
def normalize_weekday_field(weekday, is_aws: false)
|
|
196
|
+
return weekday if ['*', '?'].include?(weekday) || weekday =~ /^\d/
|
|
197
|
+
|
|
198
|
+
# Handle ranges like "mon-wed"
|
|
199
|
+
if weekday.include?('-')
|
|
200
|
+
parts = weekday.split('-')
|
|
201
|
+
start_num = weekday_abbr_to_num(parts[0], is_aws: is_aws)
|
|
202
|
+
end_num = weekday_abbr_to_num(parts[1], is_aws: is_aws)
|
|
203
|
+
return "#{start_num}-#{end_num}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Handle lists like "mon,wed,fri"
|
|
207
|
+
if weekday.include?(',')
|
|
208
|
+
parts = weekday.split(',').map { |w| weekday_abbr_to_num(w, is_aws: is_aws) }
|
|
209
|
+
return parts.join(',')
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Handle steps like "mon/2" and "*/2" - the wildcard base is not a name
|
|
213
|
+
if weekday.include?('/')
|
|
214
|
+
parts = weekday.split('/')
|
|
215
|
+
start = ['*', '?'].include?(parts[0]) ? parts[0] : weekday_abbr_to_num(parts[0], is_aws: is_aws)
|
|
216
|
+
return "#{start}/#{parts[1]}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Single weekday abbreviation
|
|
220
|
+
weekday_abbr_to_num(weekday, is_aws: is_aws).to_s
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Normalize weekday in last spec (e.g., "MONL" to "1L")
|
|
224
|
+
def normalize_weekday_in_last_spec(dow_spec)
|
|
225
|
+
return dow_spec if dow_spec.match?(CRON_PATTERNS[:last_weekday_numeric])
|
|
226
|
+
|
|
227
|
+
match = dow_spec.match(CRON_PATTERNS[:last_weekday_name])
|
|
228
|
+
return dow_spec unless match
|
|
229
|
+
|
|
230
|
+
day_num = AWS_DAY_NAMES[match[:name].upcase] || match[:name]
|
|
231
|
+
"#{day_num}L"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Normalize month field (e.g., "jan", "feb-mar", "apr/2")
|
|
235
|
+
def normalize_month_field(month)
|
|
236
|
+
return month if ['*', '?'].include?(month) || month =~ /^\d/
|
|
237
|
+
|
|
238
|
+
# Handle ranges like "jan-mar"
|
|
239
|
+
if month.include?('-')
|
|
240
|
+
parts = month.split('-')
|
|
241
|
+
start_num = month_abbr_to_num(parts[0])
|
|
242
|
+
end_num = month_abbr_to_num(parts[1])
|
|
243
|
+
return "#{start_num}-#{end_num}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Handle lists like "jan,mar,jun"
|
|
247
|
+
if month.include?(',')
|
|
248
|
+
parts = month.split(',').map { |m| month_abbr_to_num(m) }
|
|
249
|
+
return parts.join(',')
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Handle steps like "jan/2" and "*/2" - the wildcard base is not a name
|
|
253
|
+
if month.include?('/')
|
|
254
|
+
parts = month.split('/')
|
|
255
|
+
start = ['*', '?'].include?(parts[0]) ? parts[0] : month_abbr_to_num(parts[0])
|
|
256
|
+
return "#{start}/#{parts[1]}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Single month abbreviation
|
|
260
|
+
month_abbr_to_num(month).to_s
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def weekday_only?(source)
|
|
264
|
+
tokens = normalize_source(source).split(/\s+/)
|
|
265
|
+
return false if tokens.length < 5
|
|
266
|
+
|
|
267
|
+
dom_idx = tokens.length == 5 ? 2 : 3
|
|
268
|
+
dow_idx = tokens.length == 5 ? 4 : 5
|
|
269
|
+
|
|
270
|
+
dom = tokens[dom_idx]
|
|
271
|
+
dow = tokens[dow_idx]
|
|
272
|
+
|
|
273
|
+
dom == '?' && dow != '?' && dow.exclude?('#') && dow.exclude?('L')
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Helpers
|
|
5
|
+
module Numbers
|
|
6
|
+
# Convert number to ordinal string (1 => "1st", 2 => "2nd", etc.)
|
|
7
|
+
def ordinal(num)
|
|
8
|
+
num = num.to_i
|
|
9
|
+
case num % 100
|
|
10
|
+
when 11, 12, 13
|
|
11
|
+
"#{num}th"
|
|
12
|
+
else
|
|
13
|
+
case num % 10
|
|
14
|
+
when 1 then "#{num}st"
|
|
15
|
+
when 2 then "#{num}nd"
|
|
16
|
+
when 3 then "#{num}rd"
|
|
17
|
+
else "#{num}th"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Convert number to ordinal word (1 => "first", 2 => "second", etc.)
|
|
23
|
+
def ordinal_word(num)
|
|
24
|
+
num = num.to_i
|
|
25
|
+
words = %w[zeroth first second third fourth fifth sixth seventh eighth ninth tenth
|
|
26
|
+
eleventh twelfth thirteenth fourteenth fifteenth sixteenth seventeenth
|
|
27
|
+
eighteenth nineteenth twentieth]
|
|
28
|
+
words[num] || ordinal(num).to_s
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Helpers
|
|
5
|
+
def self.included(base)
|
|
6
|
+
[
|
|
7
|
+
AWSCronParser::Helpers::Calendar,
|
|
8
|
+
AWSCronParser::Helpers::Clock,
|
|
9
|
+
AWSCronParser::Helpers::CronExpression,
|
|
10
|
+
AWSCronParser::Helpers::Numbers
|
|
11
|
+
].each do |m|
|
|
12
|
+
base.include(m)
|
|
13
|
+
base.extend(m)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/object/blank'
|
|
4
|
+
|
|
5
|
+
module AWSCronParser
|
|
6
|
+
class Parser
|
|
7
|
+
include AWSCronParser::Helpers
|
|
8
|
+
|
|
9
|
+
def initialize(source, time_source = Time)
|
|
10
|
+
@original_source = source
|
|
11
|
+
@time_source = time_source
|
|
12
|
+
|
|
13
|
+
validate!
|
|
14
|
+
|
|
15
|
+
# Route to appropriate parser
|
|
16
|
+
@parser = if RATE_REGEX.match?(@original_source)
|
|
17
|
+
AWSCronParser::Parsers::AWSRate.new(@original_source, @time_source)
|
|
18
|
+
elsif AT_REGEX.match?(@original_source)
|
|
19
|
+
AWSCronParser::Parsers::AWSAt.new(@original_source, @time_source)
|
|
20
|
+
elsif AWS_CRON_REGEX.match?(@original_source) || @original_source.start_with?('@')
|
|
21
|
+
AWSCronParser::Parsers::AWSCron.new(@original_source, @time_source)
|
|
22
|
+
else
|
|
23
|
+
AWSCronParser::Parsers::QuartzCron.new(@original_source, @time_source)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def next(now = @time_source.now)
|
|
28
|
+
@parser.next(now)
|
|
29
|
+
rescue ZeroDivisionError => e
|
|
30
|
+
# Normalize low-level arithmetic errors from underlying parser into
|
|
31
|
+
# a user-facing ArgumentError so callers (and controllers) can
|
|
32
|
+
# return a proper 4xx validation response instead of crashing.
|
|
33
|
+
raise ArgumentError, "Invalid CRON expression: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def last(now = @time_source.now)
|
|
37
|
+
if @parser.is_a?(AWSCronParser::Parsers::AWSCron) ||
|
|
38
|
+
@parser.is_a?(AWSCronParser::Parsers::AWSRate) ||
|
|
39
|
+
@parser.is_a?(AWSCronParser::Parsers::AWSAt)
|
|
40
|
+
raise ArgumentError, 'last() not supported for special patterns'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@parser.last(now)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get multiple next runs (shows unique occurrences for UI when appropriate)
|
|
47
|
+
# @param count [Integer] Number of next runs to calculate
|
|
48
|
+
# @param from [Time] Starting time (defaults to now)
|
|
49
|
+
# @return [Array<Time>] Array of next execution times
|
|
50
|
+
def next_runs(count = 5, from = @time_source.now)
|
|
51
|
+
# Always show all executions - the UI can handle pagination/filtering if needed
|
|
52
|
+
next_runs_all(count, from)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get ALL next runs (raw behavior - every single execution)
|
|
56
|
+
# @param count [Integer] Number of next runs to calculate
|
|
57
|
+
# @param from [Time] Starting time (defaults to now)
|
|
58
|
+
# @return [Array<Time>] Array of next execution times
|
|
59
|
+
def next_runs_all(count = 5, from = @time_source.now)
|
|
60
|
+
raise ArgumentError, 'count must be positive' if count <= 0
|
|
61
|
+
|
|
62
|
+
results = []
|
|
63
|
+
current = from
|
|
64
|
+
|
|
65
|
+
count.times do
|
|
66
|
+
current = @parser.next(current)
|
|
67
|
+
# A one-time at() schedule stops yielding once its instant has passed.
|
|
68
|
+
break if current.nil?
|
|
69
|
+
|
|
70
|
+
results << current
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
results
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get unique dates (remove time component)
|
|
77
|
+
def next_unique_dates(count, from = @time_source.now)
|
|
78
|
+
seen_dates = Set.new
|
|
79
|
+
results = []
|
|
80
|
+
current = from
|
|
81
|
+
|
|
82
|
+
while results.length < count && results.length < 1000 # Safety limit
|
|
83
|
+
current = self.next(current)
|
|
84
|
+
break if current.nil?
|
|
85
|
+
|
|
86
|
+
date_key = current.to_date
|
|
87
|
+
|
|
88
|
+
next if seen_dates.include?(date_key)
|
|
89
|
+
|
|
90
|
+
seen_dates.add(date_key)
|
|
91
|
+
# Set to start of day for cleaner display
|
|
92
|
+
results << current.beginning_of_day
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
results
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get unique hours (remove minute component)
|
|
99
|
+
def next_unique_hours(count, from = @time_source.now)
|
|
100
|
+
seen_hours = Set.new
|
|
101
|
+
results = []
|
|
102
|
+
current = from
|
|
103
|
+
|
|
104
|
+
while results.length < count && results.length < 1000 # Safety limit
|
|
105
|
+
current = self.next(current)
|
|
106
|
+
break if current.nil?
|
|
107
|
+
|
|
108
|
+
hour_key = [current.to_date, current.hour]
|
|
109
|
+
|
|
110
|
+
next if seen_hours.include?(hour_key)
|
|
111
|
+
|
|
112
|
+
seen_hours.add(hour_key)
|
|
113
|
+
# Set to start of hour for cleaner display
|
|
114
|
+
results << current.beginning_of_hour
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
results
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def source
|
|
121
|
+
@parser.source
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def expression
|
|
125
|
+
@original_source
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def describe
|
|
129
|
+
Describer.describe(@original_source)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate!
|
|
133
|
+
Validator.validate(@original_source)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate
|
|
137
|
+
@errors = nil
|
|
138
|
+
Validator.validate(@original_source)
|
|
139
|
+
rescue ArgumentError => e
|
|
140
|
+
@errors = [e.message]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def valid?
|
|
144
|
+
validate
|
|
145
|
+
errors.empty?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def errors
|
|
149
|
+
@errors || []
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Parsers
|
|
5
|
+
# AWS one-time schedule: at(yyyy-MM-ddThh:mm:ss). Fires exactly once.
|
|
6
|
+
class AWSAt < Base
|
|
7
|
+
attr_reader :at
|
|
8
|
+
|
|
9
|
+
def initialize(source, time_source = Time)
|
|
10
|
+
super
|
|
11
|
+
|
|
12
|
+
match = source.strip.match(AT_REGEX)
|
|
13
|
+
raise ArgumentError, "Invalid at format: #{source}" unless match
|
|
14
|
+
|
|
15
|
+
@at = zone.parse(match[:datetime].strip)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# nil once the instant has passed: a one-time schedule has no further runs.
|
|
19
|
+
def next(now = @time_source.now)
|
|
20
|
+
@at > ensure_time(now) ? @at : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# AWS at() carries no offset, so it is read in the caller's zone when one
|
|
26
|
+
# was given, and otherwise in Time.zone (UTC unless the host set it).
|
|
27
|
+
def zone
|
|
28
|
+
@time_source.is_a?(ActiveSupport::TimeZone) ? @time_source : ::Time.zone
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|