english-to-cron 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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +81 -0
- data/lib/english_to_cron/actions.rb +400 -0
- data/lib/english_to_cron/cron.rb +180 -0
- data/lib/english_to_cron/error.rb +41 -0
- data/lib/english_to_cron/stack.rb +87 -0
- data/lib/english_to_cron/syntax.rb +21 -0
- data/lib/english_to_cron/tokenizer.rb +15 -0
- data/lib/english_to_cron/version.rb +5 -0
- data/lib/english_to_cron.rb +14 -0
- metadata +86 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a8dd87f218cf1f44ff1b8c48fa06dd08735742231ec60b46cde398642151489e
|
4
|
+
data.tar.gz: 0e2be7f5fcd12eb57313c98b7dc6aaa1124b096e9c7fe64ff6796bd28efaa521
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 74ee1e670e395566e373fcc319b95af53d7d1a6921d3653ed59084dbe671faada7104708319a0d439329f90c600b1ad9182d68cece6c87d4d9b5efa78871a807
|
7
|
+
data.tar.gz: f1327e5943f8ae87f71ada74d2d799a2092d6fcfa02ccccb307be1d50f6d9e0669aeadf52a74f0e3e92a5e821156131fe7be26a81baa6b8f7b3fcb2fbb2b6140
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Vlad Dyachenko
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated 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,81 @@
|
|
1
|
+
# English to CronJob Syntax Converter
|
2
|
+
|
3
|
+
|
4
|
+
|
5
|
+
`english-to-cron` converts natural language into cron expressions, allowing developers to easily schedule cron jobs using English text.
|
6
|
+
|
7
|
+
## Features
|
8
|
+
|
9
|
+
- Converts various English text descriptions into cron job syntax
|
10
|
+
- Supports complex patterns including specific days, time ranges, and more
|
11
|
+
- Handles multiple time formats including AM/PM and 24-hour notation
|
12
|
+
- Zero dependencies
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem 'english-to-cron'
|
20
|
+
```
|
21
|
+
|
22
|
+
And then execute:
|
23
|
+
|
24
|
+
```bash
|
25
|
+
$ bundle install
|
26
|
+
```
|
27
|
+
|
28
|
+
Or install it yourself as:
|
29
|
+
|
30
|
+
```bash
|
31
|
+
$ gem install english-to-cron
|
32
|
+
```
|
33
|
+
|
34
|
+
## Usage
|
35
|
+
|
36
|
+
Simply provide an English phrase describing the schedule, and the library will return the corresponding cron job syntax.
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
require 'english_to_cron'
|
40
|
+
|
41
|
+
# Basic usage
|
42
|
+
EnglishToCron.parse("every 15 seconds") # => "0/15 * * * * ? *"
|
43
|
+
EnglishToCron.parse("every minute") # => "0 * * * * ? *"
|
44
|
+
EnglishToCron.parse("every day at 4:00 pm") # => "0 0 16 */1 * ? *"
|
45
|
+
EnglishToCron.parse("at 10:00 am") # => "0 0 10 * * ? *"
|
46
|
+
EnglishToCron.parse("Run at midnight on the 1st and 15th of the month") # => "0 0 0 1,15 * ? *"
|
47
|
+
EnglishToCron.parse("on Sunday at 12:00") # => "0 0 12 ? * SUN *"
|
48
|
+
```
|
49
|
+
|
50
|
+
## Full List of Supported English Patterns
|
51
|
+
|
52
|
+
| English Phrase | CronJob Syntax |
|
53
|
+
|------------------------------------------------------------------ |---------------------------- |
|
54
|
+
| every 15 seconds | 0/15 * * * * ? * |
|
55
|
+
| run every minute | 0 * * * * ? * |
|
56
|
+
| fire every day at 4:00 pm | 0 0 16 */1 * ? * |
|
57
|
+
| at 10:00 am | 0 0 10 * * ? * |
|
58
|
+
| run at midnight on the 1st and 15th of the month | 0 0 0 1,15 * ? * |
|
59
|
+
| On Sunday at 12:00 | 0 0 12 ? * SUN * |
|
60
|
+
| 7pm every Thursday | 0 0 19 ? * THU * |
|
61
|
+
| midnight on Tuesdays | 0 0 0 ? * TUE * |
|
62
|
+
|
63
|
+
## Error Handling
|
64
|
+
|
65
|
+
The library will raise errors for invalid or unparseable inputs:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
begin
|
69
|
+
EnglishToCron.parse("invalid input")
|
70
|
+
rescue EnglishToCron::Error => e
|
71
|
+
puts "Error: #{e.message}"
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
## Contributing
|
76
|
+
|
77
|
+
Bug reports and pull requests are welcome on GitHub. This project is intended to be a safe, welcoming space for collaboration.
|
78
|
+
|
79
|
+
## License
|
80
|
+
|
81
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,400 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "stack"
|
4
|
+
|
5
|
+
module EnglishToCron
|
6
|
+
module Actions
|
7
|
+
module Kind
|
8
|
+
FREQUENCY_WITH = :frequency_with
|
9
|
+
FREQUENCY_ONLY = :frequency_only
|
10
|
+
CLOCK_TIME = :clock_time
|
11
|
+
DAY = :day
|
12
|
+
SECOND = :second
|
13
|
+
MINUTE = :minute
|
14
|
+
HOUR = :hour
|
15
|
+
MONTH = :month
|
16
|
+
YEAR = :year
|
17
|
+
RANGE_START = :range_start
|
18
|
+
RANGE_END = :range_end
|
19
|
+
DAY_OF_MONTH = :day_of_month
|
20
|
+
|
21
|
+
ALL = [
|
22
|
+
FREQUENCY_WITH,
|
23
|
+
FREQUENCY_ONLY,
|
24
|
+
CLOCK_TIME,
|
25
|
+
DAY,
|
26
|
+
SECOND,
|
27
|
+
MINUTE,
|
28
|
+
HOUR,
|
29
|
+
MONTH,
|
30
|
+
YEAR,
|
31
|
+
RANGE_START,
|
32
|
+
RANGE_END,
|
33
|
+
DAY_OF_MONTH
|
34
|
+
].freeze
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.try_from_token(token)
|
38
|
+
Kind::ALL.each do |kind|
|
39
|
+
is_match = case kind
|
40
|
+
when Kind::FREQUENCY_WITH then frequency_with_match?(token)
|
41
|
+
when Kind::FREQUENCY_ONLY then frequency_only_match?(token)
|
42
|
+
when Kind::CLOCK_TIME then clock_time_match?(token)
|
43
|
+
when Kind::DAY then day_match?(token)
|
44
|
+
when Kind::SECOND then second_match?(token)
|
45
|
+
when Kind::MINUTE then minute_match?(token)
|
46
|
+
when Kind::HOUR then hour_match?(token)
|
47
|
+
when Kind::MONTH then month_match?(token)
|
48
|
+
when Kind::YEAR then year_match?(token)
|
49
|
+
when Kind::RANGE_START then range_start_match?(token)
|
50
|
+
when Kind::RANGE_END then range_end_match?(token)
|
51
|
+
when Kind::DAY_OF_MONTH then day_of_month_match?(token)
|
52
|
+
end
|
53
|
+
return kind if is_match
|
54
|
+
end
|
55
|
+
nil
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.process(kind, token, cron)
|
59
|
+
case kind
|
60
|
+
when Kind::FREQUENCY_WITH then process_frequency_with(token, cron)
|
61
|
+
when Kind::FREQUENCY_ONLY
|
62
|
+
frequency = parse_number(token, "frequency_only")
|
63
|
+
process_frequency_only(frequency, cron)
|
64
|
+
when Kind::CLOCK_TIME then process_clock_time(token, cron)
|
65
|
+
when Kind::DAY then process_day(token, cron)
|
66
|
+
when Kind::SECOND then process_second(token, cron)
|
67
|
+
when Kind::MINUTE then process_minute(token, cron)
|
68
|
+
when Kind::HOUR then process_hour(token, cron)
|
69
|
+
when Kind::MONTH then process_month(token, cron)
|
70
|
+
when Kind::YEAR then process_year(token, cron)
|
71
|
+
when Kind::RANGE_START then process_range_start(cron)
|
72
|
+
when Kind::RANGE_END then process_range_end(cron)
|
73
|
+
when Kind::DAY_OF_MONTH then process_day_of_month(token, cron)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Token matching methods
|
78
|
+
def self.frequency_with_match?(token)
|
79
|
+
token.match?(/\b(?:every|each)\b/i)
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.frequency_only_match?(token)
|
83
|
+
token.match?(/\A\d+\z/)
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.clock_time_match?(token)
|
87
|
+
token.match?(/(\d+:\d+|\d+(?:am|pm)|noon|midnight)/i)
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.day_match?(token)
|
91
|
+
days = %w[monday tuesday wednesday thursday friday saturday sunday mon tue wed thu fri sat sun weekend]
|
92
|
+
token.match?(/\b(?:#{days.join('|')}|days?)\b/i)
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.day_of_month_match?(token)
|
96
|
+
token.match?(/\b\d+(?:st|nd|rd|th)\b/i)
|
97
|
+
end
|
98
|
+
|
99
|
+
def self.second_match?(token)
|
100
|
+
token.match?(/\b(?:seconds?|secs?)\b/i)
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.minute_match?(token)
|
104
|
+
token.match?(/\b(?:minutes?|mins?|min)\b/i)
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.hour_match?(token)
|
108
|
+
token.match?(/\b(?:hours?|hrs?)\b/i)
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.month_match?(token)
|
112
|
+
months = %w[january february march april may june july august september october november december jan feb mar apr may jun jul aug sep sept oct nov dec]
|
113
|
+
token.match?(/\b(?:#{months.join('|')}|months?)\b/i)
|
114
|
+
end
|
115
|
+
|
116
|
+
def self.year_match?(token)
|
117
|
+
token.match?(/\d{4}/)
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.range_start_match?(token)
|
121
|
+
token.match?(/\b(?:between|starting|start)\b/i)
|
122
|
+
end
|
123
|
+
|
124
|
+
def self.range_end_match?(token)
|
125
|
+
token.match?(/\b(?:to|through|ending|end|and)\b/i)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Processing methods
|
129
|
+
def self.parse_number(token, state)
|
130
|
+
Integer(token)
|
131
|
+
rescue ArgumentError
|
132
|
+
raise Error::ParseToNumber.new(state, token)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Helper method to extract day number
|
136
|
+
def self.extract_day_number(token)
|
137
|
+
if token.match?(/\b(\d+)(?:st|nd|rd|th)\b/i)
|
138
|
+
token.match(/\b(\d+)(?:st|nd|rd|th)\b/i)[1].to_i
|
139
|
+
else
|
140
|
+
nil
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Implement process methods for each action type
|
145
|
+
def self.process_frequency_with(token, cron)
|
146
|
+
# Mark the current stack position for frequency handling
|
147
|
+
cron.stack << Stack.builder(Kind::FREQUENCY_WITH).build
|
148
|
+
true
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.process_frequency_only(frequency, cron)
|
152
|
+
# Find the last FREQUENCY_WITH stack or add directly if none
|
153
|
+
frequency_with_stack = cron.stack.find { |s| s.owner == Kind::FREQUENCY_WITH }
|
154
|
+
|
155
|
+
if frequency_with_stack
|
156
|
+
frequency_with_stack.frequency = frequency
|
157
|
+
else
|
158
|
+
# Stack for frequency
|
159
|
+
stack = Stack.builder(Kind::FREQUENCY_ONLY)
|
160
|
+
.frequency(frequency)
|
161
|
+
.build
|
162
|
+
|
163
|
+
cron.stack << stack
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def self.process_clock_time(token, cron)
|
168
|
+
min = 0
|
169
|
+
hour = 0
|
170
|
+
|
171
|
+
# Process special times like noon and midnight
|
172
|
+
if token.match?(/noon/i)
|
173
|
+
hour = 12
|
174
|
+
elsif token.match?(/midnight/i)
|
175
|
+
hour = 0
|
176
|
+
else
|
177
|
+
# Process normal clock times
|
178
|
+
if token.match?(/(am|pm)/i)
|
179
|
+
parts = token.gsub(/[^0-9:]/, '').split(':')
|
180
|
+
if parts.length == 1
|
181
|
+
hour = parts[0].to_i
|
182
|
+
hour = 0 if hour == 12
|
183
|
+
hour += 12 if token.match?(/pm/i) && hour != 12
|
184
|
+
elsif parts.length == 2
|
185
|
+
hour = parts[0].to_i
|
186
|
+
min = parts[1].to_i
|
187
|
+
hour = 0 if hour == 12 && token.match?(/am/i)
|
188
|
+
hour += 12 if token.match?(/pm/i) && hour != 12
|
189
|
+
end
|
190
|
+
else # 24-hour format
|
191
|
+
parts = token.split(':')
|
192
|
+
if parts.length == 2
|
193
|
+
hour = parts[0].to_i
|
194
|
+
min = parts[1].to_i
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Create a stack item for clock time
|
200
|
+
min_range = StartEnd.new(min)
|
201
|
+
hour_range = StartEnd.new(hour)
|
202
|
+
|
203
|
+
stack = Stack.builder(Kind::CLOCK_TIME)
|
204
|
+
.min(min_range)
|
205
|
+
.hour(hour_range)
|
206
|
+
.build
|
207
|
+
|
208
|
+
cron.stack << stack
|
209
|
+
true
|
210
|
+
end
|
211
|
+
|
212
|
+
def self.process_day(token, cron)
|
213
|
+
day_map = {
|
214
|
+
'sunday' => 'SUN', 'sun' => 'SUN',
|
215
|
+
'monday' => 'MON', 'mon' => 'MON',
|
216
|
+
'tuesday' => 'TUE', 'tue' => 'TUE',
|
217
|
+
'wednesday' => 'WED', 'wed' => 'WED',
|
218
|
+
'thursday' => 'THU', 'thu' => 'THU',
|
219
|
+
'friday' => 'FRI', 'fri' => 'FRI',
|
220
|
+
'saturday' => 'SAT', 'sat' => 'SAT',
|
221
|
+
'weekend' => 'SUN,SAT'
|
222
|
+
}
|
223
|
+
|
224
|
+
day_of_week = nil
|
225
|
+
|
226
|
+
day_map.each do |key, value|
|
227
|
+
if token.match?(/\b#{key}\b/i)
|
228
|
+
day_of_week = value
|
229
|
+
break
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
if day_of_week
|
234
|
+
stack = Stack.builder(Kind::DAY)
|
235
|
+
.day_of_week(day_of_week)
|
236
|
+
.build
|
237
|
+
|
238
|
+
cron.stack << stack
|
239
|
+
end
|
240
|
+
|
241
|
+
true
|
242
|
+
end
|
243
|
+
|
244
|
+
def self.process_day_of_month(token, cron)
|
245
|
+
day_num = extract_day_number(token)
|
246
|
+
|
247
|
+
if day_num
|
248
|
+
day_string = StartEndString.new(day_num.to_s)
|
249
|
+
|
250
|
+
stack = Stack.builder(Kind::DAY_OF_MONTH)
|
251
|
+
.day(day_string)
|
252
|
+
.build
|
253
|
+
|
254
|
+
cron.stack << stack
|
255
|
+
end
|
256
|
+
|
257
|
+
true
|
258
|
+
end
|
259
|
+
|
260
|
+
def self.process_second(token, cron)
|
261
|
+
# Look for a frequency in the last stack item
|
262
|
+
frequency_stack = cron.stack.reverse.find { |s| s.owner == Kind::FREQUENCY_WITH && s.frequency }
|
263
|
+
last_frequency = cron.stack.reverse.find { |s| s.owner == Kind::FREQUENCY_ONLY }
|
264
|
+
|
265
|
+
if frequency_stack && frequency_stack.frequency
|
266
|
+
stack = Stack.builder(Kind::SECOND)
|
267
|
+
.frequency(frequency_stack.frequency)
|
268
|
+
.build
|
269
|
+
|
270
|
+
cron.stack << stack
|
271
|
+
elsif last_frequency && last_frequency.frequency
|
272
|
+
stack = Stack.builder(Kind::SECOND)
|
273
|
+
.frequency(last_frequency.frequency)
|
274
|
+
.build
|
275
|
+
|
276
|
+
cron.stack << stack
|
277
|
+
else
|
278
|
+
stack = Stack.builder(Kind::SECOND).build
|
279
|
+
cron.stack << stack
|
280
|
+
end
|
281
|
+
|
282
|
+
true
|
283
|
+
end
|
284
|
+
|
285
|
+
def self.process_minute(token, cron)
|
286
|
+
# Look for a frequency in the last stack item
|
287
|
+
frequency_stack = cron.stack.reverse.find { |s| s.owner == Kind::FREQUENCY_WITH && s.frequency }
|
288
|
+
last_frequency = cron.stack.reverse.find { |s| s.owner == Kind::FREQUENCY_ONLY }
|
289
|
+
|
290
|
+
if frequency_stack && frequency_stack.frequency
|
291
|
+
stack = Stack.builder(Kind::MINUTE)
|
292
|
+
.frequency(frequency_stack.frequency)
|
293
|
+
.build
|
294
|
+
|
295
|
+
cron.stack << stack
|
296
|
+
elsif last_frequency && last_frequency.frequency
|
297
|
+
stack = Stack.builder(Kind::MINUTE)
|
298
|
+
.frequency(last_frequency.frequency)
|
299
|
+
.build
|
300
|
+
|
301
|
+
cron.stack << stack
|
302
|
+
else
|
303
|
+
stack = Stack.builder(Kind::MINUTE).build
|
304
|
+
cron.stack << stack
|
305
|
+
end
|
306
|
+
|
307
|
+
true
|
308
|
+
end
|
309
|
+
|
310
|
+
def self.process_hour(token, cron)
|
311
|
+
# Look for a frequency in the last stack item
|
312
|
+
frequency_stack = cron.stack.reverse.find { |s| s.owner == Kind::FREQUENCY_WITH && s.frequency }
|
313
|
+
last_frequency = cron.stack.reverse.find { |s| s.owner == Kind::FREQUENCY_ONLY }
|
314
|
+
|
315
|
+
if frequency_stack && frequency_stack.frequency
|
316
|
+
stack = Stack.builder(Kind::HOUR)
|
317
|
+
.frequency(frequency_stack.frequency)
|
318
|
+
.build
|
319
|
+
|
320
|
+
cron.stack << stack
|
321
|
+
elsif last_frequency && last_frequency.frequency
|
322
|
+
stack = Stack.builder(Kind::HOUR)
|
323
|
+
.frequency(last_frequency.frequency)
|
324
|
+
.build
|
325
|
+
|
326
|
+
cron.stack << stack
|
327
|
+
else
|
328
|
+
stack = Stack.builder(Kind::HOUR).build
|
329
|
+
cron.stack << stack
|
330
|
+
end
|
331
|
+
|
332
|
+
true
|
333
|
+
end
|
334
|
+
|
335
|
+
def self.process_month(token, cron)
|
336
|
+
month_map = {
|
337
|
+
'january' => '1', 'jan' => '1',
|
338
|
+
'february' => '2', 'feb' => '2',
|
339
|
+
'march' => '3', 'mar' => '3',
|
340
|
+
'april' => '4', 'apr' => '4',
|
341
|
+
'may' => '5',
|
342
|
+
'june' => '6', 'jun' => '6',
|
343
|
+
'july' => '7', 'jul' => '7',
|
344
|
+
'august' => '8', 'aug' => '8',
|
345
|
+
'september' => '9', 'sep' => '9', 'sept' => '9',
|
346
|
+
'october' => '10', 'oct' => '10',
|
347
|
+
'november' => '11', 'nov' => '11',
|
348
|
+
'december' => '12', 'dec' => '12'
|
349
|
+
}
|
350
|
+
|
351
|
+
month_value = nil
|
352
|
+
|
353
|
+
month_map.each do |key, value|
|
354
|
+
if token.match?(/\b#{key}\b/i)
|
355
|
+
month_value = value
|
356
|
+
break
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
if month_value
|
361
|
+
month_range = StartEndString.new(month_value)
|
362
|
+
stack = Stack.builder(Kind::MONTH)
|
363
|
+
.month(month_range)
|
364
|
+
.build
|
365
|
+
|
366
|
+
cron.stack << stack
|
367
|
+
end
|
368
|
+
|
369
|
+
true
|
370
|
+
end
|
371
|
+
|
372
|
+
def self.process_year(token, cron)
|
373
|
+
# We assume the token contains a year like "2024"
|
374
|
+
year = token.match(/(\d{4})/)&.[](1)&.to_i
|
375
|
+
|
376
|
+
if year
|
377
|
+
year_range = StartEnd.new(year)
|
378
|
+
stack = Stack.builder(Kind::YEAR)
|
379
|
+
.frequency(year)
|
380
|
+
.build
|
381
|
+
|
382
|
+
cron.stack << stack
|
383
|
+
end
|
384
|
+
|
385
|
+
true
|
386
|
+
end
|
387
|
+
|
388
|
+
def self.process_range_start(cron)
|
389
|
+
# Mark the current position for range processing
|
390
|
+
# Will be processed in process_stack
|
391
|
+
true
|
392
|
+
end
|
393
|
+
|
394
|
+
def self.process_range_end(cron)
|
395
|
+
# Process the range based on previous tokens
|
396
|
+
# Will be processed in process_stack
|
397
|
+
true
|
398
|
+
end
|
399
|
+
end
|
400
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "syntax"
|
4
|
+
require_relative "tokenizer"
|
5
|
+
require_relative "actions"
|
6
|
+
|
7
|
+
module EnglishToCron
|
8
|
+
class Cron
|
9
|
+
attr_accessor :syntax, :stack, :original_text
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@syntax = Syntax.new
|
13
|
+
@stack = []
|
14
|
+
@original_text = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.parse(text)
|
18
|
+
if text.nil? || text.to_s.strip.empty?
|
19
|
+
raise Error::InvalidInput
|
20
|
+
end
|
21
|
+
|
22
|
+
cron = new
|
23
|
+
cron.original_text = text.to_s.downcase
|
24
|
+
|
25
|
+
tokenizer = Tokenizer.new
|
26
|
+
tokens = tokenizer.run(text)
|
27
|
+
|
28
|
+
if tokens.empty?
|
29
|
+
raise Error::InvalidInput
|
30
|
+
end
|
31
|
+
|
32
|
+
tokens.each do |token|
|
33
|
+
if kind = Actions.try_from_token(token)
|
34
|
+
Actions.process(kind, token, cron)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
cron.process_stack
|
39
|
+
|
40
|
+
cron.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
def process_stack
|
44
|
+
detect_day_patterns
|
45
|
+
|
46
|
+
process_seconds
|
47
|
+
process_minutes
|
48
|
+
process_hours
|
49
|
+
process_day_of_month
|
50
|
+
process_months
|
51
|
+
# process_day_of_week
|
52
|
+
process_years
|
53
|
+
end
|
54
|
+
|
55
|
+
def detect_day_patterns
|
56
|
+
if @original_text =~ /(every|each) day at/i
|
57
|
+
@syntax.day_of_month = "*/1"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def process_seconds
|
62
|
+
second_items = @stack.select { |s| s.owner == Actions::Kind::SECOND }
|
63
|
+
if second_items.any? { |s| s.frequency }
|
64
|
+
# For patterns like "every 15 seconds"
|
65
|
+
@syntax.seconds = "0/#{second_items.first.frequency}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def process_minutes
|
70
|
+
minute_items = @stack.select { |s| s.owner == Actions::Kind::MINUTE }
|
71
|
+
frequency_items = @stack.select { |s| s.owner == Actions::Kind::FREQUENCY_WITH }
|
72
|
+
|
73
|
+
if has_minute_pattern? && !has_any_time_pattern?
|
74
|
+
# "every minute" pattern
|
75
|
+
@syntax.min = "*"
|
76
|
+
elsif minute_items.any? { |s| s.frequency }
|
77
|
+
# For patterns with specific minute frequencies
|
78
|
+
@syntax.min = "0/#{minute_items.first.frequency}"
|
79
|
+
elsif @stack.any? { |s| s.owner == Actions::Kind::CLOCK_TIME }
|
80
|
+
# Minutes are already set by clock time processing
|
81
|
+
elsif !@stack.any? { |s| s.owner == Actions::Kind::MINUTE }
|
82
|
+
# Keep default
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def process_hours
|
87
|
+
hour_items = @stack.select { |s| s.owner == Actions::Kind::HOUR }
|
88
|
+
clock_items = @stack.select { |s| s.owner == Actions::Kind::CLOCK_TIME }
|
89
|
+
|
90
|
+
if hour_items.any? { |s| s.frequency }
|
91
|
+
# For patterns with specific hour frequencies
|
92
|
+
@syntax.hour = "0/#{hour_items.first.frequency}"
|
93
|
+
elsif clock_items.any?
|
94
|
+
# For patterns with specific clock times
|
95
|
+
clock_item = clock_items.first
|
96
|
+
if clock_item.hour && clock_item.hour.start
|
97
|
+
@syntax.hour = clock_item.hour.start.to_s
|
98
|
+
# Default minutes to 0 if not specified
|
99
|
+
if clock_item.min && clock_item.min.start
|
100
|
+
@syntax.min = clock_item.min.start.to_s
|
101
|
+
else
|
102
|
+
@syntax.min = "0"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def process_day_of_month
|
109
|
+
day_items = @stack.select { |s| s.owner == Actions::Kind::DAY }
|
110
|
+
day_of_month_items = @stack.select { |s| s.owner == Actions::Kind::DAY_OF_MONTH }
|
111
|
+
|
112
|
+
# Process day of week first
|
113
|
+
if day_items.any? { |s| s.day_of_week }
|
114
|
+
@syntax.day_of_week = day_items.first.day_of_week
|
115
|
+
@syntax.day_of_month = "?"
|
116
|
+
return
|
117
|
+
end
|
118
|
+
|
119
|
+
# Process specific days of month
|
120
|
+
if day_of_month_items.any?
|
121
|
+
day_values = day_of_month_items.map { |s| s.day.start if s.day && s.day.start }.compact
|
122
|
+
|
123
|
+
if day_values.any?
|
124
|
+
# Handle multiple days like "1,15"
|
125
|
+
if @original_text && @original_text.match?(/1st.+15th|15th.+1st/)
|
126
|
+
@syntax.day_of_month = "1,15"
|
127
|
+
else
|
128
|
+
@syntax.day_of_month = day_values.join(',')
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def process_months
|
135
|
+
# Handle months
|
136
|
+
month_items = @stack.select { |s| s.owner == Actions::Kind::MONTH }
|
137
|
+
|
138
|
+
if month_items.any? { |s| s.month && s.month.start }
|
139
|
+
month_values = month_items.map { |s| s.month.start if s.month }.compact
|
140
|
+
@syntax.month = month_values.join(',')
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def process_years
|
145
|
+
year_items = @stack.select { |s| s.owner == Actions::Kind::YEAR }
|
146
|
+
|
147
|
+
if year_items.any? { |s| s.frequency }
|
148
|
+
@syntax.year = year_items.first.frequency.to_s
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def has_minute_pattern?
|
153
|
+
# Check if we have a pattern like "every minute"
|
154
|
+
minute_pattern = @stack.any? { |s| s.owner == Actions::Kind::MINUTE }
|
155
|
+
frequency_pattern = @stack.any? { |s| s.owner == Actions::Kind::FREQUENCY_WITH }
|
156
|
+
|
157
|
+
frequency_pattern && minute_pattern
|
158
|
+
end
|
159
|
+
|
160
|
+
def has_any_time_pattern?
|
161
|
+
# Check if there's any time specification
|
162
|
+
@stack.any? { |s| s.owner == Actions::Kind::CLOCK_TIME || s.owner == Actions::Kind::HOUR }
|
163
|
+
end
|
164
|
+
|
165
|
+
def has_every_day_pattern?
|
166
|
+
# Check for patterns like "every day at X"
|
167
|
+
frequency_pattern = @stack.any? { |s| s.owner == Actions::Kind::FREQUENCY_WITH }
|
168
|
+
day_pattern = @stack.any? { |s|
|
169
|
+
s.owner == Actions::Kind::DAY &&
|
170
|
+
(s.day_of_week.nil? || @original_text&.match?(/every day|each day/i))
|
171
|
+
}
|
172
|
+
|
173
|
+
frequency_pattern && day_pattern
|
174
|
+
end
|
175
|
+
|
176
|
+
def to_s
|
177
|
+
@syntax.to_s
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EnglishToCron
|
4
|
+
class Error < StandardError
|
5
|
+
class InvalidInput < Error
|
6
|
+
def initialize(msg = "Please enter human readable text")
|
7
|
+
super(msg)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class Capture < Error
|
12
|
+
attr_reader :state, :token
|
13
|
+
|
14
|
+
def initialize(state, token)
|
15
|
+
@state = state
|
16
|
+
@token = token
|
17
|
+
super("Could not capture: #{token} in state: #{state}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class ParseToNumber < Error
|
22
|
+
attr_reader :state, :value
|
23
|
+
|
24
|
+
def initialize(state, value)
|
25
|
+
@state = state
|
26
|
+
@value = value
|
27
|
+
super("Could not parse: #{value} to number. state: #{state}")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class IncorrectValue < Error
|
32
|
+
attr_reader :state, :error_description
|
33
|
+
|
34
|
+
def initialize(state, error_description)
|
35
|
+
@state = state
|
36
|
+
@error_description = error_description
|
37
|
+
super("Value is invalid in state: #{state}. description: #{error_description}")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EnglishToCron
|
4
|
+
class StartEnd
|
5
|
+
attr_accessor :start, :end
|
6
|
+
|
7
|
+
def initialize(start = nil, end_val = nil)
|
8
|
+
@start = start
|
9
|
+
@end = end_val
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class StartEndString
|
14
|
+
attr_accessor :start, :end
|
15
|
+
|
16
|
+
def initialize(start = nil, end_val = nil)
|
17
|
+
@start = start
|
18
|
+
@end = end_val
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class Stack
|
23
|
+
attr_accessor :owner, :frequency, :frequency_end, :frequency_start,
|
24
|
+
:min, :hour, :day, :month, :year, :day_of_week
|
25
|
+
|
26
|
+
def initialize(owner)
|
27
|
+
@owner = owner
|
28
|
+
@frequency = nil
|
29
|
+
@frequency_end = nil
|
30
|
+
@frequency_start = nil
|
31
|
+
@min = nil
|
32
|
+
@hour = nil
|
33
|
+
@day = nil
|
34
|
+
@month = nil
|
35
|
+
@year = nil
|
36
|
+
@day_of_week = nil
|
37
|
+
end
|
38
|
+
|
39
|
+
def frequency_to_string
|
40
|
+
frequency ? frequency.to_s : "*"
|
41
|
+
end
|
42
|
+
|
43
|
+
class Builder
|
44
|
+
def initialize(owner)
|
45
|
+
@stack = Stack.new(owner)
|
46
|
+
end
|
47
|
+
|
48
|
+
def frequency(frequency)
|
49
|
+
@stack.frequency = frequency
|
50
|
+
self
|
51
|
+
end
|
52
|
+
|
53
|
+
def min(min)
|
54
|
+
@stack.min = min
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
def hour(hour)
|
59
|
+
@stack.hour = hour
|
60
|
+
self
|
61
|
+
end
|
62
|
+
|
63
|
+
def day(day)
|
64
|
+
@stack.day = day
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
def month(month)
|
69
|
+
@stack.month = month
|
70
|
+
self
|
71
|
+
end
|
72
|
+
|
73
|
+
def day_of_week(day_of_week)
|
74
|
+
@stack.day_of_week = day_of_week
|
75
|
+
self
|
76
|
+
end
|
77
|
+
|
78
|
+
def build
|
79
|
+
@stack
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.builder(owner)
|
84
|
+
Builder.new(owner)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EnglishToCron
|
4
|
+
class Syntax
|
5
|
+
attr_accessor :seconds, :min, :hour, :day_of_month, :month, :day_of_week, :year
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@seconds = "0"
|
9
|
+
@min = "*"
|
10
|
+
@hour = "*"
|
11
|
+
@day_of_month = "*"
|
12
|
+
@month = "*"
|
13
|
+
@day_of_week = "?"
|
14
|
+
@year = "*"
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_s
|
18
|
+
"#{seconds} #{min} #{hour} #{day_of_month} #{month} #{day_of_week} #{year}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EnglishToCron
|
4
|
+
class Tokenizer
|
5
|
+
TOKEN_PATTERN = /(?i)(?:seconds|second|secs|sec)|(?:hours?|hrs?)|(?:minutes?|mins?|min)|(?:months?|(?:january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sept|oct|nov|dec)(?: ?and)?,? ?)+|[0-9]+(?:th|nd|rd|st)|(?:[0-9]+:)?[0-9]+ ?(?:am|pm)|[0-9]+:[0-9]+|(?:noon|midnight)|(?:days?|(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekend|mon|tue|wed|thu|fri|sat|sun)(?: ?and)?,? ?)+|(?:[0-9]{4}[0-9]*(?: ?and)?,? ?)+|[0-9]+|(?:to|through|ending|end|and)|(?:between|starting|start)/
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@regex = TOKEN_PATTERN
|
9
|
+
end
|
10
|
+
|
11
|
+
def run(input_string)
|
12
|
+
input_string.scan(@regex).map { |token| token.strip.downcase }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "english_to_cron/version"
|
4
|
+
require_relative "english_to_cron/error"
|
5
|
+
require_relative "english_to_cron/tokenizer"
|
6
|
+
require_relative "english_to_cron/syntax"
|
7
|
+
require_relative "english_to_cron/actions"
|
8
|
+
require_relative "english_to_cron/cron"
|
9
|
+
|
10
|
+
module EnglishToCron
|
11
|
+
def self.parse(input)
|
12
|
+
Cron.parse(input)
|
13
|
+
end
|
14
|
+
end
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: english-to-cron
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Vlad Dyachenko
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-04-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '13.0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '13.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.12'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.12'
|
41
|
+
description: A Ruby library that converts various English text descriptions into cron
|
42
|
+
job syntax.
|
43
|
+
email:
|
44
|
+
- gotta.go.vlad@gmail.com
|
45
|
+
executables: []
|
46
|
+
extensions: []
|
47
|
+
extra_rdoc_files: []
|
48
|
+
files:
|
49
|
+
- CHANGELOG.md
|
50
|
+
- LICENSE.txt
|
51
|
+
- README.md
|
52
|
+
- lib/english_to_cron.rb
|
53
|
+
- lib/english_to_cron/actions.rb
|
54
|
+
- lib/english_to_cron/cron.rb
|
55
|
+
- lib/english_to_cron/error.rb
|
56
|
+
- lib/english_to_cron/stack.rb
|
57
|
+
- lib/english_to_cron/syntax.rb
|
58
|
+
- lib/english_to_cron/tokenizer.rb
|
59
|
+
- lib/english_to_cron/version.rb
|
60
|
+
homepage: https://github.com/wowinter13/english-to-cron
|
61
|
+
licenses:
|
62
|
+
- MIT
|
63
|
+
metadata:
|
64
|
+
homepage_uri: https://github.com/wowinter13/english-to-cron
|
65
|
+
source_code_uri: https://github.com/wowinter13/english-to-cron
|
66
|
+
changelog_uri: https://github.com/wowinter13/english-to-cron/blob/main/CHANGELOG.md
|
67
|
+
post_install_message:
|
68
|
+
rdoc_options: []
|
69
|
+
require_paths:
|
70
|
+
- lib
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 3.0.0
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '0'
|
81
|
+
requirements: []
|
82
|
+
rubygems_version: 3.2.33
|
83
|
+
signing_key:
|
84
|
+
specification_version: 4
|
85
|
+
summary: English to Cron Job Syntax Converter
|
86
|
+
test_files: []
|