cronex 0.2.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 +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
|