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 +7 -0
- data/README.md +93 -0
- data/lib/on_calendar/condition/base.rb +125 -0
- data/lib/on_calendar/condition/day_of_month.rb +19 -0
- data/lib/on_calendar/condition/day_of_week.rb +15 -0
- data/lib/on_calendar/condition/hour.rb +9 -0
- data/lib/on_calendar/condition/minute.rb +9 -0
- data/lib/on_calendar/condition/month.rb +9 -0
- data/lib/on_calendar/condition/second.rb +9 -0
- data/lib/on_calendar/condition/year.rb +21 -0
- data/lib/on_calendar/condition.rb +16 -0
- data/lib/on_calendar/parser.rb +251 -0
- data/lib/on_calendar/segment.rb +100 -0
- data/lib/on_calendar/version.rb +5 -0
- data/lib/on_calendar.rb +10 -0
- metadata +72 -0
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,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
|
data/lib/on_calendar.rb
ADDED
|
@@ -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: []
|