tickle 1.0.2 → 2.0.0rc3

Sign up to get free protection for your applications and to get access to all the features.
@@ -19,243 +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_relative "../numerizer/numerizer.rb"
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.
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]
30
43
  #
31
- # Use by calling Tickle.parse and passing natural language with or without options.
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}
32
47
  #
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
37
- #
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 < 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
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 = /^(start(?:s|ing)?)\s(.*)(\s(?:every|each|\bon\b|repeat)(?:s|ing)?)(.*)/i
111
- every_start_regex = /^(every|each|\bon\b|repeat(?:the)?)\s(.*)(\s(?:start)(?:s|ing)?)(.*)/i
112
- start_ending_regex = /^(start(?:s|ing)?)\s(.*)(\s(?:\bend|until)(?:s|ing)?)(.*)/i
113
- if text =~ start_every_regex
114
- starting = text.match(start_every_regex)[2].strip
115
- text = text.match(start_every_regex)[4].strip
116
- event, ending = process_for_ending(text)
117
- elsif text =~ every_start_regex
118
- event = text.match(every_start_regex)[2].strip
119
- text = text.match(every_start_regex)[4].strip
120
- starting, ending = process_for_ending(text)
121
- elsif text =~ start_ending_regex
122
- starting = text.match(start_ending_regex)[2].strip
123
- ending = text.match(start_ending_regex)[4].strip
124
- event = 'day'
125
- else
126
- 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)
127
109
  end
128
-
110
+ tickled.starting = starting unless starting.nil?
111
+ tickled.event = event unless event.nil?
112
+ tickled.ending = ending unless ending.nil?
129
113
  # they gave a phrase so if we can't interpret then we need to raise an error
130
- if starting
131
- Tickle.dwrite("starting: #{starting}")
132
- @start = chronic_parse(pre_filter(starting))
114
+ if tickled.starting && !tickled.starting.to_s.blank?
115
+ @start = chronic_parse(tickled.starting,tickled, :start)
133
116
  if @start
134
117
  @start.to_time
135
118
  else
136
- raise(InvalidDateExpression,"the starting date expression \"#{starting}\" could not be interpretted")
119
+ fail(InvalidDateExpression,"the starting date expression \"#{tickled.starting}\" could not be interpretted")
137
120
  end
138
121
  else
139
- @start = options[:start].to_time rescue nil
122
+ @start = tickled.start && tickled.start.to_time
140
123
  end
141
124
 
142
- if ending
143
- @until = chronic_parse(pre_filter(ending))
125
+
126
+ if tickled.ending && !tickled.ending.blank?
127
+ @until = chronic_parse(tickled.ending.filter,tickled, :until)
144
128
  if @until
145
129
  @until.to_time
146
130
  else
147
- raise(InvalidDateExpression,"the ending date expression \"#{ending}\" could not be interpretted")
131
+ fail(InvalidDateExpression,"the ending date expression \"#{tickled.ending}\" could not be interpretted")
148
132
  end
149
133
  else
150
- @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 > @start
138
+ tickled.until.to_time
139
+ end
140
+ end
141
+ end
151
142
  end
152
143
 
153
144
  @next = nil
154
-
155
- return event
145
+ tickled
156
146
  end
157
147
 
148
+
158
149
  # process the remaining expression to see if an until, end, ending is specified
159
150
  def process_for_ending(text)
160
- regex = /^(.*)(\s(?:\bend|until)(?:s|ing)?)(.*)/i
161
- if text =~ regex
162
- return text.match(regex)[1], text.match(regex)[3]
163
- else
164
- return text, nil
165
- end
166
- end
167
-
168
- # Normalize natural string removing prefix language
169
- def pre_filter(text)
170
- return nil unless text
171
-
172
- text.gsub!(/every(\s)?/, '')
173
- text.gsub!(/each(\s)?/, '')
174
- text.gsub!(/repeat(s|ing)?(\s)?/, '')
175
- text.gsub!(/on the(\s)?/, '')
176
- text.gsub!(/([^\w\d\s])+/, '')
177
- text.downcase.strip
178
- text = normalize_us_holidays(text)
179
- end
180
-
181
- # Split the text on spaces and convert each word into
182
- # a Token
183
- def base_tokenize(text) #:nodoc:
184
- text.split(' ').map { |word| Token.new(word) }
151
+ (md = Patterns::PROCESS_FOR_ENDING.match text) ?
152
+ [ md[:target], md[:ending] ] :
153
+ [text, nil]
185
154
  end
186
155
 
187
156
  # normalizes each token
188
- def post_tokenize
189
- @tokens.each do |token|
190
- token.word = normalize(token.original)
157
+ def post_tokenize(tokens)
158
+ _tokens = tokens.map(&:clone)
159
+ _tokens.each do |token|
160
+ token.normalize!
191
161
  end
162
+ _tokens
192
163
  end
193
164
 
194
- # Clean up the specified input text by stripping unwanted characters,
195
- # converting idioms to their canonical form, converting number words
196
- # to numbers (three => 3), and converting ordinal words to numeric
197
- # ordinals (third => 3rd)
198
- def normalize(text) #:nodoc:
199
- normalized_text = text.to_s.downcase
200
- normalized_text = Numerizer.numerize(normalized_text)
201
- normalized_text.gsub!(/['"\.]/, '')
202
- normalized_text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
203
- normalized_text
204
- end
205
-
206
- # Converts natural language US Holidays into a date expression to be
207
- # parsed.
208
- def normalize_us_holidays(text) #:nodoc:
209
- normalized_text = text.to_s.downcase
210
- normalized_text.gsub!(/\bnew\syear'?s?(\s)?(day)?\b/, "january 1, #{next_appropriate_year(1, 1)}")
211
- normalized_text.gsub!(/\bnew\syear'?s?(\s)?(eve)?\b/, "december 31, #{next_appropriate_year(12, 31)}")
212
- normalized_text.gsub!(/\bm(artin\s)?l(uther\s)?k(ing)?(\sday)?\b/, 'third monday in january')
213
- normalized_text.gsub!(/\binauguration(\sday)?\b/, 'january 20')
214
- normalized_text.gsub!(/\bpresident'?s?(\sday)?\b/, 'third monday in february')
215
- normalized_text.gsub!(/\bmemorial\sday\b/, '4th monday of may')
216
- normalized_text.gsub!(/\bindepend(e|a)nce\sday\b/, "july 4, #{next_appropriate_year(7, 4)}")
217
- normalized_text.gsub!(/\blabor\sday\b/, 'first monday in september')
218
- normalized_text.gsub!(/\bcolumbus\sday\b/, 'second monday in october')
219
- normalized_text.gsub!(/\bveterans?\sday\b/, "november 11, #{next_appropriate_year(11, 1)}")
220
- normalized_text.gsub!(/\bthanksgiving(\sday)?\b/, 'fourth thursday in november')
221
- normalized_text.gsub!(/\bchristmas\seve\b/, "december 24, #{next_appropriate_year(12, 24)}")
222
- normalized_text.gsub!(/\bchristmas(\sday)?\b/, "december 25, #{next_appropriate_year(12, 25)}")
223
- normalized_text.gsub!(/\bsuper\sbowl(\ssunday)?\b/, 'first sunday in february')
224
- normalized_text.gsub!(/\bgroundhog(\sday)?\b/, "february 2, #{next_appropriate_year(2, 2)}")
225
- normalized_text.gsub!(/\bvalentine'?s?(\sday)?\b/, "february 14, #{next_appropriate_year(2, 14)}")
226
- normalized_text.gsub!(/\bs(ain)?t\spatrick'?s?(\sday)?\b/, "march 17, #{next_appropriate_year(3, 17)}")
227
- normalized_text.gsub!(/\bapril\sfool'?s?(\sday)?\b/, "april 1, #{next_appropriate_year(4, 1)}")
228
- normalized_text.gsub!(/\bearth\sday\b/, "april 22, #{next_appropriate_year(4, 22)}")
229
- normalized_text.gsub!(/\barbor\sday\b/, 'fourth friday in april')
230
- normalized_text.gsub!(/\bcinco\sde\smayo\b/, "may 5, #{next_appropriate_year(5, 5)}")
231
- normalized_text.gsub!(/\bmother'?s?\sday\b/, 'second sunday in may')
232
- normalized_text.gsub!(/\bflag\sday\b/, "june 14, #{next_appropriate_year(6, 14)}")
233
- normalized_text.gsub!(/\bfather'?s?\sday\b/, 'third sunday in june')
234
- normalized_text.gsub!(/\bhalloween\b/, "october 31, #{next_appropriate_year(10, 31)}")
235
- normalized_text.gsub!(/\belection\sday\b/, 'second tuesday in november')
236
- normalized_text.gsub!(/\bkwanzaa\b/, "january 1, #{next_appropriate_year(1, 1)}")
237
- normalized_text
238
- end
239
-
240
- # Turns compound numbers, like 'twenty first' => 21
241
- def combine_multiple_numbers
242
- if [:number, :ordinal].all? {|type| token_types.include? type}
243
- number = token_of_type(:number)
244
- ordinal = token_of_type(:ordinal)
245
- combined_original = "#{number.original} #{ordinal.original}"
246
- combined_word = (number.start.to_s[0] + ordinal.word)
247
- combined_value = (number.start.to_s[0] + ordinal.start.to_s)
248
- new_number_token = Token.new(combined_original, combined_word, :ordinal, combined_value, 365)
249
- @tokens.reject! {|token| (token.type == :number || token.type == :ordinal)}
250
- @tokens << new_number_token
251
- end
252
- end
253
165
 
254
166
  # Returns an array of types for all tokens
255
167
  def token_types
256
168
  @tokens.map(&:type)
257
169
  end
258
170
 
171
+
259
172
  # Returns the next available month based on the current day of the month.
260
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.
261
174
  # However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month.
@@ -263,55 +176,38 @@ module Tickle
263
176
  month = number.to_i < @start.day ? (@start.month == 12 ? 1 : @start.month + 1) : @start.month
264
177
  end
265
178
 
179
+
266
180
  def next_appropriate_year(month, day)
267
181
  year = (Date.new(@start.year.to_i, month.to_i, day.to_i) == @start.to_date) ? @start.year + 1 : @start.year
268
182
  return year
269
183
  end
270
184
 
271
- # Return the number of days in a specified month.
272
- # If no month is specified, current month is used.
273
- def days_in_month(month=nil)
274
- month ||= Date.today.month
275
- days_in_mon = Date.civil(Date.today.year, month, -1).day
276
- end
277
185
 
278
186
  private
279
187
 
188
+
280
189
  # slightly modified chronic parser to ensure that the date found is in the future
281
190
  # first we check to see if an explicit date was passed and, if so, dont do anything.
282
191
  # if, however, a date expression was passed we evaluate and shift forward if needed
283
- def chronic_parse(exp)
284
- result = Chronic.parse(exp.ordinal_as_number)
285
- result = Time.local(result.year + 1, result.month, result.day, result.hour, result.min, result.sec) if result && result.to_time < Time.now
286
- 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
287
205
  result
288
206
  end
289
207
 
290
208
  end
291
209
 
292
- class Token #:nodoc:
293
- attr_accessor :original, :word, :type, :interval, :start
294
210
 
295
- def initialize(original, word=nil, type=nil, start=nil, interval=nil)
296
- @original = original
297
- @word = word
298
- @type = type
299
- @interval = interval
300
- @start = start
301
- end
302
-
303
- # Updates an existing token. Mostly used by the repeater class.
304
- def update(type, start=nil, interval=nil)
305
- @start = start
306
- @type = type
307
- @interval = interval
308
- end
309
- end
310
-
311
- # This exception is raised if an invalid argument is provided to
312
- # any of Tickle's methods
313
- class InvalidArgumentException < Exception
314
- end
315
211
 
316
212
  # This exception is raised if there is an issue with the parsing
317
213
  # output from the date expression provided
@@ -0,0 +1,173 @@
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