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,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Describer
|
|
5
|
+
# Composes time descriptions (second, minute, hour)
|
|
6
|
+
class TimeComposer
|
|
7
|
+
include AWSCronParser::Helpers
|
|
8
|
+
|
|
9
|
+
attr_reader :second, :minute, :hour, :is_aws
|
|
10
|
+
|
|
11
|
+
def initialize(config, is_aws: false)
|
|
12
|
+
@second = config[:second]
|
|
13
|
+
@minute = config[:minute]
|
|
14
|
+
@hour = config[:hour]
|
|
15
|
+
@is_aws = is_aws
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compose
|
|
19
|
+
return nil if all_wildcards?
|
|
20
|
+
|
|
21
|
+
# Try specific patterns first
|
|
22
|
+
return describe_specific_time if specific_time?
|
|
23
|
+
return describe_step_pattern if step_pattern?
|
|
24
|
+
return describe_specific_second if specific_second_pattern?
|
|
25
|
+
|
|
26
|
+
# General composition
|
|
27
|
+
describe_general
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def all_wildcards?
|
|
33
|
+
[second, minute, hour].all? { |f| f.nil? || f == '*' || f == '?' }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def specific_time?
|
|
37
|
+
hour =~ DIGIT_ONLY_REGEX && minute =~ DIGIT_ONLY_REGEX
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def step_pattern?
|
|
41
|
+
(minute&.include?('/') && hour == '*') ||
|
|
42
|
+
(hour&.include?('/') && minute == '*') ||
|
|
43
|
+
(minute&.include?('/') && hour =~ DIGIT_ONLY_REGEX) || # minute step with specific hour
|
|
44
|
+
(minute&.include?('/') && hour&.include?('-')) # minute step with hour range
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def specific_second_pattern?
|
|
48
|
+
second.present? && second =~ DIGIT_ONLY_REGEX && minute == '*' && hour == '*'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def describe_specific_time
|
|
52
|
+
h = hour.to_i
|
|
53
|
+
m = minute.to_i
|
|
54
|
+
|
|
55
|
+
time_str = if second.present? && second =~ DIGIT_ONLY_REGEX
|
|
56
|
+
s = second.to_i
|
|
57
|
+
format_time_with_seconds(h, m, s)
|
|
58
|
+
else
|
|
59
|
+
format_time(h, m)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
"At #{time_str}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def describe_step_pattern
|
|
66
|
+
# Hour step: */2 or 0/2 or 1/5 (should be checked first)
|
|
67
|
+
# Check if minute is 0 (start of the hour) - simplify description
|
|
68
|
+
if hour&.match?(%r{^(\*|\d+)/(\d+)$}) && (minute == '0')
|
|
69
|
+
match = hour.match(%r{^(\*|\d+)/(\d+)$})
|
|
70
|
+
step = match[2].to_i
|
|
71
|
+
plural = step == 1 ? '' : 's'
|
|
72
|
+
return step == 1 ? 'Every hour' : "Every #{step} hour#{plural}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Minute step: */5 or 0/5
|
|
76
|
+
if minute&.match?(%r{^(\*|\d+)/(\d+)$})
|
|
77
|
+
match = minute.match(%r{^(\*|\d+)/(\d+)$})
|
|
78
|
+
step = match[2].to_i
|
|
79
|
+
|
|
80
|
+
# Check for hour constraints
|
|
81
|
+
if hour.present? && hour != '*' && hour != '?'
|
|
82
|
+
return describe_constrained_minute_step(step)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
plural = step == 1 ? '' : 's'
|
|
86
|
+
return step == 1 ? 'Every minute' : "Every #{step} minute#{plural}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def describe_constrained_minute_step(step)
|
|
93
|
+
plural = step == 1 ? '' : 's'
|
|
94
|
+
base = step == 1 ? 'Every minute' : "Every #{step} minute#{plural}"
|
|
95
|
+
|
|
96
|
+
# Hour range: 9-17
|
|
97
|
+
if hour.include?('-')
|
|
98
|
+
parts = hour.split('-')
|
|
99
|
+
start_h = format_hour_12(parts[0].to_i)
|
|
100
|
+
end_h = format_hour_12(parts[1].to_i)
|
|
101
|
+
return "#{base} between #{start_h} and #{end_h}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Hour list: 9,12,15
|
|
105
|
+
if hour.include?(',')
|
|
106
|
+
hours = hour.split(',').map { |h| format_hour_12(h.to_i) }
|
|
107
|
+
hour_part = if hours.length == 2
|
|
108
|
+
hours.join(' and ')
|
|
109
|
+
else
|
|
110
|
+
last = hours.pop
|
|
111
|
+
"#{hours.join(', ')}, and #{last}"
|
|
112
|
+
end
|
|
113
|
+
return "#{base} at #{hour_part}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Single hour: e.g., "1/5 9 * * *" -> "Every 5 minutes from 9:01 AM to 9:56 AM"
|
|
117
|
+
if /^\d+$/.match?(hour)
|
|
118
|
+
hour_int = hour.to_i
|
|
119
|
+
match = minute.match(%r{^(\d+)/(\d+)$})
|
|
120
|
+
start_min = match ? match[1].to_i : 0
|
|
121
|
+
|
|
122
|
+
# Calculate end minute (start + step * floor((59-start)/step))
|
|
123
|
+
end_min = start_min + (step * ((59 - start_min) / step))
|
|
124
|
+
|
|
125
|
+
# Convert to 12-hour format
|
|
126
|
+
hour_12 = hour_int % 12
|
|
127
|
+
hour_12 = 12 if hour_12.zero?
|
|
128
|
+
period = hour_int < 12 ? 'AM' : 'PM'
|
|
129
|
+
|
|
130
|
+
return "#{base} from #{hour_12}:#{start_min.to_s.rjust(
|
|
131
|
+
2, '0'
|
|
132
|
+
)} #{period} to #{hour_12}:#{end_min.to_s.rjust(
|
|
133
|
+
2, '0'
|
|
134
|
+
)} #{period}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
base
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def describe_specific_second
|
|
141
|
+
s = second.to_i
|
|
142
|
+
Template.interpolate('composition.every_minute_at_second', second: s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def describe_general
|
|
146
|
+
# Handle minute-specific with hour=*
|
|
147
|
+
if hour == '*' && minute.present? && minute != '*' && minute != '?' && !minute.match?(%r{[-,/]})
|
|
148
|
+
m = minute.to_i
|
|
149
|
+
return Template.interpolate('composition.every_hour_at_minute', minute: m)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Complex case: describe each component
|
|
153
|
+
parts = []
|
|
154
|
+
|
|
155
|
+
# Add hour if set
|
|
156
|
+
parts << describe_component(hour, :hour) if hour.present? && hour != '*' && hour != '?'
|
|
157
|
+
|
|
158
|
+
# Add minute only if it's not 0 when we have an hour step pattern, hour range, or hour list
|
|
159
|
+
# Example: "0 */4 * * *" should be "Every 4 hours", not "0 minutes and every 4 hours"
|
|
160
|
+
# Example: "0 10-15 * * *" should be "Every day from 10 AM to 3 PM", not "0 minutes and from..."
|
|
161
|
+
# Example: "0 9,12,15 * * *" should be "at 9 AM, 12 PM, and 3 PM", not "0 minutes and at..."
|
|
162
|
+
should_skip_minute = minute == '0' && (hour&.include?('/') || hour&.include?('-') || hour&.include?(','))
|
|
163
|
+
if !should_skip_minute && minute.present? && minute != '*' && minute != '?'
|
|
164
|
+
parts << describe_component(minute, :minute)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Add second if significant
|
|
168
|
+
if second.present? && second != '*' && second != '?' && second != '0'
|
|
169
|
+
parts << describe_component(second, :second)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return nil if parts.empty?
|
|
173
|
+
|
|
174
|
+
# Special case: hour step with specific minute (e.g., "30 */6 * * *")
|
|
175
|
+
if parts.length == 2 && hour&.include?('/') && minute =~ DIGIT_ONLY_REGEX
|
|
176
|
+
hour_desc = parts[0]
|
|
177
|
+
m = minute.to_i
|
|
178
|
+
return "#{hour_desc} at minute #{m}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Join parts
|
|
182
|
+
if parts.length == 1
|
|
183
|
+
# Single part - return as is if it starts with a capital letter (proper sentence)
|
|
184
|
+
parts[0]
|
|
185
|
+
elsif hour&.include?('-') && minute =~ DIGIT_ONLY_REGEX
|
|
186
|
+
# Multiple parts - join with spaces (descriptions already have proper formatting)
|
|
187
|
+
# For hour ranges, we want to avoid "30 minutes and from 10 AM to 3 PM"
|
|
188
|
+
# Instead: "Every day at 10:30 AM to 3:30 PM" or similar
|
|
189
|
+
# We have a time range - format differently
|
|
190
|
+
format_time_range
|
|
191
|
+
else
|
|
192
|
+
parts.reverse.join(' ')
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def format_time_range
|
|
197
|
+
hour_parts = hour.split('-')
|
|
198
|
+
start_h = hour_parts[0].to_i
|
|
199
|
+
end_h = hour_parts[1].to_i
|
|
200
|
+
m = minute.to_i
|
|
201
|
+
|
|
202
|
+
start_time = format_time(start_h, m)
|
|
203
|
+
end_time = format_time(end_h, m)
|
|
204
|
+
|
|
205
|
+
# Return without "Every day" prefix - it will be added by composer if needed
|
|
206
|
+
"from #{start_time} to #{end_time}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def describe_component(value, type)
|
|
210
|
+
FieldDescribers::TimeField.new(value, type, is_aws: is_aws).describe
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def format_time(hour, min)
|
|
214
|
+
hour_24 = hour.to_i % 24
|
|
215
|
+
am_pm = hour_24 < 12 ? 'AM' : 'PM'
|
|
216
|
+
hour_12 = if hour_24.zero?
|
|
217
|
+
12
|
|
218
|
+
elsif hour_24 > 12
|
|
219
|
+
hour_24 - 12
|
|
220
|
+
else
|
|
221
|
+
hour_24
|
|
222
|
+
end
|
|
223
|
+
time_str = hour_12.to_s
|
|
224
|
+
time_str += ":#{min.to_s.rjust(2, '0')}" if min != 0 || !is_aws
|
|
225
|
+
"#{time_str} #{am_pm}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def format_time_with_seconds(hour, min, sec)
|
|
229
|
+
hour_24 = hour.to_i % 24
|
|
230
|
+
am_pm = hour_24 < 12 ? 'AM' : 'PM'
|
|
231
|
+
hour_12 = if hour_24.zero?
|
|
232
|
+
12
|
|
233
|
+
elsif hour_24 > 12
|
|
234
|
+
hour_24 - 12
|
|
235
|
+
else
|
|
236
|
+
hour_24
|
|
237
|
+
end
|
|
238
|
+
"#{hour_12}:#{min.to_s.rjust(2, '0')}:#{sec.to_s.rjust(2, '0')} #{am_pm}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def format_hour_12(hour_24)
|
|
242
|
+
hour_24 = hour_24.to_i % 24
|
|
243
|
+
period = hour_24 < 12 ? 'AM' : 'PM'
|
|
244
|
+
hour_12 = if hour_24.zero?
|
|
245
|
+
12
|
|
246
|
+
elsif hour_24 > 12
|
|
247
|
+
hour_24 - 12
|
|
248
|
+
else
|
|
249
|
+
hour_24
|
|
250
|
+
end
|
|
251
|
+
"#{hour_12} #{period}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Describer
|
|
5
|
+
include AWSCronParser::Helpers
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Main entry point: describe a CRON expression in human-readable form
|
|
10
|
+
# Returns string like "Every weekday at 9:00 AM"
|
|
11
|
+
def describe(expression)
|
|
12
|
+
return nil if expression.nil? || expression.to_s.strip.empty? || !expression.is_a?(String)
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
AWSCronParser::Validator.validate(expression)
|
|
16
|
+
config = parse_config(expression)
|
|
17
|
+
return nil unless config
|
|
18
|
+
|
|
19
|
+
case config[:type]
|
|
20
|
+
when 'rate'
|
|
21
|
+
describe_rate(config)
|
|
22
|
+
when 'macro'
|
|
23
|
+
describe_macro(config)
|
|
24
|
+
when 'at'
|
|
25
|
+
describe_at(config)
|
|
26
|
+
when /^(aws-)?cron/
|
|
27
|
+
describe_cron(config)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse parser into config hash for describing
|
|
33
|
+
def parse_config(expression)
|
|
34
|
+
trimmed = expression.strip
|
|
35
|
+
|
|
36
|
+
# Rate expression: rate(value unit)
|
|
37
|
+
if (rate_match = trimmed.match(RATE_REGEX))
|
|
38
|
+
unit = rate_match[:unit].downcase.chomp('s').to_sym
|
|
39
|
+
return { type: 'rate', value: rate_match[:value].to_i, unit: unit }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Macro expressions: @yearly, @monthly, etc.
|
|
43
|
+
if (macro_match = trimmed.match(MACRO_REGEX))
|
|
44
|
+
macro = macro_match[:macro].downcase
|
|
45
|
+
return { type: 'macro', macro: macro }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# At expression: at(iso8601)
|
|
49
|
+
if (at_match = trimmed.match(AT_REGEX))
|
|
50
|
+
return { type: 'at', dateTime: at_match[:datetime] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Detect AWS cron wrapper
|
|
54
|
+
is_aws = false
|
|
55
|
+
content = trimmed
|
|
56
|
+
if (cron_match = content.match(AWS_CRON_REGEX))
|
|
57
|
+
is_aws = true
|
|
58
|
+
content = cron_match[:expression].strip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
fields = content.split(/\s+/)
|
|
62
|
+
case fields.length
|
|
63
|
+
when 5
|
|
64
|
+
# Standard CRON: MINUTE HOUR DAY MONTH DOW
|
|
65
|
+
# OR AWS (no wrapper): not applicable, AWS needs cron() wrapper
|
|
66
|
+
{ type: is_aws ? 'aws-cron-5' : 'cron-5', second: nil, minute: fields[0], hour: fields[1], day: fields[2],
|
|
67
|
+
month: fields[3], dow: fields[4], is_aws: is_aws }
|
|
68
|
+
when 6
|
|
69
|
+
if is_aws
|
|
70
|
+
# AWS with wrapper: MINUTE HOUR DAY MONTH DOW YEAR
|
|
71
|
+
{ type: 'aws-cron-6', second: nil, minute: fields[0], hour: fields[1], day: fields[2], month: fields[3],
|
|
72
|
+
dow: fields[4], year: fields[5], is_aws: is_aws }
|
|
73
|
+
else
|
|
74
|
+
# Unix CRON: SECOND MINUTE HOUR DAY MONTH DOW
|
|
75
|
+
{ type: 'cron-6', second: fields[0], minute: fields[1], hour: fields[2], day: fields[3], month: fields[4],
|
|
76
|
+
dow: fields[5], is_aws: is_aws }
|
|
77
|
+
end
|
|
78
|
+
when 7
|
|
79
|
+
# AWS with seconds: SECOND MINUTE HOUR DAY MONTH DOW YEAR
|
|
80
|
+
{ type: 'aws-cron-7', second: fields[0], minute: fields[1], hour: fields[2], day: fields[3], month: fields[4],
|
|
81
|
+
dow: fields[5], year: fields[6], is_aws: is_aws }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Describe rate expression
|
|
86
|
+
def describe_rate(config)
|
|
87
|
+
value = config[:value]
|
|
88
|
+
unit = config[:unit]
|
|
89
|
+
|
|
90
|
+
Template.interpolate(value == 1 ? 'rate.singular' : 'rate.plural', value: value, unit: unit)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Describe macro expression
|
|
94
|
+
def describe_macro(config)
|
|
95
|
+
macro = config[:macro]
|
|
96
|
+
Template.interpolate("macro.#{macro}") || "Every #{macro}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Describe at expression
|
|
100
|
+
def describe_at(config)
|
|
101
|
+
date_time = Time.zone.parse(config[:dateTime])
|
|
102
|
+
"At #{date_time.strftime('%A, %b %d, %Y %I:%M:%S %p')}"
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Describe CRON expression using composers
|
|
108
|
+
def describe_cron(config)
|
|
109
|
+
CronComposer.new(config).compose
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Legacy method - kept for backward compatibility
|
|
113
|
+
# Delegates to appropriate field describer
|
|
114
|
+
def describe_field(value, field_type, is_aws: false)
|
|
115
|
+
return nil if value.blank? || ['*', '?'].include?(value)
|
|
116
|
+
|
|
117
|
+
case field_type
|
|
118
|
+
when 'minute', 'second', 'hour'
|
|
119
|
+
FieldDescribers::TimeField.new(value, field_type.to_sym, is_aws: is_aws).describe
|
|
120
|
+
when 'day-of-month'
|
|
121
|
+
FieldDescribers::DayOfMonth.new(value, is_aws: is_aws).describe
|
|
122
|
+
when 'day-of-week'
|
|
123
|
+
FieldDescribers::DayOfWeek.new(value, is_aws: is_aws).describe
|
|
124
|
+
when 'month'
|
|
125
|
+
FieldDescribers::Month.new(value, is_aws: is_aws).describe
|
|
126
|
+
else
|
|
127
|
+
value
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fugit'
|
|
4
|
+
|
|
5
|
+
module AWSCronParser
|
|
6
|
+
# Adapter to use Fugit gem as replacement for parse-cron
|
|
7
|
+
# Provides a unified interface for next/last time calculations
|
|
8
|
+
class FugitAdapter
|
|
9
|
+
def initialize(source, time_source = Time)
|
|
10
|
+
@source = source
|
|
11
|
+
@time_source = time_source
|
|
12
|
+
@normalized_source = normalize_source(source)
|
|
13
|
+
|
|
14
|
+
# Parse with fugit
|
|
15
|
+
@fugit_cron = Fugit::Cron.parse(@normalized_source)
|
|
16
|
+
|
|
17
|
+
raise ArgumentError, "Invalid CRON expression: #{source}" if @fugit_cron.nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Get next occurrence of the cron expression
|
|
21
|
+
def next(now)
|
|
22
|
+
result = @fugit_cron.next_time(now)
|
|
23
|
+
normalize_to_time_zone(result)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get previous occurrence of the cron expression
|
|
27
|
+
def last(now)
|
|
28
|
+
result = @fugit_cron.previous_time(now)
|
|
29
|
+
normalize_to_time_zone(result)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get the original source expression
|
|
33
|
+
def original
|
|
34
|
+
@source
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Normalize cron expression for fugit compatibility
|
|
40
|
+
# Fugit doesn't support ? and L in the same way as Quartz/AWS
|
|
41
|
+
def normalize_source(source)
|
|
42
|
+
normalized = source.dup
|
|
43
|
+
|
|
44
|
+
# Replace ? with * for compatibility
|
|
45
|
+
# ? means "no specific value" in Quartz, but fugit uses * for wildcard
|
|
46
|
+
normalized.tr('?', '*')
|
|
47
|
+
|
|
48
|
+
# NOTE: L (last day) is supported by fugit in 5-field format
|
|
49
|
+
# No need to replace it like parse-cron required
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Convert EtOrbi time to Rails Time.zone if available
|
|
53
|
+
# This ensures consistency with the rest of the application
|
|
54
|
+
def normalize_to_time_zone(time)
|
|
55
|
+
return nil unless time
|
|
56
|
+
|
|
57
|
+
# If Time.zone is not available, return as-is
|
|
58
|
+
return time unless Time.respond_to?(:zone) && Time.zone
|
|
59
|
+
|
|
60
|
+
# Convert EtOrbi::EoTime to Time.zone
|
|
61
|
+
Time.zone.at(time.to_i)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Helpers
|
|
5
|
+
module Calendar
|
|
6
|
+
AWS_DAY_NAMES = {
|
|
7
|
+
'SUN' => 1, 'SUNDAY' => 1,
|
|
8
|
+
'MON' => 2, 'MONDAY' => 2,
|
|
9
|
+
'TUE' => 3, 'TUESDAY' => 3,
|
|
10
|
+
'WED' => 4, 'WEDNESDAY' => 4,
|
|
11
|
+
'THU' => 5, 'THURSDAY' => 5,
|
|
12
|
+
'FRI' => 6, 'FRIDAY' => 6,
|
|
13
|
+
'SAT' => 7, 'SATURDAY' => 7
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
DAY_NAMES = {
|
|
17
|
+
'SUN' => 0, 'SUNDAY' => 0,
|
|
18
|
+
'MON' => 1, 'MONDAY' => 1,
|
|
19
|
+
'TUE' => 2, 'TUESDAY' => 2,
|
|
20
|
+
'WED' => 3, 'WEDNESDAY' => 3,
|
|
21
|
+
'THU' => 4, 'THURSDAY' => 4,
|
|
22
|
+
'FRI' => 5, 'FRIDAY' => 5,
|
|
23
|
+
'SAT' => 6, 'SATURDAY' => 6
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Weekday names (0-6: SUN-SAT, where 0=Sunday, 6=Saturday)
|
|
27
|
+
WEEKDAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
28
|
+
|
|
29
|
+
# AWS weekday names (1-7: SUN-SAT, where 1=Sunday, 7=Saturday).
|
|
30
|
+
# Must stay aligned with AWS_DAY_NAMES above and with the parser, which
|
|
31
|
+
# matches candidates on Date#wday + 1.
|
|
32
|
+
AWS_WEEKDAY_NAMES = [nil, 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].freeze
|
|
33
|
+
|
|
34
|
+
# Month names (1-based indexing for CRON)
|
|
35
|
+
MONTH_NAMES = [nil, 'January', 'February', 'March', 'April', 'May', 'June',
|
|
36
|
+
'July', 'August', 'September', 'October', 'November', 'December'].freeze
|
|
37
|
+
|
|
38
|
+
# Month abbreviations (for parsing JAN, FEB, etc.)
|
|
39
|
+
# Using upcase in lookup to avoid case duplication
|
|
40
|
+
MONTH_ABBREVIATIONS = {
|
|
41
|
+
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6,
|
|
42
|
+
'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Month full names (for parsing JANUARY, FEBRUARY, etc.)
|
|
46
|
+
# Using upcase in lookup to avoid case duplication
|
|
47
|
+
MONTH_FULL_NAMES = {
|
|
48
|
+
'JANUARY' => 1, 'FEBRUARY' => 2, 'MARCH' => 3, 'APRIL' => 4, 'MAY' => 5, 'JUNE' => 6,
|
|
49
|
+
'JULY' => 7, 'AUGUST' => 8, 'SEPTEMBER' => 9, 'OCTOBER' => 10, 'NOVEMBER' => 11, 'DECEMBER' => 12
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Convert day name from number or string
|
|
53
|
+
def day_name(num_or_str, is_aws: false)
|
|
54
|
+
if num_or_str.is_a?(String) && num_or_str.match?(/[A-Z]/)
|
|
55
|
+
case num_or_str.upcase
|
|
56
|
+
when 'MON' then 'Monday'
|
|
57
|
+
when 'TUE' then 'Tuesday'
|
|
58
|
+
when 'WED' then 'Wednesday'
|
|
59
|
+
when 'THU' then 'Thursday'
|
|
60
|
+
when 'FRI' then 'Friday'
|
|
61
|
+
when 'SAT' then 'Saturday'
|
|
62
|
+
when 'SUN' then 'Sunday'
|
|
63
|
+
else num_or_str
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
weekday_names = is_aws ? AWS_WEEKDAY_NAMES : WEEKDAY_NAMES
|
|
67
|
+
num = num_or_str.to_i
|
|
68
|
+
# Standard CRON spells Sunday either 0 or 7; AWS only uses 1-7.
|
|
69
|
+
num = 0 if !is_aws && num == 7
|
|
70
|
+
weekday_names[num] || "day #{num_or_str}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Map month name from number
|
|
75
|
+
def month_name(num)
|
|
76
|
+
MONTH_NAMES[num.to_i] || "month #{num}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert month abbreviation to number
|
|
80
|
+
def month_abbr_to_num(abbr)
|
|
81
|
+
raise ArgumentError, "Invalid month abbreviation: #{abbr.inspect}" unless abbr.is_a?(String) && !abbr.empty?
|
|
82
|
+
|
|
83
|
+
return abbr.to_i if /^\d+$/.match?(abbr)
|
|
84
|
+
|
|
85
|
+
upcase_abbr = abbr.upcase
|
|
86
|
+
MONTH_FULL_NAMES[upcase_abbr] || MONTH_ABBREVIATIONS[upcase_abbr] ||
|
|
87
|
+
(raise ArgumentError, "Invalid month abbreviation: #{abbr}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Convert weekday abbreviation to number
|
|
91
|
+
def weekday_abbr_to_num(abbr, is_aws: false)
|
|
92
|
+
raise ArgumentError, "Invalid weekday abbreviation: #{abbr.inspect}" unless abbr.is_a?(String) && !abbr.empty?
|
|
93
|
+
|
|
94
|
+
return abbr.to_i if /^\d+$/.match?(abbr)
|
|
95
|
+
|
|
96
|
+
day_names = is_aws ? AWS_DAY_NAMES : DAY_NAMES
|
|
97
|
+
day_names[abbr.upcase] || (raise ArgumentError, "Invalid weekday abbreviation: #{abbr}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Convert weekday name from abbreviation
|
|
101
|
+
def weekday_name_from_abbr(abbr)
|
|
102
|
+
case abbr.upcase
|
|
103
|
+
when 'MON' then 'Monday'
|
|
104
|
+
when 'TUE' then 'Tuesday'
|
|
105
|
+
when 'WED' then 'Wednesday'
|
|
106
|
+
when 'THU' then 'Thursday'
|
|
107
|
+
when 'FRI' then 'Friday'
|
|
108
|
+
when 'SAT' then 'Saturday'
|
|
109
|
+
when 'SUN' then 'Sunday'
|
|
110
|
+
else abbr
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Helpers
|
|
5
|
+
# Deliberately not named Time: every class that includes Helpers would then
|
|
6
|
+
# resolve a bare `Time` to this module instead of ::Time through the
|
|
7
|
+
# ancestor chain, silently breaking default arguments and Time.zone calls.
|
|
8
|
+
module Clock
|
|
9
|
+
# Ensure the given 'now' is a Time object
|
|
10
|
+
def ensure_time(now, time_source = nil)
|
|
11
|
+
time_source ||= @time_source || ::Time
|
|
12
|
+
return now if now.is_a?(::Time)
|
|
13
|
+
|
|
14
|
+
# If it's a Date or DateTime, convert preserving timezone info
|
|
15
|
+
if now.respond_to?(:hour) && now.respond_to?(:min)
|
|
16
|
+
# It's a DateTime
|
|
17
|
+
time = time_source.local(now.year, now.month, now.day, now.hour, now.min, 0)
|
|
18
|
+
now.respond_to?(:utc?) && now.utc? ? time.utc : time
|
|
19
|
+
else
|
|
20
|
+
# It's a Date
|
|
21
|
+
time_source.local(now.year, now.month, now.day, 0, 0, 0)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Get the start of the next month from a given date
|
|
26
|
+
def next_month_start(search_date, time_source = nil)
|
|
27
|
+
time_source ||= @time_source || ::Time
|
|
28
|
+
next_month = Date.new(search_date.year, search_date.month, 1).next_month
|
|
29
|
+
|
|
30
|
+
# Handle both Time.zone (Rails) and ::Time (Ruby)
|
|
31
|
+
if time_source.respond_to?(:local)
|
|
32
|
+
time_source.local(next_month.year, next_month.month, 1, 0, 0, 0)
|
|
33
|
+
else
|
|
34
|
+
::Time.zone.local(next_month.year, next_month.month, 1, 0, 0, 0)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Adjust time to the target second, rolling over minute if needed
|
|
39
|
+
def adjust_seconds(time, target_second)
|
|
40
|
+
if time.sec == target_second
|
|
41
|
+
time
|
|
42
|
+
elsif time.sec < target_second
|
|
43
|
+
time.change(sec: target_second)
|
|
44
|
+
else
|
|
45
|
+
(time + 60.seconds).change(sec: target_second)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|