Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Montrose ical RRULE parsing #139

Merged
merged 33 commits into from
Feb 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d30098a
Add Montrose::Recurrence.from_yaml
rossta Jul 18, 2019
ffa7098
Partially implement Recurrence#from_ical
rossta Jul 18, 2019
f049de8
Extract Montrose::Month for month number parsing
rossta Feb 5, 2021
e479ae7
Extract Montrose::Day for day number parsing
rossta Feb 5, 2021
247c4dd
Additional implementation of ICAL RRULE parsing
rossta Feb 5, 2021
f183aff
Add ical spec
rossta Feb 6, 2021
4ec279c
Add ical spec
rossta Feb 6, 2021
93908bc
Add ical spec
rossta Feb 6, 2021
2676958
Add week_start option
rossta Feb 6, 2021
ff73e4d
Add ical spec
rossta Feb 6, 2021
bf5d62a
Add and update ical specs
rossta Feb 6, 2021
90b44ad
Add ical spec
rossta Feb 6, 2021
b97cae2
Extract and expand day parsing to include ical
rossta Feb 6, 2021
678d58d
Add ical spec
rossta Feb 6, 2021
c77cdf5
Extract Month parsing
rossta Feb 6, 2021
5452251
Add ical spec
rossta Feb 6, 2021
e4662a0
Add ical spec
rossta Feb 6, 2021
136e1d8
Extract MonthDay parsing
rossta Feb 6, 2021
7623a6d
Add ical specs
rossta Feb 6, 2021
e3e5b34
Add ical specs
rossta Feb 6, 2021
c232d6c
Extract YearDay parsing
rossta Feb 6, 2021
07fa755
Update method references
rossta Feb 6, 2021
1ef0ef3
Remove method
rossta Feb 7, 2021
6d318ad
Extract Week number parsing
rossta Feb 7, 2021
77ee654
Add ical spec for exdate
rossta Feb 7, 2021
0995291
Add ical specs
rossta Feb 7, 2021
c104d46
Add ical spec
rossta Feb 7, 2021
256aa3d
Support BYHOUR and BYMINUTE ical rrules
rossta Feb 7, 2021
96ebd0f
Add ical spec
rossta Feb 7, 2021
72b7b0e
Make ical parsing time zone aware
rossta Feb 7, 2021
7aeabee
Use to_a for Enumerator
rossta Feb 7, 2021
05c73ed
Update specs to use explicit tz for expected dates
rossta Feb 7, 2021
ab12943
Address Minitest 6 deprecation warnings
rossta Feb 7, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/montrose.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
require "montrose/version"

module Montrose
autoload :Minute, "montrose/minute"
autoload :Hour, "montrose/hour"
autoload :Day, "montrose/day"
autoload :Month, "montrose/month"
autoload :MonthDay, "montrose/month_day"
autoload :YearDay, "montrose/year_day"
autoload :Week, "montrose/week"

extend Chainable

class << self
Expand Down
83 changes: 83 additions & 0 deletions lib/montrose/day.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
module Montrose
class Day
extend Montrose::Utils

NAMES = ::Date::DAYNAMES
TWO_LETTER_ABBREVIATIONS = %w[SU MO TU WE TH FR SA].freeze
THREE_LETTER_ABBREVIATIONS = %w[SUN MON TUE WED THU FRI SAT]
NUMBERS = NAMES.map.with_index { |_n, i| i.to_s }

ICAL_MATCH = /(?<ordinal>[+-]?\d+)?(?<day>[A-Z]{2})/ # e.g. 1FR

class << self
def parse(arg)
case arg
when Hash
parse_entries(arg.entries)
when String
parse(arg.split(","))
else
parse_entries(map_arg(arg) { |value| parse_value(value) })
end
end

def parse_entries(entries)
hash = Hash.new { |h, k| h[k] = [] }
result = entries.each_with_object(hash) { |(k, v), hash|
index = number!(k)
hash[index] = hash[index] + [*v]
}
result.values.all?(&:empty?) ? result.keys : result
end

def parse_value(value)
parse_ical(value) || [number!(value), nil]
end

def parse_ical(value)
(match = ICAL_MATCH.match(value.to_s)) || (return nil)
index = number!(match[:day])
ordinal = match[:ordinal]&.to_i
[index, ordinal]
end

def map_arg(arg, &block)
return nil unless arg.present?

Array(arg).map(&block)
end

def names
NAMES
end

def number(name)
case name
when 0..6
name
when Symbol, String
string = name.to_s.downcase
NAMES.index(string.titleize) ||
TWO_LETTER_ABBREVIATIONS.index(string.upcase) ||
THREE_LETTER_ABBREVIATIONS.index(string.upcase) ||
number(to_index(string))
when Array
number name.first
end
end

def number!(name)
number(name) || raise(ConfigurationError,
"Did not recognize day #{name}, must be one of #{(names + abbreviations + numbers).inspect}")
end

def numbers
NUMBERS
end

def abbreviations
TWO_LETTER_ABBREVIATIONS + THREE_LETTER_ABBREVIATIONS
end
end
end
end
68 changes: 52 additions & 16 deletions lib/montrose/frequency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,60 @@ class Frequency

attr_reader :time, :starts

# Factory method for instantiating the appropriate Frequency
# subclass.
#
def self.from_options(opts)
frequency = opts.fetch(:every) { fail ConfigurationError, "Please specify the :every option" }
class_name = FREQUENCY_TERMS.fetch(frequency.to_s) {
fail "Don't know how to enumerate every: #{frequency}"
}

Montrose::Frequency.const_get(class_name).new(opts)
end
class << self
def parse(input)
if input.respond_to?(:parts)
frequency, interval = duration_to_frequency_parts(input)
{every: frequency.to_s.singularize.to_sym, interval: interval}
elsif input.is_a?(Numeric)
frequency, interval = numeric_to_frequency_parts(input)
{every: frequency, interval: interval}
else
{every: Frequency.assert(input)}
end
end

# Factory method for instantiating the appropriate Frequency
# subclass.
#
def from_options(opts)
frequency = opts.fetch(:every) { fail ConfigurationError, "Please specify the :every option" }
class_name = FREQUENCY_TERMS.fetch(frequency.to_s) {
fail "Don't know how to enumerate every: #{frequency}"
}

Montrose::Frequency.const_get(class_name).new(opts)
end

def from_term(term)
FREQUENCY_TERMS.invert.map { |k, v| [k.downcase, v] }.to_h.fetch(term.downcase) do
fail "Don't know how to convert #{term} to a Montrose frequency"
end
end

# @private
def assert(frequency)
FREQUENCY_TERMS.key?(frequency.to_s) || fail(ConfigurationError,
"Don't know how to enumerate every: #{frequency}")

frequency.to_sym
end

# @private
def self.assert(frequency)
FREQUENCY_TERMS.key?(frequency.to_s) || fail(ConfigurationError,
"Don't know how to enumerate every: #{frequency}")
# @private
def numeric_to_frequency_parts(number)
parts = nil
%i[year month week day hour minute].each do |freq|
div, mod = number.divmod(1.send(freq))
parts = [freq, div]
return parts if mod.zero?
end
parts
end

frequency.to_sym
# @private
def duration_to_frequency_parts(duration)
duration.parts.first
end
end

def initialize(opts = {})
Expand Down
22 changes: 22 additions & 0 deletions lib/montrose/hour.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Montrose
class Hour
HOURS_IN_DAY = 1.upto(24).to_a.freeze

class << self
def parse(arg)
case arg
when String
parse(arg.split(","))
else
Array(arg).map { |h| assert(h.to_i) }.presence
end
end

def assert(hour)
raise ConfigurationError, "Out of range: #{HOURS_IN_DAY.inspect} does not include #{hour}" unless HOURS_IN_DAY.include?(hour)

hour
end
end
end
end
100 changes: 100 additions & 0 deletions lib/montrose/ical.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

require "montrose/frequency"

module Montrose
class ICal
# DTSTART;TZID=US-Eastern:19970902T090000
# RRULE:FREQ=DAILY;INTERVAL=2
def self.parse(ical)
new(ical).parse
end

def initialize(ical)
@ical = ical
end

def parse
dtstart, rrule = @ical.split("RRULE:")
dtstart, exdate = dtstart.split("\n")
_label, time_string = (dtstart || "").split(";")
time_zone = parse_timezone(time_string)

Time.use_zone(time_zone) do
Hash[
*parse_dtstart(dtstart) +
parse_exdate(exdate) +
parse_rrule(rrule)
]
end
end

private

def parse_timezone(time_string)
time_zone_rule, _ = (time_string || "").split(":")
_label, time_zone = (time_zone_rule || "").split("=")
time_zone
end

def parse_dtstart(dtstart)
return [] unless dtstart.present?

_label, time_string = dtstart.split(";")
@starts_at = parse_ical_time(time_string)

[:starts, @starts_at]
end

def parse_ical_time(time_string)
time_zone = parse_timezone(time_string)

Montrose::Utils.parse_time(time_string).in_time_zone(time_zone)
end

def parse_exdate(exdate)
return [] unless exdate.present?

_label, date_string = exdate.split(";")
@except = Montrose::Utils.as_date(date_string) # only currently supports dates

[:except, @except]
end

def parse_rrule(rrule)
rrule.gsub(/\s+/, "").split(";").flat_map do |rule|
prop, value = rule.split("=")
case prop
when "FREQ"
[:every, Montrose::Frequency.from_term(value)]
when "INTERVAL"
[:interval, value.to_i]
when "COUNT"
[:total, value.to_i]
when "UNTIL"
[:until, parse_ical_time(value)]
when "BYMINUTE"
[:minute, Montrose::Minute.parse(value)]
when "BYHOUR"
[:hour, Montrose::Hour.parse(value)]
when "BYMONTH"
[:month, Montrose::Month.parse(value)]
when "BYDAY"
[:day, Montrose::Day.parse(value)]
when "BYMONTHDAY"
[:mday, Montrose::MonthDay.parse(value)]
when "BYYEARDAY"
[:yday, Montrose::YearDay.parse(value)]
when "BYWEEKNO"
[:week, Montrose::Week.parse(value)]
when "WKST"
[:week_start, value]
when "BYSETPOS"
warn "BYSETPOS not currently supported!"
else
raise "Unrecognized rrule '#{rule}'"
end
end
end
end
end
22 changes: 22 additions & 0 deletions lib/montrose/minute.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Montrose
class Minute
MINUTES_IN_HOUR = 0.upto(59).to_a.freeze

class << self
def parse(arg)
case arg
when String
parse(arg.split(","))
else
Array(arg).map { |m| assert(m.to_i) }.presence
end
end

def assert(minute)
raise ConfigurationError, "Out of range: #{MINUTES_IN_HOUR.inspect} does not include #{minute}" unless MINUTES_IN_HOUR.include?(minute)

minute
end
end
end
end
47 changes: 47 additions & 0 deletions lib/montrose/month.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Montrose
class Month
extend Montrose::Utils

NAMES = ::Date::MONTHNAMES # starts with nil to match 1-12 numbering
NUMBERS = NAMES.map.with_index { |_n, i| i.to_s }.slice(1, 12)

class << self
def parse(value)
case value
when String
parse(value.split(",").compact)
when Array
value.map { |m|
Montrose::Month.number!(m)
}.presence
else
parse(Array(value))
end
end

def names
NAMES
end

def numbers
NUMBERS
end

def number(name)
case name
when Symbol, String
string = name.to_s
NAMES.index(string.titleize) || number(to_index(string))
when 1..12
name
end
end

def number!(name)
numbers = NAMES.map.with_index { |_n, i| i.to_s }.slice(1, 12)
number(name) || raise(ConfigurationError,
"Did not recognize month #{name}, must be one of #{(NAMES + numbers).inspect}")
end
end
end
end
25 changes: 25 additions & 0 deletions lib/montrose/month_day.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module Montrose
class MonthDay
class << self
MDAYS = (-31.upto(-1).to_a + 1.upto(31).to_a)

def parse(mdays)
return nil unless mdays.present?

case mdays
when String
parse(mdays.split(","))
else
Array(mdays).map { |d| assert(d.to_i) }
end
end

def assert(number)
test = number.abs
raise ConfigurationError, "Out of range: #{MDAYS.inspect} does not include #{test}" unless MDAYS.include?(number.abs)

number
end
end
end
end
Loading