tickle 1.2.0 → 2.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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