cronex 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +20 -0
- data/.rspec +1 -0
- data/.rubocop.yml +47 -0
- data/CHANGELOG.md +2 -0
- data/Gemfile +2 -0
- data/LICENSE.md +13 -0
- data/README.md +56 -0
- data/Rakefile +8 -0
- data/cronex.gemspec +26 -0
- data/lib/cronex.rb +10 -0
- data/lib/cronex/description.rb +8 -0
- data/lib/cronex/description/base.rb +59 -0
- data/lib/cronex/description/day_of_month.rb +23 -0
- data/lib/cronex/description/day_of_week.rb +52 -0
- data/lib/cronex/description/hours.rb +23 -0
- data/lib/cronex/description/minutes.rb +27 -0
- data/lib/cronex/description/month.rb +23 -0
- data/lib/cronex/description/seconds.rb +23 -0
- data/lib/cronex/description/year.rb +23 -0
- data/lib/cronex/errors.rb +4 -0
- data/lib/cronex/exp_descriptor.rb +189 -0
- data/lib/cronex/parser.rb +92 -0
- data/lib/cronex/resource.rb +33 -0
- data/lib/cronex/utils.rb +43 -0
- data/lib/cronex/version.rb +3 -0
- data/resources/resources_en.yml +56 -0
- data/spec/casing_spec.rb +22 -0
- data/spec/exp_descriptor_spec.rb +363 -0
- data/spec/spec_helper.rb +11 -0
- metadata +118 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
module Cronex
|
2
|
+
class SecondsDescription < Description
|
3
|
+
def single_item_description(expression)
|
4
|
+
expression
|
5
|
+
end
|
6
|
+
|
7
|
+
def interval_description_format(expression)
|
8
|
+
format(resources.get('every_x_seconds'), expression)
|
9
|
+
end
|
10
|
+
|
11
|
+
def between_description_format(expression)
|
12
|
+
resources.get('seconds_through_past_the_minute')
|
13
|
+
end
|
14
|
+
|
15
|
+
def description_format(expression)
|
16
|
+
resources.get('at_x_seconds_past_the_minute')
|
17
|
+
end
|
18
|
+
|
19
|
+
def starting_description_format(expression)
|
20
|
+
resources.get('starting') + ' ' + description_format(expression)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Cronex
|
2
|
+
class YearDescription < Description
|
3
|
+
def single_item_description(expression)
|
4
|
+
DateTime.new(Integer(expression)).strftime('%Y')
|
5
|
+
end
|
6
|
+
|
7
|
+
def interval_description_format(expression)
|
8
|
+
format(', ' + resources.get('every_x') + ' ' + plural(expression, resources.get('year'), resources.get('years')), expression)
|
9
|
+
end
|
10
|
+
|
11
|
+
def between_description_format(expression)
|
12
|
+
', ' + resources.get('between_description_format')
|
13
|
+
end
|
14
|
+
|
15
|
+
def description_format(expression)
|
16
|
+
', ' + resources.get('only_in')
|
17
|
+
end
|
18
|
+
|
19
|
+
def starting_description_format(expression)
|
20
|
+
resources.get('starting') + ' ' + resources.get('in_x')
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
module Cronex
|
2
|
+
|
3
|
+
CASINGS = [:title, :sentence, :lower]
|
4
|
+
SEGMENTS = [:seconds, :minutes, :hours, :dayofmonth, :month, :dayofweek, :year, :timeofday, :full]
|
5
|
+
|
6
|
+
SPECIAL_CHARS = ['/', '-', ',', '*']
|
7
|
+
|
8
|
+
CRONEX_OPTS = {
|
9
|
+
casing: :sentence,
|
10
|
+
verbose: false,
|
11
|
+
zero_based_dow: true,
|
12
|
+
use_24_hour_time_format: false,
|
13
|
+
throw_exception_on_parse_error: true
|
14
|
+
}
|
15
|
+
|
16
|
+
class ExpressionDescriptor
|
17
|
+
attr_accessor :expression, :expression_parts, :options, :parsed, :resources
|
18
|
+
|
19
|
+
def initialize(expression, options = {}, locale = nil)
|
20
|
+
@expression = expression
|
21
|
+
@options = CRONEX_OPTS.merge(options)
|
22
|
+
@expression_parts = []
|
23
|
+
@parsed = false
|
24
|
+
@resources = Cronex::Resource.new(locale)
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_hash
|
28
|
+
Hash[SEGMENTS.take(7).zip(expression_parts)]
|
29
|
+
end
|
30
|
+
|
31
|
+
def description(type = :full)
|
32
|
+
desc = ''
|
33
|
+
|
34
|
+
begin
|
35
|
+
unless parsed
|
36
|
+
parser = Parser.new(expression, options)
|
37
|
+
@expression_parts = parser.parse
|
38
|
+
@parsed = true
|
39
|
+
end
|
40
|
+
|
41
|
+
desc = case type
|
42
|
+
when :full
|
43
|
+
full_description(expression_parts)
|
44
|
+
when :timeofday
|
45
|
+
time_of_day_description(expression_parts)
|
46
|
+
when :hours
|
47
|
+
hours_description(expression_parts)
|
48
|
+
when :minutes
|
49
|
+
minutes_description(expression_parts)
|
50
|
+
when :seconds
|
51
|
+
seconds_description(expression_parts)
|
52
|
+
when :dayofmonth
|
53
|
+
day_of_month_description(expression_parts)
|
54
|
+
when :month
|
55
|
+
month_description(expression_parts)
|
56
|
+
when :dayofweek
|
57
|
+
day_of_week_description(expression_parts)
|
58
|
+
when :year
|
59
|
+
year_description(expression_parts)
|
60
|
+
else
|
61
|
+
seconds_description(expression_parts)
|
62
|
+
end
|
63
|
+
rescue StandardError => e
|
64
|
+
if options[:throw_exception_on_parse_error]
|
65
|
+
raise e
|
66
|
+
else
|
67
|
+
desc = e.message
|
68
|
+
puts "Exception parsing expression: #{e}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
desc
|
73
|
+
end
|
74
|
+
|
75
|
+
def seconds_description(expression_parts)
|
76
|
+
desc = SecondsDescription.new(resources)
|
77
|
+
desc.segment_description(expression_parts[0], resources.get('every_second'))
|
78
|
+
end
|
79
|
+
|
80
|
+
def minutes_description(expression_parts)
|
81
|
+
desc = MinutesDescription.new(resources)
|
82
|
+
desc.segment_description(expression_parts[1], resources.get('every_minute'))
|
83
|
+
end
|
84
|
+
|
85
|
+
def hours_description(expression_parts)
|
86
|
+
desc = HoursDescription.new(resources)
|
87
|
+
desc.segment_description(expression_parts[2], resources.get('every_hour'))
|
88
|
+
end
|
89
|
+
|
90
|
+
def year_description(expression_parts)
|
91
|
+
desc = YearDescription.new(resources, options)
|
92
|
+
desc.segment_description(expression_parts[6], ', ' + resources.get('every_year'))
|
93
|
+
end
|
94
|
+
|
95
|
+
def day_of_week_description(expression_parts)
|
96
|
+
desc = DayOfWeekDescription.new(resources, options)
|
97
|
+
desc.segment_description(expression_parts[5], ', ' + resources.get('every_day'))
|
98
|
+
end
|
99
|
+
|
100
|
+
def month_description(expression_parts)
|
101
|
+
desc = MonthDescription.new(resources)
|
102
|
+
desc.segment_description(expression_parts[4], '')
|
103
|
+
end
|
104
|
+
|
105
|
+
def day_of_month_description(expression_parts)
|
106
|
+
exp = expression_parts[3].gsub('?', '*')
|
107
|
+
if exp == 'L'
|
108
|
+
description = ', ' + resources.get('on_the_last_day_of_the_month')
|
109
|
+
elsif exp == 'LW' || exp == 'WL'
|
110
|
+
description = ', ' + resources.get('on_the_last_weekday_of_the_month')
|
111
|
+
else
|
112
|
+
match = exp.match(/(\d{1,2}W)|(W\d{1,2})/)
|
113
|
+
if match
|
114
|
+
day_num = Integer(match[0].gsub('W', ''))
|
115
|
+
day_str = day_num == 1 ? resources.get('first_weekday') : format(resources.get('weekday_nearest_day'), day_num)
|
116
|
+
description = format(', ' + resources.get('on_the_of_the_month'), day_str)
|
117
|
+
else
|
118
|
+
desc = DayOfMonthDescription.new(resources)
|
119
|
+
description = desc.segment_description(exp, ', ' + resources.get('every_day'))
|
120
|
+
end
|
121
|
+
end
|
122
|
+
description
|
123
|
+
end
|
124
|
+
|
125
|
+
def time_of_day_description(expression_parts)
|
126
|
+
sec_exp, min_exp, hour_exp = expression_parts
|
127
|
+
description = ''
|
128
|
+
if [sec_exp, min_exp, hour_exp].all? { |exp| !Cronex::Utils.include_any?(exp, SPECIAL_CHARS) }
|
129
|
+
# specific time of day (i.e. 10 14)
|
130
|
+
description += resources.get('at') + ' ' + Cronex::Utils.format_time(hour_exp, min_exp, sec_exp)
|
131
|
+
elsif min_exp.include?('-') && !min_exp.include?('/') && !min_exp.include?(',') && !Cronex::Utils.include_any?(hour_exp, SPECIAL_CHARS)
|
132
|
+
# Minute range in single hour (e.g. 0-10 11)
|
133
|
+
min_parts = min_exp.split('-')
|
134
|
+
description += format(
|
135
|
+
resources.get('every_minute_between'),
|
136
|
+
Cronex::Utils.format_time(hour_exp, min_parts[0]),
|
137
|
+
Cronex::Utils.format_time(hour_exp, min_parts[1]))
|
138
|
+
elsif hour_exp.include?(',') && !Cronex::Utils.include_any?(min_exp, SPECIAL_CHARS)
|
139
|
+
# Hours list with single minute (e.g. 30 6,14,16)
|
140
|
+
hour_parts = hour_exp.split(',')
|
141
|
+
description += resources.get('at')
|
142
|
+
h_parts = hour_parts.map { |part| ' ' + Cronex::Utils.format_time(part, min_exp) }
|
143
|
+
description += h_parts[0...-1].join(',') + ' ' + resources.get('and') + h_parts.last
|
144
|
+
else
|
145
|
+
sec_desc = seconds_description(expression_parts)
|
146
|
+
min_desc = minutes_description(expression_parts)
|
147
|
+
hour_desc = hours_description(expression_parts)
|
148
|
+
description += sec_desc
|
149
|
+
description += ', ' if description.size > 0 && !min_desc.empty?
|
150
|
+
description += min_desc
|
151
|
+
description += ', ' if description.size > 0 && !hour_desc.empty?
|
152
|
+
description += hour_desc
|
153
|
+
end
|
154
|
+
description
|
155
|
+
end
|
156
|
+
|
157
|
+
def full_description(expression_parts)
|
158
|
+
time_segment = time_of_day_description(expression_parts)
|
159
|
+
dom_desc = day_of_month_description(expression_parts)
|
160
|
+
month_desc = month_description(expression_parts)
|
161
|
+
dow_desc = day_of_week_description(expression_parts)
|
162
|
+
year_desc = year_description(expression_parts)
|
163
|
+
day_desc = expression_parts[3] == '*' ? dow_desc : dom_desc
|
164
|
+
description = format('%s%s%s%s', time_segment, day_desc, month_desc, year_desc)
|
165
|
+
description = transform_verbosity(description, options[:verbose])
|
166
|
+
description = transform_case(description, options[:casing])
|
167
|
+
description
|
168
|
+
end
|
169
|
+
|
170
|
+
def transform_case(desc, case_type = :lower)
|
171
|
+
case case_type
|
172
|
+
when :sentence
|
173
|
+
desc.sub(/\w+/, &:capitalize)
|
174
|
+
when :title
|
175
|
+
desc.gsub(/\w+/, &:capitalize)
|
176
|
+
else
|
177
|
+
desc.downcase
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def transform_verbosity(desc, verbose = false)
|
182
|
+
return desc if verbose
|
183
|
+
parts = %w(every_minute every_hour every_day)
|
184
|
+
parts.each { |part| desc.gsub!(resources.get(part.gsub('_', '_1_')), resources.get(part)) }
|
185
|
+
parts.each { |part| desc.gsub!(', ' + resources.get(part), '') }
|
186
|
+
desc
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Cronex
|
2
|
+
|
3
|
+
DAYS = Date::ABBR_DAYNAMES.map(&:upcase)
|
4
|
+
MONTHS = Date::ABBR_MONTHNAMES[1..-1].map(&:upcase)
|
5
|
+
|
6
|
+
DAY_NUM = Hash[DAYS.zip(0..(DAYS.size - 1))]
|
7
|
+
MONTH_NUM = Hash[MONTHS.zip(1..MONTHS.size)]
|
8
|
+
|
9
|
+
# abbr dayname => long dayname
|
10
|
+
DAY_DAY = Hash[DAYS.zip(Date::DAYNAMES.map(&:upcase))]
|
11
|
+
|
12
|
+
class Parser
|
13
|
+
attr_accessor :expression, :options
|
14
|
+
|
15
|
+
def initialize(expression, options = {})
|
16
|
+
@expression = expression
|
17
|
+
@options = options
|
18
|
+
end
|
19
|
+
|
20
|
+
def parse(exp = expression)
|
21
|
+
parsed_parts = Array.new(7, '')
|
22
|
+
|
23
|
+
fail ExpressionError, 'Error: Expression null or emtpy' unless Cronex::Utils.present?(exp)
|
24
|
+
parts = sanitize(exp).split(' ')
|
25
|
+
len = parts.size
|
26
|
+
|
27
|
+
if len < 5
|
28
|
+
fail ExpressionError, "Error: Expression only has #{len} parts. At least 5 parts are required"
|
29
|
+
elsif len == 5
|
30
|
+
# 5 part CRON so shift array past seconds element
|
31
|
+
parsed_parts.insert(1, *parts)
|
32
|
+
elsif len == 6
|
33
|
+
# if last element ends with 4 digits, a year element has been supplied and no seconds element
|
34
|
+
if parts.last.match(/\d{4}$/)
|
35
|
+
parsed_parts.insert(1, *parts)
|
36
|
+
else
|
37
|
+
parsed_parts.insert(0, *parts)
|
38
|
+
end
|
39
|
+
elsif len == 7
|
40
|
+
parsed_parts = parts
|
41
|
+
else
|
42
|
+
fail ExpressionError, "Error: Expression has too many parts (#{len}). Expression must not have more than 7 parts"
|
43
|
+
end
|
44
|
+
|
45
|
+
parsed_parts = parsed_parts.take(7) # ; p parsed_parts
|
46
|
+
normalize(parsed_parts, options)
|
47
|
+
end
|
48
|
+
|
49
|
+
def sanitize(exp = expression)
|
50
|
+
# remove extra spaces
|
51
|
+
exp = exp.strip.gsub(/\s+/, ' ').upcase
|
52
|
+
# convert non-standard day names (e.g. THURS, TUES) to 3-letter names
|
53
|
+
DAY_DAY.each do |day, longname|
|
54
|
+
matched = exp.scan(/\W*(#{day}\w*)/).flatten.uniq
|
55
|
+
matched.each { |m| exp.gsub!(m, day) if longname.include?(m) }
|
56
|
+
end
|
57
|
+
exp
|
58
|
+
end
|
59
|
+
|
60
|
+
def normalize(expression_parts, options = {})
|
61
|
+
parts = expression_parts.dup
|
62
|
+
|
63
|
+
# convert ? to * only for DOM and DOW
|
64
|
+
parts[3].gsub!('?', '*')
|
65
|
+
parts[5].gsub!('?', '*')
|
66
|
+
|
67
|
+
# Convert 0/, 1/ to */
|
68
|
+
parts[0].gsub!('0/', '*/') if parts[0].start_with?('0/') # seconds
|
69
|
+
parts[1].gsub!('0/', '*/') if parts[1].start_with?('0/') # minutes
|
70
|
+
parts[2].gsub!('0/', '*/') if parts[2].start_with?('0/') # hours
|
71
|
+
parts[3].gsub!('1/', '*/') if parts[3].start_with?('1/') # day of month
|
72
|
+
parts[4].gsub!('1/', '*/') if parts[4].start_with?('1/') # month
|
73
|
+
parts[5].gsub!('1/', '*/') if parts[5].start_with?('1/') # day of week
|
74
|
+
|
75
|
+
# convert */1 to *
|
76
|
+
parts = parts.map { |part| part == '*/1' ? '*' : part }
|
77
|
+
|
78
|
+
# convert SUN-SAT format to 0-6 format
|
79
|
+
DAY_NUM.each { |day, i| i += 1 if !options[:zero_based_dow]; parts[5].gsub!(day, i.to_s) }
|
80
|
+
|
81
|
+
# convert JAN-DEC format to 1-12 format
|
82
|
+
MONTH_NUM.each { |month, i| parts[4].gsub!(month, i.to_s) }
|
83
|
+
|
84
|
+
# convert 0 second to (empty)
|
85
|
+
parts[0] = '' if parts[0] == '0'
|
86
|
+
|
87
|
+
# convert 0 DOW to 7 so that 0 for Sunday in zeroBasedDayOfWeek is valid
|
88
|
+
parts[5] = '7' if (!options || options[:zero_based_dow]) && parts[5] == '0' # ; p parts
|
89
|
+
parts
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Cronex
|
2
|
+
|
3
|
+
RESOURCES_DIR = File.expand_path('../../../resources', __FILE__)
|
4
|
+
|
5
|
+
class Resource
|
6
|
+
attr_reader :locale, :messages
|
7
|
+
|
8
|
+
def initialize(loc = :en)
|
9
|
+
self.locale = loc || :en
|
10
|
+
end
|
11
|
+
|
12
|
+
def locale=(loc)
|
13
|
+
result = load(loc)
|
14
|
+
if result.nil? || result.empty?
|
15
|
+
fail ResourceError, "Error: Invalid resource file for '#{loc}' locale"
|
16
|
+
else
|
17
|
+
@locale = loc
|
18
|
+
@messages = result
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def load(loc)
|
23
|
+
file = File.join(RESOURCES_DIR, "resources_#{loc}.yml")
|
24
|
+
fail ResourceError, "Resource file #{file} for '#{loc}' locale not found" unless File.exist?(file)
|
25
|
+
YAML.load_file(file)
|
26
|
+
end
|
27
|
+
|
28
|
+
def [](key)
|
29
|
+
@messages[key]
|
30
|
+
end
|
31
|
+
alias_method :get, :[]
|
32
|
+
end
|
33
|
+
end
|
data/lib/cronex/utils.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
module Cronex
|
2
|
+
module Utils
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def present?(str)
|
6
|
+
!str.to_s.strip.empty?
|
7
|
+
end
|
8
|
+
|
9
|
+
def include_any?(str, chars)
|
10
|
+
chars.any? { |char| str.include?(char) }
|
11
|
+
end
|
12
|
+
|
13
|
+
def number?(str)
|
14
|
+
Integer(str) rescue nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def day_of_week_name(number)
|
18
|
+
Date::DAYNAMES[number.to_i % 7]
|
19
|
+
end
|
20
|
+
|
21
|
+
def format_minutes(minute_expression)
|
22
|
+
if minute_expression.include?(',')
|
23
|
+
minute_expression.split(',').map { |m| format('%02d', m) }.join(',')
|
24
|
+
else
|
25
|
+
format('%02d', minute_expression)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def format_time(hour_expression, minute_expression, second_expression = '')
|
30
|
+
hour = Integer(hour_expression)
|
31
|
+
period = hour >= 12 ? 'PM' : 'AM'
|
32
|
+
hour -= 12 if hour > 12
|
33
|
+
minute = Integer(minute_expression)
|
34
|
+
minute = format('%02d', minute)
|
35
|
+
second = ''
|
36
|
+
if Cronex::Utils.present?(second_expression)
|
37
|
+
second = Integer(second_expression)
|
38
|
+
second = ':' + format('%02d', second)
|
39
|
+
end
|
40
|
+
format('%s:%s%s %s', hour, minute, second, period)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# resources_en.yml
|
2
|
+
---
|
3
|
+
expression_empty_exception: 'Expression cannot be null or empty'
|
4
|
+
interval_description_format: 'every %s days of the week'
|
5
|
+
between_description_format: '%s through %s'
|
6
|
+
between_weekday_description_format: '%s through %s'
|
7
|
+
on_the_day_of_the_month: 'on the {{nth}} %s of the month'
|
8
|
+
on_the_of_the_month: 'on the %s of the month'
|
9
|
+
on_the_last_of_the_month: 'on the last %s of the month'
|
10
|
+
on_the_last_day_of_the_month: 'on the last day of the month'
|
11
|
+
on_the_last_weekday_of_the_month: 'on the last weekday of the month'
|
12
|
+
between_days_of_the_month: 'between day %s and %s of the month'
|
13
|
+
seconds_through_past_the_minute: 'seconds %s through %s past the minute'
|
14
|
+
between_x_and_y: 'between %s and %s'
|
15
|
+
past_the_hour: 'past the hour'
|
16
|
+
at_x_seconds_past_the_minute: 'at %s seconds past the minute'
|
17
|
+
minutes_through_past_the_hour: 'minutes %s through %s past the hour'
|
18
|
+
on_day_of_the_month: 'on day %s of the month'
|
19
|
+
first_weekday: 'first weekday'
|
20
|
+
weekday_nearest_day: 'weekday nearest day %s'
|
21
|
+
starting: 'starting'
|
22
|
+
only_on: 'only on %s'
|
23
|
+
only_in: 'only in %s'
|
24
|
+
every_x_seconds: 'every %s seconds'
|
25
|
+
every_minute_between: 'Every minute between %s and %s'
|
26
|
+
every_second: 'every second'
|
27
|
+
every_minute: 'every minute'
|
28
|
+
every_1_minute: 'every 1 minute'
|
29
|
+
every_hour: 'every hour'
|
30
|
+
every_1_hour: 'every 1 hour'
|
31
|
+
every_day: 'every day'
|
32
|
+
every_1_day: 'every 1 day'
|
33
|
+
every_year: 'every year'
|
34
|
+
every_x: 'every %s'
|
35
|
+
at_x: 'at %s'
|
36
|
+
on_x: 'on %s'
|
37
|
+
in_x: 'in %s'
|
38
|
+
first: first
|
39
|
+
second: second
|
40
|
+
third: third
|
41
|
+
fourth: fourth
|
42
|
+
fifth: fifth
|
43
|
+
time_pm: PM
|
44
|
+
time_am: AM
|
45
|
+
and: and
|
46
|
+
at: At
|
47
|
+
day: day
|
48
|
+
days: days
|
49
|
+
hour: hour
|
50
|
+
hours: hours
|
51
|
+
minute: minute
|
52
|
+
minutes: minutes
|
53
|
+
month: month
|
54
|
+
months: months
|
55
|
+
year: year
|
56
|
+
years: years
|