montrose 0.12.0 → 0.13.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 +4 -4
- data/.circleci/config.yml +7 -6
- data/Appraisals +7 -3
- data/CHANGELOG.md +24 -0
- data/README.md +20 -15
- data/gemfiles/activesupport_5.2.gemfile +1 -1
- data/gemfiles/activesupport_6.0.gemfile +1 -1
- data/gemfiles/activesupport_6.1.gemfile +1 -1
- data/gemfiles/activesupport_7.0.gemfile +16 -0
- data/lib/montrose/chainable.rb +0 -1
- data/lib/montrose/day.rb +83 -0
- data/lib/montrose/frequency.rb +59 -26
- data/lib/montrose/hour.rb +22 -0
- data/lib/montrose/ical.rb +128 -0
- data/lib/montrose/minute.rb +22 -0
- data/lib/montrose/month.rb +47 -0
- data/lib/montrose/month_day.rb +25 -0
- data/lib/montrose/options.rb +56 -65
- data/lib/montrose/recurrence.rb +11 -5
- data/lib/montrose/rule/during.rb +7 -15
- data/lib/montrose/rule/minute_of_hour.rb +25 -0
- data/lib/montrose/rule/nth_day_of_month.rb +0 -2
- data/lib/montrose/rule/nth_day_of_year.rb +0 -2
- data/lib/montrose/rule/time_of_day.rb +1 -1
- data/lib/montrose/rule.rb +18 -16
- data/lib/montrose/stack.rb +1 -2
- data/lib/montrose/time_of_day.rb +48 -0
- data/lib/montrose/utils.rb +0 -38
- data/lib/montrose/version.rb +1 -1
- data/lib/montrose/week.rb +20 -0
- data/lib/montrose/year_day.rb +25 -0
- data/lib/montrose.rb +20 -8
- data/montrose.gemspec +2 -2
- metadata +19 -8
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: c4b7914b70270912f8acf6757a8f130112d9137c50c34b933714f0edf97ac3c1
         | 
| 4 | 
            +
              data.tar.gz: d70713d758bf62b45a7a74196f3d2c5bcc0a4bcf48b899164d7b5d4e2afe6b4a
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 5ea9a0877d09b1d360c3de7dc9b68a4fd9b6f64047728991d0398333777c18afa06f12fba1b2e178f53bd412f054314b125a174099a896c554cc53a337672fb5
         | 
| 7 | 
            +
              data.tar.gz: f0b7431307846f0fa969352cfb98101ae2542ad37dd443e8324dd1480b01edfa81f8c06f92b6dc96f4278410f7cb221b7a1c1a487241a0faf8f5ee07fd763db3
         | 
    
        data/.circleci/config.yml
    CHANGED
    
    | @@ -1,6 +1,6 @@ | |
| 1 1 | 
             
            version: 2.1
         | 
| 2 2 | 
             
            orbs:
         | 
| 3 | 
            -
              ruby: circleci/ruby@1. | 
| 3 | 
            +
              ruby: circleci/ruby@1.3.0
         | 
| 4 4 | 
             
            commands:
         | 
| 5 5 | 
             
              run-tests:
         | 
| 6 6 | 
             
                description: Run tests
         | 
| @@ -37,7 +37,7 @@ jobs: | |
| 37 37 | 
             
                  gemfile:
         | 
| 38 38 | 
             
                    type: string
         | 
| 39 39 | 
             
                docker:
         | 
| 40 | 
            -
                  - image: ' | 
| 40 | 
            +
                  - image: 'cimg/ruby:<< parameters.ruby_version >>'
         | 
| 41 41 | 
             
                environment:
         | 
| 42 42 | 
             
                  GEMFILE_NAME: <<parameters.gemfile>>
         | 
| 43 43 | 
             
                steps:
         | 
| @@ -53,13 +53,14 @@ workflows: | |
| 53 53 | 
             
                  - test:
         | 
| 54 54 | 
             
                      matrix:
         | 
| 55 55 | 
             
                        parameters:
         | 
| 56 | 
            -
                          ruby_version: ['2. | 
| 56 | 
            +
                          ruby_version: ['2.6', '2.7', '3.0', '3.1']
         | 
| 57 57 | 
             
                          gemfile:
         | 
| 58 58 | 
             
                            [
         | 
| 59 59 | 
             
                              'gemfiles/activesupport_5.2.gemfile',
         | 
| 60 60 | 
             
                              'gemfiles/activesupport_6.0.gemfile',
         | 
| 61 61 | 
             
                              'gemfiles/activesupport_6.1.gemfile',
         | 
| 62 | 
            +
                              'gemfiles/activesupport_7.0.gemfile',
         | 
| 62 63 | 
             
                            ]
         | 
| 63 | 
            -
                         | 
| 64 | 
            -
             | 
| 65 | 
            -
             | 
| 64 | 
            +
                        exclude:
         | 
| 65 | 
            +
                          - ruby_version: '2.6'
         | 
| 66 | 
            +
                            gemfile: gemfiles/activesupport_7.0.gemfile
         | 
    
        data/Appraisals
    CHANGED
    
    | @@ -1,13 +1,17 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            +
            appraise "activesupport-7.0" do
         | 
| 4 | 
            +
              gem "activesupport", "~> 7.0"
         | 
| 5 | 
            +
            end
         | 
| 6 | 
            +
             | 
| 3 7 | 
             
            appraise "activesupport-6.1" do
         | 
| 4 | 
            -
              gem "activesupport", "~> 6.1 | 
| 8 | 
            +
              gem "activesupport", "~> 6.1"
         | 
| 5 9 | 
             
            end
         | 
| 6 10 |  | 
| 7 11 | 
             
            appraise "activesupport-6.0" do
         | 
| 8 | 
            -
              gem "activesupport", "~> 6.0 | 
| 12 | 
            +
              gem "activesupport", "~> 6.0"
         | 
| 9 13 | 
             
            end
         | 
| 10 14 |  | 
| 11 15 | 
             
            appraise "activesupport-5.2" do
         | 
| 12 | 
            -
              gem "activesupport", "~> 5.2 | 
| 16 | 
            +
              gem "activesupport", "~> 5.2"
         | 
| 13 17 | 
             
            end
         | 
    
        data/CHANGELOG.md
    CHANGED
    
    | @@ -1,3 +1,27 @@ | |
| 1 | 
            +
            ### 0.12.0 - (2021-02-02)
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            * enhancements
         | 
| 4 | 
            +
              * Adds `Montrose.covering` to disambiguate `Montrose.between` behavior
         | 
| 5 | 
            +
              `#covering` provides recurrence masking behavior, i.e., only recurrences
         | 
| 6 | 
            +
              within the given range will be emitted
         | 
| 7 | 
            +
              * Added support for ActiveSupport 6 and Ruby 2.7
         | 
| 8 | 
            +
              * Adds `Montrose#infinite?` and ensures `Montrose.finite?` returns a boolean
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            * bug fixes
         | 
| 11 | 
            +
              * Fixes `Recurrence#include?` behavior for infinite recurrences with
         | 
| 12 | 
            +
              intervals > 1
         | 
| 13 | 
            +
             | 
| 14 | 
            +
            * breaking changes
         | 
| 15 | 
            +
              * `Montrose.between` no longer provides masking behavior, which is now
         | 
| 16 | 
            +
              provided by `Montrose.covering`. A global option can be used
         | 
| 17 | 
            +
              `Montrose.enable_deprecated_between_masking = true` to retain the legacy
         | 
| 18 | 
            +
              behavior for `Montrose.between`. This option will be removed in v1.0.
         | 
| 19 | 
            +
              * Dropped official support for EOL'd rubies and ActiveSupport < 5.2
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            * miscellaneous
         | 
| 22 | 
            +
              * switched from Travis to CircleCi for builds
         | 
| 23 | 
            +
              * switched default branch to `main`
         | 
| 24 | 
            +
            #
         | 
| 1 25 | 
             
            ### 0.11.0 - (2019-08-16)
         | 
| 2 26 |  | 
| 3 27 | 
             
            * enhancements
         | 
    
        data/README.md
    CHANGED
    
    | @@ -4,10 +4,10 @@ | |
| 4 4 | 
             
            [](https://codeclimate.com/github/rossta/montrose)
         | 
| 5 5 | 
             
            [](https://coveralls.io/github/rossta/montrose?branch=master)
         | 
| 6 6 |  | 
| 7 | 
            -
            Montrose is an easy-to-use library for defining recurring events in Ruby. It uses a simple chaining system for building recurrences, inspired heavily by the design principles of [HTTP.rb](https://github.com/httprb/http) and rule definitions available in [Recurrence](https://github.com/fnando/recurrence).
         | 
| 7 | 
            +
            Montrose is an easy-to-use library for defining recurring events in Ruby. It uses a simple chaining system for building enumerable recurrences, inspired heavily by the design principles of [HTTP.rb](https://github.com/httprb/http) and rule definitions available in [Recurrence](https://github.com/fnando/recurrence).
         | 
| 8 8 |  | 
| 9 | 
            -
             | 
| 10 | 
            -
             | 
| 9 | 
            +
            - [Introductory blog post](http://bit.ly/1PA68Zb)
         | 
| 10 | 
            +
            - [NYC.rb
         | 
| 11 11 | 
             
              presentation](https://speakerdeck.com/rossta/recurring-events-with-montrose)
         | 
| 12 12 |  | 
| 13 13 | 
             
            ## Installation
         | 
| @@ -32,17 +32,17 @@ Dealing with recurring events is hard. `Montrose` provides a simple interface fo | |
| 32 32 |  | 
| 33 33 | 
             
            More specifically, this project intends to:
         | 
| 34 34 |  | 
| 35 | 
            -
             | 
| 36 | 
            -
             | 
| 37 | 
            -
             | 
| 38 | 
            -
             | 
| 39 | 
            -
             | 
| 40 | 
            -
             | 
| 35 | 
            +
            - model recurring events in Ruby
         | 
| 36 | 
            +
            - embrace Ruby idioms
         | 
| 37 | 
            +
            - support recent Rubies
         | 
| 38 | 
            +
            - be reasonably performant
         | 
| 39 | 
            +
            - serialize to yaml, hash, and [ical](http://www.kanzaki.com/docs/ical/rrule.html#basic) formats
         | 
| 40 | 
            +
            - be suitable for integration with persistence libraries
         | 
| 41 41 |  | 
| 42 42 | 
             
            What `Montrose` doesn't do:
         | 
| 43 43 |  | 
| 44 | 
            -
             | 
| 45 | 
            -
             | 
| 44 | 
            +
            - support all calendaring use cases under the sun
         | 
| 45 | 
            +
            - schedule recurring jobs for your Rails app. Use one of these instead: [cron](https://en.wikipedia.org/wiki/Cron), [rufus-scheduler](https://github.com/jmettraux/rufus-scheduler), [sidekiq-cron](https://github.com/ondrejbartas/sidekiq-cron), [sidetiq](https://github.com/tobiassvn/sidetiq), [whenever](https://github.com/javan/whenever)
         | 
| 46 46 |  | 
| 47 47 | 
             
            ## Concepts
         | 
| 48 48 |  | 
| @@ -392,13 +392,16 @@ end | |
| 392 392 | 
             
            # add after building
         | 
| 393 393 | 
             
            s << Montrose.yearly
         | 
| 394 394 | 
             
            ```
         | 
| 395 | 
            +
             | 
| 395 396 | 
             
            The `Schedule#<<` method also accepts valid recurrence options as hashes:
         | 
| 397 | 
            +
             | 
| 396 398 | 
             
            ```ruby
         | 
| 397 399 | 
             
            schedule = Montrose::Schedule.build do |s|
         | 
| 398 400 | 
             
              s << { day: { friday: [1] } }
         | 
| 399 401 | 
             
              s << { on: :tuesday }
         | 
| 400 402 | 
             
            end
         | 
| 401 403 | 
             
            ```
         | 
| 404 | 
            +
             | 
| 402 405 | 
             
            A schedule acts like a collection of recurrence rules that also behaves as a single
         | 
| 403 406 | 
             
            stream of events:
         | 
| 404 407 |  | 
| @@ -419,7 +422,9 @@ class RecurringEvent < ApplicationRecord | |
| 419 422 |  | 
| 420 423 | 
             
            end
         | 
| 421 424 | 
             
            ```
         | 
| 425 | 
            +
             | 
| 422 426 | 
             
            `Montrose::Schedule` can also be serialized:
         | 
| 427 | 
            +
             | 
| 423 428 | 
             
            ```ruby
         | 
| 424 429 | 
             
            class RecurringEvent < ApplicationRecord
         | 
| 425 430 | 
             
              serialize :recurrence, Montrose::Schedule
         | 
| @@ -435,10 +440,10 @@ Montrose is named after the beautifully diverse and artistic [neighborhood in Ho | |
| 435 440 |  | 
| 436 441 | 
             
            Check out following related projects, all of which have provided inspiration for `Montrose`.
         | 
| 437 442 |  | 
| 438 | 
            -
             | 
| 439 | 
            -
             | 
| 440 | 
            -
             | 
| 441 | 
            -
             | 
| 443 | 
            +
            - [ice_cube](https://github.com/seejohnrun/ice_cube)
         | 
| 444 | 
            +
            - [recurrence](https://github.com/fnando/recurrence)
         | 
| 445 | 
            +
            - [runt](https://github.com/mlipper/runt)
         | 
| 446 | 
            +
            - [http.rb](https://github.com/httprb/http) - not a recurrence project, but inspirational to design, implementation, and interface of `Montrose`
         | 
| 442 447 |  | 
| 443 448 | 
             
            ## Development
         | 
| 444 449 |  | 
| @@ -0,0 +1,16 @@ | |
| 1 | 
            +
            # This file was generated by Appraisal
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            source "https://rubygems.org"
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            gem "activesupport", "~> 7.0"
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            group :development do
         | 
| 8 | 
            +
              gem "coveralls"
         | 
| 9 | 
            +
              gem "yard"
         | 
| 10 | 
            +
              gem "guard"
         | 
| 11 | 
            +
              gem "guard-minitest"
         | 
| 12 | 
            +
              gem "guard-rubocop"
         | 
| 13 | 
            +
              gem "pry-byebug"
         | 
| 14 | 
            +
            end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            gemspec path: "../"
         | 
    
        data/lib/montrose/chainable.rb
    CHANGED
    
    
    
        data/lib/montrose/day.rb
    ADDED
    
    | @@ -0,0 +1,83 @@ | |
| 1 | 
            +
            module Montrose
         | 
| 2 | 
            +
              class Day
         | 
| 3 | 
            +
                extend Montrose::Utils
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                NAMES = ::Date::DAYNAMES
         | 
| 6 | 
            +
                TWO_LETTER_ABBREVIATIONS = %w[SU MO TU WE TH FR SA].freeze
         | 
| 7 | 
            +
                THREE_LETTER_ABBREVIATIONS = %w[SUN MON TUE WED THU FRI SAT]
         | 
| 8 | 
            +
                NUMBERS = NAMES.map.with_index { |_n, i| i.to_s }
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                ICAL_MATCH = /(?<ordinal>[+-]?\d+)?(?<day>[A-Z]{2})/ # e.g. 1FR
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                class << self
         | 
| 13 | 
            +
                  def parse(arg)
         | 
| 14 | 
            +
                    case arg
         | 
| 15 | 
            +
                    when Hash
         | 
| 16 | 
            +
                      parse_entries(arg.entries)
         | 
| 17 | 
            +
                    when String
         | 
| 18 | 
            +
                      parse(arg.split(","))
         | 
| 19 | 
            +
                    else
         | 
| 20 | 
            +
                      parse_entries(map_arg(arg) { |value| parse_value(value) })
         | 
| 21 | 
            +
                    end
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  def parse_entries(entries)
         | 
| 25 | 
            +
                    hash = Hash.new { |h, k| h[k] = [] }
         | 
| 26 | 
            +
                    result = entries.each_with_object(hash) { |(k, v), hash|
         | 
| 27 | 
            +
                      index = number!(k)
         | 
| 28 | 
            +
                      hash[index] = hash[index] + [*v]
         | 
| 29 | 
            +
                    }
         | 
| 30 | 
            +
                    result.values.all?(&:empty?) ? result.keys : result
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def parse_value(value)
         | 
| 34 | 
            +
                    parse_ical(value) || [number!(value), nil]
         | 
| 35 | 
            +
                  end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                  def parse_ical(value)
         | 
| 38 | 
            +
                    (match = ICAL_MATCH.match(value.to_s)) || (return nil)
         | 
| 39 | 
            +
                    index = number!(match[:day])
         | 
| 40 | 
            +
                    ordinal = match[:ordinal]&.to_i
         | 
| 41 | 
            +
                    [index, ordinal]
         | 
| 42 | 
            +
                  end
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                  def map_arg(arg, &block)
         | 
| 45 | 
            +
                    return nil unless arg.present?
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                    Array(arg).map(&block)
         | 
| 48 | 
            +
                  end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                  def names
         | 
| 51 | 
            +
                    NAMES
         | 
| 52 | 
            +
                  end
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                  def number(name)
         | 
| 55 | 
            +
                    case name
         | 
| 56 | 
            +
                    when 0..6
         | 
| 57 | 
            +
                      name
         | 
| 58 | 
            +
                    when Symbol, String
         | 
| 59 | 
            +
                      string = name.to_s.downcase
         | 
| 60 | 
            +
                      NAMES.index(string.titleize) ||
         | 
| 61 | 
            +
                        TWO_LETTER_ABBREVIATIONS.index(string.upcase) ||
         | 
| 62 | 
            +
                        THREE_LETTER_ABBREVIATIONS.index(string.upcase) ||
         | 
| 63 | 
            +
                        number(to_index(string))
         | 
| 64 | 
            +
                    when Array
         | 
| 65 | 
            +
                      number name.first
         | 
| 66 | 
            +
                    end
         | 
| 67 | 
            +
                  end
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                  def number!(name)
         | 
| 70 | 
            +
                    number(name) || raise(ConfigurationError,
         | 
| 71 | 
            +
                      "Did not recognize day #{name}, must be one of #{(names + abbreviations + numbers).inspect}")
         | 
| 72 | 
            +
                  end
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                  def numbers
         | 
| 75 | 
            +
                    NUMBERS
         | 
| 76 | 
            +
                  end
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                  def abbreviations
         | 
| 79 | 
            +
                    TWO_LETTER_ABBREVIATIONS + THREE_LETTER_ABBREVIATIONS
         | 
| 80 | 
            +
                  end
         | 
| 81 | 
            +
                end
         | 
| 82 | 
            +
              end
         | 
| 83 | 
            +
            end
         | 
    
        data/lib/montrose/frequency.rb
    CHANGED
    
    | @@ -1,14 +1,19 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            require "montrose/errors"
         | 
| 4 | 
            -
            require "montrose/options"
         | 
| 5 | 
            -
             | 
| 6 3 | 
             
            module Montrose
         | 
| 7 4 | 
             
              # Abstract class for special recurrence rule required
         | 
| 8 5 | 
             
              # in all instances of Recurrence. Frequency describes
         | 
| 9 6 | 
             
              # the base recurrence interval.
         | 
| 10 7 | 
             
              #
         | 
| 11 8 | 
             
              class Frequency
         | 
| 9 | 
            +
                autoload :Daily, "montrose/frequency/daily"
         | 
| 10 | 
            +
                autoload :Hourly, "montrose/frequency/hourly"
         | 
| 11 | 
            +
                autoload :Minutely, "montrose/frequency/minutely"
         | 
| 12 | 
            +
                autoload :Monthly, "montrose/frequency/monthly"
         | 
| 13 | 
            +
                autoload :Secondly, "montrose/frequency/secondly"
         | 
| 14 | 
            +
                autoload :Weekly, "montrose/frequency/weekly"
         | 
| 15 | 
            +
                autoload :Yearly, "montrose/frequency/yearly"
         | 
| 16 | 
            +
             | 
| 12 17 | 
             
                include Montrose::Rule
         | 
| 13 18 |  | 
| 14 19 | 
             
                FREQUENCY_TERMS = {
         | 
| @@ -25,24 +30,60 @@ module Montrose | |
| 25 30 |  | 
| 26 31 | 
             
                attr_reader :time, :starts
         | 
| 27 32 |  | 
| 28 | 
            -
                 | 
| 29 | 
            -
             | 
| 30 | 
            -
             | 
| 31 | 
            -
             | 
| 32 | 
            -
             | 
| 33 | 
            -
             | 
| 34 | 
            -
             | 
| 35 | 
            -
             | 
| 33 | 
            +
                class << self
         | 
| 34 | 
            +
                  def parse(input)
         | 
| 35 | 
            +
                    if input.respond_to?(:parts)
         | 
| 36 | 
            +
                      frequency, interval = duration_to_frequency_parts(input)
         | 
| 37 | 
            +
                      {every: frequency.to_s.singularize.to_sym, interval: interval}
         | 
| 38 | 
            +
                    elsif input.is_a?(Numeric)
         | 
| 39 | 
            +
                      frequency, interval = numeric_to_frequency_parts(input)
         | 
| 40 | 
            +
                      {every: frequency, interval: interval}
         | 
| 41 | 
            +
                    else
         | 
| 42 | 
            +
                      {every: Frequency.assert(input)}
         | 
| 43 | 
            +
                    end
         | 
| 44 | 
            +
                  end
         | 
| 36 45 |  | 
| 37 | 
            -
                   | 
| 38 | 
            -
             | 
| 46 | 
            +
                  # Factory method for instantiating the appropriate Frequency
         | 
| 47 | 
            +
                  # subclass.
         | 
| 48 | 
            +
                  #
         | 
| 49 | 
            +
                  def from_options(opts)
         | 
| 50 | 
            +
                    frequency = opts.fetch(:every) { fail ConfigurationError, "Please specify the :every option" }
         | 
| 51 | 
            +
                    class_name = FREQUENCY_TERMS.fetch(frequency.to_s) {
         | 
| 52 | 
            +
                      fail "Don't know how to enumerate every: #{frequency}"
         | 
| 53 | 
            +
                    }
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                    Montrose::Frequency.const_get(class_name).new(opts)
         | 
| 56 | 
            +
                  end
         | 
| 39 57 |  | 
| 40 | 
            -
             | 
| 41 | 
            -
             | 
| 42 | 
            -
             | 
| 43 | 
            -
                     | 
| 58 | 
            +
                  def from_term(term)
         | 
| 59 | 
            +
                    FREQUENCY_TERMS.invert.map { |k, v| [k.downcase, v] }.to_h.fetch(term.downcase) do
         | 
| 60 | 
            +
                      fail "Don't know how to convert #{term} to a Montrose frequency"
         | 
| 61 | 
            +
                    end
         | 
| 62 | 
            +
                  end
         | 
| 44 63 |  | 
| 45 | 
            -
                   | 
| 64 | 
            +
                  # @private
         | 
| 65 | 
            +
                  def assert(frequency)
         | 
| 66 | 
            +
                    FREQUENCY_TERMS.key?(frequency.to_s) || fail(ConfigurationError,
         | 
| 67 | 
            +
                      "Don't know how to enumerate every: #{frequency}")
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                    frequency.to_sym
         | 
| 70 | 
            +
                  end
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                  # @private
         | 
| 73 | 
            +
                  def numeric_to_frequency_parts(number)
         | 
| 74 | 
            +
                    parts = nil
         | 
| 75 | 
            +
                    %i[year month week day hour minute].each do |freq|
         | 
| 76 | 
            +
                      div, mod = number.divmod(1.send(freq))
         | 
| 77 | 
            +
                      parts = [freq, div]
         | 
| 78 | 
            +
                      return parts if mod.zero?
         | 
| 79 | 
            +
                    end
         | 
| 80 | 
            +
                    parts
         | 
| 81 | 
            +
                  end
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                  # @private
         | 
| 84 | 
            +
                  def duration_to_frequency_parts(duration)
         | 
| 85 | 
            +
                    duration.parts.first
         | 
| 86 | 
            +
                  end
         | 
| 46 87 | 
             
                end
         | 
| 47 88 |  | 
| 48 89 | 
             
                def initialize(opts = {})
         | 
| @@ -67,11 +108,3 @@ module Montrose | |
| 67 108 | 
             
                end
         | 
| 68 109 | 
             
              end
         | 
| 69 110 | 
             
            end
         | 
| 70 | 
            -
             | 
| 71 | 
            -
            require "montrose/frequency/daily"
         | 
| 72 | 
            -
            require "montrose/frequency/hourly"
         | 
| 73 | 
            -
            require "montrose/frequency/minutely"
         | 
| 74 | 
            -
            require "montrose/frequency/monthly"
         | 
| 75 | 
            -
            require "montrose/frequency/secondly"
         | 
| 76 | 
            -
            require "montrose/frequency/weekly"
         | 
| 77 | 
            -
            require "montrose/frequency/yearly"
         | 
| @@ -0,0 +1,22 @@ | |
| 1 | 
            +
            module Montrose
         | 
| 2 | 
            +
              class Hour
         | 
| 3 | 
            +
                HOURS_IN_DAY = 1.upto(24).to_a.freeze
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                class << self
         | 
| 6 | 
            +
                  def parse(arg)
         | 
| 7 | 
            +
                    case arg
         | 
| 8 | 
            +
                    when String
         | 
| 9 | 
            +
                      parse(arg.split(","))
         | 
| 10 | 
            +
                    else
         | 
| 11 | 
            +
                      Array(arg).map { |h| assert(h.to_i) }.presence
         | 
| 12 | 
            +
                    end
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  def assert(hour)
         | 
| 16 | 
            +
                    raise ConfigurationError, "Out of range: #{HOURS_IN_DAY.inspect} does not include #{hour}" unless HOURS_IN_DAY.include?(hour)
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    hour
         | 
| 19 | 
            +
                  end
         | 
| 20 | 
            +
                end
         | 
| 21 | 
            +
              end
         | 
| 22 | 
            +
            end
         | 
| @@ -0,0 +1,128 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Montrose
         | 
| 4 | 
            +
              class ICal
         | 
| 5 | 
            +
                # DTSTART;TZID=US-Eastern:19970902T090000
         | 
| 6 | 
            +
                # RRULE:FREQ=DAILY;INTERVAL=2
         | 
| 7 | 
            +
                def self.parse(ical)
         | 
| 8 | 
            +
                  new(ical).parse
         | 
| 9 | 
            +
                end
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                def initialize(ical)
         | 
| 12 | 
            +
                  @ical = ical
         | 
| 13 | 
            +
                end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                def parse
         | 
| 16 | 
            +
                  time_zone = extract_time_zone(@ical)
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  Time.use_zone(time_zone) do
         | 
| 19 | 
            +
                    Hash[*parse_properties(@ical)]
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                private
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def extract_time_zone(ical_string)
         | 
| 26 | 
            +
                  _label, time_string = ical_string.split("\n").grep(/^DTSTART/).join.split(";")
         | 
| 27 | 
            +
                  time_zone_rule, _ = time_string.split(":")
         | 
| 28 | 
            +
                  _label, time_zone = (time_zone_rule || "").split("=")
         | 
| 29 | 
            +
                  time_zone
         | 
| 30 | 
            +
                end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                # First pass parsing to normalize arbitrary line breaks
         | 
| 33 | 
            +
                def property_lines(ical_string)
         | 
| 34 | 
            +
                  ical_string.split("\n").each_with_object([]) do |line, lines|
         | 
| 35 | 
            +
                    case line
         | 
| 36 | 
            +
                    when /^(DTSTART|DTEND|EXDATE|RDATE|RRULE)/
         | 
| 37 | 
            +
                      lines << line
         | 
| 38 | 
            +
                    else
         | 
| 39 | 
            +
                      (lines.last || lines << "")
         | 
| 40 | 
            +
                      lines.last << line
         | 
| 41 | 
            +
                    end
         | 
| 42 | 
            +
                  end
         | 
| 43 | 
            +
                end
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                def parse_properties(ical_string)
         | 
| 46 | 
            +
                  property_lines(ical_string).flat_map do |line|
         | 
| 47 | 
            +
                    (property, value) = line.split(":")
         | 
| 48 | 
            +
                    (property, tzid) = property.split(";")
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                    case property
         | 
| 51 | 
            +
                    when "DTSTART"
         | 
| 52 | 
            +
                      parse_dtstart(tzid, value)
         | 
| 53 | 
            +
                    when "DTEND"
         | 
| 54 | 
            +
                      warn "DTEND not currently supported!"
         | 
| 55 | 
            +
                    when "EXDATE"
         | 
| 56 | 
            +
                      parse_exdate(value)
         | 
| 57 | 
            +
                    when "RDATE"
         | 
| 58 | 
            +
                      warn "RDATE not currently supported!"
         | 
| 59 | 
            +
                    when "RRULE"
         | 
| 60 | 
            +
                      parse_rrule(value)
         | 
| 61 | 
            +
                    end
         | 
| 62 | 
            +
                  end
         | 
| 63 | 
            +
                end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                def parse_dtstart(tzid, time)
         | 
| 66 | 
            +
                  return [] unless time.present?
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                  @starts_at = parse_time([tzid, time].compact.join(":"))
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                  [:starts, @starts_at]
         | 
| 71 | 
            +
                end
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                def parse_timezone(time_string)
         | 
| 74 | 
            +
                   time_zone_rule, _ = time_string.split(":")
         | 
| 75 | 
            +
                   _label, time_zone = (time_zone_rule || "").split("=")
         | 
| 76 | 
            +
                   time_zone
         | 
| 77 | 
            +
                end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                def parse_time(time_string)
         | 
| 80 | 
            +
                  time_zone = parse_timezone(time_string)
         | 
| 81 | 
            +
                  Montrose::Utils.parse_time(time_string).in_time_zone(time_zone)
         | 
| 82 | 
            +
                end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                def parse_exdate(exdate)
         | 
| 85 | 
            +
                  return [] unless exdate.present?
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                  @except = Montrose::Utils.as_date(exdate) # only currently supports dates
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                  [:except, @except]
         | 
| 90 | 
            +
                end
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                def parse_rrule(rrule)
         | 
| 93 | 
            +
                  rrule.gsub(/\s+/, "").split(";").flat_map do |rule|
         | 
| 94 | 
            +
                    prop, value = rule.split("=")
         | 
| 95 | 
            +
                    case prop
         | 
| 96 | 
            +
                    when "FREQ"
         | 
| 97 | 
            +
                      [:every, Montrose::Frequency.from_term(value)]
         | 
| 98 | 
            +
                    when "INTERVAL"
         | 
| 99 | 
            +
                      [:interval, value.to_i]
         | 
| 100 | 
            +
                    when "COUNT"
         | 
| 101 | 
            +
                      [:total, value.to_i]
         | 
| 102 | 
            +
                    when "UNTIL"
         | 
| 103 | 
            +
                      [:until, parse_time(value)]
         | 
| 104 | 
            +
                    when "BYMINUTE"
         | 
| 105 | 
            +
                      [:minute, Montrose::Minute.parse(value)]
         | 
| 106 | 
            +
                    when "BYHOUR"
         | 
| 107 | 
            +
                      [:hour, Montrose::Hour.parse(value)]
         | 
| 108 | 
            +
                    when "BYMONTH"
         | 
| 109 | 
            +
                      [:month, Montrose::Month.parse(value)]
         | 
| 110 | 
            +
                    when "BYDAY"
         | 
| 111 | 
            +
                      [:day, Montrose::Day.parse(value)]
         | 
| 112 | 
            +
                    when "BYMONTHDAY"
         | 
| 113 | 
            +
                      [:mday, Montrose::MonthDay.parse(value)]
         | 
| 114 | 
            +
                    when "BYYEARDAY"
         | 
| 115 | 
            +
                      [:yday, Montrose::YearDay.parse(value)]
         | 
| 116 | 
            +
                    when "BYWEEKNO"
         | 
| 117 | 
            +
                      [:week, Montrose::Week.parse(value)]
         | 
| 118 | 
            +
                    when "WKST"
         | 
| 119 | 
            +
                      [:week_start, value]
         | 
| 120 | 
            +
                    when "BYSETPOS"
         | 
| 121 | 
            +
                      warn "BYSETPOS not currently supported!"
         | 
| 122 | 
            +
                    else
         | 
| 123 | 
            +
                      raise "Unrecognized rrule '#{rule}'"
         | 
| 124 | 
            +
                    end
         | 
| 125 | 
            +
                  end
         | 
| 126 | 
            +
                end
         | 
| 127 | 
            +
              end
         | 
| 128 | 
            +
            end
         | 
| @@ -0,0 +1,22 @@ | |
| 1 | 
            +
            module Montrose
         | 
| 2 | 
            +
              class Minute
         | 
| 3 | 
            +
                MINUTES_IN_HOUR = 0.upto(59).to_a.freeze
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                class << self
         | 
| 6 | 
            +
                  def parse(arg)
         | 
| 7 | 
            +
                    case arg
         | 
| 8 | 
            +
                    when String
         | 
| 9 | 
            +
                      parse(arg.split(","))
         | 
| 10 | 
            +
                    else
         | 
| 11 | 
            +
                      Array(arg).map { |m| assert(m.to_i) }.presence
         | 
| 12 | 
            +
                    end
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  def assert(minute)
         | 
| 16 | 
            +
                    raise ConfigurationError, "Out of range: #{MINUTES_IN_HOUR.inspect} does not include #{minute}" unless MINUTES_IN_HOUR.include?(minute)
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    minute
         | 
| 19 | 
            +
                  end
         | 
| 20 | 
            +
                end
         | 
| 21 | 
            +
              end
         | 
| 22 | 
            +
            end
         | 
| @@ -0,0 +1,47 @@ | |
| 1 | 
            +
            module Montrose
         | 
| 2 | 
            +
              class Month
         | 
| 3 | 
            +
                extend Montrose::Utils
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                NAMES = ::Date::MONTHNAMES # starts with nil to match 1-12 numbering
         | 
| 6 | 
            +
                NUMBERS = NAMES.map.with_index { |_n, i| i.to_s }.slice(1, 12)
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                class << self
         | 
| 9 | 
            +
                  def parse(value)
         | 
| 10 | 
            +
                    case value
         | 
| 11 | 
            +
                    when String
         | 
| 12 | 
            +
                      parse(value.split(",").compact)
         | 
| 13 | 
            +
                    when Array
         | 
| 14 | 
            +
                      value.map { |m|
         | 
| 15 | 
            +
                        Montrose::Month.number!(m)
         | 
| 16 | 
            +
                      }.presence
         | 
| 17 | 
            +
                    else
         | 
| 18 | 
            +
                      parse(Array(value))
         | 
| 19 | 
            +
                    end
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                  def names
         | 
| 23 | 
            +
                    NAMES
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  def numbers
         | 
| 27 | 
            +
                    NUMBERS
         | 
| 28 | 
            +
                  end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  def number(name)
         | 
| 31 | 
            +
                    case name
         | 
| 32 | 
            +
                    when Symbol, String
         | 
| 33 | 
            +
                      string = name.to_s
         | 
| 34 | 
            +
                      NAMES.index(string.titleize) || number(to_index(string))
         | 
| 35 | 
            +
                    when 1..12
         | 
| 36 | 
            +
                      name
         | 
| 37 | 
            +
                    end
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  def number!(name)
         | 
| 41 | 
            +
                    numbers = NAMES.map.with_index { |_n, i| i.to_s }.slice(1, 12)
         | 
| 42 | 
            +
                    number(name) || raise(ConfigurationError,
         | 
| 43 | 
            +
                      "Did not recognize month #{name}, must be one of #{(NAMES + numbers).inspect}")
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
                end
         | 
| 46 | 
            +
              end
         | 
| 47 | 
            +
            end
         | 
| @@ -0,0 +1,25 @@ | |
| 1 | 
            +
            module Montrose
         | 
| 2 | 
            +
              class MonthDay
         | 
| 3 | 
            +
                class << self
         | 
| 4 | 
            +
                  MDAYS = (-31.upto(-1).to_a + 1.upto(31).to_a)
         | 
| 5 | 
            +
             | 
| 6 | 
            +
                  def parse(mdays)
         | 
| 7 | 
            +
                    return nil unless mdays.present?
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                    case mdays
         | 
| 10 | 
            +
                    when String
         | 
| 11 | 
            +
                      parse(mdays.split(","))
         | 
| 12 | 
            +
                    else
         | 
| 13 | 
            +
                      Array(mdays).map { |d| assert(d.to_i) }
         | 
| 14 | 
            +
                    end
         | 
| 15 | 
            +
                  end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def assert(number)
         | 
| 18 | 
            +
                    test = number.abs
         | 
| 19 | 
            +
                    raise ConfigurationError, "Out of range: #{MDAYS.inspect} does not include #{test}" unless MDAYS.include?(number.abs)
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    number
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
              end
         | 
| 25 | 
            +
            end
         |