├── git-flow-version ├── test ├── git-flow-version ├── helper.rb └── test_parsing.rb ├── .document ├── lib ├── tickle │ ├── version.rb │ ├── helpers.rb │ ├── patterns.rb │ ├── filters.rb │ ├── token.rb │ ├── tickled.rb │ ├── repeater.rb │ ├── tickle.rb │ └── handler.rb ├── ext │ ├── array.rb │ ├── string.rb │ └── date_and_time.rb └── tickle.rb ├── .travis.yml ├── .gitignore ├── Gemfile ├── Rakefile ├── examples.rb ├── spec ├── spec_helper.rb ├── helpers_spec.rb ├── token_spec.rb ├── patterns_spec.rb └── tickle_spec.rb ├── tickle.gemspec ├── LICENCE ├── CHANGES.md ├── benchmarks └── main.rb ├── SCENARIOS.rdoc └── README.md /git-flow-version: -------------------------------------------------------------------------------- 1 | GITFLOW_VERSION=0.1.7 2 | -------------------------------------------------------------------------------- /test/git-flow-version: -------------------------------------------------------------------------------- 1 | GITFLOW_VERSION=0.1.0 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /lib/tickle/version.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | 3 | # This library's current version. 4 | VERSION = "2.0.0rc3" 5 | end 6 | -------------------------------------------------------------------------------- /lib/ext/array.rb: -------------------------------------------------------------------------------- 1 | class Array 2 | # compares two arrays to determine if they both contain the same elements 3 | def same?(y) 4 | self.sort == y.sort 5 | end 6 | end -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.7 4 | - jruby 5 | - truffleruby 6 | 7 | # whitelist 8 | branches: 9 | only: 10 | - master 11 | - develop 12 | - v1-branch 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | *.tmproj 4 | tmtags 5 | 6 | *~ 7 | \#* 8 | .\#* 9 | 10 | *.swp 11 | doc/ 12 | coverage 13 | rdoc 14 | pkg 15 | bin/ 16 | vendor/ 17 | vendor.noindex/ 18 | Gemfile.lock 19 | .yardoc/ 20 | .bundle/ 21 | .rspec 22 | *.gem 23 | .ruby-version 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem "maruku" 7 | gem "yard" 8 | unless RUBY_ENGINE == 'jruby' 9 | gem "pry-byebug" 10 | gem "pry-state" 11 | gem "rb-readline" 12 | end 13 | end 14 | 15 | group :test do 16 | gem "rspec" 17 | gem "rspec-its" 18 | gem "timecop" 19 | gem "simplecov" 20 | gem "rake" 21 | end -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter "/vendor/" 4 | add_filter "/bin/" 5 | end 6 | 7 | require 'test/unit' 8 | require 'shoulda' 9 | 10 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 11 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 12 | 13 | require File.join(File.dirname(__FILE__), '..', 'lib', 'tickle') 14 | 15 | class Test::Unit::TestCase 16 | end 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | RSpec::Core::RakeTask.new(:spec) 4 | 5 | task :default => :spec 6 | 7 | 8 | desc "(Re-) generate documentation and place it in the docs/ dir. Open the index.html file in there to read it." 9 | task :docs => [:"docs:environment", :"docs:yard"] 10 | namespace :docs do 11 | 12 | task :environment do 13 | ENV["RACK_ENV"] = "documentation" 14 | end 15 | 16 | require 'yard' 17 | 18 | YARD::Rake::YardocTask.new :yard do |t| 19 | t.files = ['lib/**/*.rb'] 20 | t.options = ['-odoc/'] # optional 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /examples.rb: -------------------------------------------------------------------------------- 1 | 2 | require File.join(File.dirname(__FILE__), 'lib', 'tickle') 3 | 4 | =begin 5 | 6 | Tickle creates standard Ruby Time objects. 7 | 8 | Time is ignored if there's also a date, unless the date is 'tomorrow'(?) 9 | 10 | Tickle creates times in the servers local time zone. 11 | To go from server time to user's local time... 12 | user_time = server_time + (user_time_offset - server_time_offset) 13 | eg (5:00PM Central) = (6:00PM Eastern + (-6 - -5)) 14 | 15 | Tickle makes 'May 30th' at 0:00, but 'June 18, 2011' and 'Christmas' at 12:00. 16 | =end 17 | 18 | 19 | server_offset = Time.now.utc_offset / 60 / 60 20 | 21 | ['May 30th', '6:30 PM', 22 | 'tomorrow at 6:00', 'June 18, 2011', 'Christmas'].each {|s| 23 | time = Tickle.parse(s)[:next] 24 | if s == 'Christmas' 25 | time -= 12 * 60 * 60 26 | end 27 | print s, " --> server date: ", time 28 | puts 29 | } 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'rspec' 4 | require 'rspec/its' 5 | Spec_dir = File.expand_path( File.dirname __FILE__ ) 6 | 7 | 8 | # code coverage 9 | require 'simplecov' 10 | SimpleCov.start do 11 | add_filter "/vendor/" 12 | add_filter "/vendor.noindex/" 13 | add_filter "/bin/" 14 | add_filter "/spec/" 15 | add_filter "/coverage/" # It used to do this for some reason, defensive of me. 16 | end 17 | 18 | 19 | Dir[ File.join( Spec_dir, "/support/**/*.rb")].each do |f| 20 | require f 21 | end 22 | 23 | Time_now = Time.parse "2010-05-09 20:57:36 +0000" 24 | 25 | require 'timecop' 26 | 27 | RSpec.configure do |config| 28 | config.expect_with :rspec do |c| 29 | c.syntax = [:should, :expect] 30 | end 31 | 32 | tz = ENV["TZ"] 33 | config.before(:all, :frozen => true) do 34 | Timecop.freeze Time_now 35 | ENV["TZ"] = "UTC" 36 | end 37 | config.after(:all, :frozen => true) do 38 | Timecop.return 39 | ENV["TZ"] = tz 40 | end 41 | end 42 | 43 | warn "Actual Time now => #{Time.now}" -------------------------------------------------------------------------------- /spec/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require_relative "../lib/tickle/helpers.rb" 3 | 4 | 5 | module Tickle # for convenience 6 | 7 | 8 | describe "Helpers module" do 9 | 10 | describe "combine_multiple_numbers" do 11 | subject(:out) { Helpers.combine_multiple_numbers tokens} 12 | context "When given an empty set" do 13 | let(:tokens) { [] } 14 | it { should == [] } 15 | end 16 | context "When given compound numbers" do 17 | context "like 'twenty first'" do 18 | let(:tokens) { [ 19 | Token.new("twenty", word: "20", type: :number, start: 20, interval: 20), 20 | Token.new("first", word: "1st", type: :ordinal, start: 1, interval: 1), 21 | ] } 22 | subject{ out.first } 23 | its(:original) { should == "twenty first" } 24 | its(:word) { should == "21st" } 25 | its(:type) { should == :ordinal } 26 | its(:start) { should == "21"} 27 | its(:interval) {should == 365 } 28 | end 29 | end 30 | 31 | end 32 | 33 | end 34 | 35 | 36 | end # of convenience -------------------------------------------------------------------------------- /tickle.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'tickle/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{tickle} 8 | s.version = Tickle::VERSION 9 | 10 | s.authors = ["Joshua Lippiner", "Iain Barnett"] 11 | s.email = %q{iainspeed@gmail.com} 12 | s.description = %q{Tickle is a date/time helper gem to help parse natural language into a recurring pattern. Tickle is designed to be a compliment of Chronic and can interpret things such as "every 2 days, every Sunday, Sundays, Weekly, etc.} 13 | s.summary = %q{natural language parser for recurring events} 14 | s.homepage = %q{http://github.com/yb66/tickle} 15 | s.license = "MIT" 16 | 17 | s.files = `git ls-files`.split($/) 18 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 20 | 21 | s.require_paths = ["lib"] 22 | 23 | s.add_dependency "numerizer", "~> 0.2.0" 24 | s.add_dependency "gitlab-chronic", "~> 0.10.6" 25 | s.add_dependency "texttube", "~> 6.0.0" 26 | end 27 | 28 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Joshua Lippiner 2 | Copyright (c) 2015 Iain Barnett 3 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/tickle.rb: -------------------------------------------------------------------------------- 1 | #============================================================================= 2 | # 3 | # Name: Tickle 4 | # Author: Joshua Lippiner 5 | # Purpose: Parse natural language into recuring intervals 6 | # 7 | #============================================================================= 8 | 9 | 10 | if ENV["DEBUG"] 11 | warn "DEBUG MODE ON" 12 | require 'pry-byebug' 13 | require 'pry-state' 14 | binding.pry 15 | end 16 | 17 | require 'date' 18 | require 'time' 19 | require 'gitlab-chronic' 20 | 21 | require 'tickle/tickle' 22 | require 'tickle/handler' 23 | require 'tickle/repeater' 24 | require_relative "tickle/tickled.rb" 25 | require_relative "ext/array.rb" 26 | require_relative "ext/date_and_time.rb" 27 | require_relative "ext/string.rb" 28 | 29 | 30 | # Tickle is a natural language parser for recurring events. 31 | module Tickle 32 | 33 | def self.parse(asked, options = {}) 34 | # check to see if a datetime was passed 35 | # if so, give it back 36 | # TODO Consider converting to a Tickled 37 | return asked if asked.respond_to? :day 38 | 39 | tickled = Tickled.new asked.dup, options 40 | _parse tickled 41 | end 42 | 43 | 44 | def self.is_date(str) 45 | begin 46 | Date.parse(str.to_s) 47 | return true 48 | rescue Exception => e 49 | return false 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # CH CH CH CH CHANGES! # 2 | 3 | ## Friday the 18th of September 2002, v2.0.0rc3 4 | 5 | * Changed the dependency from chronic to gitlab-chronic, thanks to @bjonord 6 | 7 | ---- 8 | 9 | ## v2.0.0.rc2, Friday the 8th of December 2017 ## 10 | 11 | * Fixed issue #20 https://github.com/yb66/tickle/pull/20, thanks to [@antogon](https://github.com/antogon) 12 | 13 | ---- 14 | 15 | 16 | ## Saturday the 7th of November 2015, 2.0.0.rc1 ## 17 | 18 | * Rewrite of the internals. 19 | * API should be pretty much the same from the outside, but enough things on the inside change to warrant the major version bump. 20 | * Chronic updated to 0.10.2. 21 | 22 | ---- 23 | 24 | 25 | ## Monday the 11th of November 2013, v1.0.2 ## 26 | 27 | * Shoulda and simplecov aren't runtime dependencies, fixed that in the gemfile. 28 | * Got the version number right this time ;-) 29 | 30 | ---- 31 | 32 | 33 | ## Monday the 11th of November 2013, v1.0.1 ## 34 | 35 | * Moved library to new maintainer [https://github.com/yb66/tickle](@yb66) 36 | * Moved library to [http://semver.org/](semver). 37 | * Merged in some changes from @dan335 and @JesseAldridge, thanks to them. 38 | * Moved rdocs to markdown for niceness. 39 | * Updated licences with dates and correct spelling ;) 40 | * Fix incorporated for "NameError: uninitialized constant Module::Numerizer" 41 | * Moved library to Bundler to make it easier to set up and develop against. 42 | * Started using Yardoc for more niceness with documentation. 43 | 44 | ---- 45 | -------------------------------------------------------------------------------- /lib/ext/string.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | class Ordinal < ::String 3 | # returns true if the sending string is a text or numeric ordinal (e.g. first or 1st) 4 | def is_ordinal? 5 | scanner = %w{first second third fourth fifth sixth seventh eighth ninth tenth eleventh twelfth thirteenth fourteenth fifteenth sixteenth seventeenth eighteenth nineteenth twenty thirty thirtieth} 6 | regex = /\b(\d*)(st|nd|rd|th)\b/ 7 | !(self =~ regex).nil? || scanner.include?(self.downcase) 8 | end 9 | 10 | def ordinal_as_number 11 | return self unless self.is_ordinal? 12 | scanner = {/first/ => '1st', 13 | /second/ => '2nd', 14 | /third/ => '3rd', 15 | /fourth/ => '4th', 16 | /fifth/ => '5th', 17 | /sixth/ => '6th', 18 | /seventh/ => '7th', 19 | /eighth/ => '8th', 20 | /ninth/ => '9th', 21 | /tenth/ => '10th', 22 | /eleventh/ => '11th', 23 | /twelfth/ => '12th', 24 | /thirteenth/ => '13th', 25 | /fourteenth/ => '14th', 26 | /fifteenth/ => '15th', 27 | /sixteenth/ => '16th', 28 | /seventeenth/ => '17th', 29 | /eighteenth/ => '18th', 30 | /nineteenth/ => '19th', 31 | /twentieth/ => '20th', 32 | /thirtieth/ => '30th', 33 | } 34 | result = self 35 | scanner.keys.each {|scanner_item| result = scanner[scanner_item] if scanner_item =~ self} 36 | return result.gsub(/\b(\d*)(st|nd|rd|th)\b/, '\1') 37 | end 38 | end 39 | end -------------------------------------------------------------------------------- /lib/tickle/helpers.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | 3 | require_relative "token.rb" 4 | 5 | # static methods that are used across classes. 6 | module Helpers 7 | 8 | # Returns the next available month based on the current day of the month. 9 | # For example, if get_next_month(15) is called and the start date is the 10th, then it will return the 15th of this month. 10 | # However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month. 11 | def self.get_next_month(number,start=nil) 12 | start ||= @start || Time.now 13 | month = 14 | if number.to_i < start.day 15 | start.month == 12 ? 16 | 1 : 17 | start.month + 1 18 | else 19 | start.month 20 | end 21 | end 22 | 23 | 24 | 25 | # Return the number of days in a specified month. 26 | # If no month is specified, current month is used. 27 | def self.days_in_month(month=nil) 28 | month ||= Date.today.month 29 | days_in_mon = Date.civil(Date.today.year, month, -1).day 30 | end 31 | 32 | 33 | # Turns compound numbers, like 'twenty first' => 21 34 | def self.combine_multiple_numbers(tokens) 35 | if Token.types(tokens).include?(:number) && 36 | Token.types(tokens).include?(:ordinal) 37 | number = Token.token_of_type(:number, tokens) 38 | ordinal = Token.token_of_type(:ordinal, tokens) 39 | combined_original = "#{number.original} #{ordinal.original}" 40 | combined_word = (number.start.to_s[0] + ordinal.word) 41 | combined_value = (number.start.to_s[0] + ordinal.start.to_s) 42 | new_number_token = Token.new(combined_original, word: combined_word, type: :ordinal, start: combined_value, interval: 365) 43 | tokens.reject! {|token| (token.type == :number || token.type == :ordinal)} 44 | tokens << new_number_token 45 | end 46 | tokens 47 | end 48 | 49 | 50 | end # Helpers 51 | end -------------------------------------------------------------------------------- /lib/tickle/patterns.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | 3 | # A place to keep all the regular expressions. 4 | module Patterns 5 | 6 | PLURAL_OR_PRESENT_PARTICIPLE = / 7 | s 8 | | 9 | ing 10 | /x 11 | 12 | ON_THE = / 13 | \bon\b 14 | (?: 15 | \s+ 16 | the 17 | )? 18 | /x 19 | 20 | END_OR_UNTIL = / 21 | (?: 22 | (?:\band\b\s+)? 23 | \bend 24 | (?: #{PLURAL_OR_PRESENT_PARTICIPLE} )? 25 | (?: 26 | \s+ 27 | #{ON_THE} 28 | )? 29 | ) 30 | | 31 | (:? 32 | until 33 | (?: 34 | \s+ 35 | \bthe\b 36 | )? 37 | ) 38 | /x 39 | 40 | SET_IDENTIFIER = / 41 | every 42 | | 43 | each 44 | | 45 | (?: #{ON_THE} ) 46 | /x 47 | 48 | # This is here so we can check for repetition 49 | # and set 'until' more easily. If so desired. 50 | REPETITION = / 51 | (? 52 | repeat 53 | ) 54 | /x 55 | 56 | START = / 57 | start 58 | (?: #{PLURAL_OR_PRESENT_PARTICIPLE} )? 59 | /x 60 | 61 | START_EVERY_REGEX = /^ 62 | (?: 63 | #{START} 64 | ) 65 | \s+ 66 | (?.*?) 67 | (?: 68 | \s+ 69 | #{REPETITION} 70 | )? 71 | \s+ 72 | #{SET_IDENTIFIER} 73 | \s+ 74 | (?.*) 75 | /ix 76 | 77 | 78 | EVERY_START_REGEX = /^ 79 | (?: #{SET_IDENTIFIER} ) 80 | \s+ 81 | (?.*) 82 | (?: 83 | \s+ 84 | #{START} 85 | (?: 86 | \s+ 87 | #{ON_THE} 88 | )? 89 | ) 90 | \s+ 91 | (?.*) 92 | /ix 93 | 94 | START_ENDING_REGEX = /^ 95 | #{START} 96 | \s+ 97 | (?.*?) 98 | (?: 99 | \s+ 100 | #{END_OR_UNTIL} 101 | ) 102 | \s+ 103 | (?.*) 104 | /ix 105 | 106 | PROCESS_FOR_ENDING = /^ 107 | (?.*) 108 | \s+ 109 | (?: #{END_OR_UNTIL}) 110 | \s+ 111 | (?.*) 112 | /ix 113 | 114 | end 115 | end -------------------------------------------------------------------------------- /lib/tickle/filters.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | 3 | require 'texttube/filterable' 4 | module Filters 5 | 6 | extend TextTube::Filterable 7 | # Normalize natural string removing prefix language 8 | filter_with :remove_prefix do |text| 9 | text.gsub(/every(\s)?/, '') 10 | .gsub(/each(\s)?/, '') 11 | .gsub(/repeat(s|ing)?(\s)?/, '') 12 | .gsub(/on the(\s)?/, '') 13 | .gsub(/([^\w\d\s])+/, '') 14 | .downcase.strip 15 | text 16 | end 17 | 18 | 19 | # Converts natural language US Holidays into a date expression to be 20 | # parsed. 21 | filter_with :normalize_us_holidays do |text| 22 | normalized_text = text.to_s.downcase 23 | normalized_text.gsub(/\bnew\syear'?s?(\s)?(day)?\b/){|md| $1 } 24 | .gsub(/\bnew\syear'?s?(\s)?(eve)?\b/){|md| $1 } 25 | .gsub(/\bm(artin\s)?l(uther\s)?k(ing)?(\sday)?\b/){|md| $1 } 26 | .gsub(/\binauguration(\sday)?\b/){|md| $1 } 27 | .gsub(/\bpresident'?s?(\sday)?\b/){|md| $1 } 28 | .gsub(/\bmemorial\sday\b/){|md| $1 } 29 | .gsub(/\bindepend(e|a)nce\sday\b/){|md| $1 } 30 | .gsub(/\blabor\sday\b/){|md| $1 } 31 | .gsub(/\bcolumbus\sday\b/){|md| $1 } 32 | .gsub(/\bveterans?\sday\b/){|md| $1 } 33 | .gsub(/\bthanksgiving(\sday)?\b/){|md| $1 } 34 | .gsub(/\bchristmas\seve\b/){|md| $1 } 35 | .gsub(/\bchristmas(\sday)?\b/){|md| $1 } 36 | .gsub(/\bsuper\sbowl(\ssunday)?\b/){|md| $1 } 37 | .gsub(/\bgroundhog(\sday)?\b/){|md| $1 } 38 | .gsub(/\bvalentine'?s?(\sday)?\b/){|md| $1 } 39 | .gsub(/\bs(ain)?t\spatrick'?s?(\sday)?\b/){|md| $1 } 40 | .gsub(/\bapril\sfool'?s?(\sday)?\b/){|md| $1 } 41 | .gsub(/\bearth\sday\b/){|md| $1 } 42 | .gsub(/\barbor\sday\b/){|md| $1 } 43 | .gsub(/\bcinco\sde\smayo\b/){|md| $1 } 44 | .gsub(/\bmother'?s?\sday\b/){|md| $1 } 45 | .gsub(/\bflag\sday\b/){|md| $1 } 46 | .gsub(/\bfather'?s?\sday\b/){|md| $1 } 47 | .gsub(/\bhalloween\b/){|md| $1 } 48 | .gsub(/\belection\sday\b/){|md| $1 } 49 | .gsub(/\bkwanzaa\b/){|md| $1 } 50 | normalized_text 51 | end 52 | 53 | # filter_with :strip do |text| 54 | # text.strip 55 | # end 56 | 57 | end 58 | end -------------------------------------------------------------------------------- /spec/token_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require_relative "../lib/tickle/token.rb" 3 | require 'rspec/its' 4 | 5 | module Tickle # for convenience 6 | 7 | describe "Token" do 8 | 9 | describe "The basics" do 10 | subject { Token.new( "" ) } 11 | it { should be_a_kind_of ::String } 12 | it { should respond_to :original } 13 | it { should respond_to :word } 14 | it { should respond_to :type } 15 | it { should respond_to :interval } 16 | it { should respond_to :start } 17 | it { should respond_to :update! } 18 | it { should respond_to :normalize! } 19 | end 20 | 21 | 22 | describe "Instantation" do 23 | context "Given a token" do 24 | context "and no options" do 25 | subject { Token.new( "Today" ) } 26 | its(:original) { should == "Today" } 27 | its(:word) { should be_nil } 28 | its(:type) { should be_nil } 29 | its(:interval) { should be_nil } 30 | its(:start) { should be_nil } 31 | it { should == "Today" } 32 | end 33 | end 34 | end 35 | 36 | describe "After normalization" do 37 | context "Given a token" do 38 | context "That is not a number" do 39 | context "and no options" do 40 | subject { Token.new( "Today" ).normalize! } 41 | it { should == "Today" } 42 | its(:word) { should == "today" } 43 | end 44 | end 45 | context "That is a number" do 46 | context "and no options" do 47 | subject { Token.new( "Twenty" ).normalize! } 48 | it { should == "Twenty" } 49 | its(:word) { should == "20" } 50 | end 51 | end 52 | end 53 | end 54 | 55 | 56 | describe "update!" do 57 | pending 58 | end 59 | 60 | 61 | describe "tokenize" do 62 | subject { Token.tokenize "Next Monday" } 63 | it { should == ["Next", "Monday"] } 64 | its(:first) { should be_a_kind_of Token } 65 | end 66 | 67 | 68 | describe "scan" do 69 | context "Given an invalid argument" do 70 | it { 71 | expect {Token.scan Token.tokenize 9999}.to raise_error ArgumentError 72 | } 73 | end 74 | context "Given a valid argument (tokens)" do 75 | subject { Token.scan! Token.tokenize "Next Monday" } 76 | it { should == ["Next", "Monday"] } 77 | end 78 | end 79 | 80 | end 81 | 82 | end -------------------------------------------------------------------------------- /lib/ext/date_and_time.rb: -------------------------------------------------------------------------------- 1 | class Date 2 | # returns the days in the sending month 3 | def days_in_month 4 | d,m,y = mday,month,year 5 | d += 1 while Date.valid_civil?(y,m,d) 6 | d - 1 7 | end 8 | 9 | 10 | # no idea what this is for 11 | def bump(attr, amount=nil) 12 | amount ||= 1 13 | case attr 14 | when :day then 15 | Date.civil(self.year, self.month, self.day + amount) 16 | when :wday then 17 | amount = Date::ABBR_DAYNAMES.index(amount) if amount.is_a?(String) 18 | raise Exception, "specified day of week invalid. Use #{Date::ABBR_DAYNAMES}" unless amount 19 | diff = (amount > self.wday) ? (amount - self.wday) : (7 - (self.wday - amount)) 20 | Date.civil(self.year, self.month, self.day + diff) 21 | when :week then 22 | Date.civil(self.year, self.month, self.day + (7*amount)) 23 | when :month then 24 | Date.civil(self.year, self.month+amount, self.day) 25 | when :year then 26 | Date.civil(self.year + amount, self.month, self.day) 27 | when :sec then 28 | Date.civil(self.year, self.month, self.day, self.hour, self.minute, self.sec + amount) 29 | else 30 | raise Exception, "type \"#{attr}\" not supported." 31 | end 32 | end 33 | end 34 | 35 | class Time 36 | 37 | # same again, no idea what this is for 38 | def bump(attr, amount=nil) 39 | amount ||= 1 40 | case attr 41 | when :sec then 42 | Time.local(self.year, self.month, self.day, self.hour, self.min, self.sec + amount) 43 | when :min then 44 | Time.local(self.year, self.month, self.day, self.hour, self.min + amount, self.sec) 45 | when :hour then 46 | Time.local(self.year, self.month, self.day, self.hour + amount, self.min, self.sec) 47 | when :day then 48 | Time.local(self.year, self.month, self.day + amount, self.hour, self.min, self.sec) 49 | when :wday then 50 | amount = Time::RFC2822_DAY_NAME.index(amount) if amount.is_a?(String) 51 | raise Exception, "specified day of week invalid. Use #{Time::RFC2822_DAY_NAME}" unless amount 52 | diff = (amount > self.wday) ? (amount - self.wday) : (7 - (self.wday - amount)) 53 | Time.local(self.year, self.month, self.day + diff, self.hour, self.min, self.sec) 54 | when :week then 55 | Time.local(self.year, self.month, self.day + (amount * 7), self.hour, self.min, self.sec) 56 | when :month then 57 | Time.local(self.year, self.month + amount, self.day, self.hour, self.min, self.sec) 58 | when :year then 59 | Time.local(self.year + amount, self.month, self.day, self.hour, self.min, self.sec) 60 | else 61 | raise Exception, "type \"#{attr}\" not supported." 62 | end 63 | end 64 | end -------------------------------------------------------------------------------- /lib/tickle/token.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | 3 | require 'numerizer' 4 | require_relative "repeater.rb" 5 | 6 | # An extended String 7 | class Token < ::String 8 | attr_accessor :original 9 | 10 | 11 | # !@attribute [rw] word Normalized original 12 | # @return [String] 13 | attr_accessor:word 14 | 15 | attr_accessor :type, :interval, :start 16 | 17 | 18 | # @param [#downcase] original 19 | # @param [Hash] options 20 | # @option options [String] :word Normalized original, the implied word 21 | def initialize(original, options={}) 22 | @original = original 23 | @word = options[:word] 24 | @type = options[:type] 25 | @interval = options[:interval] 26 | @start = options[:start] 27 | super @original 28 | end 29 | 30 | 31 | def update!(options={}) 32 | options = { 33 | :start => nil, 34 | :interval => nil, 35 | }.merge( options ) 36 | fail ArgumentError, "Token#update! must be passed a 'type'" if options.nil? or options.empty? or not options.has_key?(:type) or options[:type].nil? 37 | 38 | @type = options[:type] 39 | @start = options[:start] 40 | @interval = options[:interval] 41 | self 42 | end 43 | 44 | 45 | COMMON_SYMBOLS = %r{ 46 | ( 47 | [ / \- , @ ] 48 | ) 49 | }x 50 | 51 | 52 | # Clean up the specified input text by stripping unwanted characters, 53 | # converting idioms to their canonical form, converting number words 54 | # to numbers (three => 3), and converting ordinal words to numeric 55 | # ordinals (third => 3rd) 56 | def normalize! 57 | @word = Numerizer.numerize(@original.downcase) 58 | .gsub(/['"\.]/, '') 59 | .gsub(COMMON_SYMBOLS) {" #{$1} "} 60 | self 61 | end 62 | 63 | 64 | # Split the text on spaces and convert each word into 65 | # a Token 66 | # @param [#split] text The text to be tokenized. 67 | # @return [Array] The tokens. 68 | def self.tokenize(text) 69 | fail ArgumentError unless text.respond_to? :split 70 | text.split(/\s+/).map { |word| Token.new(word) } 71 | end 72 | 73 | 74 | # Returns an array of types for all tokens 75 | def self.types(tokens) 76 | tokens.map(&:type) 77 | end 78 | 79 | 80 | def self.token_of_type(type, tokens) 81 | tokens.detect {|token| token.type == type} 82 | end 83 | 84 | 85 | # @return [Array] 86 | def self.scan!( tokens ) 87 | fail ArgumentError, "Token#scan must be provided with and Array of Tokens to work with." unless tokens.respond_to? :each 88 | repeater = Repeater.new tokens 89 | repeater.scan! 90 | repeater.tokens 91 | end 92 | 93 | end 94 | end -------------------------------------------------------------------------------- /lib/tickle/tickled.rb: -------------------------------------------------------------------------------- 1 | require 'texttube/base' 2 | require_relative "filters.rb" 3 | require 'gitlab-chronic' 4 | 5 | module Tickle 6 | 7 | 8 | 9 | # Contains the initial input and the result of parsing it. 10 | class Tickled < TextTube::Base 11 | register Filters 12 | 13 | # @param [String] asked The string Tickle should parse. 14 | # @param [Hash] options 15 | # @see Tickle.parse for specific options 16 | # @see ::Hash#new 17 | def initialize(asked, options={}, &block) 18 | fail ArgumentError, "You must pass a string to Tickled.new" if asked.nil? 19 | 20 | 21 | default_options = { 22 | :start => Time.now, 23 | :next_only => false, 24 | :until => nil, 25 | :now => Time.now, 26 | } 27 | 28 | unless options.nil? || options.empty? 29 | # ensure the specified options are valid 30 | options.keys.each do |key| 31 | fail(ArgumentError, "#{key} is not a valid option key.") unless default_options.keys.include?(key) 32 | end 33 | 34 | [:start,:until,:now].each do |key| 35 | if options.has_key? key 36 | test_for_correctness options[key], key 37 | end 38 | end 39 | end 40 | 41 | t = 42 | if asked.respond_to?(:to_time) 43 | asked 44 | elsif (t = Time.parse(asked) rescue nil) # a legitimate use! 45 | t 46 | elsif (t = Chronic.parse("#{asked}") rescue nil) # another legitimate use! 47 | t 48 | end 49 | 50 | unless t.nil? 51 | define_singleton_method :to_time do 52 | @as_time ||= t 53 | end 54 | end 55 | 56 | @opts = default_options.merge(options) 57 | super(asked.to_s) 58 | end 59 | 60 | 61 | def asked 62 | self 63 | end 64 | 65 | 66 | def parser= parser 67 | @parser = parser 68 | end 69 | 70 | def parse! 71 | @parser.parse self 72 | end 73 | 74 | def now=( value ) 75 | @opts[:now] = value 76 | end 77 | 78 | def now 79 | @opts[:now] 80 | end 81 | 82 | def next_only=( value ) 83 | @opts[:next_only] = value 84 | end 85 | 86 | 87 | def next_only? 88 | @opts[:next_only] 89 | end 90 | 91 | 92 | # param [Date,Time,String,nil] v The value given for the key. 93 | # param [Symbol] key The name of the key being tested. 94 | def test_for_correctness( v, key ) 95 | # Must be be a time or a string or be able to convert to a time 96 | # If it is a string, must parse ok by Chronic 97 | fail ArgumentError, "The value (#{v}) given for :#{key} does not appear to be a valid date or time." unless v.respond_to?(:to_time) or (v.respond_to?(:downcase) and ::Chronic.parse(v)) 98 | end 99 | 100 | 101 | def asked 102 | self 103 | end 104 | 105 | 106 | # @param [Date,Time,String] 107 | def asked=( text ) 108 | #test_for_correctness text, :asked 109 | @opts[:asked] = text 110 | end 111 | 112 | def start 113 | @opts[:start] ||= Time.now 114 | end 115 | 116 | def start=( value ) 117 | @opts[:start] = test_for_correctness value, :start 118 | end 119 | 120 | def until 121 | @opts[:until] ||= Tickled.new( Time.now ) 122 | end 123 | 124 | def until=( value ) 125 | @opts[:until] = test_for_correctness value, :until 126 | end 127 | 128 | 129 | [:starting, :ending, :event].each do |meth| 130 | define_method meth do 131 | @opts[meth] 132 | end 133 | define_method "#{meth}=" do |value| 134 | @opts[meth] = Tickled.new value 135 | end 136 | end 137 | 138 | 139 | def event 140 | @opts[:event] ||= self 141 | end 142 | def event= value 143 | @opts[:event] = Tickled.new value 144 | end 145 | 146 | 147 | def filtered=(filtered_text) 148 | @filtered = filtered_text 149 | end 150 | 151 | def filtered 152 | @filtered 153 | end 154 | 155 | 156 | def to_s 157 | self 158 | end 159 | 160 | def blank? 161 | if respond_to? :empty? 162 | empty? || !self 163 | elsif respond_to? :localtime 164 | false 165 | end 166 | end 167 | 168 | # def inspect 169 | # "#{self} #{@opts.inspect}" 170 | # end 171 | 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /benchmarks/main.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require_relative "../lib/tickle.rb" 3 | warn Benchmark.measure { 4 | Tickle.parse('day') 5 | # Tickle.parse('week') 6 | Tickle.parse('month') 7 | Tickle.parse('year') 8 | Tickle.parse('daily') 9 | # Tickle.parse('weekly') 10 | Tickle.parse('monthly') 11 | Tickle.parse('yearly') 12 | Tickle.parse('3 days') 13 | # Tickle.parse('3 weeks') 14 | Tickle.parse('3 months') 15 | Tickle.parse('3 years') 16 | Tickle.parse('other day') 17 | # Tickle.parse('other week') 18 | Tickle.parse('other month') 19 | Tickle.parse('other year') 20 | Tickle.parse('Monday') 21 | Tickle.parse('Wednesday') 22 | Tickle.parse('Friday') 23 | Tickle.parse('February', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 24 | Tickle.parse('May', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 25 | Tickle.parse('june', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 26 | Tickle.parse('beginning of the week') 27 | Tickle.parse('middle of the week') 28 | Tickle.parse('end of the week') 29 | Tickle.parse('beginning of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 30 | Tickle.parse('middle of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 31 | Tickle.parse('end of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 32 | Tickle.parse('beginning of the year', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 33 | Tickle.parse('middle of the year', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 34 | Tickle.parse('end of the year', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 35 | Tickle.parse('the 3rd of May', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 36 | Tickle.parse('the 3rd of February', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 37 | Tickle.parse('the 3rd of February 2022', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 38 | Tickle.parse('the 3rd of Feb 2022', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 39 | Tickle.parse('the 4th of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 40 | Tickle.parse('the 10th of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 41 | Tickle.parse('the tenth of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 42 | Tickle.parse('first', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 43 | Tickle.parse('the first of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 44 | Tickle.parse('the thirtieth', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 45 | Tickle.parse('the fifth', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 46 | Tickle.parse('the 1st Wednesday of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 47 | Tickle.parse('the 3rd Sunday of May', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 48 | Tickle.parse('the 3rd Sunday of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 49 | Tickle.parse('the 23rd of June', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 50 | Tickle.parse('the twenty third of June', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 51 | Tickle.parse('the thirty first of July', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 52 | Tickle.parse('the twenty first', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 53 | Tickle.parse('the twenty first of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) 54 | Tickle.parse('starting today and ending one week from now') 55 | Tickle.parse('starting tomorrow and ending one week from now') 56 | Tickle.parse('starting Monday repeat every month') 57 | Tickle.parse('starting May 13th repeat every week') 58 | Tickle.parse('starting May 13th repeat every other day') 59 | Tickle.parse('every other day starts May 13th') 60 | Tickle.parse('every other day starts May 13') 61 | Tickle.parse('every other day starting May 13th') 62 | Tickle.parse('every other day starting May 13') 63 | Tickle.parse('every week starts this wednesday') 64 | Tickle.parse('every week starting this wednesday') 65 | Tickle.parse('every other day starting May 1st 2021') 66 | Tickle.parse('every other day starting May 1 2021') 67 | Tickle.parse('every other week starting this Sunday') 68 | Tickle.parse('every week starting this wednesday until May 13th') 69 | Tickle.parse('every week starting this wednesday ends May 13th') 70 | Tickle.parse('every week starting this wednesday ending May 13th') 71 | } -------------------------------------------------------------------------------- /lib/tickle/repeater.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | require_relative "../ext/string.rb" 3 | class Repeater 4 | require_relative "token.rb" 5 | 6 | attr_reader :tokens 7 | 8 | def initialize( tokens ) 9 | @tokens = tokens.map(&:clone) 10 | end 11 | 12 | 13 | SCANNING_METHODS = [ 14 | :scan_for_numbers, 15 | :scan_for_ordinal_names, 16 | :scan_for_ordinals, 17 | :scan_for_month_names, 18 | :scan_for_day_names, 19 | :scan_for_year_name, 20 | :scan_for_special_text, 21 | :scan_for_units, 22 | ] 23 | 24 | 25 | # 26 | def scan! 27 | # for each token 28 | @tokens.each do |token| 29 | new_details = catch(:token_found) { 30 | SCANNING_METHODS.each{|meth| 31 | send meth, token 32 | } 33 | nil # if nothing matched, set to nil 34 | } 35 | token.update! new_details if new_details 36 | end 37 | self 38 | end 39 | 40 | 41 | def detection(token, scanner, &block ) 42 | scanner = [scanner] unless scanner.respond_to? :keys 43 | scanner.each do |key,value| 44 | if (md = key.match token.downcase) or (md = key.match token.word) 45 | throw :token_found, block.call(md,key,value) 46 | end 47 | end 48 | nil # if it reaches here nothing was found so return nil 49 | end 50 | 51 | 52 | SCAN_FOR_NUMBERS = / 53 | \b 54 | (?\d\d?) 55 | \b 56 | /x 57 | 58 | def scan_for_numbers(token) 59 | detection token, SCAN_FOR_NUMBERS do |md,key,value| 60 | n = md[:number].to_i 61 | {type: :number, start: n, interval: n } 62 | end 63 | end 64 | 65 | 66 | SCAN_FOR_ORDINAL_NAMES = { 67 | /first/ => Ordinal.new( '1st' ), 68 | /second\b/ => Ordinal.new( '2nd' ), 69 | /third/ => Ordinal.new( '3rd' ), 70 | /fourth/ => Ordinal.new( '4th' ), 71 | /fifth/ => Ordinal.new( '5th' ), 72 | /sixth/ => Ordinal.new( '6th' ), 73 | /seventh/ => Ordinal.new( '7th' ), 74 | /eighth/ => Ordinal.new( '8th' ), 75 | /ninth/ => Ordinal.new( '9th' ), 76 | /tenth/ => Ordinal.new( '10th' ), 77 | /eleventh/ => Ordinal.new( '11th' ), 78 | /twelfth/ => Ordinal.new( '12th' ), 79 | /thirteenth/ => Ordinal.new( '13th' ), 80 | /fourteenth/ => Ordinal.new( '14th' ), 81 | /fifteenth/ => Ordinal.new( '15th' ), 82 | /sixteenth/ => Ordinal.new( '16th' ), 83 | /seventeenth/ => Ordinal.new( '17th' ), 84 | /eighteenth/ => Ordinal.new( '18th' ), 85 | /nineteenth/ => Ordinal.new( '19th' ), 86 | /twentieth/ => Ordinal.new( '20th' ), 87 | /thirtieth/ => Ordinal.new( '30th' ), 88 | } 89 | 90 | 91 | def scan_for_ordinal_names(token) 92 | detection token, SCAN_FOR_ORDINAL_NAMES do |md,key,value| 93 | { :type => :ordinal, 94 | :start => value.ordinal_as_number, 95 | :interval => Tickle::Helpers.days_in_month( Tickle::Helpers.get_next_month( value.ordinal_as_number )), 96 | } 97 | end 98 | end 99 | 100 | 101 | SCAN_FOR_ORDINALS = / 102 | \b 103 | (?\d+) 104 | (?: 105 | st 106 | | 107 | nd 108 | | 109 | rd 110 | |th 111 | ) 112 | \b 113 | /x 114 | 115 | def scan_for_ordinals(token) 116 | detection token, SCAN_FOR_ORDINALS do |md,key,value| 117 | number = Ordinal.new(md[:number]) 118 | { :type => :ordinal, 119 | :start => number.ordinal_as_number, 120 | :interval => Tickle::Helpers.days_in_month(Tickle::Helpers.get_next_month number ) 121 | } 122 | end 123 | end 124 | 125 | 126 | def scan_for_month_names(token) 127 | scanner = {/^jan\.?(uary)?$/ => 1, 128 | /^feb\.?(ruary)?$/ => 2, 129 | /^mar\.?(ch)?$/ => 3, 130 | /^apr\.?(il)?$/ => 4, 131 | /^may$/ => 5, 132 | /^jun\.?e?$/ => 6, 133 | /^jul\.?y?$/ => 7, 134 | /^aug\.?(ust)?$/ => 8, 135 | /^sep\.?(t\.?|tember)?$/ => 9, 136 | /^oct\.?(ober)?$/ => 10, 137 | /^nov\.?(ember)?$/ => 11, 138 | /^dec\.?(ember)?$/ => 12} 139 | detection token, scanner do |md,key,value| 140 | { 141 | :type => :month_name, 142 | :start => value, 143 | :interval => 30, 144 | } 145 | end 146 | end 147 | 148 | 149 | def scan_for_day_names(token) 150 | scanner = { 151 | /^m[ou]n(day)?$/ => :monday, 152 | /^t(ue|eu|oo|u|)s(day)?$/ => :tuesday, 153 | /^tue$/ => :tuesday, 154 | /^we(dnes|nds|nns)day$/ => :wednesday, 155 | /^wed$/ => :wednesday, 156 | /^th(urs|ers)day$/ => :thursday, 157 | /^thu$/ => :thursday, 158 | /^fr[iy](day)?$/ => :friday, 159 | /^sat(t?[ue]rday)?$/ => :saturday, 160 | /^su[nm](day)?$/ => :sunday 161 | } 162 | detection token, scanner do |md,key,value| 163 | { 164 | :type => :weekday, 165 | :start => value, 166 | :interval => 7, 167 | } 168 | end 169 | end 170 | 171 | 172 | def scan_for_year_name(token) 173 | detection token, /\b(?\d{4})\b/ do |md,key,value| 174 | { 175 | :type => :specific_year, 176 | :start => md[:year], 177 | :interval => 365, 178 | } 179 | end 180 | end 181 | 182 | 183 | def scan_for_special_text(token) 184 | scanner = { 185 | /^other$/ => :other, 186 | /^begin(ing|ning)?$/ => :beginning, 187 | /^start$/ => :beginning, 188 | /^end$/ => :end, 189 | /^mid(d)?le$/ => :middle 190 | } 191 | detection token, scanner do |md,key,value| 192 | { 193 | :type => :special, 194 | :start => value, 195 | :interval => 7, 196 | } 197 | end 198 | end 199 | 200 | 201 | def scan_for_units(token) 202 | scanner = { 203 | /^year(?:ly)?s?$/ => { 204 | :type => :year, 205 | :interval => 365, 206 | :start => :today 207 | }, 208 | /^month(?:ly|s)?$/ => { 209 | :type => :month, 210 | :interval => 30, 211 | :start => :today 212 | }, 213 | /^fortnights?$/ => { 214 | :type => :fortnight, 215 | :interval => 14, 216 | :start => :today 217 | }, 218 | /^week(?:ly|s)?$/ => { 219 | :type => :week, 220 | :interval => 7, 221 | :start => :today 222 | }, 223 | /^weekends?$/ => { 224 | :type => :weekend, 225 | :interval => 7, 226 | :start => :saturday 227 | }, 228 | /^days?$/ => { 229 | :type => :day, 230 | :interval => 1, 231 | :start => :today 232 | }, 233 | /^daily$/ => { 234 | :type => :day, 235 | :interval => 1, 236 | :start => :today 237 | }, 238 | /^sec(?:onds)?$/ => { 239 | :type => :sec, 240 | :interval => 1, 241 | :start => :today 242 | }, 243 | } 244 | 245 | detection token, scanner do |md,key,value| 246 | value 247 | end 248 | end 249 | 250 | 251 | end 252 | end -------------------------------------------------------------------------------- /spec/patterns_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require_relative "../lib/tickle/patterns.rb" 3 | 4 | module Tickle # for convenience 5 | 6 | # TODO move these to patterns_spec 7 | describe "Patterns" do 8 | let(:example1) { "every thursday starting tomorrow until May 15th" } 9 | let(:example2) { "starting thursday every Tuesday until May 15th" } 10 | let(:example3) { "every thursday starting tomorrow until May 15th" } 11 | let(:example4) { "starting thursday on the 5th day of each month until May 15th" } 12 | describe "START_EVERY_REGEX" do 13 | context "Given example1" do 14 | subject { Patterns::START_EVERY_REGEX.match example1 } 15 | it { should be_nil } 16 | end 17 | context "Given example2" do 18 | subject { Patterns::START_EVERY_REGEX.match example2 } 19 | it { should_not be_nil } 20 | end 21 | context "Given example3" do 22 | subject { Patterns::START_EVERY_REGEX.match example3 } 23 | it { should be_nil } 24 | end 25 | context "Given example4" do 26 | subject { Patterns::START_EVERY_REGEX.match example4 } 27 | it { should_not be_nil } 28 | end 29 | end 30 | describe "EVERY_START_REGEX" do 31 | context "Given example1" do 32 | subject { Patterns::EVERY_START_REGEX.match example1 } 33 | it { should_not be_nil } 34 | end 35 | context "Given example2" do 36 | subject { Patterns::EVERY_START_REGEX.match example2 } 37 | it { should be_nil } 38 | end 39 | context "Given example3" do 40 | subject { Patterns::EVERY_START_REGEX.match example3 } 41 | it { should_not be_nil } 42 | end 43 | context "Given example4" do 44 | subject { Patterns::EVERY_START_REGEX.match example4 } 45 | it { should be_nil } 46 | end 47 | end 48 | describe "START_ENDING_REGEX" do 49 | context "Given example1" do 50 | subject { Patterns::START_ENDING_REGEX.match example1 } 51 | it { should be_nil } 52 | end 53 | context "Given example2" do 54 | subject { Patterns::START_ENDING_REGEX.match example2 } 55 | it { should_not be_nil } 56 | end 57 | context "Given example3" do 58 | subject { Patterns::START_ENDING_REGEX.match example3 } 59 | it { should be_nil } 60 | end 61 | context "Given example4" do 62 | subject { Patterns::START_ENDING_REGEX.match example4 } 63 | it { should_not be_nil } 64 | end 65 | end 66 | 67 | 68 | describe "SET_IDENTIFIER" do 69 | subject { Patterns::SET_IDENTIFIER } 70 | context "Given 'every'" do 71 | it { should match "every" } 72 | end 73 | context "Given 'each'" do 74 | it { should match "each" } 75 | end 76 | context "Given 'on'" do 77 | it { should match "on" } 78 | end 79 | context "Given 'on the'" do 80 | it { should match "on the" } 81 | end 82 | end 83 | 84 | describe "REPETITION" do 85 | subject { Patterns::REPETITION } 86 | context "Given 'repeat'" do 87 | it { should match "repeat" } 88 | end 89 | end 90 | 91 | describe "ON_THE" do 92 | subject { Patterns::ON_THE } 93 | context "Given 'on'" do 94 | it { should match "on" } 95 | end 96 | context "Given 'on the'" do 97 | it { should match "on the" } 98 | end 99 | end 100 | 101 | describe "END_OR_UNTIL" do 102 | subject { Patterns::END_OR_UNTIL } 103 | context "Given 'end'" do 104 | it { should match "end" } 105 | end 106 | context "Given 'ends'" do 107 | it { should match "ends" } 108 | end 109 | context "Given 'ends on'" do 110 | it { should match "ends on" } 111 | end 112 | context "Given 'ends on the'" do 113 | it { should match "ends on the" } 114 | end 115 | context "Given 'ending'" do 116 | it { should match "ending" } 117 | end 118 | context "Given 'ending on'" do 119 | it { should match "ending on" } 120 | end 121 | context "Given 'ending on the'" do 122 | it { should match "ending on the" } 123 | end 124 | context "Given 'until'" do 125 | it { should match "until" } 126 | end 127 | context "Given 'until the'" do 128 | it { should match "until the" } 129 | end 130 | context "Given 'send'" do 131 | it { should_not match "send" } 132 | end 133 | end 134 | 135 | describe "PLURAL_OR_PRESENT_PARTICIPLE" do 136 | subject { Patterns::PLURAL_OR_PRESENT_PARTICIPLE } 137 | context "Given 's'" do 138 | it { should match "s" } 139 | end 140 | context "Given 'ing'" do 141 | it { should match "ing" } 142 | end 143 | end 144 | 145 | describe "START" do 146 | subject { Patterns::START } 147 | context "Given 'starting'" do 148 | it { should match "starting" } 149 | end 150 | context "Given 'starts'" do 151 | it { should match "starts" } 152 | end 153 | context "Given 'start'" do 154 | it { should match "start" } 155 | end 156 | end 157 | 158 | describe "START_EVERY_REGEX" do 159 | subject { Patterns::START_EVERY_REGEX } 160 | context "Given 'starting today on the 12th'" do 161 | let(:phrase) { "starting today on the 12th" } 162 | it { should match phrase } 163 | its(:names) { should =~ %w{start event repeat} } 164 | describe "Captures" do 165 | subject { Patterns::START_EVERY_REGEX.match phrase } 166 | its([:start]) { should == "today" } 167 | its([:event]) { should == "12th" } 168 | end 169 | end 170 | context "Given 'starting Monday repeat every month'" do 171 | let(:phrase) { "starting Monday repeat every month" } 172 | it { should match phrase } 173 | describe "Captures" do 174 | subject { Patterns::START_EVERY_REGEX.match phrase } 175 | its([:start]) { should == "Monday" } 176 | its([:event]) { should == "month" } 177 | end 178 | end 179 | end 180 | 181 | describe "START_ENDING_REGEX" do 182 | subject { Patterns::START_ENDING_REGEX } 183 | context "Given 'starting today until the 12th'" do 184 | let(:phrase) { "starting today until the 12th" } 185 | it { should match phrase } 186 | describe "Captures" do 187 | subject { Patterns::START_ENDING_REGEX.match phrase } 188 | its([:start]) { should == "today" } 189 | its([:finish]) { should == "12th" } 190 | end 191 | end 192 | context "Given 'starting today and ending one week from now'" do 193 | let(:phrase) { "starting today and ending one week from now"} 194 | it { should match phrase } 195 | describe "Captures" do 196 | subject { Patterns::START_ENDING_REGEX.match phrase } 197 | its([:start]) { should == "today" } 198 | its([:finish]) { should == "one week from now" } 199 | end 200 | end 201 | end 202 | 203 | describe "EVERY_START_REGEX" do 204 | subject { Patterns::EVERY_START_REGEX } 205 | context "Given 'every Monday starting the 12th'" do 206 | let(:phrase) { "every Monday starting on the 12th" } 207 | it { should match phrase } 208 | describe "Captures" do 209 | subject { Patterns::EVERY_START_REGEX.match phrase } 210 | its([:start]) { should == "12th" } 211 | its([:event]) { should == "Monday" } 212 | end 213 | end 214 | end 215 | 216 | describe "PROCESS_FOR_ENDING" do 217 | subject { Patterns::PROCESS_FOR_ENDING } 218 | context "Given 'Monday until the 12th'" do 219 | let(:phrase) { "Monday until the 12th" } 220 | it { should match phrase } 221 | describe "Captures" do 222 | subject { Patterns::PROCESS_FOR_ENDING.match phrase } 223 | its([:target]) { should == "Monday" } 224 | its([:ending]) { should == "12th" } 225 | end 226 | end 227 | context "Given 'Tuesday ending on the 12th'" do 228 | let(:phrase) { "Tuesday ending on the 12th" } 229 | it { should match phrase } 230 | describe "Captures" do 231 | subject { Patterns::PROCESS_FOR_ENDING.match phrase } 232 | its([:target]) { should == "Tuesday" } 233 | its([:ending]) { should == "12th" } 234 | end 235 | end 236 | end 237 | 238 | 239 | end 240 | end -------------------------------------------------------------------------------- /lib/tickle/tickle.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2010 Joshua Lippiner 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | module Tickle 23 | 24 | require_relative "patterns.rb" 25 | require 'numerizer' 26 | require_relative "helpers.rb" 27 | require_relative "token.rb" 28 | require_relative "tickled.rb" 29 | 30 | 31 | class << self 32 | 33 | 34 | # == Configuration options 35 | # 36 | # @param [String] text The string Tickle should parse. 37 | # @param [Hash] specified_options See actual defaults below. 38 | # @option specified_options [Date,Time,String] :start (Time.now) Start date for future occurrences. Must be in valid date format. 39 | # @option specified_options [Date,Time,String] :until (nil) Last date to run occurrences until. Must be in valid date format. 40 | # @option specified_options [true,false] :next_only (false) 41 | # @option specified_options [Date,Time] :now (Time.now) 42 | # @return [Hash] 43 | # 44 | # @example Use by calling Tickle.parse and passing natural language with or without options. 45 | # Tickle.parse("every Tuesday") 46 | # # => {:next=>2014-08-26 12:00:00 0100, :expression=>"tuesday", :starting=>2014-08-25 16:31:12 0100, :until=>nil} 47 | # 48 | def _parse( tickled ) 49 | 50 | # check to see if this event starts some other time and reset now 51 | scan_expression! tickled 52 | 53 | fail(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur in the past for a future event") if @start && @start.to_date < tickled.now.to_date 54 | fail(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur after the end date") if @until && @start.to_date > @until.to_date 55 | 56 | # no need to guess at expression if the start_date is in the future 57 | best_guess = nil 58 | if @start.to_date > tickled.now.to_date 59 | best_guess = @start 60 | else 61 | # put the text into a normal format to ease scanning using Chronic 62 | tickled.filtered = tickled.event.filter 63 | # split into tokens and then 64 | # process each original word for implied word 65 | @tokens = post_tokenize Token.tokenize(tickled.filtered) 66 | 67 | # scan the tokens with each token scanner 68 | @tokens = Token.scan!(@tokens) 69 | 70 | # remove all tokens without a type 71 | @tokens.reject! {|token| token.type.nil? } 72 | 73 | # combine number and ordinals into single number 74 | @tokens = Helpers.combine_multiple_numbers(@tokens) 75 | 76 | # if we can't guess it maybe chronic can 77 | _guess = guess(@tokens, @start) 78 | best_guess = _guess || chronic_parse(tickled.event) # TODO fix this call 79 | end 80 | 81 | fail(InvalidDateExpression, "the next occurrence takes place after the end date specified") if @until && (best_guess.to_date > @until.to_date) 82 | if !best_guess 83 | return nil 84 | elsif !tickled.next_only? 85 | return {:next => best_guess.to_time, :expression => tickled.event.filter, :starting => @start, :until => @until} 86 | else 87 | return best_guess 88 | end 89 | end 90 | 91 | 92 | # scans the expression for a variety of natural formats, such as 'every thursday starting tomorrow until May 15th 93 | def scan_expression!(tickled) 94 | starting,ending,event = nil, nil, nil 95 | if (md = Patterns::START_EVERY_REGEX.match tickled) 96 | starting = md[:start].strip 97 | text = md[:event].strip 98 | event, ending = process_for_ending(text) 99 | elsif (md = Patterns::EVERY_START_REGEX.match tickled) 100 | event = md[:event].strip 101 | text = md[:start].strip 102 | starting, ending = process_for_ending(text) 103 | elsif (md = Patterns::START_ENDING_REGEX.match tickled) 104 | starting = md[:start].strip 105 | ending = md[:finish].strip 106 | event = 'day' 107 | else 108 | event, ending = process_for_ending(text) 109 | end 110 | tickled.starting = starting unless starting.nil? 111 | tickled.event = event unless event.nil? 112 | tickled.ending = ending unless ending.nil? 113 | # they gave a phrase so if we can't interpret then we need to raise an error 114 | if tickled.starting && !tickled.starting.to_s.blank? 115 | @start = chronic_parse(tickled.starting,tickled, :start) 116 | if @start 117 | @start.to_time 118 | else 119 | fail(InvalidDateExpression,"the starting date expression \"#{tickled.starting}\" could not be interpretted") 120 | end 121 | else 122 | @start = tickled.start && tickled.start.to_time 123 | end 124 | 125 | 126 | if tickled.ending && !tickled.ending.blank? 127 | @until = chronic_parse(tickled.ending.filter,tickled, :until) 128 | if @until 129 | @until.to_time 130 | else 131 | fail(InvalidDateExpression,"the ending date expression \"#{tickled.ending}\" could not be interpretted") 132 | end 133 | else 134 | @until = 135 | if tickled.starting && !tickled.starting.to_s.blank? 136 | if tickled.until && !tickled.until.to_s.blank? 137 | if tickled.until.to_time > @start 138 | tickled.until.to_time 139 | end 140 | end 141 | end 142 | end 143 | 144 | @next = nil 145 | tickled 146 | end 147 | 148 | 149 | # process the remaining expression to see if an until, end, ending is specified 150 | def process_for_ending(text) 151 | (md = Patterns::PROCESS_FOR_ENDING.match text) ? 152 | [ md[:target], md[:ending] ] : 153 | [text, nil] 154 | end 155 | 156 | # normalizes each token 157 | def post_tokenize(tokens) 158 | _tokens = tokens.map(&:clone) 159 | _tokens.each do |token| 160 | token.normalize! 161 | end 162 | _tokens 163 | end 164 | 165 | 166 | # Returns an array of types for all tokens 167 | def token_types 168 | @tokens.map(&:type) 169 | end 170 | 171 | 172 | # Returns the next available month based on the current day of the month. 173 | # For example, if get_next_month(15) is called and the start date is the 10th, then it will return the 15th of this month. 174 | # However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month. 175 | def get_next_month(number) 176 | month = number.to_i < @start.day ? (@start.month == 12 ? 1 : @start.month + 1) : @start.month 177 | end 178 | 179 | 180 | def next_appropriate_year(month, day) 181 | year = (Date.new(@start.year.to_i, month.to_i, day.to_i) == @start.to_date) ? @start.year + 1 : @start.year 182 | return year 183 | end 184 | 185 | 186 | private 187 | 188 | 189 | # slightly modified chronic parser to ensure that the date found is in the future 190 | # first we check to see if an explicit date was passed and, if so, dont do anything. 191 | # if, however, a date expression was passed we evaluate and shift forward if needed 192 | def chronic_parse(exp, tickled, start_or_until) 193 | exp = Ordinal.new exp 194 | result = 195 | if r = Chronic.parse(exp.ordinal_as_number, :now => tickled.now) 196 | r 197 | elsif r = (start_or_until && tickled[start_or_until]) 198 | r 199 | elsif r = (start_or_until == :start && tickled.now) 200 | r 201 | end 202 | if result && result.to_time < Time.now 203 | result = Time.local(result.year + 1, result.month, result.day, result.hour, result.min, result.sec) 204 | end 205 | result 206 | end 207 | 208 | end 209 | 210 | 211 | 212 | # This exception is raised if there is an issue with the parsing 213 | # output from the date expression provided 214 | class InvalidDateExpression < Exception 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /lib/tickle/handler.rb: -------------------------------------------------------------------------------- 1 | module Tickle 2 | 3 | require_relative "helpers.rb" 4 | require_relative "token.rb" 5 | 6 | 7 | # The heavy lifting. Goes through each token groupings to determine what natural language should either by 8 | # parsed by Chronic or returned. This methodology makes extension fairly simple, as new token types can be 9 | # easily added in repeater and then processed by the guess method 10 | # 11 | def self.guess(tokens, start) 12 | return nil if tokens.empty? 13 | 14 | _next = catch(:guessed) { 15 | %w{guess_unit_types guess_weekday guess_month_names guess_number_and_unit guess_ordinal guess_ordinal_and_unit guess_special}.each do |meth| # TODO pick better enumerator 16 | send meth, tokens, start 17 | end 18 | nil # stop each sending the array to _next 19 | } 20 | 21 | # check to see if next is less than now and, if so, set it to next year 22 | if _next && 23 | _next.to_date < start.to_date 24 | _next = Time.local(_next.year + 1, _next.month, _next.day, _next.hour, _next.min, _next.sec) 25 | end 26 | # return the next occurrence 27 | _next.to_time if _next 28 | end 29 | 30 | 31 | def self.guess_unit_types( tokens, start) 32 | [:sec,:day,:week,:month,:year].each {|unit| 33 | if Token.types(tokens).same?([unit]) 34 | throw :guessed, start.bump(unit) 35 | end 36 | } 37 | nil 38 | end 39 | 40 | 41 | def self.guess_weekday( tokens, start) 42 | if Token.types(tokens).same? [:weekday] 43 | throw :guessed, chronic_parse_with_start( 44 | "#{Token.token_of_type(:weekday,tokens).start.to_s}", start 45 | ) 46 | end 47 | nil 48 | end 49 | 50 | 51 | def self.guess_month_names( tokens, start) 52 | if Token.types(tokens).same? [:month_name] 53 | throw :guessed, chronic_parse_with_start( 54 | "#{Date::MONTHNAMES[Token.token_of_type(:month_name,tokens).start]} 1", start 55 | ) 56 | end 57 | nil 58 | end 59 | 60 | 61 | def self.guess_number_and_unit( tokens, start) 62 | _next = 63 | [:sec,:day,:week,:month,:year].each {|unit| 64 | if Token.types(tokens).same?([:number, unit]) 65 | throw :guessed, start.bump( unit, Token.token_of_type(:number,tokens).interval ) 66 | end 67 | } 68 | 69 | if Token.types(tokens).same?([:number, :month_name]) 70 | throw :guessed, chronic_parse_with_start( 71 | "#{Token.token_of_type(:month_name,tokens, start).word} #{Token.token_of_type(:number,tokens).start}", start 72 | ) 73 | end 74 | 75 | if Token.types(tokens).same?([:number, :month_name, :specific_year]) 76 | throw :guessed, chronic_parse_with_start( 77 | [ 78 | Token.token_of_type(:specific_year,tokens, start).word, 79 | Token.token_of_type(:month_name,tokens).start, 80 | Token.token_of_type(:number,tokens).start 81 | ].join("_"), start 82 | ) 83 | end 84 | nil 85 | end 86 | 87 | 88 | def self.guess_ordinal( tokens, start) 89 | if Token.types(tokens).same?([:ordinal]) 90 | throw :guessed, handle_same_day_chronic_issue( 91 | start.year, start.month, Token.token_of_type(:ordinal,tokens).start, start 92 | ) 93 | end 94 | nil 95 | end 96 | 97 | 98 | def self.guess_ordinal_and_unit( tokens, start) 99 | if Token.types(tokens).same?([:ordinal, :month_name]) 100 | throw :guessed, handle_same_day_chronic_issue( 101 | start.year, Token.token_of_type(:month_name,tokens).start, Token.token_of_type(:ordinal,tokens).start, start 102 | ) 103 | nil 104 | end 105 | 106 | if Token.types(tokens).same?([:ordinal, :month]) 107 | throw :guessed, handle_same_day_chronic_issue( 108 | start.year, 109 | start.month, 110 | Token.token_of_type(:ordinal,tokens).start, 111 | start 112 | ) 113 | nil 114 | end 115 | 116 | if Token.types(tokens).same?([:ordinal, :month_name, :specific_year]) 117 | throw :guessed, handle_same_day_chronic_issue( 118 | Token.token_of_type(:specific_year,tokens).word, Token.token_of_type(:month_name,tokens).start, Token.token_of_type(:ordinal,tokens).start, start 119 | ) 120 | nil 121 | end 122 | 123 | if Token.types(tokens).same?([:ordinal, :weekday, :month_name]) 124 | _next = chronic_parse_with_start( 125 | "#{Token.token_of_type(:ordinal,tokens).word} #{Token.token_of_type(:weekday,tokens).start.to_s} in #{Date::MONTHNAMES[Token.token_of_type(:month_name,tokens).start]}", start 126 | ) 127 | if _next.to_date == start.to_date 128 | throw :guessed, handle_same_day_chronic_issue(start.year, Token.token_of_type(:month_name,tokens).start, Token.token_of_type(:ordinal,tokens).start, start) 129 | end 130 | throw :guessed, _next 131 | nil 132 | end 133 | 134 | if Token.types(tokens).same?([:ordinal, :weekday, :month]) 135 | _next = chronic_parse_with_start( 136 | "#{Token.token_of_type(:ordinal,tokens).word} #{Token.token_of_type(:weekday,tokens).start.to_s} in #{Date::MONTHNAMES[get_next_month(Token.token_of_type(:ordinal,tokens).start)]}", start 137 | ) 138 | _next = 139 | if _next.to_date == start.to_date 140 | handle_same_day_chronic_issue( 141 | start.year, start.month, Token.token_of_type(:ordinal,tokens).start, start 142 | ) 143 | else 144 | _next 145 | end 146 | throw :guessed, _next 147 | nil 148 | end 149 | nil 150 | end 151 | 152 | 153 | def self.guess_special( tokens, start) 154 | guess_special_other tokens, start 155 | guess_special_beginning tokens, start 156 | guess_special_middle tokens, start 157 | guess_special_end tokens, start 158 | nil 159 | end 160 | 161 | private 162 | 163 | def self.guess_special_other( tokens, start) 164 | if Token.types(tokens).same?([:special, :day]) && 165 | Token.token_of_type(:special, tokens).start == :other 166 | throw :guessed, start.bump(:day, 2) 167 | nil 168 | end 169 | 170 | if Token.types(tokens).same?([:special, :week]) && 171 | Token.token_of_type(:special, tokens).start == :other 172 | throw :guessed, start.bump(:week, 2) 173 | nil 174 | end 175 | 176 | if Token.types(tokens).same?([:special, :month]) && 177 | Token.token_of_type(:special, tokens).start == :other 178 | throw :guessed, chronic_parse_with_start('2 months from now', start) 179 | nil 180 | end 181 | 182 | if Token.types(tokens).same?([:special, :year]) && 183 | Token.token_of_type(:special, tokens).start == :other 184 | throw :guessed, chronic_parse_with_start('2 years from now', start) 185 | nil 186 | end 187 | nil 188 | end 189 | 190 | 191 | def self.guess_special_beginning( tokens, start) 192 | if Token.types(tokens).same?([:special, :week]) && 193 | Token.token_of_type(:special, tokens).start == :beginning 194 | throw :guessed, chronic_parse_with_start('Sunday', start) 195 | nil 196 | end 197 | if Token.types(tokens).same?([:special, :month]) && 198 | Token.token_of_type(:special, tokens).start == :beginning 199 | throw :guessed, Date.civil(start.year, start.month + 1, 1) 200 | nil 201 | end 202 | if Token.types(tokens).same?([:special, :year]) && 203 | Token.token_of_type(:special, tokens).start == :beginning 204 | throw :guessed, Date.civil(start.year+1, 1, 1) 205 | nil 206 | end 207 | nil 208 | end 209 | 210 | def self.guess_special_middle( tokens, start) 211 | if Token.types(tokens).same?([:special, :week]) && 212 | Token.token_of_type(:special, tokens).start == :middle 213 | throw :guessed, chronic_parse_with_start('Wednesday', start) 214 | nil 215 | end 216 | 217 | if Token.types(tokens).same?([:special, :month]) && 218 | Token.token_of_type(:special, tokens).start == :middle 219 | _next = start.day > 15 ? 220 | Date.civil(start.year, start.month + 1, 15) : 221 | Date.civil(start.year, start.month, 15) 222 | throw :guessed, _next 223 | nil 224 | end 225 | 226 | if Token.types(tokens).same?([:special, :year]) && 227 | Token.token_of_type(:special, tokens).start == :middle 228 | _next = 229 | start.day > 15 && start.month > 6 ? 230 | Date.new(start.year+1, 6, 15) : 231 | Date.new(start.year, 6, 15) 232 | throw :guessed, _next 233 | nil 234 | end 235 | nil 236 | end 237 | 238 | 239 | def self.guess_special_end( tokens, start) 240 | if Token.types(tokens).same?([:special, :week]) && 241 | (Token.token_of_type(:special, tokens).start == :end) 242 | throw :guessed, chronic_parse_with_start('Saturday', start) 243 | nil 244 | end 245 | if Token.types(tokens).same?([:special, :month]) && 246 | (Token.token_of_type(:special, tokens).start == :end) 247 | throw :guessed, Date.civil(start.year, start.month, -1) 248 | nil 249 | end 250 | if Token.types(tokens).same?([:special, :year]) && 251 | (Token.token_of_type(:special, tokens).start == :end) 252 | throw :guessed, Date.new(start.year, 12, 31) 253 | nil 254 | end 255 | nil 256 | end 257 | 258 | 259 | # runs Chronic.parse with now being set to the specified start date for Tickle parsing 260 | def self.chronic_parse_with_start(exp,start) 261 | Chronic.parse(exp, :now => start) 262 | end 263 | 264 | # needed to handle the unique situation where a number or ordinal plus optional month or month name is passed that is EQUAL to the start date since Chronic returns that day. 265 | def self.handle_same_day_chronic_issue(year, month, day, start) 266 | arg_date = 267 | Date.new(year.to_i, month.to_i, day.to_i) == start.to_date ? 268 | Time.local(year, month+1, day) : 269 | Time.local(year, month, day) 270 | arg_date 271 | end 272 | end 273 | -------------------------------------------------------------------------------- /test/test_parsing.rb: -------------------------------------------------------------------------------- 1 | require_relative './helper.rb' 2 | require 'time' 3 | require 'test/unit' 4 | 5 | class TestParsing < Test::Unit::TestCase 6 | 7 | def setup 8 | Tickle.debug = (ARGV.detect {|a| a == '--d'}) 9 | @verbose = (ARGV.detect {|a| a == '--v'}) 10 | 11 | puts "Time.now" 12 | p Time.now 13 | 14 | @date = Date.today 15 | end 16 | 17 | def test_parse_best_guess_simple 18 | start = Date.new(2020, 04, 01) 19 | 20 | assert_date_match(@date.bump(:day, 1), 'each day') 21 | assert_date_match(@date.bump(:day, 1), 'every day') 22 | assert_date_match(@date.bump(:week, 1), 'every week') 23 | assert_date_match(@date.bump(:month, 1), 'every month') 24 | assert_date_match(@date.bump(:year, 1), 'every year') 25 | 26 | assert_date_match(@date.bump(:day, 1), 'daily') 27 | assert_date_match(@date.bump(:week, 1) , 'weekly') 28 | assert_date_match(@date.bump(:month, 1) , 'monthly') 29 | assert_date_match(@date.bump(:year, 1) , 'yearly') 30 | 31 | assert_date_match(@date.bump(:day, 3), 'every 3 days') 32 | assert_date_match(@date.bump(:week, 3), 'every 3 weeks') 33 | assert_date_match(@date.bump(:month, 3), 'every 3 months') 34 | assert_date_match(@date.bump(:year, 3), 'every 3 years') 35 | 36 | assert_date_match(@date.bump(:day, 2), 'every other day') 37 | assert_date_match(@date.bump(:week, 2), 'every other week') 38 | assert_date_match(@date.bump(:month, 2), 'every other month') 39 | assert_date_match(@date.bump(:year, 2), 'every other year') 40 | 41 | assert_date_match(@date.bump(:wday, 'Mon'), 'every Monday') 42 | assert_date_match(@date.bump(:wday, 'Wed'), 'every Wednesday') 43 | assert_date_match(@date.bump(:wday, 'Fri'), 'every Friday') 44 | 45 | assert_date_match(Date.new(2021, 2, 1), 'every February', {:start => start, :now => start}) 46 | assert_date_match(Date.new(2020, 5, 1), 'every May', {:start => start, :now => start}) 47 | assert_date_match(Date.new(2020, 6, 1), 'every june', {:start => start, :now => start}) 48 | 49 | assert_date_match(@date.bump(:wday, 'Sun'), 'beginning of the week') 50 | assert_date_match(@date.bump(:wday, 'Wed'), 'middle of the week') 51 | assert_date_match(@date.bump(:wday, 'Sat'), 'end of the week') 52 | 53 | assert_date_match(Date.new(2020, 05, 01), 'beginning of the month', {:start => start, :now => start}) 54 | assert_date_match(Date.new(2020, 04, 15), 'middle of the month', {:start => start, :now => start}) 55 | assert_date_match(Date.new(2020, 04, 30), 'end of the month', {:start => start, :now => start}) 56 | 57 | assert_date_match(Date.new(2021, 01, 01), 'beginning of the year', {:start => start, :now => start}) 58 | assert_date_match(Date.new(2020, 06, 15), 'middle of the year', {:start => start, :now => start}) 59 | assert_date_match(Date.new(2020, 12, 31), 'end of the year', {:start => start, :now => start}) 60 | 61 | assert_date_match(Date.new(2020, 05, 03), 'the 3rd of May', {:start => start, :now => start}) 62 | assert_date_match(Date.new(2021, 02, 03), 'the 3rd of February', {:start => start, :now => start}) 63 | assert_date_match(Date.new(2022, 02, 03), 'the 3rd of February 2022', {:start => start, :now => start}) 64 | assert_date_match(Date.new(2022, 02, 03), 'the 3rd of Feb, 2022', {:start => start, :now => start}) 65 | 66 | assert_date_match(Date.new(2020, 04, 04), 'the 4th of the month', {:start => start, :now => start}) 67 | assert_date_match(Date.new(2020, 04, 10), 'the 10th of the month', {:start => start, :now => start}) 68 | assert_date_match(Date.new(2020, 04, 10), 'the tenth of the month', {:start => start, :now => start}) 69 | 70 | assert_date_match(Date.new(2020, 05, 01), 'first', {:start => start, :now => start}) 71 | 72 | assert_date_match(Date.new(2020, 05, 01), 'the first of the month', {:start => start, :now => start}) 73 | assert_date_match(Date.new(2020, 04, 30), 'the thirtieth', {:start => start, :now => start}) 74 | assert_date_match(Date.new(2020, 04, 05), 'the fifth', {:start => start, :now => start}) 75 | 76 | assert_date_match(Date.new(2020, 05, 01), 'the 1st Wednesday of the month', {:start => start, :now => start}) 77 | assert_date_match(Date.new(2020, 05, 17), 'the 3rd Sunday of May', {:start => start, :now => start}) 78 | assert_date_match(Date.new(2020, 04, 19), 'the 3rd Sunday of the month', {:start => start, :now => start}) 79 | 80 | assert_date_match(Date.new(2020, 06, 23), 'the 23rd of June', {:start => start, :now => start}) 81 | assert_date_match(Date.new(2020, 06, 23), 'the twenty third of June', {:start => start, :now => start}) 82 | assert_date_match(Date.new(2020, 07, 31), 'the thirty first of July', {:start => start, :now => start}) 83 | 84 | assert_date_match(Date.new(2020, 04, 21), 'the twenty first', {:start => start, :now => start}) 85 | assert_date_match(Date.new(2020, 04, 21), 'the twenty first of the month', {:start => start, :now => start}) 86 | end 87 | 88 | def test_parse_best_guess_complex 89 | start = Date.new(2020, 04, 01) 90 | 91 | assert_tickle_match(@date.bump(:day, 1), @date, @date.bump(:week, 1), 'day', 'starting today and ending one week from now') if Time.now.hour < 21 # => demonstrates leaving out the actual time period and implying it as daily 92 | assert_tickle_match(@date.bump(:day, 1), @date.bump(:day, 1), @date.bump(:week, 1), 'day','starting tomorrow and ending one week from now') # => demonstrates leaving out the actual time period and implying it as daily. 93 | 94 | assert_tickle_match(@date.bump(:wday, 'Mon'), @date.bump(:wday, 'Mon'), nil, 'month', 'starting Monday repeat every month') 95 | 96 | year = @date >= Date.new(@date.year, 5, 13) ? @date.bump(:year,1) : @date.year 97 | assert_tickle_match(Date.new(year, 05, 13), Date.new(year, 05, 13), nil, 'week', 'starting May 13th repeat every week') 98 | assert_tickle_match(Date.new(year, 05, 13), Date.new(year, 05, 13), nil, 'other day', 'starting May 13th repeat every other day') 99 | assert_tickle_match(Date.new(year, 05, 13), Date.new(year, 05, 13), nil, 'other day', 'every other day starts May 13th') 100 | assert_tickle_match(Date.new(year, 05, 13), Date.new(year, 05, 13), nil, 'other day', 'every other day starts May 13') 101 | assert_tickle_match(Date.new(year, 05, 13), Date.new(year, 05, 13), nil, 'other day', 'every other day starting May 13th') 102 | assert_tickle_match(Date.new(year, 05, 13), Date.new(year, 05, 13), nil, 'other day', 'every other day starting May 13') 103 | 104 | assert_tickle_match(@date.bump(:wday, 'Wed'), @date.bump(:wday, 'Wed'), nil, 'week', 'every week starts this wednesday') 105 | assert_tickle_match(@date.bump(:wday, 'Wed'), @date.bump(:wday, 'Wed'), nil, 'week', 'every week starting this wednesday') 106 | 107 | assert_tickle_match(Date.new(2021, 05, 01), Date.new(2021, 05, 01), nil, 'other day', "every other day starting May 1st #{start.bump(:year, 1).year}") 108 | assert_tickle_match(Date.new(2021, 05, 01), Date.new(2021, 05, 01), nil, 'other day', "every other day starting May 1 #{start.bump(:year, 1).year}") 109 | assert_tickle_match(@date.bump(:wday, 'Sun'), @date.bump(:wday, 'Sun'), nil, 'other week', 'every other week starting this Sunday') 110 | 111 | assert_tickle_match(@date.bump(:wday, 'Wed'), @date.bump(:wday, 'Wed'), Date.new(year, 05, 13), 'week', 'every week starting this wednesday until May 13th') 112 | assert_tickle_match(@date.bump(:wday, 'Wed'), @date.bump(:wday, 'Wed'), Date.new(year, 05, 13), 'week', 'every week starting this wednesday ends May 13th') 113 | assert_tickle_match(@date.bump(:wday, 'Wed'), @date.bump(:wday, 'Wed'), Date.new(year, 05, 13), 'week', 'every week starting this wednesday ending May 13th') 114 | end 115 | 116 | def test_tickle_args 117 | actual_next_only = parse_now('May 1st, 2020', {:next_only => true}).to_date 118 | assert(Date.new(2020, 05, 01).to_date == actual_next_only, "\"May 1st, 2011\" :next parses to #{actual_next_only} but should be equal to #{Date.new(2020, 05, 01).to_date}") 119 | 120 | start_date = Time.now 121 | assert_tickle_match(start_date.bump(:day, 3), @date, nil, '3 days', 'every 3 days', {:start => start_date}) 122 | assert_tickle_match(start_date.bump(:week, 3), @date, nil, '3 weeks', 'every 3 weeks', {:start => start_date}) 123 | assert_tickle_match(start_date.bump(:month, 3), @date, nil, '3 months', 'every 3 months', {:start => start_date}) 124 | assert_tickle_match(start_date.bump(:year, 3), @date, nil, '3 years', 'every 3 years', {:start => start_date}) 125 | 126 | end_date = Date.civil(Date.today.year, Date.today.month+5, Date.today.day).to_time 127 | assert_tickle_match(start_date.bump(:day, 3), @date, start_date.bump(:month, 5), '3 days', 'every 3 days', {:start => start_date, :until => end_date}) 128 | assert_tickle_match(start_date.bump(:week, 3), @date, start_date.bump(:month, 5), '3 weeks', 'every 3 weeks', {:start => start_date, :until => end_date}) 129 | assert_tickle_match(start_date.bump(:month, 3), @date, start_date.bump(:month, 5), '3 months', 'every 3 months', {:until => end_date}) 130 | end 131 | 132 | def test_us_holidays 133 | start = Date.new(2020, 01, 01) 134 | assert_date_match(Date.new(2021, 1, 1), 'New Years Day', {:start => start, :now => start}) 135 | assert_date_match(Date.new(2020, 1, 20), 'Inauguration', {:start => start, :now => start}) 136 | assert_date_match(Date.new(2020, 1, 20), 'Martin Luther King Day', {:start => start, :now => start}) 137 | assert_date_match(Date.new(2020, 1, 20), 'MLK', {:start => start, :now => start}) 138 | assert_date_match(Date.new(2020, 2, 17), 'Presidents Day', {:start => start, :now => start}) 139 | assert_date_match(Date.new(2020, 5, 25), 'Memorial Day', {:start => start, :now => start}) 140 | assert_date_match(Date.new(2020, 7, 4), 'Independence Day', {:start => start, :now => start}) 141 | assert_date_match(Date.new(2020, 9, 7), 'Labor Day', {:start => start, :now => start}) 142 | assert_date_match(Date.new(2020, 10, 12), 'Columbus Day', {:start => start, :now => start}) 143 | assert_date_match(Date.new(2020, 11, 11), 'Veterans Day', {:start => start, :now => start}) 144 | # assert_date_match(Date.new(2020, 11, 26), 'Thanksgiving', {:start => start, :now => start}) # Chronic returning incorrect date. Routine is correct 145 | assert_date_match(Date.new(2020, 12, 25), 'Christmas', {:start => start, :now => start}) 146 | 147 | assert_date_match(Date.new(2020, 2, 2), 'Super Bowl Sunday', {:start => start, :now => start}) 148 | assert_date_match(Date.new(2020, 2, 2), 'Groundhog Day', {:start => start, :now => start}) 149 | assert_date_match(Date.new(2020, 2, 14), "Valentine's Day", {:start => start, :now => start}) 150 | assert_date_match(Date.new(2020, 3, 17), "Saint Patrick's day", {:start => start, :now => start}) 151 | assert_date_match(Date.new(2020, 4, 1), "April Fools Day", {:start => start, :now => start}) 152 | assert_date_match(Date.new(2020, 4, 22), "Earth Day", {:start => start, :now => start}) 153 | assert_date_match(Date.new(2020, 4, 24), "Arbor Day", {:start => start, :now => start}) 154 | assert_date_match(Date.new(2020, 5, 5), "Cinco De Mayo", {:start => start, :now => start}) 155 | assert_date_match(Date.new(2020, 5, 10), "Mother's Day", {:start => start, :now => start}) 156 | assert_date_match(Date.new(2020, 6, 14), "Flag Day", {:start => start, :now => start}) 157 | assert_date_match(Date.new(2020, 6, 21), "Fathers Day", {:start => start, :now => start}) 158 | assert_date_match(Date.new(2020, 10, 31), "Halloween", {:start => start, :now => start}) 159 | # assert_date_match(Date.new(2020, 11, 10), "Election Day", {:start => start, :now => start}) # Damn Chronic again. Expression is correct 160 | assert_date_match(Date.new(2020, 12, 25), 'Christmas Day', {:start => start, :now => start}) 161 | assert_date_match(Date.new(2020, 12, 24), 'Christmas Eve', {:start => start, :now => start}) 162 | assert_date_match(Date.new(2021, 1, 1), 'Kwanzaa', {:start => start, :now => start}) 163 | 164 | end 165 | 166 | def test_argument_validation 167 | assert_raise(Tickle::InvalidArgumentException) do 168 | time = Tickle.parse("may 27", :today => 'something odd') 169 | end 170 | 171 | assert_raise(Tickle::InvalidArgumentException) do 172 | time = Tickle.parse("may 27", :foo => :bar) 173 | end 174 | 175 | assert_raise(Tickle::InvalidArgumentException) do 176 | time = Tickle.parse(nil) 177 | end 178 | 179 | assert_raise(Tickle::InvalidDateExpression) do 180 | past_date = Date.civil(Date.today.year, Date.today.month, Date.today.day - 1) 181 | time = Tickle.parse("every other day", {:start => past_date}) 182 | end 183 | 184 | assert_raise(Tickle::InvalidDateExpression) do 185 | start_date = Date.civil(Date.today.year, Date.today.month, Date.today.day + 10) 186 | end_date = Date.civil(Date.today.year, Date.today.month, Date.today.day + 5) 187 | time = Tickle.parse("every other day", :start => start_date, :until => end_date) 188 | end 189 | 190 | assert_raise(Tickle::InvalidDateExpression) do 191 | end_date = Date.civil(Date.today.year, Date.today.month+2, Date.today.day) 192 | parse_now('every 3 months', {:until => end_date}) 193 | end 194 | end 195 | 196 | private 197 | def parse_now(string, options={}) 198 | out = Tickle.parse(string, {}.merge(options)) 199 | puts (options.empty? ? ("Tickle.parse('#{string}')\n\r #=> #{out}\n\r") : ("Tickle.parse('#{string}', #{options})\n\r #=> #{out}\n\r")) if @verbose 200 | out 201 | end 202 | 203 | def assert_date_match(expected_next, date_expression, options={}) 204 | actual_next = parse_now(date_expression, options)[:next].to_date 205 | assert (expected_next.to_date == actual_next.to_date), "\"#{date_expression}\" parses to #{actual_next} but should be equal to #{expected_next}" 206 | end 207 | 208 | def assert_tickle_match(expected_next, expected_start, expected_until, expected_expression, date_expression, options={}) 209 | result = parse_now(date_expression, options) 210 | actual_next = result[:next].to_date 211 | actual_start = result[:starting].to_date 212 | actual_until = result[:until].to_date rescue nil 213 | expected_until = expected_until.to_date rescue nil 214 | actual_expression = result[:expression] 215 | 216 | assert (expected_next.to_date == actual_next.to_date), "\"#{date_expression}\" :next parses to #{actual_next} but should be equal to #{expected_next}" 217 | assert (expected_start.to_date == actual_start.to_date), "\"#{date_expression}\" :starting parses to #{actual_start} but should be equal to #{expected_start}" 218 | assert (expected_until == actual_until), "\"#{date_expression}\" :until parses to #{actual_until} but should be equal to #{expected_until}" 219 | assert (expected_expression == actual_expression), "\"#{date_expression}\" :expression parses to \"#{actual_expression}\" but should be equal to \"#{expected_expression}\"" 220 | end 221 | 222 | end 223 | -------------------------------------------------------------------------------- /SCENARIOS.rdoc: -------------------------------------------------------------------------------- 1 | === EXAMPLES 2 | 3 | require 'rubygems' 4 | require 'tickle' 5 | 6 | SIMPLE 7 | Tickle.parse('day') 8 | #=> {:next=>2010-05-10 20:57:36 -0400, :expression=>"day", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 9 | 10 | Tickle.parse('day') 11 | #=> {:next=>2010-05-10 20:57:36 -0400, :expression=>"day", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 12 | 13 | Tickle.parse('week') 14 | #=> {:next=>2010-05-16 20:57:36 -0400, :expression=>"week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 15 | 16 | Tickle.parse('month') 17 | #=> {:next=>2010-06-09 20:57:36 -0400, :expression=>"month", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 18 | 19 | Tickle.parse('year') 20 | #=> {:next=>2011-05-09 20:57:36 -0400, :expression=>"year", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 21 | 22 | Tickle.parse('daily') 23 | #=> {:next=>2010-05-10 20:57:36 -0400, :expression=>"daily", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 24 | 25 | Tickle.parse('weekly') 26 | #=> {:next=>2010-05-16 20:57:36 -0400, :expression=>"weekly", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 27 | 28 | Tickle.parse('monthly') 29 | #=> {:next=>2010-06-09 20:57:36 -0400, :expression=>"monthly", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 30 | 31 | Tickle.parse('yearly') 32 | #=> {:next=>2011-05-09 20:57:36 -0400, :expression=>"yearly", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 33 | 34 | Tickle.parse('3 days') 35 | #=> {:next=>2010-05-12 20:57:36 -0400, :expression=>"3 days", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 36 | 37 | Tickle.parse('3 weeks') 38 | #=> {:next=>2010-05-30 20:57:36 -0400, :expression=>"3 weeks", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 39 | 40 | Tickle.parse('3 months') 41 | #=> {:next=>2010-08-09 20:57:36 -0400, :expression=>"3 months", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 42 | 43 | Tickle.parse('3 years') 44 | #=> {:next=>2013-05-09 20:57:36 -0400, :expression=>"3 years", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 45 | 46 | Tickle.parse('other day') 47 | #=> {:next=>2010-05-11 20:57:36 -0400, :expression=>"other day", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 48 | 49 | Tickle.parse('other week') 50 | #=> {:next=>2010-05-23 20:57:36 -0400, :expression=>"other week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 51 | 52 | Tickle.parse('other month') 53 | #=> {:next=>2010-07-09 20:57:36 -0400, :expression=>"other month", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 54 | 55 | Tickle.parse('other year') 56 | #=> {:next=>2012-05-09 20:57:36 -0400, :expression=>"other year", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 57 | 58 | Tickle.parse('Monday') 59 | #=> {:next=>2010-05-10 12:00:00 -0400, :expression=>"monday", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 60 | 61 | Tickle.parse('Wednesday') 62 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"wednesday", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 63 | 64 | Tickle.parse('Friday') 65 | #=> {:next=>2010-05-14 12:00:00 -0400, :expression=>"friday", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 66 | 67 | Tickle.parse('February', {:start=>#, :now=>#}) 68 | #=> {:next=>2021-02-01 12:00:00 -0500, :expression=>"february", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 69 | 70 | Tickle.parse('May', {:start=>#, :now=>#}) 71 | #=> {:next=>2020-05-01 12:00:00 -0400, :expression=>"may", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 72 | 73 | Tickle.parse('june', {:start=>#, :now=>#}) 74 | #=> {:next=>2020-06-01 12:00:00 -0400, :expression=>"june", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 75 | 76 | Tickle.parse('beginning of the week') 77 | #=> {:next=>2010-05-16 12:00:00 -0400, :expression=>"beginning of the week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 78 | 79 | Tickle.parse('middle of the week') 80 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"middle of the week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 81 | 82 | Tickle.parse('end of the week') 83 | #=> {:next=>2010-05-15 12:00:00 -0400, :expression=>"end of the week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 84 | 85 | Tickle.parse('beginning of the month', {:start=>#, :now=>#}) 86 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"beginning of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 87 | 88 | Tickle.parse('middle of the month', {:start=>#, :now=>#}) 89 | #=> {:next=>2020-04-15 00:00:00 -0400, :expression=>"middle of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 90 | 91 | Tickle.parse('end of the month', {:start=>#, :now=>#}) 92 | #=> {:next=>2020-04-30 00:00:00 -0400, :expression=>"end of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 93 | 94 | Tickle.parse('beginning of the year', {:start=>#, :now=>#}) 95 | #=> {:next=>2021-01-01 00:00:00 -0500, :expression=>"beginning of the year", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 96 | 97 | Tickle.parse('middle of the year', {:start=>#, :now=>#}) 98 | #=> {:next=>2020-06-15 00:00:00 -0400, :expression=>"middle of the year", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 99 | 100 | Tickle.parse('end of the year', {:start=>#, :now=>#}) 101 | #=> {:next=>2020-12-31 00:00:00 -0500, :expression=>"end of the year", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 102 | 103 | Tickle.parse('the 3rd of May', {:start=>#, :now=>#}) 104 | #=> {:next=>2020-05-03 00:00:00 -0400, :expression=>"the 3rd of may", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 105 | 106 | Tickle.parse('the 3rd of February', {:start=>#, :now=>#}) 107 | #=> {:next=>2021-02-03 00:00:00 -0500, :expression=>"the 3rd of february", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 108 | 109 | Tickle.parse('the 3rd of February 2022', {:start=>#, :now=>#}) 110 | #=> {:next=>2022-02-03 00:00:00 -0500, :expression=>"the 3rd of february 2022", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 111 | 112 | Tickle.parse('the 3rd of Feb 2022', {:start=>#, :now=>#}) 113 | #=> {:next=>2022-02-03 00:00:00 -0500, :expression=>"the 3rd of feb 2022", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 114 | 115 | Tickle.parse('the 4th of the month', {:start=>#, :now=>#}) 116 | #=> {:next=>2020-04-04 00:00:00 -0400, :expression=>"the 4th of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 117 | 118 | Tickle.parse('the 10th of the month', {:start=>#, :now=>#}) 119 | #=> {:next=>2020-04-10 00:00:00 -0400, :expression=>"the 10th of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 120 | 121 | Tickle.parse('the tenth of the month', {:start=>#, :now=>#}) 122 | #=> {:next=>2020-04-10 00:00:00 -0400, :expression=>"the tenth of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 123 | 124 | Tickle.parse('first', {:start=>#, :now=>#}) 125 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"first", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 126 | 127 | Tickle.parse('the first of the month', {:start=>#, :now=>#}) 128 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"the first of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 129 | 130 | Tickle.parse('the thirtieth', {:start=>#, :now=>#}) 131 | #=> {:next=>2020-04-30 00:00:00 -0400, :expression=>"the thirtieth", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 132 | 133 | Tickle.parse('the fifth', {:start=>#, :now=>#}) 134 | #=> {:next=>2020-04-05 00:00:00 -0400, :expression=>"the fifth", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 135 | 136 | Tickle.parse('the 1st Wednesday of the month', {:start=>#, :now=>#}) 137 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"the 1st wednesday of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 138 | 139 | Tickle.parse('the 3rd Sunday of May', {:start=>#, :now=>#}) 140 | #=> {:next=>2020-05-17 12:00:00 -0400, :expression=>"the 3rd sunday of may", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 141 | 142 | Tickle.parse('the 3rd Sunday of the month', {:start=>#, :now=>#}) 143 | #=> {:next=>2020-04-19 12:00:00 -0400, :expression=>"the 3rd sunday of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 144 | 145 | Tickle.parse('the 23rd of June', {:start=>#, :now=>#}) 146 | #=> {:next=>2020-06-23 00:00:00 -0400, :expression=>"the 23rd of june", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 147 | 148 | Tickle.parse('the twenty third of June', {:start=>#, :now=>#}) 149 | #=> {:next=>2020-06-23 00:00:00 -0400, :expression=>"the twenty third of june", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 150 | 151 | Tickle.parse('the thirty first of July', {:start=>#, :now=>#}) 152 | #=> {:next=>2020-07-31 00:00:00 -0400, :expression=>"the thirty first of july", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 153 | 154 | Tickle.parse('the twenty first', {:start=>#, :now=>#}) 155 | #=> {:next=>2020-04-21 00:00:00 -0400, :expression=>"the twenty first", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 156 | 157 | Tickle.parse('the twenty first of the month', {:start=>#, :now=>#}) 158 | #=> {:next=>2020-04-21 00:00:00 -0400, :expression=>"the twenty first of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 159 | 160 | COMPLEX 161 | Tickle.parse('starting today and ending one week from now') 162 | #=> {:next=>2010-05-10 22:30:00 -0400, :expression=>"day", :starting=>2010-05-09 22:30:00 -0400, :until=>2010-05-16 20:57:35 -0400} 163 | 164 | Tickle.parse('starting tomorrow and ending one week from now') 165 | #=> {:next=>2010-05-10 12:00:00 -0400, :expression=>"day", :starting=>2010-05-10 12:00:00 -0400, :until=>2010-05-16 20:57:35 -0400} 166 | 167 | Tickle.parse('starting Monday repeat every month') 168 | #=> {:next=>2010-05-10 12:00:00 -0400, :expression=>"month", :starting=>2010-05-10 12:00:00 -0400, :until=>nil} 169 | 170 | Tickle.parse('starting May 13th repeat every week') 171 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"week", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 172 | 173 | Tickle.parse('starting May 13th repeat every other day') 174 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 175 | 176 | Tickle.parse('every other day starts May 13th') 177 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 178 | 179 | Tickle.parse('every other day starts May 13') 180 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 181 | 182 | Tickle.parse('every other day starting May 13th') 183 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 184 | 185 | Tickle.parse('every other day starting May 13') 186 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 187 | 188 | Tickle.parse('every week starts this wednesday') 189 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>nil} 190 | 191 | Tickle.parse('every week starting this wednesday') 192 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>nil} 193 | 194 | Tickle.parse('every other day starting May 1st 2021') 195 | #=> {:next=>2021-05-01 12:00:00 -0400, :expression=>"other day", :starting=>2021-05-01 12:00:00 -0400, :until=>nil} 196 | 197 | Tickle.parse('every other day starting May 1 2021') 198 | #=> {:next=>2021-05-01 12:00:00 -0400, :expression=>"other day", :starting=>2021-05-01 12:00:00 -0400, :until=>nil} 199 | 200 | Tickle.parse('every other week starting this Sunday') 201 | #=> {:next=>2010-05-16 12:00:00 -0400, :expression=>"other week", :starting=>2010-05-16 12:00:00 -0400, :until=>nil} 202 | 203 | Tickle.parse('every week starting this wednesday until May 13th') 204 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>2010-05-13 12:00:00 -0400} 205 | 206 | Tickle.parse('every week starting this wednesday ends May 13th') 207 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>2010-05-13 12:00:00 -0400} 208 | 209 | Tickle.parse('every week starting this wednesday ending May 13th') 210 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>2010-05-13 12:00:00 -0400} 211 | 212 | 213 | OPTIONS HASH 214 | Tickle.parse('May 1st 2020', {:next_only=>true}) 215 | #=> 2020-05-01 00:00:00 -0400 216 | 217 | Tickle.parse('3 days', {:start=>2010-05-09 20:57:36 -0400}) 218 | #=> {:next=>2010-05-12 20:57:36 -0400, :expression=>"3 days", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 219 | 220 | Tickle.parse('3 weeks', {:start=>2010-05-09 20:57:36 -0400}) 221 | #=> {:next=>2010-05-30 20:57:36 -0400, :expression=>"3 weeks", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 222 | 223 | Tickle.parse('3 months', {:start=>2010-05-09 20:57:36 -0400}) 224 | #=> {:next=>2010-08-09 20:57:36 -0400, :expression=>"3 months", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 225 | 226 | Tickle.parse('3 years', {:start=>2010-05-09 20:57:36 -0400}) 227 | #=> {:next=>2013-05-09 20:57:36 -0400, :expression=>"3 years", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 228 | 229 | Tickle.parse('3 days', {:start=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400}) 230 | #=> {:next=>2010-05-12 20:57:36 -0400, :expression=>"3 days", :starting=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400} 231 | 232 | Tickle.parse('3 weeks', {:start=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400}) 233 | #=> {:next=>2010-05-30 20:57:36 -0400, :expression=>"3 weeks", :starting=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400} 234 | 235 | Tickle.parse('3 months', {:until=>2010-10-09 00:00:00 -0400}) 236 | #=> {:next=>2010-08-09 20:57:36 -0400, :expression=>"3 months", :starting=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400} 237 | 238 | US HOLIDAYS 239 | 240 | Tickle.parse('New Years Day', {:start=>#, :now=>#}) 241 | #=> {:next=>2021-01-01 12:00:00 -0500, :expression=>"january 1, 2021", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 242 | 243 | Tickle.parse('Inauguration', {:start=>#, :now=>#}) 244 | #=> {:next=>2020-01-20 12:00:00 -0500, :expression=>"january 20", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 245 | 246 | Tickle.parse('Martin Luther King Day', {:start=>#, :now=>#}) 247 | #=> {:next=>2020-01-20 12:00:00 -0500, :expression=>"third monday in january", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 248 | 249 | Tickle.parse('MLK', {:start=>#, :now=>#}) 250 | #=> {:next=>2020-01-20 12:00:00 -0500, :expression=>"third monday in january", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 251 | 252 | Tickle.parse('Presidents Day', {:start=>#, :now=>#}) 253 | #=> {:next=>2020-02-17 12:00:00 -0500, :expression=>"third monday in february", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 254 | 255 | Tickle.parse('Memorial Day', {:start=>#, :now=>#}) 256 | #=> {:next=>2020-05-25 12:00:00 -0400, :expression=>"4th monday of may", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 257 | 258 | Tickle.parse('Independence Day', {:start=>#, :now=>#}) 259 | #=> {:next=>2020-07-04 12:00:00 -0400, :expression=>"july 4, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 260 | 261 | Tickle.parse('Labor Day', {:start=>#, :now=>#}) 262 | #=> {:next=>2020-09-07 12:00:00 -0400, :expression=>"first monday in september", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 263 | 264 | Tickle.parse('Columbus Day', {:start=>#, :now=>#}) 265 | #=> {:next=>2020-10-12 12:00:00 -0400, :expression=>"second monday in october", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 266 | 267 | Tickle.parse('Veterans Day', {:start=>#, :now=>#}) 268 | #=> {:next=>2020-11-11 12:00:00 -0500, :expression=>"november 11, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 269 | 270 | Tickle.parse('Christmas', {:start=>#, :now=>#}) 271 | #=> {:next=>2020-12-25 12:00:00 -0500, :expression=>"december 25, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 272 | 273 | Tickle.parse('Super Bowl Sunday', {:start=>#, :now=>#}) 274 | #=> {:next=>2020-02-02 12:00:00 -0500, :expression=>"first sunday in february", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 275 | 276 | Tickle.parse('Groundhog Day', {:start=>#, :now=>#}) 277 | #=> {:next=>2020-02-02 12:00:00 -0500, :expression=>"february 2, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 278 | 279 | Tickle.parse('Valentines Day', {:start=>#, :now=>#}) 280 | #=> {:next=>2020-02-14 12:00:00 -0500, :expression=>"february 14, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 281 | 282 | Tickle.parse('Saint Patricks day', {:start=>#, :now=>#}) 283 | #=> {:next=>2020-03-17 12:00:00 -0400, :expression=>"march 17, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 284 | 285 | Tickle.parse('April Fools Day', {:start=>#, :now=>#}) 286 | #=> {:next=>2020-04-01 12:00:00 -0400, :expression=>"april 1, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 287 | 288 | Tickle.parse('Earth Day', {:start=>#, :now=>#}) 289 | #=> {:next=>2020-04-22 12:00:00 -0400, :expression=>"april 22, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 290 | 291 | Tickle.parse('Arbor Day', {:start=>#, :now=>#}) 292 | #=> {:next=>2020-04-24 12:00:00 -0400, :expression=>"fourth friday in april", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 293 | 294 | Tickle.parse('Cinco De Mayo', {:start=>#, :now=>#}) 295 | #=> {:next=>2020-05-05 12:00:00 -0400, :expression=>"may 5, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 296 | 297 | Tickle.parse('Mothers Day', {:start=>#, :now=>#}) 298 | #=> {:next=>2020-05-10 12:00:00 -0400, :expression=>"second sunday in may", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 299 | 300 | Tickle.parse('Flag Day', {:start=>#, :now=>#}) 301 | #=> {:next=>2020-06-14 12:00:00 -0400, :expression=>"june 14, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 302 | 303 | Tickle.parse('Fathers Day', {:start=>#, :now=>#}) 304 | #=> {:next=>2020-06-21 12:00:00 -0400, :expression=>"third sunday in june", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 305 | 306 | Tickle.parse('Halloween', {:start=>#, :now=>#}) 307 | #=> {:next=>2020-10-31 12:00:00 -0400, :expression=>"october 31, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 308 | 309 | Tickle.parse('Christmas Day', {:start=>#, :now=>#}) 310 | #=> {:next=>2020-12-25 12:00:00 -0500, :expression=>"december 25, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 311 | 312 | Tickle.parse('Christmas Eve', {:start=>#, :now=>#}) 313 | #=> {:next=>2020-12-24 12:00:00 -0500, :expression=>"december 24, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 314 | 315 | Tickle.parse('Kwanzaa', {:start=>#, :now=>#}) 316 | #=> {:next=>2021-01-01 12:00:00 -0500, :expression=>"january 1, 2021", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 317 | 318 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Tickle ## 2 | 3 | This is now the home of ***Tickle***, previously found at [github.com/noctivityinc/tickle](https://github.com/noctivityinc/tickle) 4 | 5 | If you wish to contribute then please take a look at the Contribution section further down the page, but I'd be really, *really* grateful if anyone wishes to contribute specs. Not unit tests, but specs. This library's internals will be changing a lot over the coming months, and it would be good to have integration tests - a black-box spec of the library - to work against. Even if you've never contributed to a library before, now is your chance! I'll help anyone through with what they may need, and I promise not to be the standard snarky Open Source dictator that a lot of projects have. We'll try and improve this library together. 6 | 7 | Take a look at the `develop` branch where all this stuff will be happening. 8 | 9 | 10 | ### DESCRIPTION ### 11 | 12 | Tickle is a natural language parser for recurring events. 13 | 14 | Tickle is designed to be a complement to [Chronic](https://rubygems.org/gems/chronic) and can interpret things such as "every 2 days", "every Sunday", “Sundays", “Weekly", etc. 15 | 16 | Tickle has one main method, `Tickle.parse`, which returns the next time the event should occur, at which point you simply call `Tickle.parse` again. 17 | 18 | ### *LEGACY WARNING* ### 19 | 20 | If you starting using Tickle pre version 0.1.X, you will need to update your code to either include the `:next_only => true` option or read correctly from the options hash. Sorry. 21 | 22 | 23 | ### USAGE ### 24 | 25 | You can parse strings containing a natural language interval using the `Tickle.parse` method. 26 | 27 | You can either pass a string prefixed with the word "every", "each" or "on the" or simply the timeframe. 28 | 29 | Tickle.parse returns a hash containing the following keys: 30 | * `next` the next occurrence of the event. This is NEVER today as its always the next date in the future. 31 | * `starting` the date all calculations as based on. If not passed as an option, the start date is right now. 32 | * `until` the last date you want this event to run until. 33 | * `expression` this is the natural language expression to store to run through tickle later to get the next occurrence. 34 | 35 | Tickle returns `nil` if it cannot parse the string. 36 | 37 | Tickle ***heavily*** uses Chronic for parsing both the event and the start date. 38 | 39 | ### OPTIONS ### 40 | 41 | There are two ways to pass options: natural language or an options hash. 42 | 43 | NATURAL LANGUAGE: 44 | * Pass a start date with the word "starting", "start", or "stars" e.g. `Tickle.parse('every 3 days starting next friday')` 45 | * Pass an end date with the word "until", "end", "ends", or "ending" e.g. `Tickle.parse('every 3 days until next friday')` 46 | * Pass both at the same time e.g. `"starting May 5th repeat every other week until December 1"` 47 | 48 | OPTIONS HASH 49 | Valid options are: 50 | * `start` - must be a valid date or Chronic date expression. e.g. `Tickle.parse('every other day', {:start => Date.new(2010,8,1) })` 51 | * `until` - must be a valid date or Chronic date expression. e.g. `Tickle.parse('every other day', {:until => Date.new(2010,10,1) })` 52 | * `next_only` - legacy switch to *only* return the next occurrence as a date and not return a hash 53 | 54 | ### SUPER IMPORTANT NOTE ABOUT NEXT OCCURRENCE & START DATE ### 55 | 56 | You may notice when parsing an expression with a start date that the next occurrence **is** the start date passed. This is **designed behaviour**. 57 | 58 | Here's why - assume your user says "remind me every 3 weeks starting Dec 1" and today is May 8th. Well the first reminder needs to be sent on Dec 1, not Dec 21 (three weeks later). 59 | 60 | If you don't like that, fork and have fun but don't say you weren't warned. 61 | 62 | 63 | ### EXAMPLES ### 64 | 65 | require 'tickle' 66 | 67 | #### SIMPLE #### 68 | 69 | Tickle.parse('day') 70 | #=> {:next=>2010-05-10 20:57:36 -0400, :expression=>"day", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 71 | 72 | Tickle.parse('day') 73 | #=> {:next=>2010-05-10 20:57:36 -0400, :expression=>"day", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 74 | 75 | Tickle.parse('week') 76 | #=> {:next=>2010-05-16 20:57:36 -0400, :expression=>"week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 77 | 78 | Tickle.parse('month') 79 | #=> {:next=>2010-06-09 20:57:36 -0400, :expression=>"month", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 80 | 81 | Tickle.parse('year') 82 | #=> {:next=>2011-05-09 20:57:36 -0400, :expression=>"year", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 83 | 84 | Tickle.parse('daily') 85 | #=> {:next=>2010-05-10 20:57:36 -0400, :expression=>"daily", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 86 | 87 | Tickle.parse('weekly') 88 | #=> {:next=>2010-05-16 20:57:36 -0400, :expression=>"weekly", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 89 | 90 | Tickle.parse('monthly') 91 | #=> {:next=>2010-06-09 20:57:36 -0400, :expression=>"monthly", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 92 | 93 | Tickle.parse('yearly') 94 | #=> {:next=>2011-05-09 20:57:36 -0400, :expression=>"yearly", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 95 | 96 | Tickle.parse('3 days') 97 | #=> {:next=>2010-05-12 20:57:36 -0400, :expression=>"3 days", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 98 | 99 | Tickle.parse('3 weeks') 100 | #=> {:next=>2010-05-30 20:57:36 -0400, :expression=>"3 weeks", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 101 | 102 | Tickle.parse('3 months') 103 | #=> {:next=>2010-08-09 20:57:36 -0400, :expression=>"3 months", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 104 | 105 | Tickle.parse('3 years') 106 | #=> {:next=>2013-05-09 20:57:36 -0400, :expression=>"3 years", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 107 | 108 | Tickle.parse('other day') 109 | #=> {:next=>2010-05-11 20:57:36 -0400, :expression=>"other day", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 110 | 111 | Tickle.parse('other week') 112 | #=> {:next=>2010-05-23 20:57:36 -0400, :expression=>"other week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 113 | 114 | Tickle.parse('other month') 115 | #=> {:next=>2010-07-09 20:57:36 -0400, :expression=>"other month", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 116 | 117 | Tickle.parse('other year') 118 | #=> {:next=>2012-05-09 20:57:36 -0400, :expression=>"other year", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 119 | 120 | Tickle.parse('Monday') 121 | #=> {:next=>2010-05-10 12:00:00 -0400, :expression=>"monday", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 122 | 123 | Tickle.parse('Wednesday') 124 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"wednesday", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 125 | 126 | Tickle.parse('Friday') 127 | #=> {:next=>2010-05-14 12:00:00 -0400, :expression=>"friday", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 128 | 129 | Tickle.parse('February', {:start=>#, :now=>#}) 130 | #=> {:next=>2021-02-01 12:00:00 -0500, :expression=>"february", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 131 | 132 | Tickle.parse('May', {:start=>#, :now=>#}) 133 | #=> {:next=>2020-05-01 12:00:00 -0400, :expression=>"may", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 134 | 135 | Tickle.parse('june', {:start=>#, :now=>#}) 136 | #=> {:next=>2020-06-01 12:00:00 -0400, :expression=>"june", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 137 | 138 | Tickle.parse('beginning of the week') 139 | #=> {:next=>2010-05-16 12:00:00 -0400, :expression=>"beginning of the week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 140 | 141 | Tickle.parse('middle of the week') 142 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"middle of the week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 143 | 144 | Tickle.parse('end of the week') 145 | #=> {:next=>2010-05-15 12:00:00 -0400, :expression=>"end of the week", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 146 | 147 | Tickle.parse('beginning of the month', {:start=>#, :now=>#}) 148 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"beginning of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 149 | 150 | Tickle.parse('middle of the month', {:start=>#, :now=>#}) 151 | #=> {:next=>2020-04-15 00:00:00 -0400, :expression=>"middle of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 152 | 153 | Tickle.parse('end of the month', {:start=>#, :now=>#}) 154 | #=> {:next=>2020-04-30 00:00:00 -0400, :expression=>"end of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 155 | 156 | Tickle.parse('beginning of the year', {:start=>#, :now=>#}) 157 | #=> {:next=>2021-01-01 00:00:00 -0500, :expression=>"beginning of the year", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 158 | 159 | Tickle.parse('middle of the year', {:start=>#, :now=>#}) 160 | #=> {:next=>2020-06-15 00:00:00 -0400, :expression=>"middle of the year", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 161 | 162 | Tickle.parse('end of the year', {:start=>#, :now=>#}) 163 | #=> {:next=>2020-12-31 00:00:00 -0500, :expression=>"end of the year", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 164 | 165 | Tickle.parse('the 3rd of May', {:start=>#, :now=>#}) 166 | #=> {:next=>2020-05-03 00:00:00 -0400, :expression=>"the 3rd of may", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 167 | 168 | Tickle.parse('the 3rd of February', {:start=>#, :now=>#}) 169 | #=> {:next=>2021-02-03 00:00:00 -0500, :expression=>"the 3rd of february", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 170 | 171 | Tickle.parse('the 3rd of February 2022', {:start=>#, :now=>#}) 172 | #=> {:next=>2022-02-03 00:00:00 -0500, :expression=>"the 3rd of february 2022", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 173 | 174 | Tickle.parse('the 3rd of Feb 2022', {:start=>#, :now=>#}) 175 | #=> {:next=>2022-02-03 00:00:00 -0500, :expression=>"the 3rd of feb 2022", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 176 | 177 | Tickle.parse('the 4th of the month', {:start=>#, :now=>#}) 178 | #=> {:next=>2020-04-04 00:00:00 -0400, :expression=>"the 4th of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 179 | 180 | Tickle.parse('the 10th of the month', {:start=>#, :now=>#}) 181 | #=> {:next=>2020-04-10 00:00:00 -0400, :expression=>"the 10th of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 182 | 183 | Tickle.parse('the tenth of the month', {:start=>#, :now=>#}) 184 | #=> {:next=>2020-04-10 00:00:00 -0400, :expression=>"the tenth of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 185 | 186 | Tickle.parse('first', {:start=>#, :now=>#}) 187 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"first", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 188 | 189 | Tickle.parse('the first of the month', {:start=>#, :now=>#}) 190 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"the first of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 191 | 192 | Tickle.parse('the thirtieth', {:start=>#, :now=>#}) 193 | #=> {:next=>2020-04-30 00:00:00 -0400, :expression=>"the thirtieth", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 194 | 195 | Tickle.parse('the fifth', {:start=>#, :now=>#}) 196 | #=> {:next=>2020-04-05 00:00:00 -0400, :expression=>"the fifth", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 197 | 198 | Tickle.parse('the 1st Wednesday of the month', {:start=>#, :now=>#}) 199 | #=> {:next=>2020-05-01 00:00:00 -0400, :expression=>"the 1st wednesday of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 200 | 201 | Tickle.parse('the 3rd Sunday of May', {:start=>#, :now=>#}) 202 | #=> {:next=>2020-05-17 12:00:00 -0400, :expression=>"the 3rd sunday of may", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 203 | 204 | Tickle.parse('the 3rd Sunday of the month', {:start=>#, :now=>#}) 205 | #=> {:next=>2020-04-19 12:00:00 -0400, :expression=>"the 3rd sunday of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 206 | 207 | Tickle.parse('the 23rd of June', {:start=>#, :now=>#}) 208 | #=> {:next=>2020-06-23 00:00:00 -0400, :expression=>"the 23rd of june", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 209 | 210 | Tickle.parse('the twenty third of June', {:start=>#, :now=>#}) 211 | #=> {:next=>2020-06-23 00:00:00 -0400, :expression=>"the twenty third of june", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 212 | 213 | Tickle.parse('the thirty first of July', {:start=>#, :now=>#}) 214 | #=> {:next=>2020-07-31 00:00:00 -0400, :expression=>"the thirty first of july", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 215 | 216 | Tickle.parse('the twenty first', {:start=>#, :now=>#}) 217 | #=> {:next=>2020-04-21 00:00:00 -0400, :expression=>"the twenty first", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 218 | 219 | Tickle.parse('the twenty first of the month', {:start=>#, :now=>#}) 220 | #=> {:next=>2020-04-21 00:00:00 -0400, :expression=>"the twenty first of the month", :starting=>2020-04-01 00:00:00 -0400, :until=>nil} 221 | 222 | #### COMPLEX #### 223 | 224 | Tickle.parse('starting today and ending one week from now') 225 | #=> {:next=>2010-05-10 22:30:00 -0400, :expression=>"day", :starting=>2010-05-09 22:30:00 -0400, :until=>2010-05-16 20:57:35 -0400} 226 | 227 | Tickle.parse('starting tomorrow and ending one week from now') 228 | #=> {:next=>2010-05-10 12:00:00 -0400, :expression=>"day", :starting=>2010-05-10 12:00:00 -0400, :until=>2010-05-16 20:57:35 -0400} 229 | 230 | Tickle.parse('starting Monday repeat every month') 231 | #=> {:next=>2010-05-10 12:00:00 -0400, :expression=>"month", :starting=>2010-05-10 12:00:00 -0400, :until=>nil} 232 | 233 | Tickle.parse('starting May 13th repeat every week') 234 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"week", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 235 | 236 | Tickle.parse('starting May 13th repeat every other day') 237 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 238 | 239 | Tickle.parse('every other day starts May 13th') 240 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 241 | 242 | Tickle.parse('every other day starts May 13') 243 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 244 | 245 | Tickle.parse('every other day starting May 13th') 246 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 247 | 248 | Tickle.parse('every other day starting May 13') 249 | #=> {:next=>2010-05-13 12:00:00 -0400, :expression=>"other day", :starting=>2010-05-13 12:00:00 -0400, :until=>nil} 250 | 251 | Tickle.parse('every week starts this wednesday') 252 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>nil} 253 | 254 | Tickle.parse('every week starting this wednesday') 255 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>nil} 256 | 257 | Tickle.parse('every other day starting May 1st 2021') 258 | #=> {:next=>2021-05-01 12:00:00 -0400, :expression=>"other day", :starting=>2021-05-01 12:00:00 -0400, :until=>nil} 259 | 260 | Tickle.parse('every other day starting May 1 2021') 261 | #=> {:next=>2021-05-01 12:00:00 -0400, :expression=>"other day", :starting=>2021-05-01 12:00:00 -0400, :until=>nil} 262 | 263 | Tickle.parse('every other week starting this Sunday') 264 | #=> {:next=>2010-05-16 12:00:00 -0400, :expression=>"other week", :starting=>2010-05-16 12:00:00 -0400, :until=>nil} 265 | 266 | Tickle.parse('every week starting this wednesday until May 13th') 267 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>2010-05-13 12:00:00 -0400} 268 | 269 | Tickle.parse('every week starting this wednesday ends May 13th') 270 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>2010-05-13 12:00:00 -0400} 271 | 272 | Tickle.parse('every week starting this wednesday ending May 13th') 273 | #=> {:next=>2010-05-12 12:00:00 -0400, :expression=>"week", :starting=>2010-05-12 12:00:00 -0400, :until=>2010-05-13 12:00:00 -0400} 274 | 275 | 276 | #### OPTIONS HASH #### 277 | 278 | Tickle.parse('May 1st 2020', {:next_only=>true}) 279 | #=> 2020-05-01 00:00:00 -0400 280 | 281 | Tickle.parse('3 days', {:start=>2010-05-09 20:57:36 -0400}) 282 | #=> {:next=>2010-05-12 20:57:36 -0400, :expression=>"3 days", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 283 | 284 | Tickle.parse('3 weeks', {:start=>2010-05-09 20:57:36 -0400}) 285 | #=> {:next=>2010-05-30 20:57:36 -0400, :expression=>"3 weeks", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 286 | 287 | Tickle.parse('3 months', {:start=>2010-05-09 20:57:36 -0400}) 288 | #=> {:next=>2010-08-09 20:57:36 -0400, :expression=>"3 months", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 289 | 290 | Tickle.parse('3 years', {:start=>2010-05-09 20:57:36 -0400}) 291 | #=> {:next=>2013-05-09 20:57:36 -0400, :expression=>"3 years", :starting=>2010-05-09 20:57:36 -0400, :until=>nil} 292 | 293 | Tickle.parse('3 days', {:start=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400}) 294 | #=> {:next=>2010-05-12 20:57:36 -0400, :expression=>"3 days", :starting=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400} 295 | 296 | Tickle.parse('3 weeks', {:start=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400}) 297 | #=> {:next=>2010-05-30 20:57:36 -0400, :expression=>"3 weeks", :starting=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400} 298 | 299 | Tickle.parse('3 months', {:until=>2010-10-09 00:00:00 -0400}) 300 | #=> {:next=>2010-08-09 20:57:36 -0400, :expression=>"3 months", :starting=>2010-05-09 20:57:36 -0400, :until=>2010-10-09 00:00:00 -0400} 301 | 302 | #### US HOLIDAYS #### 303 | 304 | Tickle.parse('New Years Day', {:start=>#, :now=>#}) 305 | #=> {:next=>2021-01-01 12:00:00 -0500, :expression=>"january 1, 2021", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 306 | 307 | Tickle.parse('Inauguration', {:start=>#, :now=>#}) 308 | #=> {:next=>2020-01-20 12:00:00 -0500, :expression=>"january 20", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 309 | 310 | Tickle.parse('Martin Luther King Day', {:start=>#, :now=>#}) 311 | #=> {:next=>2020-01-20 12:00:00 -0500, :expression=>"third monday in january", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 312 | 313 | Tickle.parse('MLK', {:start=>#, :now=>#}) 314 | #=> {:next=>2020-01-20 12:00:00 -0500, :expression=>"third monday in january", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 315 | 316 | Tickle.parse('Presidents Day', {:start=>#, :now=>#}) 317 | #=> {:next=>2020-02-17 12:00:00 -0500, :expression=>"third monday in february", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 318 | 319 | Tickle.parse('Memorial Day', {:start=>#, :now=>#}) 320 | #=> {:next=>2020-05-25 12:00:00 -0400, :expression=>"4th monday of may", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 321 | 322 | Tickle.parse('Independence Day', {:start=>#, :now=>#}) 323 | #=> {:next=>2020-07-04 12:00:00 -0400, :expression=>"july 4, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 324 | 325 | Tickle.parse('Labor Day', {:start=>#, :now=>#}) 326 | #=> {:next=>2020-09-07 12:00:00 -0400, :expression=>"first monday in september", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 327 | 328 | Tickle.parse('Columbus Day', {:start=>#, :now=>#}) 329 | #=> {:next=>2020-10-12 12:00:00 -0400, :expression=>"second monday in october", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 330 | 331 | Tickle.parse('Veterans Day', {:start=>#, :now=>#}) 332 | #=> {:next=>2020-11-11 12:00:00 -0500, :expression=>"november 11, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 333 | 334 | Tickle.parse('Christmas', {:start=>#, :now=>#}) 335 | #=> {:next=>2020-12-25 12:00:00 -0500, :expression=>"december 25, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 336 | 337 | Tickle.parse('Super Bowl Sunday', {:start=>#, :now=>#}) 338 | #=> {:next=>2020-02-02 12:00:00 -0500, :expression=>"first sunday in february", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 339 | 340 | Tickle.parse('Groundhog Day', {:start=>#, :now=>#}) 341 | #=> {:next=>2020-02-02 12:00:00 -0500, :expression=>"february 2, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 342 | 343 | Tickle.parse('Valentines Day', {:start=>#, :now=>#}) 344 | #=> {:next=>2020-02-14 12:00:00 -0500, :expression=>"february 14, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 345 | 346 | Tickle.parse('Saint Patricks day', {:start=>#, :now=>#}) 347 | #=> {:next=>2020-03-17 12:00:00 -0400, :expression=>"march 17, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 348 | 349 | Tickle.parse('April Fools Day', {:start=>#, :now=>#}) 350 | #=> {:next=>2020-04-01 12:00:00 -0400, :expression=>"april 1, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 351 | 352 | Tickle.parse('Earth Day', {:start=>#, :now=>#}) 353 | #=> {:next=>2020-04-22 12:00:00 -0400, :expression=>"april 22, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 354 | 355 | Tickle.parse('Arbor Day', {:start=>#, :now=>#}) 356 | #=> {:next=>2020-04-24 12:00:00 -0400, :expression=>"fourth friday in april", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 357 | 358 | Tickle.parse('Cinco De Mayo', {:start=>#, :now=>#}) 359 | #=> {:next=>2020-05-05 12:00:00 -0400, :expression=>"may 5, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 360 | 361 | Tickle.parse('Mothers Day', {:start=>#, :now=>#}) 362 | #=> {:next=>2020-05-10 12:00:00 -0400, :expression=>"second sunday in may", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 363 | 364 | Tickle.parse('Flag Day', {:start=>#, :now=>#}) 365 | #=> {:next=>2020-06-14 12:00:00 -0400, :expression=>"june 14, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 366 | 367 | Tickle.parse('Fathers Day', {:start=>#, :now=>#}) 368 | #=> {:next=>2020-06-21 12:00:00 -0400, :expression=>"third sunday in june", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 369 | 370 | Tickle.parse('Halloween', {:start=>#, :now=>#}) 371 | #=> {:next=>2020-10-31 12:00:00 -0400, :expression=>"october 31, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 372 | 373 | Tickle.parse('Christmas Day', {:start=>#, :now=>#}) 374 | #=> {:next=>2020-12-25 12:00:00 -0500, :expression=>"december 25, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 375 | 376 | Tickle.parse('Christmas Eve', {:start=>#, :now=>#}) 377 | #=> {:next=>2020-12-24 12:00:00 -0500, :expression=>"december 24, 2020", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 378 | 379 | Tickle.parse('Kwanzaa', {:start=>#, :now=>#}) 380 | #=> {:next=>2021-01-01 12:00:00 -0500, :expression=>"january 1, 2021", :starting=>2020-01-01 00:00:00 -0500, :until=>nil} 381 | 382 | 383 | ### USING IN APP ### 384 | 385 | To use in your app, we recommend adding two attributes to your database model: 386 | 387 | * `next_occurrence` 388 | * `tickle_expression` 389 | 390 | Then call `Tickle.parse("date expression goes here")` when you need to and save the results accordingly. In your 391 | code, each day, simply check to see if today is `>= next_occurrence` and, if so, run your block. 392 | 393 | After it completes, call `next_occurrence = Tickle.parse(tickle_expression)` again to update the next occurrence of the event. 394 | 395 | 396 | ### INSTALLATION ### 397 | 398 | Tickle can be installed via RubyGems: 399 | 400 | gem install tickle 401 | 402 | or if you're using Bundler, add this to the Gemfile: 403 | 404 | gem "tickle" 405 | 406 | ### DEPENDENCIES ### 407 | 408 | Chronic gem: 409 | 410 | `gem install chronic` 411 | 412 | thoughtbot's [shoulda](https://rubygems.org/gems/shoulda): 413 | 414 | `gem install shoulda` 415 | 416 | or just run `bundle install`. 417 | 418 | 419 | ### LIMITATIONS ### 420 | 421 | Currently, Tickle only works for day intervals but feel free to fork and add time-based interval support or send me a note if you really want me to add it. 422 | 423 | 424 | ### CONTRIBUTING ### 425 | 426 | Fork it, create a new branch for your changes, and send in a pull request. 427 | 428 | * Only tested code gets in. 429 | * Document it. 430 | * If you want to work on something but aren't sure whether it'll get in, create a new branch and open a pull request before you've done any code. That will open an issue and we can discuss it. 431 | * Do not mess with the Rakefile, version, or history (if you want to have your own version, that is fine but do it on a separate branch from the one I'm going to merge.) 432 | 433 | ### TESTING ### 434 | 435 | Tickle comes with a full testing suite for simple, complex, options hash and invalid arguments. 436 | 437 | You also have some command line options: 438 | 439 | * --v verbose output like the examples above 440 | * --d debug output showing the guts of a date expression 441 | 442 | ### CREDIT ### 443 | 444 | The original work on the library was done by *Joshua Lippiner* a.k.a. [Noctivity](https://github.com/noctivityinc). 445 | 446 | ***HUGE*** shout-out to both the creator of Chronic, *Tom Preston-Werner* as well as *Brian Brownling* who maintains a Github version at [github.com/mojombo/chronic](https://github.com/mojombo/chronic). 447 | 448 | As always, ***BIG*** shout-out to the RVM Master himself, *Wayne Seguin*, for putting up with me and Ruby from day one. Ask Wayne to make you some ciabatta bread next time you see him. 449 | 450 | ### LICENCE ### 451 | 452 | See the LICENCE file. -------------------------------------------------------------------------------- /spec/tickle_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'spec_helper' 4 | require_relative "../lib/tickle.rb" 5 | require 'timecop' 6 | 7 | module Tickle # for convenience 8 | 9 | day = 86400 10 | 11 | describe "Parsing" do 12 | 13 | describe "parse", :integration => true do 14 | 15 | context "Asked with an object that responds to :to_time" do 16 | describe "Returning it immediately" do 17 | let(:expected) { Date.parse("7th October 2015") } 18 | subject{ Tickle.parse(expected) } 19 | it { should == expected } 20 | end 21 | end 22 | 23 | context "Simple examples", :frozen => true do 24 | 25 | # Can't use second as it clashes with date ordinal names 26 | context "seconds" do 27 | subject{ Tickle.parse('seconds') } 28 | let(:expected) { {:next=>Time.parse("2010-05-09 20:57:37 +0000"), :expression=>"seconds", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 29 | it { should == expected } 30 | end 31 | 32 | # Other variant for seconds 33 | context "sec" do 34 | subject{ Tickle.parse('sec') } 35 | let(:expected) { {:next=>Time.parse("2010-05-09 20:57:37 +0000"), :expression=>"sec", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 36 | it { should == expected } 37 | end 38 | 39 | context "minute" do 40 | subject{ Tickle.parse('minute') } 41 | let(:expected) { {:next=>Time.parse("2010-05-09 20:58:36 +0000"), :expression=>"minute", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 42 | xit { should == expected } 43 | end 44 | 45 | context "hour" do 46 | subject{ Tickle.parse('hour') } 47 | let(:expected) { {:next=>Time.parse("2010-05-09 21:57:36 +0000"), :expression=>"hour", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 48 | xit { should == expected } 49 | end 50 | 51 | context "day" do 52 | subject{ Tickle.parse('day') } 53 | let(:expected) { {:next=>Time.parse("2010-05-10 20:57:36 +0000"), :expression=>"day", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 54 | it { should == expected } 55 | end 56 | 57 | context "week" do 58 | subject{ Tickle.parse('week') } 59 | let(:expected) { {:next=>Time.parse("2010-05-16 20:57:36 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 60 | it { should == expected } 61 | end 62 | 63 | context "month" do 64 | subject{ Tickle.parse('month') } 65 | let(:expected) { {:next=>Time.parse("2010-06-09 20:57:36 +0000"), :expression=>"month", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 66 | it { should == expected } 67 | end 68 | 69 | context "year" do 70 | subject{ Tickle.parse('year') } 71 | let(:expected) { {:next=>Time.parse("2011-05-09 20:57:36 +0000"), :expression=>"year", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 72 | it { should == expected } 73 | end 74 | 75 | context "hourly" do 76 | subject{ Tickle.parse('hourly') } 77 | let(:expected) { {:next=>Time.parse("2010-05-09 21:57:36 +0000"), :expression=>"hourly", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 78 | xit { should == expected } 79 | end 80 | 81 | context "daily" do 82 | subject{ Tickle.parse('daily') } 83 | let(:expected) { {:next=>Time.parse("2010-05-10 20:57:36 +0000"), :expression=>"daily", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 84 | it { should == expected } 85 | end 86 | 87 | context "weekly" do 88 | subject{ Tickle.parse('weekly') } 89 | let(:expected) { {:next=>Time.parse("2010-05-16 20:57:36 +0000"), :expression=>"weekly", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 90 | it { should == expected } 91 | end 92 | 93 | context "monthly" do 94 | subject{ Tickle.parse('monthly') } 95 | let(:expected) { {:next=>Time.parse("2010-06-09 20:57:36 +0000"), :expression=>"monthly", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 96 | it { should == expected } 97 | end 98 | 99 | context "yearly" do 100 | subject{ Tickle.parse('yearly') } 101 | let(:expected) { {:next=>Time.parse("2011-05-09 20:57:36 +0000"), :expression=>"yearly", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 102 | it { should == expected } 103 | end 104 | 105 | context "3 seconds" do 106 | subject{ Tickle.parse('3 seconds') } 107 | let(:expected) { {:next=>Time.parse("2010-05-09 20:57:39 +0000"), :expression=>"3 seconds", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 108 | it { should == expected } 109 | end 110 | 111 | context "3 sec" do 112 | subject{ Tickle.parse('3 sec') } 113 | let(:expected) { {:next=>Time.parse("2010-05-09 20:57:39 +0000"), :expression=>"3 sec", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 114 | it { should == expected } 115 | end 116 | 117 | context "3 minutes" do 118 | subject{ Tickle.parse('3 minutes') } 119 | let(:expected) { {:next=>Time.parse("2010-05-09 21:00:36 +0000"), :expression=>"3 minutes", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 120 | xit { should == expected } 121 | end 122 | 123 | context "3 hours" do 124 | subject{ Tickle.parse('3 hours') } 125 | let(:expected) { {:next=>Time.parse("2010-05-09 23:57:36 +0000"), :expression=>"3 hours", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 126 | xit { should == expected } 127 | end 128 | 129 | context "3 days" do 130 | subject{ Tickle.parse('3 days') } 131 | let(:expected) { {:next=>Time.parse("2010-05-12 20:57:36 +0000"), :expression=>"3 days", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 132 | it { should == expected } 133 | end 134 | 135 | context "3 weeks" do 136 | subject{ Tickle.parse('3 weeks') } 137 | let(:expected) { {:next=>Time.parse("2010-05-30 20:57:36 +0000"), :expression=>"3 weeks", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 138 | it { should == expected } 139 | end 140 | 141 | context "3 months" do 142 | subject{ Tickle.parse('3 months') } 143 | let(:expected) { {:next=>Time.parse("2010-08-09 20:57:36 +0000"), :expression=>"3 months", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 144 | it { should == expected } 145 | end 146 | 147 | context "3 years" do 148 | subject{ Tickle.parse('3 years') } 149 | let(:expected) { {:next=>Time.parse("2013-05-09 20:57:36 +0000"), :expression=>"3 years", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 150 | it { should == expected } 151 | end 152 | 153 | context "other seconds" do 154 | subject{ Tickle.parse('other second') } 155 | let(:expected) { {:next=>Time.parse("2010-05-09 20:57:38 +0000"), :expression=>"other second", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 156 | xit { should == expected } 157 | end 158 | 159 | context "other sec" do 160 | subject{ Tickle.parse('other second') } 161 | let(:expected) { {:next=>Time.parse("2010-05-09 20:57:38 +0000"), :expression=>"other second", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 162 | xit { should == expected } 163 | end 164 | 165 | context "other minute" do 166 | subject{ Tickle.parse('other minute') } 167 | let(:expected) { {:next=>Time.parse("2010-05-09 20:59:36 +0000"), :expression=>"other minute", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 168 | xit { should == expected } 169 | end 170 | 171 | context "other hour" do 172 | subject{ Tickle.parse('other hour') } 173 | let(:expected) { {:next=>Time.parse("2010-05-09 22:57:36 +0000"), :expression=>"other hour", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 174 | xit { should == expected } 175 | end 176 | 177 | context "other day" do 178 | subject{ Tickle.parse('other day') } 179 | let(:expected) { {:next=>Time.parse("2010-05-11 20:57:36 +0000"), :expression=>"other day", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 180 | it { should == expected } 181 | end 182 | 183 | context "other week" do 184 | subject{ Tickle.parse('other week') } 185 | let(:expected) { {:next=>Time.parse("2010-05-23 20:57:36 +0000"), :expression=>"other week", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 186 | it { should == expected } 187 | end 188 | 189 | context "other month" do 190 | subject{ Tickle.parse('other month') } 191 | let(:expected) { {:next=>Time.parse("2010-07-09 20:57:36 +0000"), :expression=>"other month", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 192 | it { should == expected } 193 | end 194 | 195 | context "other year" do 196 | subject{ Tickle.parse('other year') } 197 | let(:expected) { {:next=>Time.parse("2012-05-09 20:57:36 +0000"), :expression=>"other year", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 198 | it { should == expected } 199 | end 200 | 201 | context "noon" do 202 | subject{ Tickle.parse('noon') } 203 | let(:expected) { {:next=>Time.parse("2010-05-10 12:00:00 +0000"), :expression=>"12:00", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 204 | xit { should == expected } 205 | end 206 | 207 | context "midnight" do 208 | subject{ Tickle.parse('noon') } 209 | let(:expected) { {:next=>Time.parse("2010-05-10 00:00:00 +0000"), :expression=>"00:00", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 210 | xit { should == expected } 211 | end 212 | 213 | context "Monday" do 214 | subject{ Tickle.parse('Monday') } 215 | let(:expected) { {:next=>Time.parse("2010-05-10 12:00:00 +0000"), :expression=>"monday", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 216 | it { should == expected } 217 | end 218 | 219 | context "Wednesday" do 220 | subject{ Tickle.parse('Wednesday') } 221 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"wednesday", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 222 | it { should == expected } 223 | end 224 | 225 | context "Friday" do 226 | subject{ Tickle.parse('Friday') } 227 | let(:expected) { {:next=>Time.parse("2010-05-14 12:00:00 +0000"), :expression=>"friday", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 228 | it { should == expected } 229 | end 230 | 231 | context "With time specified", :frozen => true do 232 | context "Monday at 3am" do 233 | subject{ Tickle.parse('Monday at 3am') } 234 | let(:expected) { {:next=>Time.parse("2010-05-10 15:00:00 +0000"), :expression=>"monday 15:00", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 235 | xit { should == expected } 236 | end 237 | context "daily 16:23" do 238 | subject{ Tickle.parse('daily') } 239 | let(:expected) { {:next=>Time.parse("2010-05-10 16:23:00 +0000"), :expression=>"daily 16:23", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 240 | xit { should == expected } 241 | end 242 | end 243 | 244 | context "Given that start is in the past, respect now option in parse" do 245 | context "every other day" do 246 | subject{ Tickle.parse('every other day', {:start=>Time.parse("2009-05-09 00:00:00 +0000"), :now=>Time.parse("2009-05-09 00:00:00 +0000"), :until=>Time.parse("2017-10-21 00:00:00 +0000") }) } 247 | let(:expected) { {:next=>Time.parse("2009-05-11 00:00:00 +0000"), :expression=>"every other day", :starting=>Time.parse("2009-05-09 00:00:00 +0000"), :until=>nil} } 248 | it { should == expected } 249 | end 250 | end 251 | 252 | context "Given that now is in the future, 2020-04-01 00:00:00 +0000" do 253 | context "February" do 254 | subject{ Tickle.parse('February', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 255 | let(:expected) { {:next=>Time.parse("2021-02-01 12:00:00 +0000"), :expression=>"february", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 256 | it { should == expected } 257 | end 258 | 259 | context "May" do 260 | subject{ Tickle.parse('May', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 261 | let(:expected) { {:next=>Time.parse("2020-05-01 12:00:00 +0000"), :expression=>"may", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 262 | it { should == expected } 263 | end 264 | 265 | context "june" do 266 | subject{ Tickle.parse('june', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 267 | let(:expected) { {:next=>Time.parse("2020-06-01 12:00:00 +0000"), :expression=>"june", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 268 | it { should == expected } 269 | end 270 | 271 | context "beginning of the month" do 272 | subject{ Tickle.parse('beginning of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 273 | let(:expected) { {:next=>Time.parse("2020-05-01 00:00:00 +0000"), :expression=>"beginning of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 274 | it { should == expected } 275 | end 276 | 277 | context "middle of the month" do 278 | subject{ Tickle.parse('middle of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 279 | let(:expected) { {:next=>Time.parse("2020-04-15 00:00:00 +0000"), :expression=>"middle of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 280 | it { should == expected } 281 | end 282 | 283 | context "end of the month" do 284 | subject{ Tickle.parse('end of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 285 | let(:expected) { {:next=>Time.parse("2020-04-30 00:00:00 +0000"), :expression=>"end of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 286 | it { should == expected } 287 | end 288 | 289 | context "beginning of the year" do 290 | subject{ Tickle.parse('beginning of the year', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 291 | let(:expected) { {:next=>Time.parse("2021-01-01 00:00:00 +0000"), :expression=>"beginning of the year", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 292 | it { should == expected } 293 | end 294 | 295 | context "middle of the year" do 296 | subject{ Tickle.parse('middle of the year', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 297 | let(:expected) { {:next=>Time.parse("2020-06-15 00:00:00 +0000"), :expression=>"middle of the year", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 298 | it { should == expected } 299 | end 300 | 301 | context "end of the year" do 302 | subject{ Tickle.parse('end of the year', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 303 | let(:expected) { {:next=>Time.parse("2020-12-31 00:00:00 +0000"), :expression=>"end of the year", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 304 | it { should == expected } 305 | end 306 | 307 | context "the 3rd of May" do 308 | subject{ Tickle.parse('the 3rd of May', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 309 | let(:expected) { {:next=>Time.parse("2020-05-03 00:00:00 +0000"), :expression=>"the 3rd of may", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 310 | it { should == expected } 311 | end 312 | 313 | context "the 3rd of February" do 314 | subject{ Tickle.parse('the 3rd of February', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 315 | let(:expected) { {:next=>Time.parse("2021-02-03 00:00:00 +0000"), :expression=>"the 3rd of february", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 316 | it { should == expected } 317 | end 318 | 319 | context "the 3rd of February 2022" do 320 | subject{ Tickle.parse('the 3rd of February 2022', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 321 | let(:expected) { {:next=>Time.parse("2022-02-03 00:00:00 +0000"), :expression=>"the 3rd of february 2022", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 322 | it { should == expected } 323 | end 324 | 325 | context "the 3rd of Feb 2022" do 326 | subject{ Tickle.parse('the 3rd of Feb 2022', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 327 | let(:expected) { {:next=>Time.parse("2022-02-03 00:00:00 +0000"), :expression=>"the 3rd of feb 2022", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 328 | it { should == expected } 329 | end 330 | 331 | context "the 4th of the month" do 332 | subject{ Tickle.parse('the 4th of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 333 | let(:expected) { {:next=>Time.parse("2020-04-04 00:00:00 +0000"), :expression=>"the 4th of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 334 | it { should == expected } 335 | end 336 | 337 | context "the 10th of the month" do 338 | subject{ Tickle.parse('the 10th of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 339 | let(:expected) { {:next=>Time.parse("2020-04-10 00:00:00 +0000"), :expression=>"the 10th of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 340 | it { should == expected } 341 | end 342 | 343 | context "the tenth of the month" do 344 | subject{ Tickle.parse('the tenth of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 345 | let(:expected) { {:next=>Time.parse("2020-04-10 00:00:00 +0000"), :expression=>"the tenth of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 346 | it { should == expected } 347 | end 348 | 349 | context "first" do 350 | subject{ Tickle.parse('first', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 351 | let(:expected) { {:next=>Time.parse("2020-05-01 00:00:00 +0000"), :expression=>"first", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 352 | it { should == expected } 353 | end 354 | 355 | context "the first of the month" do 356 | subject{ Tickle.parse('the first of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 357 | let(:expected) { {:next=>Time.parse("2020-05-01 00:00:00 +0000"), :expression=>"the first of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 358 | it { should == expected } 359 | end 360 | 361 | context "the thirtieth" do 362 | subject{ Tickle.parse('the thirtieth', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 363 | let(:expected) { {:next=>Time.parse("2020-04-30 00:00:00 +0000"), :expression=>"the thirtieth", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 364 | it { should == expected } 365 | end 366 | 367 | context "the fifth" do 368 | subject{ Tickle.parse('the fifth', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 369 | let(:expected) { {:next=>Time.parse("2020-04-05 00:00:00 +0000"), :expression=>"the fifth", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 370 | it { should == expected } 371 | end 372 | 373 | context "the 1st Wednesday of the month" do 374 | subject{ Tickle.parse('the 1st wednesday of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 375 | let(:expected) { 376 | {:next=>Time.parse("2020-05-01 00:00:00 +0000"), :expression=>"the 1st wednesday of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} 377 | } 378 | it { should == expected } 379 | end 380 | 381 | context "the 3rd Sunday of May" do 382 | subject{ Tickle.parse('the 3rd Sunday of May', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 383 | let(:expected) { {:next=>Time.parse("2020-05-17 12:00:00 +0000"), :expression=>"the 3rd sunday of may", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 384 | it { should == expected } 385 | end 386 | 387 | context "the 3rd Sunday of the month" do 388 | subject{ Tickle.parse('the 3rd Sunday of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 389 | let(:expected) { {:next=>Time.parse("2020-04-19 12:00:00 +0000"), :expression=>"the 3rd sunday of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 390 | it { should == expected } 391 | end 392 | 393 | context "the 23rd of June" do 394 | subject{ Tickle.parse('the 23rd of June', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 395 | let(:expected) { {:next=>Time.parse("2020-06-23 00:00:00 +0000"), :expression=>"the 23rd of june", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 396 | it { should == expected } 397 | end 398 | 399 | context "the twenty third of June" do 400 | subject{ Tickle.parse('the twenty third of June', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 401 | let(:expected) { {:next=>Time.parse("2020-06-23 00:00:00 +0000"), :expression=>"the twenty third of june", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 402 | it { should == expected } 403 | end 404 | 405 | context "the thirty first of July" do 406 | subject{ Tickle.parse('the thirty first of July', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 407 | let(:expected) { {:next=>Time.parse("2020-07-31 00:00:00 +0000"), :expression=>"the thirty first of july", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 408 | it { should == expected } 409 | end 410 | 411 | context "the twenty first" do 412 | subject{ Tickle.parse('the twenty first', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 413 | let(:expected) { {:next=>Time.parse("2020-04-21 00:00:00 +0000"), :expression=>"the twenty first", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 414 | it { should == expected } 415 | end 416 | 417 | context "the twenty first of the month" do 418 | subject{ Tickle.parse('the twenty first of the month', {:start=>Time.parse("2020-04-01 00:00:00 +0000"), :now=>Time.parse("2020-04-01 00:00:00 +0000")}) } 419 | let(:expected) { {:next=>Time.parse("2020-04-21 00:00:00 +0000"), :expression=>"the twenty first of the month", :starting=>Time.parse("2020-04-01 00:00:00 +0000"), :until=>nil} } 420 | it { should == expected } 421 | end 422 | end 423 | 424 | context "beginning of the week" do 425 | subject{ Tickle.parse('beginning of the week') } 426 | let(:expected) { {:next=>Time.parse("2010-05-16 12:00:00 +0000"), :expression=>"beginning of the week", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 427 | it { should == expected } 428 | end 429 | 430 | context "middle of the week" do 431 | subject{ Tickle.parse('middle of the week') } 432 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"middle of the week", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 433 | it { should == expected } 434 | end 435 | 436 | context "end of the week" do 437 | subject{ Tickle.parse('end of the week') } 438 | let(:expected) { {:next=>Time.parse("2010-05-15 12:00:00 +0000"), :expression=>"end of the week", :starting=>Time.parse("2010-05-09 20:57:36 +0000"), :until=>nil} } 439 | it { should == expected } 440 | end 441 | 442 | end 443 | 444 | context "Complex examples", :frozen => true do 445 | 446 | context "starting today and ending one week from now" do 447 | subject{ Tickle.parse('starting today and ending one week from now') } 448 | let(:expected) { {:next=>Time.parse("2010-05-10 22:00:00 +0000"), :expression=>"day", :starting=>Time.parse("2010-05-09 22:00:00 +0000"), :until=>Time.parse("2010-05-16 20:57:36 +0000")} } 449 | it { should == expected } 450 | end 451 | 452 | context "starting tomorrow and ending one week from now" do 453 | subject{ Tickle.parse('starting tomorrow and ending one week from now') } 454 | let(:expected) { {:next=>Time.parse("2010-05-10 12:00:00 +0000"), :expression=>"day", :starting=>Time.parse("2010-05-10 12:00:00 +0000"), :until=>Time.parse("2010-05-16 20:57:36 +0000")} } 455 | it { should == expected } 456 | end 457 | 458 | context "starting Monday repeat every month" do 459 | subject{ Tickle.parse('starting Monday repeat every month') } 460 | let(:expected) { {:next=>Time.parse("2010-05-10 12:00:00 +0000"), :expression=>"month", :starting=>Time.parse("2010-05-10 12:00:00 +0000"), :until=>nil} } 461 | it { should == expected } 462 | end 463 | 464 | context "starting May 13th repeat every week" do 465 | subject{ Tickle.parse('starting May 13th repeat every week') } 466 | let(:expected) { {:next=>Time.parse("2010-05-13 12:00:00 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-13 12:00:00 +0000"), :until=>nil} } 467 | it { should == expected } 468 | end 469 | 470 | context "starting May 13th repeat every other day" do 471 | subject{ Tickle.parse('starting May 13th repeat every other day') } 472 | let(:expected) { {:next=>Time.parse("2010-05-13 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2010-05-13 12:00:00 +0000"), :until=>nil} } 473 | it { should == expected } 474 | end 475 | 476 | context "every other day starts May 13th" do 477 | subject{ Tickle.parse('every other day starts May 13th') } 478 | let(:expected) { {:next=>Time.parse("2010-05-13 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2010-05-13 12:00:00 +0000"), :until=>nil} } 479 | it { should == expected } 480 | end 481 | 482 | context "every other day starts May 13" do 483 | subject{ Tickle.parse('every other day starts May 13') } 484 | let(:expected) { {:next=>Time.parse("2010-05-13 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2010-05-13 12:00:00 +0000"), :until=>nil} } 485 | it { should == expected } 486 | end 487 | 488 | context "every other day starting May 13th" do 489 | subject{ Tickle.parse('every other day starting May 13th') } 490 | let(:expected) { {:next=>Time.parse("2010-05-13 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2010-05-13 12:00:00 +0000"), :until=>nil} } 491 | it { should == expected } 492 | end 493 | 494 | context "every other day starting May 13" do 495 | subject{ Tickle.parse('every other day starting May 13') } 496 | let(:expected) { {:next=>Time.parse("2010-05-13 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2010-05-13 12:00:00 +0000"), :until=>nil} } 497 | it { should == expected } 498 | end 499 | 500 | context "every week starts this wednesday" do 501 | subject{ Tickle.parse('every week starts this wednesday') } 502 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-12 12:00:00 +0000"), :until=>nil} } 503 | it { should == expected } 504 | end 505 | 506 | context "every week starting this wednesday" do 507 | subject{ Tickle.parse('every week starting this wednesday') } 508 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-12 12:00:00 +0000"), :until=>nil} } 509 | it { should == expected } 510 | end 511 | 512 | context "every other day starting May 1st 2021" do 513 | subject{ Tickle.parse('every other day starting May 1st 2021') } 514 | let(:expected) { {:next=>Time.parse("2021-05-01 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2021-05-01 12:00:00 +0000"), :until=>nil} } 515 | it { should == expected } 516 | end 517 | 518 | context "every other day starting May 1 2021" do 519 | subject{ Tickle.parse('every other day starting May 1 2021') } 520 | let(:expected) { {:next=>Time.parse("2021-05-01 12:00:00 +0000"), :expression=>"other day", :starting=>Time.parse("2021-05-01 12:00:00 +0000"), :until=>nil} } 521 | it { should == expected } 522 | end 523 | 524 | context "every other week starting this Sunday" do 525 | subject{ Tickle.parse('every other week starting this Sunday') } 526 | let(:expected) { {:next=>Time.parse("2010-05-16 12:00:00 +0000"), :expression=>"other week", :starting=>Time.parse("2010-05-16 12:00:00 +0000"), :until=>nil} } 527 | it { should == expected } 528 | end 529 | 530 | context "every week starting this wednesday until May 13th" do 531 | subject{ Tickle.parse('every week starting this wednesday until May 13th') } 532 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-12 12:00:00 +0000"), :until=>Time.parse("2010-05-13 12:00:00 +0000")} } 533 | it { should == expected } 534 | end 535 | 536 | context "every week starting this wednesday ends May 13th" do 537 | subject{ Tickle.parse('every week starting this wednesday ends May 13th') } 538 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-12 12:00:00 +0000"), :until=>Time.parse("2010-05-13 12:00:00 +0000")} } 539 | it { should == expected } 540 | end 541 | 542 | context "every week starting this wednesday ending May 13th" do 543 | subject{ Tickle.parse('every week starting this wednesday ending May 13th') } 544 | let(:expected) { {:next=>Time.parse("2010-05-12 12:00:00 +0000"), :expression=>"week", :starting=>Time.parse("2010-05-12 12:00:00 +0000"), :until=>Time.parse("2010-05-13 12:00:00 +0000")} } 545 | it { should == expected } 546 | end 547 | end 548 | end 549 | end 550 | 551 | end # of module 552 | --------------------------------------------------------------------------------