tickle 1.2.0 → 2.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,260 +19,156 @@
19
19
  # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
20
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
21
 
22
- require 'numerizer'
23
-
24
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
+
25
31
  class << self
32
+
33
+
26
34
  # == Configuration options
27
35
  #
28
- # * +start+ - start date for future occurrences. Must be in valid date format.
29
- # * +until+ - last date to run occurrences until. Must be in valid date format.
30
- #
31
- # Use by calling Tickle.parse and passing natural language with or without options.
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]
32
43
  #
33
- # def get_next_occurrence
34
- # results = Tickle.parse('every Wednesday starting June 1st until Dec 15th')
35
- # return results[:next] if results
36
- # end
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}
37
47
  #
38
- def parse(text, specified_options = {})
39
- # get options and set defaults if necessary. Ability to set now is mostly for debugging
40
- default_options = {:start => Time.now, :next_only => false, :until => nil, :now => Time.now}
41
- options = default_options.merge specified_options
42
-
43
- # ensure an expression was provided
44
- raise(InvalidArgumentException, 'date expression is required') unless text
45
-
46
- # ensure the specified options are valid
47
- specified_options.keys.each do |key|
48
- raise(InvalidArgumentException, "#{key} is not a valid option key.") unless default_options.keys.include?(key)
49
- end
50
- raise(InvalidArgumentException, ':start specified is not a valid datetime.') unless (is_date(specified_options[:start]) || Chronic.parse(specified_options[:start])) if specified_options[:start]
51
-
52
- # check to see if a valid datetime was passed
53
- return text if text.is_a?(Date) || text.is_a?(Time)
48
+ def _parse( tickled )
54
49
 
55
50
  # check to see if this event starts some other time and reset now
56
- event = scan_expression(text, options)
57
-
58
- Tickle.dwrite("start: #{@start}, until: #{@until}, now: #{options[:now].to_date}")
51
+ scan_expression! tickled
59
52
 
60
- # => ** this is mostly for testing. Bump by 1 day if today (or in the past for testing)
61
- raise(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur in the past for a future event") if @start && @start.to_date < Date.today
62
- raise(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur after the end date") if @until && @start.to_date > @until.to_date
53
+ fail(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur in the past for a future event") if @start && @start.to_date < Date.today
54
+ fail(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur after the end date") if @until && @start.to_date > @until.to_date
63
55
 
64
56
  # no need to guess at expression if the start_date is in the future
65
57
  best_guess = nil
66
- if @start.to_date > options[:now].to_date
58
+ if @start.to_date > tickled.now.to_date
67
59
  best_guess = @start
68
60
  else
69
61
  # put the text into a normal format to ease scanning using Chronic
70
- event = pre_filter(event)
71
-
72
- # split into tokens
73
- @tokens = base_tokenize(event)
74
-
62
+ tickled.filtered = tickled.event.filter
63
+ # split into tokens and then
75
64
  # process each original word for implied word
76
- post_tokenize
77
-
78
- @tokens.each {|x| Tickle.dwrite("raw: #{x.inspect}")}
65
+ @tokens = post_tokenize Token.tokenize(tickled.filtered)
79
66
 
80
67
  # scan the tokens with each token scanner
81
- @tokens = Repeater.scan(@tokens)
68
+ @tokens = Token.scan!(@tokens)
82
69
 
83
70
  # remove all tokens without a type
84
71
  @tokens.reject! {|token| token.type.nil? }
85
72
 
86
73
  # combine number and ordinals into single number
87
- combine_multiple_numbers
88
-
89
- @tokens.each {|x| Tickle.dwrite("processed: #{x.inspect}")}
74
+ @tokens = Helpers.combine_multiple_numbers(@tokens)
90
75
 
91
76
  # if we can't guess it maybe chronic can
92
- best_guess = (guess || chronic_parse(event))
77
+ _guess = guess(@tokens, @start)
78
+ best_guess = _guess || chronic_parse(tickled.event) # TODO fix this call
93
79
  end
94
80
 
95
- raise(InvalidDateExpression, "the next occurrence takes place after the end date specified") if @until && best_guess.to_date > @until.to_date
96
-
81
+ fail(InvalidDateExpression, "the next occurrence takes place after the end date specified") if @until && (best_guess.to_date > @until.to_date)
97
82
  if !best_guess
98
83
  return nil
99
- elsif options[:next_only] != true
100
- return {:next => best_guess.to_time, :expression => event.strip, :starting => @start, :until => @until}
84
+ elsif !tickled.next_only?
85
+ return {:next => best_guess.to_time, :expression => tickled.event.filter, :starting => @start, :until => @until}
101
86
  else
102
87
  return best_guess
103
88
  end
104
89
  end
105
90
 
91
+
106
92
  # scans the expression for a variety of natural formats, such as 'every thursday starting tomorrow until May 15th
107
- def scan_expression(text, options)
108
- starting = ending = nil
109
-
110
- start_every_regex = /^
111
- (start(?:s|ing)?) # 0
112
- \s
113
- (.*)
114
- (\s(?:every|each|\bon\b|repeat) # 1
115
- (?:s|ing)?) # 2
116
- (.*) # 3
117
- /ix
118
- every_start_regex = /^(every|each|\bon\b|repeat(?:the)?)\s(.*)(\s(?:start)(?:s|ing)?)(.*)/i
119
- start_ending_regex = /^
120
- (start(?:s|ing)?) # 0
121
- \s+
122
- (.*?)(?:\s+and)? # 1
123
- (\s
124
- (?:\bend|until)
125
- (?:s|ing)?
126
- ) # 2
127
- (.*) # 3
128
- /ix
129
- if text =~ start_every_regex
130
- starting = text.match(start_every_regex)[2].strip
131
- text = text.match(start_every_regex)[4].strip
132
- event, ending = process_for_ending(text)
133
- elsif text =~ every_start_regex
134
- event = text.match(every_start_regex)[2].strip
135
- text = text.match(every_start_regex)[4].strip
136
- starting, ending = process_for_ending(text)
137
- elsif text =~ start_ending_regex
138
- md = text.match start_ending_regex
139
- starting = md.captures[1]
140
- ending = md.captures.last.strip
141
- event = 'day'
142
- else
143
- event, ending = process_for_ending(text)
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)
144
109
  end
145
-
110
+ tickled.starting = starting unless starting.nil?
111
+ tickled.event = event unless event.nil?
112
+ tickled.ending = ending unless ending.nil?
146
113
  # they gave a phrase so if we can't interpret then we need to raise an error
147
- if starting
148
- Tickle.dwrite("starting: #{starting}")
149
- @start ||= nil # initialize the variable to quell warnings
150
- @start = chronic_parse(pre_filter(starting))
114
+ if tickled.starting && !tickled.starting.to_s.blank?
115
+ @start = chronic_parse(tickled.starting,tickled, :start)
151
116
  if @start
152
117
  @start.to_time
153
118
  else
154
- raise(InvalidDateExpression,"the starting date expression \"#{starting}\" could not be interpretted")
119
+ fail(InvalidDateExpression,"the starting date expression \"#{tickled.starting}\" could not be interpretted")
155
120
  end
156
121
  else
157
- @start = options[:start].to_time rescue nil
122
+ @start = tickled.start && tickled.start.to_time
158
123
  end
159
124
 
160
- if ending
161
- @until = chronic_parse(pre_filter(ending))
125
+
126
+ if tickled.ending && !tickled.ending.blank?
127
+ @until = chronic_parse(tickled.ending.filter,tickled, :until)
162
128
  if @until
163
129
  @until.to_time
164
130
  else
165
- raise(InvalidDateExpression,"the ending date expression \"#{ending}\" could not be interpretted")
131
+ fail(InvalidDateExpression,"the ending date expression \"#{tickled.ending}\" could not be interpretted")
166
132
  end
167
133
  else
168
- @until = options[:until].to_time rescue nil
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 > tickled.starting.to_time
138
+ tickled.until.to_time
139
+ end
140
+ end
141
+ end
169
142
  end
170
143
 
171
144
  @next = nil
172
-
173
- return event
145
+ tickled
174
146
  end
175
147
 
148
+
176
149
  # process the remaining expression to see if an until, end, ending is specified
177
150
  def process_for_ending(text)
178
- regex = /^(.*)(\s(?:\bend|until)(?:s|ing)?)(.*)/i
179
- if text =~ regex
180
- return text.match(regex)[1], text.match(regex)[3]
181
- else
182
- return text, nil
183
- end
184
- end
185
-
186
- # Normalize natural string removing prefix language
187
- def pre_filter(text)
188
- return nil unless text
189
-
190
- text.gsub!(/every(\s)?/, '')
191
- text.gsub!(/each(\s)?/, '')
192
- text.gsub!(/repeat(s|ing)?(\s)?/, '')
193
- text.gsub!(/on the(\s)?/, '')
194
- text.gsub!(/([^\w\d\s])+/, '')
195
- normalize_us_holidays(text.downcase.strip)
196
- end
197
-
198
- # Split the text on spaces and convert each word into
199
- # a Token
200
- def base_tokenize(text) #:nodoc:
201
- text.split(' ').map { |word| Token.new(word) }
151
+ (md = Patterns::PROCESS_FOR_ENDING.match text) ?
152
+ [ md[:target], md[:ending] ] :
153
+ [text, nil]
202
154
  end
203
155
 
204
156
  # normalizes each token
205
- def post_tokenize
206
- @tokens.each do |token|
207
- token.word = normalize(token.original)
157
+ def post_tokenize(tokens)
158
+ _tokens = tokens.map(&:clone)
159
+ _tokens.each do |token|
160
+ token.normalize!
208
161
  end
162
+ _tokens
209
163
  end
210
164
 
211
- # Clean up the specified input text by stripping unwanted characters,
212
- # converting idioms to their canonical form, converting number words
213
- # to numbers (three => 3), and converting ordinal words to numeric
214
- # ordinals (third => 3rd)
215
- def normalize(text) #:nodoc:
216
- normalized_text = text.to_s.downcase
217
- normalized_text = Numerizer.numerize(normalized_text)
218
- normalized_text.gsub!(/['"\.]/, '')
219
- normalized_text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
220
- normalized_text
221
- end
222
-
223
- # Converts natural language US Holidays into a date expression to be
224
- # parsed.
225
- def normalize_us_holidays(text) #:nodoc:
226
- normalized_text = text.to_s.downcase
227
- normalized_text.gsub!(/\bnew\syear'?s?(\s)?(day)?\b/, "january 1, #{next_appropriate_year(1, 1)}")
228
- normalized_text.gsub!(/\bnew\syear'?s?(\s)?(eve)?\b/, "december 31, #{next_appropriate_year(12, 31)}")
229
- normalized_text.gsub!(/\bm(artin\s)?l(uther\s)?k(ing)?(\sday)?\b/, 'third monday in january')
230
- normalized_text.gsub!(/\binauguration(\sday)?\b/, 'january 20')
231
- normalized_text.gsub!(/\bpresident'?s?(\sday)?\b/, 'third monday in february')
232
- normalized_text.gsub!(/\bmemorial\sday\b/, '4th monday of may')
233
- normalized_text.gsub!(/\bindepend(e|a)nce\sday\b/, "july 4, #{next_appropriate_year(7, 4)}")
234
- normalized_text.gsub!(/\blabor\sday\b/, 'first monday in september')
235
- normalized_text.gsub!(/\bcolumbus\sday\b/, 'second monday in october')
236
- normalized_text.gsub!(/\bveterans?\sday\b/, "november 11, #{next_appropriate_year(11, 1)}")
237
- normalized_text.gsub!(/\bthanksgiving(\sday)?\b/, 'fourth thursday in november')
238
- normalized_text.gsub!(/\bchristmas\seve\b/, "december 24, #{next_appropriate_year(12, 24)}")
239
- normalized_text.gsub!(/\bchristmas(\sday)?\b/, "december 25, #{next_appropriate_year(12, 25)}")
240
- normalized_text.gsub!(/\bsuper\sbowl(\ssunday)?\b/, 'first sunday in february')
241
- normalized_text.gsub!(/\bgroundhog(\sday)?\b/, "february 2, #{next_appropriate_year(2, 2)}")
242
- normalized_text.gsub!(/\bvalentine'?s?(\sday)?\b/, "february 14, #{next_appropriate_year(2, 14)}")
243
- normalized_text.gsub!(/\bs(ain)?t\spatrick'?s?(\sday)?\b/, "march 17, #{next_appropriate_year(3, 17)}")
244
- normalized_text.gsub!(/\bapril\sfool'?s?(\sday)?\b/, "april 1, #{next_appropriate_year(4, 1)}")
245
- normalized_text.gsub!(/\bearth\sday\b/, "april 22, #{next_appropriate_year(4, 22)}")
246
- normalized_text.gsub!(/\barbor\sday\b/, 'fourth friday in april')
247
- normalized_text.gsub!(/\bcinco\sde\smayo\b/, "may 5, #{next_appropriate_year(5, 5)}")
248
- normalized_text.gsub!(/\bmother'?s?\sday\b/, 'second sunday in may')
249
- normalized_text.gsub!(/\bflag\sday\b/, "june 14, #{next_appropriate_year(6, 14)}")
250
- normalized_text.gsub!(/\bfather'?s?\sday\b/, 'third sunday in june')
251
- normalized_text.gsub!(/\bhalloween\b/, "october 31, #{next_appropriate_year(10, 31)}")
252
- normalized_text.gsub!(/\belection\sday\b/, 'second tuesday in november')
253
- normalized_text.gsub!(/\bkwanzaa\b/, "january 1, #{next_appropriate_year(1, 1)}")
254
- normalized_text
255
- end
256
-
257
- # Turns compound numbers, like 'twenty first' => 21
258
- def combine_multiple_numbers
259
- if [:number, :ordinal].all? {|type| token_types.include? type}
260
- number = token_of_type(:number)
261
- ordinal = token_of_type(:ordinal)
262
- combined_original = "#{number.original} #{ordinal.original}"
263
- combined_word = (number.start.to_s[0] + ordinal.word)
264
- combined_value = (number.start.to_s[0] + ordinal.start.to_s)
265
- new_number_token = Token.new(combined_original, combined_word, :ordinal, combined_value, 365)
266
- @tokens.reject! {|token| (token.type == :number || token.type == :ordinal)}
267
- @tokens << new_number_token
268
- end
269
- end
270
165
 
271
166
  # Returns an array of types for all tokens
272
167
  def token_types
273
168
  @tokens.map(&:type)
274
169
  end
275
170
 
171
+
276
172
  # Returns the next available month based on the current day of the month.
277
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.
278
174
  # However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month.
@@ -280,56 +176,38 @@ module Tickle
280
176
  month = number.to_i < @start.day ? (@start.month == 12 ? 1 : @start.month + 1) : @start.month
281
177
  end
282
178
 
179
+
283
180
  def next_appropriate_year(month, day)
284
- start = @start || Date.today
285
- year = (Date.new(start.year.to_i, month.to_i, day.to_i) == start.to_date) ? start.year + 1 : start.year
181
+ year = (Date.new(@start.year.to_i, month.to_i, day.to_i) == @start.to_date) ? @start.year + 1 : @start.year
286
182
  return year
287
183
  end
288
184
 
289
- # Return the number of days in a specified month.
290
- # If no month is specified, current month is used.
291
- def days_in_month(month=nil)
292
- month ||= Date.today.month
293
- days_in_mon = Date.civil(Date.today.year, month, -1).day
294
- end
295
185
 
296
186
  private
297
187
 
188
+
298
189
  # slightly modified chronic parser to ensure that the date found is in the future
299
190
  # first we check to see if an explicit date was passed and, if so, dont do anything.
300
191
  # if, however, a date expression was passed we evaluate and shift forward if needed
301
- def chronic_parse(exp)
302
- result = Chronic.parse(exp.ordinal_as_number)
303
- result = Time.local(result.year + 1, result.month, result.day, result.hour, result.min, result.sec) if result && result.to_time < Time.now
304
- Tickle.dwrite("Chronic.parse('#{exp}') # => #{result}")
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
305
205
  result
306
206
  end
307
207
 
308
208
  end
309
209
 
310
- class Token #:nodoc:
311
- attr_accessor :original, :word, :type, :interval, :start
312
210
 
313
- def initialize(original, word=nil, type=nil, start=nil, interval=nil)
314
- @original = original
315
- @word = word
316
- @type = type
317
- @interval = interval
318
- @start = start
319
- end
320
-
321
- # Updates an existing token. Mostly used by the repeater class.
322
- def update(type, start=nil, interval=nil)
323
- @start = start
324
- @type = type
325
- @interval = interval
326
- end
327
- end
328
-
329
- # This exception is raised if an invalid argument is provided to
330
- # any of Tickle's methods
331
- class InvalidArgumentException < Exception
332
- end
333
211
 
334
212
  # This exception is raised if there is an issue with the parsing
335
213
  # output from the date expression provided
@@ -0,0 +1,174 @@
1
+ require 'chronic'
2
+ require 'texttube/base'
3
+ require_relative "filters.rb"
4
+ require 'chronic'
5
+
6
+ module Tickle
7
+
8
+
9
+
10
+ # Contains the initial input and the result of parsing it.
11
+ class Tickled < TextTube::Base
12
+ register Filters
13
+
14
+ # @param [String] asked The string Tickle should parse.
15
+ # @param [Hash] options
16
+ # @see Tickle.parse for specific options
17
+ # @see ::Hash#new
18
+ def initialize(asked, options={}, &block)
19
+ fail ArgumentError, "You must pass a string to Tickled.new" if asked.nil?
20
+
21
+
22
+ default_options = {
23
+ :start => Time.now,
24
+ :next_only => false,
25
+ :until => nil,
26
+ :now => Time.now,
27
+ }
28
+
29
+ unless options.nil? || options.empty?
30
+ # ensure the specified options are valid
31
+ options.keys.each do |key|
32
+ fail(ArgumentError, "#{key} is not a valid option key.") unless default_options.keys.include?(key)
33
+ end
34
+
35
+ [:start,:until,:now].each do |key|
36
+ if options.has_key? key
37
+ test_for_correctness options[key], key
38
+ end
39
+ end
40
+ end
41
+
42
+ t =
43
+ if asked.respond_to?(:to_time)
44
+ asked
45
+ elsif (t = Time.parse(asked) rescue nil) # a legitimate use!
46
+ t
47
+ elsif (t = Chronic.parse("#{asked}") rescue nil) # another legitimate use!
48
+ t
49
+ end
50
+
51
+ unless t.nil?
52
+ define_singleton_method :to_time do
53
+ @as_time ||= t
54
+ end
55
+ end
56
+
57
+ @opts = default_options.merge(options)
58
+ super(asked.to_s)
59
+ end
60
+
61
+
62
+ def asked
63
+ self
64
+ end
65
+
66
+
67
+ def parser= parser
68
+ @parser = parser
69
+ end
70
+
71
+ def parse!
72
+ @parser.parse self
73
+ end
74
+
75
+ def now=( value )
76
+ @opts[:now] = value
77
+ end
78
+
79
+ def now
80
+ @opts[:now]
81
+ end
82
+
83
+ def next_only=( value )
84
+ @opts[:next_only] = value
85
+ end
86
+
87
+
88
+ def next_only?
89
+ @opts[:next_only]
90
+ end
91
+
92
+
93
+ # param [Date,Time,String,nil] v The value given for the key.
94
+ # param [Symbol] key The name of the key being tested.
95
+ def test_for_correctness( v, key )
96
+ # Must be be a time or a string or be able to convert to a time
97
+ # If it is a string, must parse ok by Chronic
98
+ 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))
99
+ end
100
+
101
+
102
+ def asked
103
+ self
104
+ end
105
+
106
+
107
+ # @param [Date,Time,String]
108
+ def asked=( text )
109
+ #test_for_correctness text, :asked
110
+ @opts[:asked] = text
111
+ end
112
+
113
+ def start
114
+ @opts[:start] ||= Time.now
115
+ end
116
+
117
+ def start=( value )
118
+ @opts[:start] = test_for_correctness value, :start
119
+ end
120
+
121
+ def until
122
+ @opts[:until] ||= Tickled.new( Time.now )
123
+ end
124
+
125
+ def until=( value )
126
+ @opts[:until] = test_for_correctness value, :until
127
+ end
128
+
129
+
130
+ [:starting, :ending, :event].each do |meth|
131
+ define_method meth do
132
+ @opts[meth]
133
+ end
134
+ define_method "#{meth}=" do |value|
135
+ @opts[meth] = Tickled.new value
136
+ end
137
+ end
138
+
139
+
140
+ def event
141
+ @opts[:event] ||= self
142
+ end
143
+ def event= value
144
+ @opts[:event] = Tickled.new value
145
+ end
146
+
147
+
148
+ def filtered=(filtered_text)
149
+ @filtered = filtered_text
150
+ end
151
+
152
+ def filtered
153
+ @filtered
154
+ end
155
+
156
+
157
+ def to_s
158
+ self
159
+ end
160
+
161
+ def blank?
162
+ if respond_to? :empty?
163
+ empty? || !self
164
+ elsif respond_to? :localtime
165
+ false
166
+ end
167
+ end
168
+
169
+ # def inspect
170
+ # "#{self} #{@opts.inspect}"
171
+ # end
172
+
173
+ end
174
+ end