Module: Tickle

Defined in:
lib/tickle.rb,
lib/tickle/tickle.rb,
lib/tickle/handler.rb,
lib/tickle/version.rb

Overview

:nodoc:

Defined Under Namespace

Classes: InvalidArgumentException, InvalidDateExpression, Repeater, Token

Constant Summary collapse

VERSION =

This library’s current version.

"1.2.0"

Class Method Summary collapse

Class Method Details

.base_tokenize(text) ⇒ Object

Split the text on spaces and convert each word into a Token



200
201
202
# File 'lib/tickle/tickle.rb', line 200

def base_tokenize(text) #:nodoc:
  text.split(' ').map { |word| Token.new(word) }
end

.combine_multiple_numbersObject

Turns compound numbers, like ‘twenty first’ => 21



258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/tickle/tickle.rb', line 258

def combine_multiple_numbers
  if [:number, :ordinal].all? {|type| token_types.include? type}
    number = token_of_type(:number)
    ordinal = token_of_type(:ordinal)
    combined_original = "#{number.original} #{ordinal.original}"
    combined_word = (number.start.to_s[0] + ordinal.word)
    combined_value = (number.start.to_s[0] + ordinal.start.to_s)
    new_number_token = Token.new(combined_original, combined_word, :ordinal, combined_value, 365)
    @tokens.reject! {|token| (token.type == :number || token.type == :ordinal)}
    @tokens << new_number_token
  end
end

.days_in_month(month = nil) ⇒ Object

Return the number of days in a specified month. If no month is specified, current month is used.



291
292
293
294
# File 'lib/tickle/tickle.rb', line 291

def days_in_month(month=nil)
  month ||= Date.today.month
  days_in_mon = Date.civil(Date.today.year, month, -1).day
end

.debug=(val) ⇒ Object



29
# File 'lib/tickle.rb', line 29

def self.debug=(val); @debug = val; end

.dwrite(msg, line_feed = nil) ⇒ Object



31
32
33
# File 'lib/tickle.rb', line 31

def self.dwrite(msg, line_feed=nil)
  (line_feed ? p(">> #{msg}") : puts(">> #{msg}")) if @debug
end

.get_next_month(number) ⇒ Object

Returns the next available month based on the current day of the month. 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. However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month.



279
280
281
# File 'lib/tickle/tickle.rb', line 279

def get_next_month(number)
  month = number.to_i < @start.day ? (@start.month == 12 ? 1 : @start.month + 1) : @start.month
end

.guessObject

The heavy lifting. Goes through each token groupings to determine what natural language should either by parsed by Chronic or returned. This methodology makes extension fairly simple, as new token types can be easily added in repeater and then processed by the guess method



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# File 'lib/tickle/handler.rb', line 8

def guess()
  return nil if @tokens.empty?

  guess_unit_types
  guess_weekday unless @next
  guess_month_names unless @next
  guess_number_and_unit unless @next
  guess_ordinal unless @next
  guess_ordinal_and_unit unless @next
  guess_special unless @next

  # check to see if next is less than now and, if so, set it to next year
  @next = Time.local(@next.year + 1, @next.month, @next.day, @next.hour, @next.min, @next.sec) if @next && @next.to_date < @start.to_date

  # return the next occurrence
  return @next.to_time if @next
end

.guess_month_namesObject



37
38
39
# File 'lib/tickle/handler.rb', line 37

def guess_month_names
  @next = chronic_parse_with_start("#{Date::MONTHNAMES[token_of_type(:month_name).start]} 1") if token_types.same?([:month_name])
end

.guess_number_and_unitObject



41
42
43
44
45
46
47
48
# File 'lib/tickle/handler.rb', line 41

def guess_number_and_unit
  @next = @start.bump(:day, token_of_type(:number).interval) if token_types.same?([:number, :day])
  @next = @start.bump(:week, token_of_type(:number).interval) if token_types.same?([:number, :week])
  @next = @start.bump(:month, token_of_type(:number).interval) if token_types.same?([:number, :month])
  @next = @start.bump(:year, token_of_type(:number).interval) if token_types.same?([:number, :year])
  @next = chronic_parse_with_start("#{token_of_type(:month_name).word} #{token_of_type(:number).start}") if token_types.same?([:number, :month_name])
  @next = chronic_parse_with_start("#{token_of_type(:specific_year).word}-#{token_of_type(:month_name).start}-#{token_of_type(:number).start}") if token_types.same?([:number, :month_name, :specific_year])
end

.guess_ordinalObject



50
51
52
# File 'lib/tickle/handler.rb', line 50

def guess_ordinal
  @next = handle_same_day_chronic_issue(@start.year, @start.month, token_of_type(:ordinal).start) if token_types.same?([:ordinal])
end

.guess_ordinal_and_unitObject



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/tickle/handler.rb', line 54

def guess_ordinal_and_unit
  @next = handle_same_day_chronic_issue(@start.year, token_of_type(:month_name).start, token_of_type(:ordinal).start) if token_types.same?([:ordinal, :month_name])
  @next = handle_same_day_chronic_issue(@start.year, @start.month, token_of_type(:ordinal).start) if token_types.same?([:ordinal, :month])
  @next = handle_same_day_chronic_issue(token_of_type(:specific_year).word, token_of_type(:month_name).start, token_of_type(:ordinal).start) if token_types.same?([:ordinal, :month_name, :specific_year])

  if token_types.same?([:ordinal, :weekday, :month_name])
    @next = chronic_parse_with_start("#{token_of_type(:ordinal).word} #{token_of_type(:weekday).start.to_s} in #{Date::MONTHNAMES[token_of_type(:month_name).start]}")
    @next = handle_same_day_chronic_issue(@start.year, token_of_type(:month_name).start, token_of_type(:ordinal).start) if @next.to_date == @start.to_date
  end

  if token_types.same?([:ordinal, :weekday, :month])
    @next = chronic_parse_with_start("#{token_of_type(:ordinal).word} #{token_of_type(:weekday).start.to_s} in #{Date::MONTHNAMES[get_next_month(token_of_type(:ordinal).start)]}")
    @next = handle_same_day_chronic_issue(@start.year, @start.month, token_of_type(:ordinal).start) if @next.to_date == @start.to_date
  end
end

.guess_specialObject



70
71
72
73
74
75
# File 'lib/tickle/handler.rb', line 70

def guess_special
  guess_special_other
  guess_special_beginning unless @next
  guess_special_middle unless @next
  guess_special_end unless @next
end

.guess_unit_typesObject



26
27
28
29
30
31
# File 'lib/tickle/handler.rb', line 26

def guess_unit_types
  @next = @start.bump(:day) if token_types.same?([:day])
  @next = @start.bump(:week) if token_types.same?([:week])
  @next = @start.bump(:month) if token_types.same?([:month])
  @next = @start.bump(:year) if token_types.same?([:year])
end

.guess_weekdayObject



33
34
35
# File 'lib/tickle/handler.rb', line 33

def guess_weekday
  @next = chronic_parse_with_start("#{token_of_type(:weekday).start.to_s}") if token_types.same?([:weekday])
end

.is_date(str) ⇒ Object



35
36
37
38
39
40
41
42
# File 'lib/tickle.rb', line 35

def self.is_date(str)
  begin
    Date.parse(str.to_s)
    return true
  rescue Exception => e
    return false
  end
end

.next_appropriate_year(month, day) ⇒ Object



283
284
285
286
287
# File 'lib/tickle/tickle.rb', line 283

def next_appropriate_year(month, day)
  start = @start || Date.today
  year = (Date.new(start.year.to_i, month.to_i, day.to_i) == start.to_date) ? start.year + 1 : start.year
  return year
end

.normalize(text) ⇒ Object

Clean up the specified input text by stripping unwanted characters, converting idioms to their canonical form, converting number words to numbers (three => 3), and converting ordinal words to numeric ordinals (third => 3rd)



215
216
217
218
219
220
221
# File 'lib/tickle/tickle.rb', line 215

def normalize(text) #:nodoc:
  normalized_text = text.to_s.downcase
  normalized_text = Numerizer.numerize(normalized_text)
  normalized_text.gsub!(/['"\.]/, '')
  normalized_text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
  normalized_text
end

.normalize_us_holidays(text) ⇒ Object

Converts natural language US Holidays into a date expression to be parsed.



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/tickle/tickle.rb', line 225

def normalize_us_holidays(text) #:nodoc:
  normalized_text = text.to_s.downcase
  normalized_text.gsub!(/\bnew\syear'?s?(\s)?(day)?\b/, "january 1, #{next_appropriate_year(1, 1)}")
  normalized_text.gsub!(/\bnew\syear'?s?(\s)?(eve)?\b/, "december 31, #{next_appropriate_year(12, 31)}")
  normalized_text.gsub!(/\bm(artin\s)?l(uther\s)?k(ing)?(\sday)?\b/, 'third monday in january')
  normalized_text.gsub!(/\binauguration(\sday)?\b/, 'january 20')
  normalized_text.gsub!(/\bpresident'?s?(\sday)?\b/, 'third monday in february')
  normalized_text.gsub!(/\bmemorial\sday\b/, '4th monday of may')
  normalized_text.gsub!(/\bindepend(e|a)nce\sday\b/, "july 4, #{next_appropriate_year(7, 4)}")
  normalized_text.gsub!(/\blabor\sday\b/, 'first monday in september')
  normalized_text.gsub!(/\bcolumbus\sday\b/, 'second monday in october')
  normalized_text.gsub!(/\bveterans?\sday\b/, "november 11, #{next_appropriate_year(11, 1)}")
  normalized_text.gsub!(/\bthanksgiving(\sday)?\b/, 'fourth thursday in november')
  normalized_text.gsub!(/\bchristmas\seve\b/, "december 24, #{next_appropriate_year(12, 24)}")
  normalized_text.gsub!(/\bchristmas(\sday)?\b/, "december 25, #{next_appropriate_year(12, 25)}")
  normalized_text.gsub!(/\bsuper\sbowl(\ssunday)?\b/, 'first sunday in february')
  normalized_text.gsub!(/\bgroundhog(\sday)?\b/, "february 2, #{next_appropriate_year(2, 2)}")
  normalized_text.gsub!(/\bvalentine'?s?(\sday)?\b/, "february 14, #{next_appropriate_year(2, 14)}")
  normalized_text.gsub!(/\bs(ain)?t\spatrick'?s?(\sday)?\b/, "march 17, #{next_appropriate_year(3, 17)}")
  normalized_text.gsub!(/\bapril\sfool'?s?(\sday)?\b/, "april 1, #{next_appropriate_year(4, 1)}")
  normalized_text.gsub!(/\bearth\sday\b/, "april 22, #{next_appropriate_year(4, 22)}")
  normalized_text.gsub!(/\barbor\sday\b/, 'fourth friday in april')
  normalized_text.gsub!(/\bcinco\sde\smayo\b/, "may 5, #{next_appropriate_year(5, 5)}")
  normalized_text.gsub!(/\bmother'?s?\sday\b/, 'second sunday in may')
  normalized_text.gsub!(/\bflag\sday\b/, "june 14, #{next_appropriate_year(6, 14)}")
  normalized_text.gsub!(/\bfather'?s?\sday\b/, 'third sunday in june')
  normalized_text.gsub!(/\bhalloween\b/, "october 31, #{next_appropriate_year(10, 31)}")
  normalized_text.gsub!(/\belection\sday\b/, 'second tuesday in november')
  normalized_text.gsub!(/\bkwanzaa\b/, "january 1, #{next_appropriate_year(1, 1)}")
  normalized_text
end

.parse(text, specified_options = {}) ⇒ Object

Configuration options

  • start - start date for future occurrences. Must be in valid date format.

  • until - last date to run occurrences until. Must be in valid date format.

Use by calling Tickle.parse and passing natural language with or without options.

def get_next_occurrence
    results = Tickle.parse('every Wednesday starting June 1st until Dec 15th')
    return results[:next] if results
end


38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/tickle/tickle.rb', line 38

def parse(text, specified_options = {})
  # get options and set defaults if necessary.  Ability to set now is mostly for debugging
  default_options = {:start => Time.now, :next_only => false, :until => nil, :now => Time.now}
  options = default_options.merge specified_options

  # ensure an expression was provided
  raise(InvalidArgumentException, 'date expression is required') unless text

  # ensure the specified options are valid
  specified_options.keys.each do |key|
    raise(InvalidArgumentException, "#{key} is not a valid option key.") unless default_options.keys.include?(key)
  end
  raise(InvalidArgumentException, ':start specified is not a valid datetime.') unless  (is_date(specified_options[:start]) || Chronic.parse(specified_options[:start])) if specified_options[:start]

  # check to see if a valid datetime was passed
  return text if text.is_a?(Date) ||  text.is_a?(Time)

  # check to see if this event starts some other time and reset now
  event = scan_expression(text, options)

  Tickle.dwrite("start: #{@start}, until: #{@until}, now: #{options[:now].to_date}")

  # => ** this is mostly for testing. Bump by 1 day if today (or in the past for testing)
  raise(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur in the past for a future event") if @start && @start.to_date < Date.today
  raise(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur after the end date") if @until && @start.to_date > @until.to_date

  # no need to guess at expression if the start_date is in the future
  best_guess = nil
  if @start.to_date > options[:now].to_date
    best_guess = @start
  else
    # put the text into a normal format to ease scanning using Chronic
    event = pre_filter(event)

    # split into tokens
    @tokens = base_tokenize(event)

    # process each original word for implied word
    post_tokenize

    @tokens.each {|x| Tickle.dwrite("raw: #{x.inspect}")}

    # scan the tokens with each token scanner
    @tokens = Repeater.scan(@tokens)

    # remove all tokens without a type
    @tokens.reject! {|token| token.type.nil? }

    # combine number and ordinals into single number
    combine_multiple_numbers

    @tokens.each {|x| Tickle.dwrite("processed: #{x.inspect}")}

    # if we can't guess it maybe chronic can
    best_guess = (guess || chronic_parse(event))
  end

  raise(InvalidDateExpression, "the next occurrence takes place after the end date specified") if @until && best_guess.to_date > @until.to_date

  if !best_guess
    return nil
  elsif options[:next_only] != true
    return {:next => best_guess.to_time, :expression => event.strip, :starting => @start, :until => @until}
  else
    return best_guess
  end
end

.post_tokenizeObject

normalizes each token



205
206
207
208
209
# File 'lib/tickle/tickle.rb', line 205

def post_tokenize
  @tokens.each do |token|
    token.word = normalize(token.original)
  end
end

.pre_filter(text) ⇒ Object

Normalize natural string removing prefix language



187
188
189
190
191
192
193
194
195
196
# File 'lib/tickle/tickle.rb', line 187

def pre_filter(text)
  return nil unless text

  text.gsub!(/every(\s)?/, '')
  text.gsub!(/each(\s)?/, '')
  text.gsub!(/repeat(s|ing)?(\s)?/, '')
  text.gsub!(/on the(\s)?/, '')
  text.gsub!(/([^\w\d\s])+/, '')
  normalize_us_holidays(text.downcase.strip)
end

.process_for_ending(text) ⇒ Object

process the remaining expression to see if an until, end, ending is specified



177
178
179
180
181
182
183
184
# File 'lib/tickle/tickle.rb', line 177

def process_for_ending(text)
  regex = /^(.*)(\s(?:\bend|until)(?:s|ing)?)(.*)/i
  if text =~ regex
    return text.match(regex)[1], text.match(regex)[3]
  else
    return text, nil
  end
end

.scan_expression(text, options) ⇒ Object

scans the expression for a variety of natural formats, such as ‘every thursday starting tomorrow until May 15th



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/tickle/tickle.rb', line 107

def scan_expression(text, options)
  starting = ending = nil

  start_every_regex = /^
    (start(?:s|ing)?)                 # 0
    \s
    (.*)
    (\s(?:every|each|\bon\b|repeat)   # 1
    (?:s|ing)?)                       # 2
    (.*)                              # 3
  /ix
  every_start_regex = /^(every|each|\bon\b|repeat(?:the)?)\s(.*)(\s(?:start)(?:s|ing)?)(.*)/i
  start_ending_regex = /^
    (start(?:s|ing)?)   # 0
    \s+
    (.*?)(?:\s+and)?      # 1
    (\s
      (?:\bend|until)
      (?:s|ing)?
    )                   # 2
    (.*)                # 3
  /ix
  if text =~ start_every_regex
    starting = text.match(start_every_regex)[2].strip
    text = text.match(start_every_regex)[4].strip
    event, ending = process_for_ending(text)
  elsif text =~ every_start_regex
    event = text.match(every_start_regex)[2].strip
    text = text.match(every_start_regex)[4].strip
    starting, ending = process_for_ending(text)
  elsif text =~ start_ending_regex
    md = text.match start_ending_regex
    starting = md.captures[1]
    ending = md.captures.last.strip
    event = 'day'
  else
    event, ending = process_for_ending(text)
  end

  # they gave a phrase so if we can't interpret then we need to raise an error
  if starting
    Tickle.dwrite("starting: #{starting}")
    @start ||= nil # initialize the variable to quell warnings
    @start = chronic_parse(pre_filter(starting))
    if @start
      @start.to_time
    else
      raise(InvalidDateExpression,"the starting date expression \"#{starting}\" could not be interpretted")
    end
  else
    @start = options[:start].to_time rescue nil
  end

  if ending
    @until = chronic_parse(pre_filter(ending))
    if @until
      @until.to_time
    else
      raise(InvalidDateExpression,"the ending date expression \"#{ending}\" could not be interpretted")
    end
  else
    @until = options[:until].to_time rescue nil
  end

  @next = nil

  return event
end

.token_typesObject

Returns an array of types for all tokens



272
273
274
# File 'lib/tickle/tickle.rb', line 272

def token_types
  @tokens.map(&:type)
end