tickle 1.0.2 → 2.0.0rc3

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,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