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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '0284e39612c4ce6c3f6a32d9f0c296d95db41827b0f9cb61a1e384c6bd81480d'
|
|
4
|
+
data.tar.gz: 4f769078b37d559c1db706aeb1b02e99c9f88d879bb2ebe93cec135f4c381170
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1f86462e91dfff10b4a9b18376572361e523a5d981ca11bd898e65a4cc4d1d2e3e4126e379114142ec9f721456b3fa98d42080bd8f19760bf33c20b76f2ca91b
|
|
7
|
+
data.tar.gz: 98ef95a962a5b3bcbcd4cc90ef20c6541b3185659d2f215991d4d75b9ac4f721616cd33508997dceba5aa4537606af519cc556b6324b798ca9eb135e0d2e46d1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0]
|
|
6
|
+
|
|
7
|
+
Extracted from an internal Rails application into a standalone gem. Autoloading
|
|
8
|
+
runs through the gem's own Zeitwerk loader and Rails is no longer required.
|
|
9
|
+
|
|
10
|
+
Fixed while extracting, all of which the inherited suite passed straight through:
|
|
11
|
+
|
|
12
|
+
- AWS weekday numbers were described one day off. The parser matches on
|
|
13
|
+
`1=SUN..7=SAT` but the describer named `1` Monday, so `cron(0 9 ? * 2 *)` ran on
|
|
14
|
+
Monday while reporting Tuesday.
|
|
15
|
+
- `Helpers::Time` shadowed `::Time` for every class including `Helpers`, since a
|
|
16
|
+
bare `Time` resolves through the ancestor chain. `Parser.new(expr).next` raised
|
|
17
|
+
`NoMethodError`, and `Describer` silently returned nil for `at(...)`. The module
|
|
18
|
+
is now `Helpers::Clock`.
|
|
19
|
+
- `@daily` and the other macros validated and described correctly but raised
|
|
20
|
+
`NotImplementedError` on `next`: the expansion was computed and discarded, and
|
|
21
|
+
`@weekly` expanded to standard CRON's `0=SUN` which this parser never matches.
|
|
22
|
+
- A day-of-week given as a bare name described as "day 0" — `FRI` was run through
|
|
23
|
+
`to_i`. Ranges and lists were unaffected.
|
|
24
|
+
- Month names described as "month January", from a double-applied formatter.
|
|
25
|
+
- `*/N` was rejected in the month and day-of-week fields, the two that also
|
|
26
|
+
resolve names: the `*` was passed to the abbreviation lookup. Past that, the
|
|
27
|
+
AWS day-of-week field had no step expansion, so `cron(0 9 ? * */2 *)` searched
|
|
28
|
+
a year of candidates and failed with a bare `RuntimeError`.
|
|
29
|
+
- Standard CRON spells Sunday 0 or 7; `0 9 * * 7` described as "day 7".
|
|
30
|
+
- `at(...)` was unreachable: the expression had a describer branch and a regex but
|
|
31
|
+
no validator or parser, so it was rejected before reaching them. It is now
|
|
32
|
+
supported end to end, including a `next` that stops once the instant passes.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Washington Silva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated software documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# aws-cron-parser
|
|
2
|
+
|
|
3
|
+
[](https://github.com/w-osilva/aws-cron-parser/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/aws-cron-parser)
|
|
5
|
+
|
|
6
|
+
Parse, validate and describe the scheduling expressions accepted by AWS EventBridge and
|
|
7
|
+
Systems Manager, plus Quartz and standard 5-field CRON.
|
|
8
|
+
|
|
9
|
+
- **AWS cron** — `cron(0 9 ? * MON-FRI *)`, 6 fields, `?` / `L` / `W` / `#`, weekdays `1=SUN..7=SAT`
|
|
10
|
+
- **AWS rate** — `rate(5 minutes)`, `rate(1 day)`
|
|
11
|
+
- **AWS at** — `at(2025-06-15T10:30:00)`, a one-time schedule
|
|
12
|
+
- **Standard / Quartz cron** — `0 9 * * 1-5`, `*/10 * * * *`, 6-field with seconds
|
|
13
|
+
- **Macros** — `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`
|
|
14
|
+
|
|
15
|
+
No Rails required. The gem autoloads through its own Zeitwerk loader.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'aws-cron-parser'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require 'aws_cron_parser'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
parser = AWSCronParser.parse('cron(0 9 ? * MON-FRI *)')
|
|
31
|
+
|
|
32
|
+
parser.describe # => "Every Monday through Friday at 9 AM"
|
|
33
|
+
parser.expression # => "cron(0 9 ? * MON-FRI *)"
|
|
34
|
+
parser.source # => "0 9 ? * MON-FRI"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Next and previous occurrences
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
now = Time.utc(2025, 1, 15, 10, 30)
|
|
41
|
+
parser = AWSCronParser.parse('0 9 * * 1-5')
|
|
42
|
+
|
|
43
|
+
parser.next(now) # => 2025-01-16 09:00:00 UTC
|
|
44
|
+
parser.last(now) # => 2025-01-15 09:00:00 UTC
|
|
45
|
+
parser.next_runs(3, now) # => [2025-01-16 09:00, 2025-01-17 09:00, 2025-01-20 09:00]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`next_unique_dates(count, from)` and `next_unique_hours(count, from)` collapse repeated
|
|
49
|
+
executions down to one entry per day or per hour — useful when rendering a schedule preview.
|
|
50
|
+
|
|
51
|
+
`last` is only available for standard/Quartz expressions; AWS `cron(...)`, `rate(...)`
|
|
52
|
+
and `at(...)` raise `ArgumentError`, since they are anchored forwards in time.
|
|
53
|
+
|
|
54
|
+
### One-time schedules
|
|
55
|
+
|
|
56
|
+
`at(...)` fires exactly once. `next` returns the instant while it is still ahead and
|
|
57
|
+
`nil` once it has passed, so `next_runs` yields at most one entry:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
parser = AWSCronParser.parse('at(2025-06-15T10:30:00)')
|
|
61
|
+
|
|
62
|
+
parser.next(Time.utc(2025, 1, 1)) # => 2025-06-15 10:30:00 UTC
|
|
63
|
+
parser.next(Time.utc(2026, 1, 1)) # => nil
|
|
64
|
+
parser.next_runs(5, Time.utc(2025, 1, 1)).size # => 1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Weekday numbering
|
|
68
|
+
|
|
69
|
+
Inside `cron(...)` weekdays follow AWS: **1 = Sunday** through 7 = Saturday. Bare
|
|
70
|
+
5-field expressions follow standard CRON, where 0 and 7 are Sunday and 1 is Monday.
|
|
71
|
+
`cron(0 9 ? * 1 *)` and `0 9 * * 1` are therefore different schedules.
|
|
72
|
+
|
|
73
|
+
### Validation
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
AWSCronParser::Validator.validate('0 9 * * 1-5') # => true
|
|
77
|
+
AWSCronParser.parse('cron(99 9 ? * MON *)') # raises ArgumentError: minute 99 out of range
|
|
78
|
+
|
|
79
|
+
parser = AWSCronParser.parse('0 9 * * 1-5')
|
|
80
|
+
parser.valid? # => true
|
|
81
|
+
parser.errors # => []
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`AWSCronParser.parse` validates eagerly and raises `ArgumentError` with a field-level
|
|
85
|
+
message. Use `valid?` / `errors` when you want to report the problem instead of raising.
|
|
86
|
+
|
|
87
|
+
### Descriptions
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
AWSCronParser::Describer.describe('cron(0/15 * * * ? *)') # => "Every 15 minutes"
|
|
91
|
+
AWSCronParser::Describer.describe('cron(0 0 L * ? *)') # => "On the last day of the month at 12 AM"
|
|
92
|
+
AWSCronParser::Describer.describe('cron(0 12 ? * 6#3 *)') # => "On the third Saturday at 12 PM"
|
|
93
|
+
AWSCronParser::Describer.describe('rate(5 minutes)') # => "Every 5 minutes"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Wording lives in `lib/aws_cron_parser/describer/template.en.yml`.
|
|
97
|
+
|
|
98
|
+
### Time zones
|
|
99
|
+
|
|
100
|
+
The parsers build zone-aware times through ActiveSupport's `Time.zone`. Outside Rails the
|
|
101
|
+
gem defaults it to UTC if the host has not set one; inside Rails your `config.time_zone`
|
|
102
|
+
wins. You can also pass an explicit time source:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
AWSCronParser.parse('0 9 * * 1-5', Time.find_zone('America/Sao_Paulo'))
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
bundle install
|
|
112
|
+
bundle exec rspec
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Describer
|
|
5
|
+
# Composes complete CRON descriptions from field descriptions
|
|
6
|
+
class CronComposer
|
|
7
|
+
include AWSCronParser::Helpers
|
|
8
|
+
|
|
9
|
+
attr_reader :config, :is_aws
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@is_aws = config[:type]&.start_with?('aws-cron')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def compose
|
|
17
|
+
# Handle simple patterns first
|
|
18
|
+
return quick_patterns if quick_patterns
|
|
19
|
+
|
|
20
|
+
# Build component descriptions
|
|
21
|
+
time_desc = TimeComposer.new(config, is_aws: is_aws).compose
|
|
22
|
+
day_desc = DayComposer.new(config, is_aws: is_aws).compose
|
|
23
|
+
month_desc = MonthComposer.new(config, is_aws: is_aws).compose
|
|
24
|
+
|
|
25
|
+
# Compose final description based on what's set
|
|
26
|
+
compose_description(time_desc, day_desc, month_desc)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def quick_patterns
|
|
32
|
+
s = config[:second]
|
|
33
|
+
m = config[:minute]
|
|
34
|
+
h = config[:hour]
|
|
35
|
+
day = config[:day]
|
|
36
|
+
dow = config[:dow]
|
|
37
|
+
month = config[:month]
|
|
38
|
+
|
|
39
|
+
# Quick patterns for common cases
|
|
40
|
+
# Standard CRON (5 fields, no seconds)
|
|
41
|
+
if s.nil? && all_wildcards?(m, h, day, dow, month)
|
|
42
|
+
return 'Every minute'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Unix CRON with seconds (6 fields) or AWS CRON with seconds (7 fields)
|
|
46
|
+
if s.present? && all_wildcards?(s, m, h, day, dow, month)
|
|
47
|
+
return 'Every second'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Only seconds field is set (rest are wildcards)
|
|
51
|
+
if s.present? && s != '*' && all_wildcards?(m, h, day, dow, month)
|
|
52
|
+
return nil # Let time composer handle it
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every minute (when second is 0 or wildcard, minute is wildcard)
|
|
56
|
+
if (s.nil? || s == '*' || s == '0') && all_wildcards?(m, h, day, dow, month)
|
|
57
|
+
return 'Every minute'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# NOTE: "0 * * * *" should be "Every hour at minute 0", not just "Every hour"
|
|
61
|
+
# Let TimeComposer handle this case
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def all_wildcards?(*fields)
|
|
67
|
+
fields.all? { |f| wildcards?(f) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def wildcards?(*fields)
|
|
71
|
+
fields.all? { |f| f.nil? || f == '*' || f == '?' }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def compose_description(time_desc, day_desc, month_desc)
|
|
75
|
+
# Determine which components are present
|
|
76
|
+
has_time = time_desc.present?
|
|
77
|
+
has_day = day_desc.present?
|
|
78
|
+
has_month = month_desc.present?
|
|
79
|
+
|
|
80
|
+
case [has_time, has_day, has_month]
|
|
81
|
+
when [true, false, false]
|
|
82
|
+
# Time only (day/dow/month are wildcards) = "Every day at..."
|
|
83
|
+
add_every_day(time_desc)
|
|
84
|
+
when [false, false, true]
|
|
85
|
+
month_desc
|
|
86
|
+
when [false, true, false]
|
|
87
|
+
day_desc
|
|
88
|
+
when [true, false, true]
|
|
89
|
+
compose_time_month(time_desc, month_desc)
|
|
90
|
+
when [true, true, false]
|
|
91
|
+
compose_time_day(time_desc, day_desc)
|
|
92
|
+
when [false, true, true]
|
|
93
|
+
compose_day_month(day_desc, month_desc)
|
|
94
|
+
when [true, true, true]
|
|
95
|
+
compose_all(time_desc, day_desc, month_desc)
|
|
96
|
+
else
|
|
97
|
+
'Every minute'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_every_day(time_desc)
|
|
102
|
+
# If time is simple "At X:XX AM/PM", "at X AM...", or "from X to Y", add "Every day" prefix
|
|
103
|
+
if time_desc.match?(/^At \d+/)
|
|
104
|
+
"Every day #{time_desc.sub(/^At /, 'at ')}"
|
|
105
|
+
elsif time_desc.match?(/^(at|from) \d+/)
|
|
106
|
+
"Every day #{time_desc}"
|
|
107
|
+
else
|
|
108
|
+
time_desc
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compose_time_month(time_desc, month_desc)
|
|
113
|
+
# Time + Month (no day): "At 12:00 AM in July"
|
|
114
|
+
"#{time_desc} #{month_desc}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def compose_time_day(time_desc, day_desc)
|
|
118
|
+
# Day + Time (no month): "On the 1st at 6:00 PM"
|
|
119
|
+
# Capitalize day description and add "of every month" if applicable
|
|
120
|
+
day_desc = capitalize_first(day_desc)
|
|
121
|
+
day_desc = add_month_context(day_desc) if needs_month_context?(day_desc)
|
|
122
|
+
|
|
123
|
+
# Handle duplicate "Every" when both time and day start with "Every"
|
|
124
|
+
if time_desc.start_with?('Every') && day_desc.start_with?('Every')
|
|
125
|
+
# Remove "Every" from time description and make it lowercase
|
|
126
|
+
time_clean = time_desc.sub(/^Every /, 'every ')
|
|
127
|
+
"#{day_desc}, #{time_clean}"
|
|
128
|
+
elsif time_desc.start_with?('Every day') && day_desc.start_with?('Every')
|
|
129
|
+
# Remove "Every day" from time, keep time portion only
|
|
130
|
+
time_clean = time_desc.sub(/^Every day (from|at) /, '')
|
|
131
|
+
"#{day_desc} from #{time_clean}"
|
|
132
|
+
elsif time_desc.start_with?('at ')
|
|
133
|
+
# Time starts with lowercase "at" - if we have DOW, just append; otherwise add "Every day"
|
|
134
|
+
if day_desc.start_with?('Every')
|
|
135
|
+
"#{day_desc} #{time_desc}"
|
|
136
|
+
else
|
|
137
|
+
# No day context, add "Every day"
|
|
138
|
+
"Every day #{time_desc}"
|
|
139
|
+
end
|
|
140
|
+
elsif time_desc.start_with?('At ')
|
|
141
|
+
time_lower = time_desc.sub(/^At /, 'at ')
|
|
142
|
+
"#{day_desc} #{time_lower}"
|
|
143
|
+
else
|
|
144
|
+
"#{day_desc} #{time_desc}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def compose_day_month(day_desc, month_desc)
|
|
149
|
+
# Day + Month (no specific time): "On the 1st in July"
|
|
150
|
+
"#{day_desc} #{month_desc}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def compose_all(time_desc, day_desc, month_desc)
|
|
154
|
+
# All three: Time + Day + Month
|
|
155
|
+
# Patterns:
|
|
156
|
+
# - Special case: "On January 1st at 1:00 AM" (day 1 of month 1)
|
|
157
|
+
# - General case: "Every October on the 10th at 10:10 AM"
|
|
158
|
+
|
|
159
|
+
# Convert month from "in October" to clean form
|
|
160
|
+
month_clean = month_desc.sub(/^in /, '')
|
|
161
|
+
|
|
162
|
+
# Convert day from "on the 1st" to "1st" (remove both "on the")
|
|
163
|
+
day_clean = day_desc.sub(/^on the /, '')
|
|
164
|
+
|
|
165
|
+
# Convert time from "At 10:10 AM" or "Every day at X" to clean form
|
|
166
|
+
time_clean = time_desc.sub(/^At /, '').sub(/^Every day at /, '')
|
|
167
|
+
|
|
168
|
+
# Check if this is a special date: January 1st
|
|
169
|
+
is_january_first = month_clean == 'January' && day_clean == '1st'
|
|
170
|
+
|
|
171
|
+
if is_january_first
|
|
172
|
+
"On #{month_clean} #{day_clean} at #{time_clean}"
|
|
173
|
+
elsif month_desc.start_with?('Every')
|
|
174
|
+
# Month has step pattern like "Every 2 months starting in January"
|
|
175
|
+
"#{month_desc} on the #{day_clean} at #{time_clean}"
|
|
176
|
+
else
|
|
177
|
+
"Every #{month_clean} on the #{day_clean} at #{time_clean}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def capitalize_first(text)
|
|
182
|
+
return text if text.blank?
|
|
183
|
+
|
|
184
|
+
text[0].upcase + text[1..]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def needs_month_context?(day_desc)
|
|
188
|
+
# Add "of every month" when describing days but month is wildcard
|
|
189
|
+
month_wildcard = wildcards?(config[:month])
|
|
190
|
+
month_wildcard && day_desc.match?(/^(on|On) the \d/)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def add_month_context(day_desc)
|
|
194
|
+
"#{day_desc} of every month"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Describer
|
|
5
|
+
# Composes day descriptions (day-of-month and day-of-week)
|
|
6
|
+
class DayComposer
|
|
7
|
+
include AWSCronParser::Helpers
|
|
8
|
+
|
|
9
|
+
attr_reader :day, :dow, :is_aws
|
|
10
|
+
|
|
11
|
+
def initialize(config, is_aws: false)
|
|
12
|
+
@day = config[:day]
|
|
13
|
+
@dow = config[:dow]
|
|
14
|
+
@is_aws = is_aws
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def compose
|
|
18
|
+
day_wildcard = wildcards?(day)
|
|
19
|
+
dow_wildcard = wildcards?(dow)
|
|
20
|
+
|
|
21
|
+
# Both wildcards - no day description
|
|
22
|
+
return nil if day_wildcard && dow_wildcard
|
|
23
|
+
|
|
24
|
+
# Only day-of-week
|
|
25
|
+
return describe_dow if day_wildcard && !dow_wildcard
|
|
26
|
+
|
|
27
|
+
# Only day-of-month
|
|
28
|
+
return describe_day if !day_wildcard && dow_wildcard
|
|
29
|
+
|
|
30
|
+
# Both specified (AWS allows this for OR logic)
|
|
31
|
+
describe_both
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def wildcards?(*fields)
|
|
37
|
+
fields.all? { |f| f.nil? || f == '*' || f == '?' }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def describe_day
|
|
41
|
+
FieldDescribers::DayOfMonth.new(day, is_aws: is_aws).describe
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def describe_dow
|
|
45
|
+
result = FieldDescribers::DayOfWeek.new(dow, is_aws: is_aws).describe
|
|
46
|
+
return result if result.nil?
|
|
47
|
+
|
|
48
|
+
# Add "Every" prefix for day ranges and simple day names
|
|
49
|
+
# "on the" prefix means it's already formatted (like "on the 2nd Monday")
|
|
50
|
+
return result if result.start_with?('on the', 'On the', 'Every')
|
|
51
|
+
|
|
52
|
+
# For "on Monday" or "Monday through Friday" patterns, add "Every"
|
|
53
|
+
if result.start_with?('on ')
|
|
54
|
+
"Every #{result.sub('on ', '')}"
|
|
55
|
+
else
|
|
56
|
+
# For ranges like "Monday through Friday", add "Every" prefix
|
|
57
|
+
"Every #{result}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def describe_both
|
|
62
|
+
day_desc = describe_day
|
|
63
|
+
dow_desc = FieldDescribers::DayOfWeek.new(dow, is_aws: is_aws).describe
|
|
64
|
+
|
|
65
|
+
# For AWS, both fields can be set for OR logic
|
|
66
|
+
# Example: day=1-7 dow=MON means "1st-7th when those days are Monday"
|
|
67
|
+
return nil if day_desc.nil? || dow_desc.nil?
|
|
68
|
+
|
|
69
|
+
# Simplify the composition
|
|
70
|
+
day_desc
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AWSCronParser
|
|
4
|
+
module Describer
|
|
5
|
+
module FieldDescribers
|
|
6
|
+
# Base class for field describers - provides common pattern matching
|
|
7
|
+
class Base
|
|
8
|
+
include AWSCronParser::Helpers
|
|
9
|
+
|
|
10
|
+
STEP_PATTERN = %r{^(?:(?<base>[\d*]+)|(?<start>\d+)-(?<end>\d+))?/(?<step>\d+)$}
|
|
11
|
+
RANGE_PATTERN = /^(?<start>\d+)-(?<end>\d+)$/
|
|
12
|
+
LIST_PATTERN = /^[\d,]+$/
|
|
13
|
+
|
|
14
|
+
attr_reader :value, :is_aws
|
|
15
|
+
|
|
16
|
+
def initialize(value, is_aws: false)
|
|
17
|
+
@value = value
|
|
18
|
+
@is_aws = is_aws
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def describe
|
|
22
|
+
return nil if wildcard?
|
|
23
|
+
|
|
24
|
+
# Try patterns in order of specificity
|
|
25
|
+
describe_step || describe_list || describe_range || describe_single
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def wildcard?
|
|
31
|
+
['*', '?', ''].include?(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def describe_step
|
|
35
|
+
return nil unless value.include?('/')
|
|
36
|
+
|
|
37
|
+
match = value.match(STEP_PATTERN)
|
|
38
|
+
return nil unless match
|
|
39
|
+
|
|
40
|
+
step = match[:step].to_i
|
|
41
|
+
start_val = match[:start] || match[:base]
|
|
42
|
+
end_val = match[:end]
|
|
43
|
+
|
|
44
|
+
if start_val.nil? || start_val == '*'
|
|
45
|
+
step_wildcard(step)
|
|
46
|
+
elsif end_val
|
|
47
|
+
# Range with step: 8-18/2
|
|
48
|
+
step_range(start_val.to_i, end_val.to_i, step)
|
|
49
|
+
else
|
|
50
|
+
step_from(start_val.to_i, step)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def describe_list
|
|
55
|
+
return nil unless value.include?(',') && value.match?(LIST_PATTERN)
|
|
56
|
+
|
|
57
|
+
items = value.split(',').map(&:strip).sort_by(&:to_i)
|
|
58
|
+
formatted = items.map { |v| format_value(v.to_i) }
|
|
59
|
+
list_template(formatted)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def describe_range
|
|
63
|
+
return nil unless value.match?(RANGE_PATTERN)
|
|
64
|
+
|
|
65
|
+
match = value.match(RANGE_PATTERN)
|
|
66
|
+
start_val = match[:start].to_i
|
|
67
|
+
end_val = match[:end].to_i
|
|
68
|
+
|
|
69
|
+
range_template(format_value(start_val), format_value(end_val))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def describe_single
|
|
73
|
+
single_template(format_value(value.to_i))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Template methods - to be overridden by subclasses
|
|
77
|
+
def step_wildcard(_step)
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def step_from(_start, _step)
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def step_range(start_val, _end_val, step)
|
|
86
|
+
# Default: fall back to step_from if not overridden
|
|
87
|
+
step_from(start_val, step)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list_template(_items)
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def range_template(_start, _end)
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def single_template(_value)
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_value(val)
|
|
103
|
+
val
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module AWSCronParser
|
|
6
|
+
module Describer
|
|
7
|
+
module FieldDescribers
|
|
8
|
+
# Describes day-of-month field
|
|
9
|
+
class DayOfMonth < Base
|
|
10
|
+
NTH_PATTERN = /^(\d+)#(\d+)$/
|
|
11
|
+
LAST_PATTERN = /^(\d+)?L$/
|
|
12
|
+
WEEKDAY_PATTERN = /^(\d+)W$/
|
|
13
|
+
|
|
14
|
+
def describe
|
|
15
|
+
return nil if wildcard?
|
|
16
|
+
|
|
17
|
+
# AWS-specific patterns
|
|
18
|
+
if is_aws
|
|
19
|
+
return describe_nth if value.match?(NTH_PATTERN)
|
|
20
|
+
return describe_last if value.match?(LAST_PATTERN)
|
|
21
|
+
return describe_closest_weekday if value.match?(WEEKDAY_PATTERN)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
super # Use base class patterns
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def describe_nth
|
|
30
|
+
# Not typically used for day-of-month, but handle if present
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def describe_last
|
|
35
|
+
return Template.interpolate('day_of_month.last_day') if value == 'L'
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def describe_closest_weekday
|
|
41
|
+
match = value.match(WEEKDAY_PATTERN)
|
|
42
|
+
day = match[1].to_i
|
|
43
|
+
Template.interpolate('day_of_month.closest_weekday', ordinal: ordinal(day))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def step_wildcard(step)
|
|
47
|
+
Template.interpolate('day_of_month.step', step: step, step_plural: Template.plural_for(step))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def step_from(start_val, step)
|
|
51
|
+
Template.interpolate('day_of_month.step_range',
|
|
52
|
+
step: step,
|
|
53
|
+
step_plural: Template.plural_for(step),
|
|
54
|
+
start_ordinal: ordinal(start_val))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def list_template(items)
|
|
58
|
+
Template.interpolate('day_of_month.list', items: format_list(items))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def range_template(start_val, end_val)
|
|
62
|
+
Template.interpolate('day_of_month.range',
|
|
63
|
+
start_ordinal: ordinal(start_val),
|
|
64
|
+
end_ordinal: ordinal(end_val))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def single_template(val)
|
|
68
|
+
Template.interpolate('day_of_month.single', ordinal: ordinal(val))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_value(val)
|
|
72
|
+
ordinal(val.to_i)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def format_list(items)
|
|
76
|
+
return items.first if items.length == 1
|
|
77
|
+
|
|
78
|
+
if items.length == 2
|
|
79
|
+
items.join(' and ')
|
|
80
|
+
else
|
|
81
|
+
last = items.pop
|
|
82
|
+
"#{items.join(', ')}, and #{last}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|