on_calendar 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2c57fa5ccc8be0efadca1c9ada15ffd43a3de7e67fb2dbbc16537459aff80387
4
+ data.tar.gz: 9dfc9ecf972a21f98ba3f91c6b0cfc73a0689a6d4e58faf952c3594d6f5b870f
5
+ SHA512:
6
+ metadata.gz: 0f306dc43ba739c83a2a4f557cb6b04316cf5f3f7d3cee6dbd7166b19fb61a7a86334265ef651c9e83a9f41c4e8e2014fa4e99fb583b777b94dae6fd2c1d16f5
7
+ data.tar.gz: 604225b8e299e52a50867821e295a3aa216b8118da982f790ff2174d5980709f72572f40b519d58f2bc995ba84614eefc37c18c0c8667b2f569a4e7089a83e83
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # OnCalendar
2
+
3
+ Provides a library to parse [Systemd.Time calendar expressions](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Calendar%20Events) and determine future iterations.
4
+
5
+ ## Getting started
6
+
7
+ Add OnCalendar to your project:
8
+
9
+ ```bash
10
+ gem install on_calendar
11
+ ```
12
+ ```ruby
13
+ require "on_calendar"
14
+ ```
15
+ Or using bundler:
16
+
17
+ ```bash
18
+ bundle add on_calendar
19
+ ```
20
+
21
+ Parse your expression, should your expression be invalid a `OnCalendar::Parser::Error` will be raised:
22
+
23
+ ```ruby
24
+ OnCalendar::Parser.new("Mon,Wed,Fri 10:23")
25
+ => #<OnCalendar::Parser:0x00007f4725b55978
26
+
27
+ OnCalendar::Parser.new("00")
28
+ => # Exception: OnCalendar::Parser::Error
29
+ ```
30
+
31
+ Determine the next future iteration of this expression:
32
+
33
+ ```ruby
34
+ OnCalendar::Parser.new("Mon,Wed,Fri 10:23").next
35
+ => [2025-09-01 10:23:00.000000000 AEST +10:00]
36
+ ```
37
+
38
+ If you need to find multiple iterations, supply a count argument:
39
+
40
+ ```ruby
41
+ OnCalendar::Parser.new("Mon,Wed,Fri 10:23").next(10)
42
+ =>
43
+ [2025-09-01 10:23:00.000000000 AEST +10:00,
44
+ 2025-09-03 10:23:00.000000000 AEST +10:00,
45
+ 2025-09-05 10:23:00.000000000 AEST +10:00,
46
+ 2025-09-08 10:23:00.000000000 AEST +10:00,
47
+ 2025-09-10 10:23:00.000000000 AEST +10:00,
48
+ 2025-09-12 10:23:00.000000000 AEST +10:00,
49
+ 2025-09-15 10:23:00.000000000 AEST +10:00,
50
+ 2025-09-17 10:23:00.000000000 AEST +10:00,
51
+ 2025-09-19 10:23:00.000000000 AEST +10:00,
52
+ 2025-09-22 10:23:00.000000000 AEST +10:00]
53
+ ```
54
+
55
+ By default `next` will use the current time and zone, but you can time travel using the `clamp` argument to pin the starting time:
56
+
57
+ ```ruby
58
+ OnCalendar::Parser.new("Mon,Wed,Fri 10:23").next(10, clamp: Time.now - 20.years)
59
+ =>
60
+ [2005-08-31 10:23:00.000000000 AEST +10:00,
61
+ 2005-09-02 10:23:00.000000000 AEST +10:00,
62
+ 2005-09-05 10:23:00.000000000 AEST +10:00,
63
+ 2005-09-07 10:23:00.000000000 AEST +10:00,
64
+ 2005-09-09 10:23:00.000000000 AEST +10:00,
65
+ 2005-09-12 10:23:00.000000000 AEST +10:00,
66
+ 2005-09-14 10:23:00.000000000 AEST +10:00,
67
+ 2005-09-16 10:23:00.000000000 AEST +10:00,
68
+ 2005-09-19 10:23:00.000000000 AEST +10:00,
69
+ 2005-09-21 10:23:00.000000000 AEST +10:00]
70
+ ```
71
+
72
+ Timezones are supported in expressions and the `clamp` argument. However all calculated iterations will default to the timezone set in the expression. The local timezone will be used if nothing is specified.
73
+
74
+ ## Limitations
75
+
76
+ A few limitations do exist at this stage:
77
+
78
+ * No support for the `~` operator.
79
+ * Time can only be specified to seconds and not sub-second level.
80
+ * Timezones must be specified as IANA TZ identifiers (ie: Australia/Brisbane, not +1000)
81
+ * Determining past iterations of an expression.
82
+
83
+ ## Support
84
+
85
+ Should you find a bug or have ideas, feel free to open an issue and I'll do my best to get back to you.
86
+
87
+ ## License
88
+
89
+ This gem is available as open source under the terms of the [MIT License](LICENSE.txt).
90
+
91
+ ## Contribution guide
92
+
93
+ Pull requests are welcome! Please make sure you include tests for any changes.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Base
6
+ attr_reader :base, :step, :wildcard
7
+
8
+ def initialize(base: nil, step: nil, wildcard: false)
9
+ @base = base
10
+ @step = step
11
+ @wildcard = wildcard
12
+
13
+ raise OnCalendar::Condition::Error, "Must supply base or wildcard=true" if
14
+ base.nil? && !wildcard
15
+ raise OnCalendar::Condition::Error, "Condition base value #{base} outside of allowed range #{range}" unless
16
+ valid?
17
+ raise OnCalendar::Condition::Error, "Condition step value #{step} must be > 0 and < than #{range.max}" if
18
+ !step.nil? && (step == 0 || step > range.max)
19
+ end
20
+
21
+ # Some subclasses need more context for RANGE
22
+ def range
23
+ self.class::RANGE
24
+ end
25
+
26
+ # Match this condition
27
+ # - If wild card return true
28
+ # No step:
29
+ # - If within range true
30
+ # - Otherwise if base == argument
31
+ # With step:
32
+ # - Expand possible options to range.max
33
+ # does our argument match
34
+ def match?(part)
35
+ return true if wildcard
36
+
37
+ if step.nil?
38
+ return base.cover?(part) if base.is_a?(Range)
39
+
40
+ base == part
41
+ else
42
+ (base.is_a?(Range) ? base : (base..range.max)).step(step).to_a.include?(part)
43
+ end
44
+ end
45
+
46
+ # Validates whether value (if passed otherwise base) is acceptable
47
+ # Note: This is not context aware so you can pass it day 31 for a 30 day month and it will return true
48
+ def valid?(value: nil)
49
+ # Always yes for wildcard when value isn't supplied
50
+ return true if wildcard && value.nil?
51
+
52
+ value ||= base
53
+ case value
54
+ when Range
55
+ # Check range is within RANGE
56
+ return true if range.cover?(value)
57
+ else
58
+ # Check value is within RANGE
59
+ return true if range.include?(value)
60
+ end
61
+ false
62
+ end
63
+
64
+ # Get next distance to valid base, if we rotate through range we get distance to min
65
+ # Note: We need to pass range_args becaue some subclasses need the context (ie: day_of_month)
66
+ def distance_to_next(current, range_args: nil)
67
+ # If we have an invalid value no point continuing
68
+ return nil unless valid?(value: current)
69
+ # Wild card return +1
70
+ return 1 if wildcard
71
+
72
+ # Build array to find needle_index
73
+ arr = range_args.nil? ? range.to_a : range(**range_args).to_a
74
+ needle_index = arr.index(current)
75
+
76
+ return nil if needle_index.nil?
77
+
78
+ distance = 0
79
+
80
+ if step.nil?
81
+ # If we are dealing with a range and the current and current+1 within range
82
+ if base.is_a?(Range)
83
+ if base.cover?(current) &&
84
+ base.cover?(arr.fetch(needle_index + 1, nil))
85
+ # Set +1 index
86
+ target_index = needle_index + 1
87
+ else
88
+ # Otherwise set the index of the minimum acceptable value
89
+ target_index = arr.index(base.min)
90
+ end
91
+ else
92
+ # Set index of base value
93
+ target_index = arr.index(base)
94
+ end
95
+ # We have a step, we have to compare stepped array to get next distance otherwise min
96
+ else
97
+ # If base is range - we find the next value in base
98
+ if base.is_a?(Range)
99
+ stepped_arr = base.step(step).to_a
100
+ next_value = stepped_arr.bsearch { |x| x > current } || base.min
101
+ # Else we find next value in full RANGE
102
+ else
103
+ stepped_arr = (base..arr.max).step(step).to_a
104
+ next_value = stepped_arr.bsearch { |x| x > current } || arr.min
105
+ end
106
+ target_index = arr.index(next_value)
107
+ end
108
+
109
+ unless target_index.nil?
110
+ # Lets work out distance between target_index and needle_index
111
+ if needle_index < target_index
112
+ # If the needle is before target get how many steps we need to step
113
+ distance = target_index - needle_index
114
+ else
115
+ # If is in front of us loop over until start of array
116
+ # Note: This sounds counter intuitive - why not give the distance until the next value
117
+ # However this forces us to re-evaluate all other date parts otherwise we might jump forward too far
118
+ distance = arr.length - needle_index
119
+ end
120
+ end
121
+ distance
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class DayOfMonth < Base
6
+ RANGE = (1..31)
7
+
8
+ # NOTE: by default we validate number in default range but this needs to be context aware
9
+ # because not all months have the same number of days
10
+ def range(year: nil, month: nil)
11
+ if year.nil? || month.nil?
12
+ RANGE
13
+ else
14
+ (RANGE.min..Time.days_in_month(month, year))
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class DayOfWeek < Base
6
+ RANGE = (0..6)
7
+
8
+ # Utility function to pass our min,max range to the segment parser
9
+ # this helps dealing with when the parser comes back with 6..0
10
+ def self.range_bounds
11
+ RANGE.minmax
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Hour < Base
6
+ RANGE = (0..23)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Minute < Base
6
+ RANGE = (0..59)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Month < Base
6
+ RANGE = (1..12)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Second < Base
6
+ RANGE = (0..59)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Year < Base
6
+ RANGE = (1970..2200)
7
+
8
+ def initialize(base: nil, step: nil, wildcard: false)
9
+ # Translate short year to long
10
+ unless base.nil?
11
+ if (0..69).cover?(base)
12
+ base += 2000
13
+ elsif (70..99).cover?(base)
14
+ base += 1900
15
+ end
16
+ end
17
+ super
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Condition
5
+ class Error < StandardError; end
6
+
7
+ autoload :Base, "on_calendar/condition/base"
8
+ autoload :Hour, "on_calendar/condition/hour"
9
+ autoload :Minute, "on_calendar/condition/minute"
10
+ autoload :Second, "on_calendar/condition/second"
11
+ autoload :Year, "on_calendar/condition/year"
12
+ autoload :Month, "on_calendar/condition/month"
13
+ autoload :DayOfMonth, "on_calendar/condition/day_of_month"
14
+ autoload :DayOfWeek, "on_calendar/condition/day_of_week"
15
+ end
16
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ class Parser
5
+ class Error < StandardError; end
6
+
7
+ MAX_ITERATIONS = 100
8
+ TIME_SEP_CHAR = ":"
9
+ DATE_SEP_CHAR = "-"
10
+ DATETIME_SEP_CHAR = "T"
11
+ DATETIME_SEP_REGEX = /\d|\*#{DATETIME_SEP_CHAR}\d|\*/
12
+ SPECIAL_EXPRESSIONS = {
13
+ minutely: "*-*-* *:*:00",
14
+ hourly: "*-*-* *:00:00",
15
+ daily: "*-*-* 00:00:00",
16
+ monthly: "*-*-01 00:00:00",
17
+ weekly: "Mon *-*-* 00:00:00",
18
+ yearly: "*-01-01 00:00:00",
19
+ quarterly: "*-01,04,07,10-01 00:00:00",
20
+ semiannually: "*-01,07-01 00:00:00"
21
+ }.freeze
22
+
23
+ attr_reader :expression, :timezone, :years, :months, :days_of_month,
24
+ :days_of_week, :hours, :minutes, :seconds
25
+
26
+ def initialize(expression)
27
+ parse(expression)
28
+ @expression = expression
29
+ end
30
+
31
+ def next(count=1, clamp: timezone.now)
32
+ raise OnCalendar::Parser::Error, "Clamp must be instance of Time" unless clamp.is_a?(Time)
33
+
34
+ # Translate to correct timezone
35
+ clamp = clamp.in_time_zone(timezone)
36
+
37
+ results = []
38
+ count.times do
39
+ result = iterate(clamp: clamp)
40
+ break if result.nil?
41
+
42
+ clamp = result + 1.second
43
+ results << result
44
+ end
45
+ results.empty? ? nil : results
46
+ end
47
+
48
+ def matches_any_conditions?(field:, base:)
49
+ send(field).each do |condition|
50
+ return true if condition.match?(base)
51
+ end
52
+ false
53
+ end
54
+
55
+ private
56
+
57
+ def iterate(clamp:)
58
+ iterations = 0
59
+
60
+ while true
61
+ # Fail safe
62
+ if iterations >= MAX_ITERATIONS
63
+ raise OnCalendar::Parser::Error, "Too many iterations: #{MAX_ITERATIONS}. Something has gone wrong."
64
+ end
65
+
66
+ iterations += 1
67
+
68
+ # Loop over segments:
69
+ # a) If we don't match any condition for that segment
70
+ # b) Find all the next distances for a possible match
71
+ # if only nil distances return nil - not possible to compute
72
+ # c) Advance clamp by the minimum distance found while resetting child segments
73
+ field_manipulation = false
74
+ {
75
+ years: {
76
+ base_method: :year,
77
+ changes: { month: 1, day: 1, hour: 0, min: 0, sec: 0 }
78
+ },
79
+ months: {
80
+ base_method: :month,
81
+ changes: { day: 1, hour: 0, min: 0, sec: 0 }
82
+ },
83
+ days_of_month: {
84
+ base_method: :day,
85
+ changes: { hour: 0, min: 0, sec: 0 },
86
+ increment_method: :days,
87
+ range_args: ->(clamp) { { year: clamp.year, month: clamp.month } }
88
+ },
89
+ days_of_week: {
90
+ base_method: :wday,
91
+ changes: { hour: 0, min: 0, sec: 0 },
92
+ increment_method: :days
93
+ },
94
+ hours: {
95
+ base_method: :hour,
96
+ changes: { min: 0, sec: 0 }
97
+ },
98
+ minutes: {
99
+ base_method: :min,
100
+ changes: { sec: 0 }
101
+ },
102
+ seconds: {
103
+ base_method: :sec
104
+ }
105
+ }.each do |field, values|
106
+ # Do we miss all condition matches - thus increment
107
+ next if matches_any_conditions?(field: field, base: clamp.send(values[:base_method]))
108
+
109
+ # Do we need any range arguments? If so calculate
110
+ range_args = values[:range_args].call(clamp) if values.key?(:range_args) || nil
111
+ # Determine distances required to jump to next match
112
+ distances = send(field).map do |condition|
113
+ condition.distance_to_next(clamp.send(values[:base_method]), range_args: range_args)
114
+ end.sort!
115
+ # Check for only nil - if so impossible to compute bail
116
+ return nil if distances.compact.empty?
117
+
118
+ # Increment by field method
119
+ method = values[:increment_method] || field
120
+ clamp = (clamp + distances.min.send(method))
121
+ # Reset desired fields
122
+ clamp = clamp.change(**values[:changes]) if values.key?(:changes)
123
+ # Force re-check everything by marking manipulation
124
+ field_manipulation = true
125
+ break
126
+ end
127
+
128
+ # If we have manipulated a field - we need to re-check, re-loop
129
+ # otherwise we break out because we have a result
130
+ field_manipulation ? next : break
131
+ end
132
+
133
+ clamp
134
+ end
135
+
136
+ def parse(expression)
137
+ # Split string on white space and reverse
138
+ segments = expression.split.reverse
139
+
140
+ # Detect if we have time zone
141
+ @timezone = parse_timezone(segments.first)
142
+ # Default timezone if no result - otherwise remove first segment
143
+ if @timezone.nil?
144
+ @timezone = ActiveSupport::TimeZone[Time.now.gmt_offset].tzinfo
145
+ else
146
+ segments.shift
147
+ end
148
+
149
+ # Detect if expression is special and override segments
150
+ if segments.length == 1
151
+ special = segments.first.downcase
152
+ SPECIAL_EXPRESSIONS.each do |k, v|
153
+ segments = v.split.reverse if special == k.to_s
154
+ end
155
+ end
156
+
157
+ # Split on 'T' separator if it exists
158
+ segments.prepend(*segments.shift.split(DATETIME_SEP_CHAR).reverse) if
159
+ segments.first.match?(DATETIME_SEP_REGEX)
160
+
161
+ # Check and parse time (default 00:00:00 otherwise)
162
+ time_expression = segments.first.include?(TIME_SEP_CHAR) ? segments.shift : "00:00:00"
163
+ @hours, @minutes, @seconds = parse_time(time_expression)
164
+
165
+ # Check we have more segments, with date separator and start with number or wildcard
166
+ if !segments.empty? && segments.first.include?(DATE_SEP_CHAR) && segments.first =~ /\A\d|\*/
167
+ @years, @months, @days_of_month = parse_date(segments.shift)
168
+ else
169
+ @years, @months, @days_of_month = parse_date("*-*-*")
170
+ end
171
+
172
+ # Parse days of week
173
+ @days_of_week = parse_day_of_week(segments.empty? ? "*" : segments.shift)
174
+
175
+ # If we have remaining parts something went wrong
176
+ raise OnCalendar::Parser::Error, "Expression parts not parsed: #{segments}" unless segments.empty?
177
+ end
178
+
179
+ def parse_time(expression)
180
+ # Split and check we have enough parts
181
+ segments = expression.split(TIME_SEP_CHAR)
182
+ raise Error, "Time component is malformed" unless
183
+ (2..3).cover?(segments.length)
184
+
185
+ # If seconds do not exist default to 00
186
+ segments << "00" if segments.length == 2
187
+
188
+ # Build conditions
189
+ build_conditions(
190
+ items: %i[Hour Minute Second],
191
+ segments: segments
192
+ )
193
+ end
194
+
195
+ def parse_date(expression)
196
+ # Split and check we have enough parts
197
+ segments = expression.split(DATE_SEP_CHAR)
198
+ raise Error, "Date component is malformed" unless
199
+ (2..3).cover?(segments.length)
200
+
201
+ # If year do not exist default to *
202
+ segments.unshift "*" if segments.length == 2
203
+
204
+ # Build conditions
205
+ build_conditions(
206
+ items: %i[Year Month DayOfMonth],
207
+ segments: segments
208
+ )
209
+ end
210
+
211
+ def parse_day_of_week(expression)
212
+ conditions = build_conditions(
213
+ items: [:DayOfWeek],
214
+ segments: [expression]
215
+ )
216
+ # NOTE: We cheat here and flatten array due to single field
217
+ conditions.first if conditions.is_a?(Array)
218
+ end
219
+
220
+ def parse_timezone(expression)
221
+ TZInfo::Timezone.get(expression)
222
+ rescue TZInfo::InvalidTimezoneIdentifier
223
+ nil
224
+ end
225
+
226
+ def build_conditions(items:, segments:)
227
+ conditions = []
228
+ items.each_with_index do |klass, idx|
229
+ # Help work with special ranges 6..0
230
+ min, max = OnCalendar::Condition.const_get(klass).try(:range_bounds) || nil
231
+ # Parse this segment
232
+ begin
233
+ parsed = OnCalendar::Segment.parse(segments.shift, max: max, min: min)
234
+ rescue OnCalendar::Segment::Error => e
235
+ raise Error, e
236
+ end
237
+ if parsed.nil?
238
+ # We are a wild card
239
+ conditions[idx] = [OnCalendar::Condition.const_get(klass).new(wildcard: true)]
240
+ else
241
+ # Lets build conditions with parsed
242
+ conditions[idx] = []
243
+ parsed.each do |c|
244
+ conditions[idx] << OnCalendar::Condition.const_get(klass).new(**c)
245
+ end
246
+ end
247
+ end
248
+ conditions
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ module Segment
5
+ class Error < StandardError; end
6
+
7
+ module_function
8
+
9
+ WILDCARD_CHAR = "*"
10
+ LIST_CHAR = ","
11
+ RANGE_CHAR = ".."
12
+ STEP_CHAR = "/"
13
+ CHARS_REGEX = /\A[a-zA-Z]+\z/
14
+ NUMERIC_REGEX = /\A\d+\z/
15
+
16
+ # Take complex segment expressions and break it down into an array of bases (integer||range) and steps (integer)
17
+ def parse(expression, max: nil, min: nil)
18
+ # Any Value
19
+ return nil if expression == WILDCARD_CHAR || expression.nil? || expression.empty?
20
+
21
+ # Check if we have a list and break into segments
22
+ segments = []
23
+ if expression.include?(LIST_CHAR)
24
+ segments.concat(expression.split(LIST_CHAR))
25
+ else
26
+ segments << expression
27
+ end
28
+
29
+ # Lets parse each segment
30
+ results = []
31
+ segments.each do |segment|
32
+ step, bases = nil
33
+
34
+ # Parse step (if present)
35
+ segment, step = parse_step(segment) if segment.include?(STEP_CHAR)
36
+
37
+ # Parse range (if present)
38
+ if segment.include?(RANGE_CHAR)
39
+ bases = parse_range(segment, max: max, min: min)
40
+ else
41
+ # Default to 0 if wild card present
42
+ segment = "0" if segment == WILDCARD_CHAR
43
+ bases = [cast(segment)]
44
+ end
45
+
46
+ # We may end up with multiple bases so lets add each
47
+ bases&.each do |b|
48
+ results << { base: b, step: step }
49
+ end
50
+ end
51
+ results
52
+ end
53
+
54
+ # First weekday name to integer conversion, otherwise numerical to integer
55
+ def cast(expression)
56
+ # If only characters lets try day_of_week
57
+ if expression.match?(CHARS_REGEX)
58
+ begin
59
+ return Date.parse(expression).wday
60
+ rescue Date::Error
61
+ # We need to try parse weekday here - otherwise try for integer
62
+ end
63
+ end
64
+
65
+ # Otherwise try numerical
66
+ unless expression.match?(NUMERIC_REGEX)
67
+ raise OnCalendar::Segment::Error, "Character not allowed in expression: #{expression}"
68
+ end
69
+
70
+ expression.to_i
71
+ end
72
+
73
+ # Parse string range to real range (also deal with desc ranges - only weekdays)
74
+ def parse_range(expression, max: nil, min: nil)
75
+ start_val, end_val = expression.split(RANGE_CHAR)
76
+ raise OnCalendar::Segment::Error, "Invalid range detected #{expression}" if start_val.nil? || end_val.nil?
77
+
78
+ results = [(cast(start_val)..cast(end_val))]
79
+ # If we have a range like 6..0 we need to split these out
80
+ if results.first.first > results.first.last
81
+ # Only transform if we intended to
82
+ raise OnCalendar::Segment::Error, "Invalid range: #{results.first}" unless !max.nil? && !min.nil?
83
+
84
+ old_range = results.pop
85
+ # Add from start of range to max || max
86
+ results << (old_range.first == max ? max : (old_range.first..max))
87
+ # Add from min to end of range || min
88
+ results << (old_range.last == min ? min : (min..old_range.last))
89
+ end
90
+ results
91
+ end
92
+
93
+ # Parse a step out of a segment 1/5 = ["1", 5]
94
+ def parse_step(expression)
95
+ base, step = expression.split(STEP_CHAR)
96
+ step = cast(step) unless step.nil?
97
+ [base, step]
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnCalendar
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+
5
+ module OnCalendar
6
+ autoload :Version, "on_calendar/version"
7
+ autoload :Parser, "on_calendar/parser"
8
+ autoload :Condition, "on_calendar/condition"
9
+ autoload :Segment, "on_calendar/segment"
10
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: on_calendar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ben Passmore
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ email:
27
+ - contact@passbe.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - README.md
33
+ - lib/on_calendar.rb
34
+ - lib/on_calendar/condition.rb
35
+ - lib/on_calendar/condition/base.rb
36
+ - lib/on_calendar/condition/day_of_month.rb
37
+ - lib/on_calendar/condition/day_of_week.rb
38
+ - lib/on_calendar/condition/hour.rb
39
+ - lib/on_calendar/condition/minute.rb
40
+ - lib/on_calendar/condition/month.rb
41
+ - lib/on_calendar/condition/second.rb
42
+ - lib/on_calendar/condition/year.rb
43
+ - lib/on_calendar/parser.rb
44
+ - lib/on_calendar/segment.rb
45
+ - lib/on_calendar/version.rb
46
+ homepage: https://github.com/passbe/on_calendar
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ bug_tracker_uri: https://github.com/passbe/on_calendar/issues
51
+ changelog_uri: https://github.com/passbe/on_calendar/releases
52
+ source_code_uri: https://github.com/passbe/on_calendar
53
+ homepage_uri: https://github.com/passbe/on_calendar
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.1'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.6.9
70
+ specification_version: 4
71
+ summary: Parser for OnCalendar expressions used by systemd time.
72
+ test_files: []