hron 0.5.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ee599aa7c2d192e696b13aa9ce580646930f177d12fea11207c8a0364ce72098
4
+ data.tar.gz: 6c0ad8e2e53cad36ceb9e3cf6f93ea2d8a981d1201c739680b3d90fecc74fcc6
5
+ SHA512:
6
+ metadata.gz: 160761a9f062f461cc93613fb4008279fac3200183b128079440c734a328e32a8f682c374692307420ce8c0e672523e8d9db0df5ee854bd096aef4f8b084ce5e
7
+ data.tar.gz: f216e50bc6d69ae4a6a6a22396eea71777b051e34e4af3ff21fd245f45a1936385f0a70999276f977c1fb9f12cc16b9a1ce9a594449d4d51b3d01e5799a2a179
data/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # hron
2
+
3
+ **Human-readable cron** — scheduling expressions that are a superset of what cron can express.
4
+
5
+ ```ruby
6
+ require 'hron'
7
+
8
+ schedule = Hron::Schedule.parse("every weekday at 9:00 in America/New_York")
9
+ ```
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ gem install hron
15
+ ```
16
+
17
+ Or add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem 'hron'
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```ruby
26
+ require 'hron'
27
+
28
+ # Parse an expression
29
+ schedule = Hron::Schedule.parse("every weekday at 9:00 except dec 25, jan 1 in America/New_York")
30
+
31
+ # Get next occurrence
32
+ now = Time.now
33
+ next_time = schedule.next_from(now)
34
+ puts next_time
35
+
36
+ # Get next 5 occurrences
37
+ upcoming = schedule.next_n_from(now, 5)
38
+ upcoming.each { |t| puts t }
39
+
40
+ # Check if a time matches
41
+ schedule.matches(Time.new(2026, 2, 9, 9, 0, 0)) # true
42
+
43
+ # Convert to/from cron
44
+ simple = Hron::Schedule.parse("every day at 9:00")
45
+ puts simple.to_cron # "0 9 * * *"
46
+
47
+ from_cron = Hron::Schedule.from_cron("*/30 * * * *")
48
+ puts from_cron # "every 30 min from 00:00 to 23:59"
49
+
50
+ # Validate without exceptions
51
+ Hron::Schedule.validate("every day at 9:00") # true
52
+ Hron::Schedule.validate("invalid") # false
53
+ ```
54
+
55
+ ## Expression Syntax
56
+
57
+ See the full [expression reference](https://github.com/prasrvenkat/hron#expression-syntax).
58
+
59
+ ## API
60
+
61
+ ### `Hron::Schedule.parse(input) -> Schedule`
62
+ Parse an hron expression string.
63
+
64
+ ### `Hron::Schedule.from_cron(cron_expr) -> Schedule`
65
+ Convert a 5-field cron expression to a Schedule.
66
+
67
+ ### `Hron::Schedule.validate(input) -> Boolean`
68
+ Check if an input string is a valid hron expression.
69
+
70
+ ### `schedule.next_from(now) -> Time | nil`
71
+ Compute the next occurrence after `now`.
72
+
73
+ ### `schedule.next_n_from(now, n) -> Array<Time>`
74
+ Compute the next `n` occurrences after `now`.
75
+
76
+ ### `schedule.matches(time) -> Boolean`
77
+ Check if a time matches this schedule.
78
+
79
+ ### `schedule.to_cron -> String`
80
+ Convert to a 5-field cron expression. Raises `Hron::HronError` if the schedule can't be expressed as cron.
81
+
82
+ ### `schedule.to_s -> String`
83
+ Render as the canonical string form (roundtrip-safe).
84
+
85
+ ### `schedule.timezone -> String | nil`
86
+ The timezone, if specified.
87
+
88
+ ### `schedule.expression -> ScheduleExpr`
89
+ The underlying schedule expression AST.
90
+
91
+ ## Requirements
92
+
93
+ - Ruby >= 3.2
94
+ - TZInfo gem for timezone support
95
+
96
+ ## License
97
+
98
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "standard/rake"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ t.test_files = FileList["test/**/*_test.rb"]
11
+ end
12
+
13
+ task default: %i[test standard]
data/hron.gemspec ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/hron/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "hron"
7
+ spec.version = Hron::VERSION
8
+ spec.authors = ["Prasanna Venkataraman"]
9
+ spec.email = ["prasrvenkat@gmail.com"]
10
+
11
+ spec.summary = "Human-readable cron — a scheduling expression language that is a superset of cron"
12
+ spec.description = "HRON (Human Readable Object Notation for schedules) is a scheduling expression language " \
13
+ "that is designed to be easy to read, write, and understand. It is a superset of cron, " \
14
+ "meaning any valid cron expression can be converted to and from HRON."
15
+ spec.homepage = "https://github.com/prasrvenkat/hron"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 4.0.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/prasrvenkat/hron"
21
+ spec.metadata["changelog_uri"] = "https://github.com/prasrvenkat/hron/blob/main/CHANGELOG.md"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ spec.add_dependency "tzinfo", "~> 2.0"
35
+
36
+ spec.add_development_dependency "minitest", "~> 5.20"
37
+ spec.add_development_dependency "rake", "~> 13.0"
38
+ spec.add_development_dependency "standard", "~> 1.43"
39
+ end
data/lib/hron/ast.rb ADDED
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hron
4
+ # Weekday enumeration (ISO 8601: Monday=1, Sunday=7)
5
+ module Weekday
6
+ MONDAY = :monday
7
+ TUESDAY = :tuesday
8
+ WEDNESDAY = :wednesday
9
+ THURSDAY = :thursday
10
+ FRIDAY = :friday
11
+ SATURDAY = :saturday
12
+ SUNDAY = :sunday
13
+
14
+ ALL = [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY].freeze
15
+ WEEKDAYS = [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY].freeze
16
+ WEEKEND = [SATURDAY, SUNDAY].freeze
17
+
18
+ NUMBERS = {
19
+ MONDAY => 1, TUESDAY => 2, WEDNESDAY => 3, THURSDAY => 4,
20
+ FRIDAY => 5, SATURDAY => 6, SUNDAY => 7
21
+ }.freeze
22
+
23
+ CRON_DOW = {
24
+ SUNDAY => 0, MONDAY => 1, TUESDAY => 2, WEDNESDAY => 3,
25
+ THURSDAY => 4, FRIDAY => 5, SATURDAY => 6
26
+ }.freeze
27
+
28
+ NUMBER_TO_WEEKDAY = NUMBERS.invert.freeze
29
+
30
+ PARSE_MAP = {
31
+ "monday" => MONDAY, "mon" => MONDAY,
32
+ "tuesday" => TUESDAY, "tue" => TUESDAY,
33
+ "wednesday" => WEDNESDAY, "wed" => WEDNESDAY,
34
+ "thursday" => THURSDAY, "thu" => THURSDAY,
35
+ "friday" => FRIDAY, "fri" => FRIDAY,
36
+ "saturday" => SATURDAY, "sat" => SATURDAY,
37
+ "sunday" => SUNDAY, "sun" => SUNDAY
38
+ }.freeze
39
+
40
+ def self.number(day)
41
+ NUMBERS[day]
42
+ end
43
+
44
+ def self.cron_dow(day)
45
+ CRON_DOW[day]
46
+ end
47
+
48
+ def self.from_number(n)
49
+ NUMBER_TO_WEEKDAY[n]
50
+ end
51
+
52
+ def self.try_parse(s)
53
+ PARSE_MAP[s.downcase]
54
+ end
55
+
56
+ def self.to_s(day)
57
+ day.to_s
58
+ end
59
+ end
60
+
61
+ # Month name enumeration
62
+ module MonthName
63
+ JAN = :jan
64
+ FEB = :feb
65
+ MAR = :mar
66
+ APR = :apr
67
+ MAY = :may
68
+ JUN = :jun
69
+ JUL = :jul
70
+ AUG = :aug
71
+ SEP = :sep
72
+ OCT = :oct
73
+ NOV = :nov
74
+ DEC = :dec
75
+
76
+ ALL = [JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC].freeze
77
+
78
+ NUMBERS = {
79
+ JAN => 1, FEB => 2, MAR => 3, APR => 4, MAY => 5, JUN => 6,
80
+ JUL => 7, AUG => 8, SEP => 9, OCT => 10, NOV => 11, DEC => 12
81
+ }.freeze
82
+
83
+ NUMBER_TO_MONTH = NUMBERS.invert.freeze
84
+
85
+ PARSE_MAP = {
86
+ "january" => JAN, "jan" => JAN,
87
+ "february" => FEB, "feb" => FEB,
88
+ "march" => MAR, "mar" => MAR,
89
+ "april" => APR, "apr" => APR,
90
+ "may" => MAY,
91
+ "june" => JUN, "jun" => JUN,
92
+ "july" => JUL, "jul" => JUL,
93
+ "august" => AUG, "aug" => AUG,
94
+ "september" => SEP, "sep" => SEP,
95
+ "october" => OCT, "oct" => OCT,
96
+ "november" => NOV, "nov" => NOV,
97
+ "december" => DEC, "dec" => DEC
98
+ }.freeze
99
+
100
+ def self.number(month)
101
+ NUMBERS[month]
102
+ end
103
+
104
+ def self.from_number(n)
105
+ NUMBER_TO_MONTH[n]
106
+ end
107
+
108
+ def self.try_parse(s)
109
+ PARSE_MAP[s.downcase]
110
+ end
111
+
112
+ def self.to_s(month)
113
+ month.to_s
114
+ end
115
+ end
116
+
117
+ # Interval unit (minutes or hours)
118
+ module IntervalUnit
119
+ MIN = :min
120
+ HOURS = :hours
121
+
122
+ def self.to_s(unit)
123
+ unit.to_s
124
+ end
125
+ end
126
+
127
+ # Ordinal position (first, second, etc.)
128
+ module OrdinalPosition
129
+ FIRST = :first
130
+ SECOND = :second
131
+ THIRD = :third
132
+ FOURTH = :fourth
133
+ FIFTH = :fifth
134
+ LAST = :last
135
+
136
+ TO_N = {
137
+ FIRST => 1, SECOND => 2, THIRD => 3, FOURTH => 4, FIFTH => 5
138
+ }.freeze
139
+
140
+ def self.to_n(ord)
141
+ TO_N[ord]
142
+ end
143
+
144
+ def self.to_s(ord)
145
+ ord.to_s
146
+ end
147
+ end
148
+
149
+ # Time of day (hour and minute)
150
+ TimeOfDay = Data.define(:hour, :minute) do
151
+ def to_s
152
+ format("%02d:%02d", hour, minute)
153
+ end
154
+ end
155
+
156
+ # --- Day filter variants ---
157
+
158
+ DayFilterEvery = Data.define
159
+ DayFilterWeekday = Data.define
160
+ DayFilterWeekend = Data.define
161
+ DayFilterDays = Data.define(:days) # days: Array<Weekday>
162
+
163
+ # --- Day of month spec ---
164
+
165
+ SingleDay = Data.define(:day)
166
+ DayRange = Data.define(:start, :end_day) # end_day to avoid Ruby keyword
167
+
168
+ # --- Month target variants ---
169
+
170
+ DaysTarget = Data.define(:specs) # specs: Array<DayOfMonthSpec>
171
+ LastDayTarget = Data.define
172
+ LastWeekdayTarget = Data.define
173
+
174
+ # --- Year target variants ---
175
+
176
+ YearDateTarget = Data.define(:month, :day)
177
+ YearOrdinalWeekdayTarget = Data.define(:ordinal, :weekday, :month)
178
+ YearDayOfMonthTarget = Data.define(:day, :month)
179
+ YearLastWeekdayTarget = Data.define(:month)
180
+
181
+ # --- Date spec variants ---
182
+
183
+ NamedDate = Data.define(:month, :day)
184
+ IsoDate = Data.define(:date) # date: String (YYYY-MM-DD)
185
+
186
+ # --- Exception spec variants ---
187
+
188
+ NamedException = Data.define(:month, :day)
189
+ IsoException = Data.define(:date)
190
+
191
+ # --- Until spec variants ---
192
+
193
+ IsoUntil = Data.define(:date)
194
+ NamedUntil = Data.define(:month, :day)
195
+
196
+ # --- Schedule expression variants ---
197
+
198
+ IntervalRepeat = Data.define(:interval, :unit, :from_time, :to_time, :day_filter)
199
+ DayRepeat = Data.define(:interval, :days, :times)
200
+ WeekRepeat = Data.define(:interval, :days, :times)
201
+ MonthRepeat = Data.define(:interval, :target, :times)
202
+ OrdinalRepeat = Data.define(:interval, :ordinal, :day, :times)
203
+ SingleDateExpr = Data.define(:date, :times)
204
+ YearRepeat = Data.define(:interval, :target, :times)
205
+
206
+ # --- Schedule data (top-level) ---
207
+
208
+ ScheduleData = Data.define(:expr, :timezone, :except, :until, :anchor, :during) do
209
+ def initialize(expr:, timezone: nil, except: [], until: nil, anchor: nil, during: [])
210
+ super
211
+ end
212
+ end
213
+
214
+ # --- Helper functions ---
215
+
216
+ def self.expand_day_spec(spec)
217
+ case spec
218
+ when SingleDay
219
+ [spec.day]
220
+ when DayRange
221
+ (spec.start..spec.end_day).to_a
222
+ else
223
+ []
224
+ end
225
+ end
226
+
227
+ def self.expand_month_target(target)
228
+ case target
229
+ when DaysTarget
230
+ target.specs.flat_map { |spec| expand_day_spec(spec) }
231
+ else
232
+ []
233
+ end
234
+ end
235
+ end
data/lib/hron/cron.rb ADDED
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ast"
4
+ require_relative "error"
5
+
6
+ module Hron
7
+ # Cron conversion module
8
+ module Cron
9
+ CRON_DOW_MAP = {
10
+ 0 => Weekday::SUNDAY,
11
+ 1 => Weekday::MONDAY,
12
+ 2 => Weekday::TUESDAY,
13
+ 3 => Weekday::WEDNESDAY,
14
+ 4 => Weekday::THURSDAY,
15
+ 5 => Weekday::FRIDAY,
16
+ 6 => Weekday::SATURDAY,
17
+ 7 => Weekday::SUNDAY
18
+ }.freeze
19
+
20
+ def self.to_cron(schedule)
21
+ raise HronError.cron("not expressible as cron (except clauses not supported)") unless schedule.except.empty?
22
+
23
+ raise HronError.cron("not expressible as cron (until clauses not supported)") if schedule.until
24
+
25
+ raise HronError.cron("not expressible as cron (during clauses not supported)") unless schedule.during.empty?
26
+
27
+ expr = schedule.expr
28
+
29
+ case expr
30
+ when DayRepeat
31
+ raise HronError.cron("not expressible as cron (multi-day intervals not supported)") if expr.interval > 1
32
+ raise HronError.cron("not expressible as cron (multiple times not supported)") if expr.times.length != 1
33
+
34
+ time = expr.times[0]
35
+ dow = day_filter_to_cron_dow(expr.days)
36
+ "#{time.minute} #{time.hour} * * #{dow}"
37
+
38
+ when IntervalRepeat
39
+ full_day = expr.from_time.hour.zero? && expr.from_time.minute.zero? &&
40
+ expr.to_time.hour == 23 && expr.to_time.minute == 59
41
+ raise HronError.cron("not expressible as cron (partial-day interval windows not supported)") unless full_day
42
+
43
+ raise HronError.cron("not expressible as cron (interval with day filter not supported)") if expr.day_filter
44
+
45
+ if expr.unit == IntervalUnit::MIN
46
+ if (60 % expr.interval) != 0
47
+ raise HronError.cron("not expressible as cron (*/#{expr.interval} breaks at hour boundaries)")
48
+ end
49
+
50
+ "*/#{expr.interval} * * * *"
51
+ else
52
+ "0 */#{expr.interval} * * *"
53
+ end
54
+
55
+ when WeekRepeat
56
+ raise HronError.cron("not expressible as cron (multi-week intervals not supported)")
57
+
58
+ when MonthRepeat
59
+ raise HronError.cron("not expressible as cron (multi-month intervals not supported)") if expr.interval > 1
60
+ raise HronError.cron("not expressible as cron (multiple times not supported)") if expr.times.length != 1
61
+
62
+ time = expr.times[0]
63
+ case expr.target
64
+ when DaysTarget
65
+ expanded = []
66
+ expr.target.specs.each do |s|
67
+ case s
68
+ when SingleDay
69
+ expanded << s.day
70
+ when DayRange
71
+ (s.start..s.end_day).each { |d| expanded << d }
72
+ end
73
+ end
74
+ dom = expanded.join(",")
75
+ "#{time.minute} #{time.hour} #{dom} * *"
76
+ when LastDayTarget
77
+ raise HronError.cron("not expressible as cron (last day of month not supported)")
78
+ else
79
+ raise HronError.cron("not expressible as cron (last weekday of month not supported)")
80
+ end
81
+
82
+ when OrdinalRepeat
83
+ raise HronError.cron("not expressible as cron (ordinal weekday of month not supported)")
84
+
85
+ when SingleDateExpr
86
+ raise HronError.cron("not expressible as cron (single dates are not repeating)")
87
+
88
+ when YearRepeat
89
+ raise HronError.cron("not expressible as cron (yearly schedules not supported in 5-field cron)")
90
+
91
+ else
92
+ raise HronError.cron("unknown expression type: #{expr.class}")
93
+ end
94
+ end
95
+
96
+ def self.day_filter_to_cron_dow(filter)
97
+ case filter
98
+ when DayFilterEvery
99
+ "*"
100
+ when DayFilterWeekday
101
+ "1-5"
102
+ when DayFilterWeekend
103
+ "0,6"
104
+ when DayFilterDays
105
+ nums = filter.days.map { |d| Weekday.cron_dow(d) }.sort
106
+ nums.join(",")
107
+ end
108
+ end
109
+
110
+ def self.from_cron(cron_str)
111
+ fields = cron_str.strip.split
112
+ raise HronError.cron("expected 5 cron fields, got #{fields.length}") if fields.length != 5
113
+
114
+ minute_field, hour_field, dom_field, _month_field, dow_field = fields
115
+
116
+ # Minute interval: */N
117
+ if minute_field.start_with?("*/")
118
+ interval_str = minute_field[2..]
119
+ begin
120
+ interval = Integer(interval_str)
121
+ rescue ArgumentError
122
+ raise HronError.cron("invalid minute interval")
123
+ end
124
+
125
+ from_hour = 0
126
+ to_hour = 23
127
+
128
+ if hour_field == "*"
129
+ # full day
130
+ elsif hour_field.include?("-")
131
+ parts = hour_field.split("-")
132
+ begin
133
+ from_hour = Integer(parts[0])
134
+ to_hour = Integer(parts[1])
135
+ rescue ArgumentError, IndexError
136
+ raise HronError.cron("invalid hour range")
137
+ end
138
+ else
139
+ begin
140
+ h = Integer(hour_field)
141
+ rescue ArgumentError
142
+ raise HronError.cron("invalid hour")
143
+ end
144
+ from_hour = h
145
+ to_hour = h
146
+ end
147
+
148
+ day_filter = (dow_field == "*") ? nil : parse_cron_dow(dow_field)
149
+
150
+ if dom_field == "*"
151
+ return ScheduleData.new(
152
+ expr: IntervalRepeat.new(
153
+ interval,
154
+ IntervalUnit::MIN,
155
+ TimeOfDay.new(from_hour, 0),
156
+ TimeOfDay.new(to_hour, (to_hour == 23) ? 59 : 0),
157
+ day_filter
158
+ )
159
+ )
160
+ end
161
+ end
162
+
163
+ # Hour interval: 0 */N
164
+ if hour_field.start_with?("*/") && minute_field == "0"
165
+ interval_str = hour_field[2..]
166
+ begin
167
+ interval = Integer(interval_str)
168
+ rescue ArgumentError
169
+ raise HronError.cron("invalid hour interval")
170
+ end
171
+ if dom_field == "*" && dow_field == "*"
172
+ return ScheduleData.new(
173
+ expr: IntervalRepeat.new(
174
+ interval,
175
+ IntervalUnit::HOURS,
176
+ TimeOfDay.new(0, 0),
177
+ TimeOfDay.new(23, 59),
178
+ nil
179
+ )
180
+ )
181
+ end
182
+ end
183
+
184
+ # Standard time-based cron
185
+ begin
186
+ minute = Integer(minute_field)
187
+ rescue ArgumentError
188
+ raise HronError.cron("invalid minute field: #{minute_field}")
189
+ end
190
+ begin
191
+ hour = Integer(hour_field)
192
+ rescue ArgumentError
193
+ raise HronError.cron("invalid hour field: #{hour_field}")
194
+ end
195
+ t = TimeOfDay.new(hour, minute)
196
+
197
+ # DOM-based (monthly)
198
+ if dom_field != "*" && dow_field == "*"
199
+ raise HronError.cron("DOM ranges not supported: #{dom_field}") if dom_field.include?("-")
200
+
201
+ day_nums = []
202
+ dom_field.split(",").each do |s|
203
+ begin
204
+ n = Integer(s)
205
+ rescue ArgumentError
206
+ raise HronError.cron("invalid DOM field: #{dom_field}")
207
+ end
208
+ day_nums << n
209
+ end
210
+ specs = day_nums.map { |d| SingleDay.new(d) }
211
+ return ScheduleData.new(
212
+ expr: MonthRepeat.new(1, DaysTarget.new(specs), [t])
213
+ )
214
+ end
215
+
216
+ # DOW-based (day repeat)
217
+ days = parse_cron_dow(dow_field)
218
+ expr = DayRepeat.new(1, days, [t])
219
+ ScheduleData.new(expr: expr)
220
+ end
221
+
222
+ def self.parse_cron_dow(field)
223
+ return DayFilterEvery.new if field == "*"
224
+ return DayFilterWeekday.new if field == "1-5"
225
+ return DayFilterWeekend.new if ["0,6", "6,0"].include?(field)
226
+
227
+ raise HronError.cron("DOW ranges not supported: #{field}") if field.include?("-")
228
+
229
+ nums = []
230
+ field.split(",").each do |s|
231
+ begin
232
+ n = Integer(s)
233
+ rescue ArgumentError
234
+ raise HronError.cron("invalid DOW field: #{field}")
235
+ end
236
+ nums << n
237
+ end
238
+
239
+ days = nums.map { |n| cron_dow_to_weekday(n) }
240
+ DayFilterDays.new(days)
241
+ end
242
+
243
+ def self.cron_dow_to_weekday(n)
244
+ result = CRON_DOW_MAP[n]
245
+ raise HronError.cron("invalid DOW number: #{n}") unless result
246
+
247
+ result
248
+ end
249
+ end
250
+ end