lapluviosilla-tickle 0.1.8
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/.rvmrc +9 -0
- data/LICENSE +20 -0
- data/README.rdoc +436 -0
- data/Rakefile +55 -0
- data/SCENARIOS.rdoc +317 -0
- data/VERSION +1 -0
- data/git-flow-version +1 -0
- data/lapluviosilla-tickle.gemspec +65 -0
- data/lib/numerizer/numerizer.rb +103 -0
- data/lib/tickle/handler.rb +129 -0
- data/lib/tickle/repeater.rb +136 -0
- data/lib/tickle/tickle.rb +323 -0
- data/lib/tickle.rb +214 -0
- data/test/git-flow-version +1 -0
- data/test/helper.rb +11 -0
- data/test/test_parsing.rb +223 -0
- metadata +117 -0
@@ -0,0 +1,136 @@
|
|
1
|
+
class Tickle::Repeater < Chronic::Tag #:nodoc:
|
2
|
+
#
|
3
|
+
def self.scan(tokens)
|
4
|
+
# for each token
|
5
|
+
tokens.each do |token|
|
6
|
+
token = self.scan_for_numbers(token)
|
7
|
+
token = self.scan_for_ordinal_names(token) unless token.type
|
8
|
+
token = self.scan_for_ordinals(token) unless token.type
|
9
|
+
token = self.scan_for_month_names(token) unless token.type
|
10
|
+
token = self.scan_for_day_names(token) unless token.type
|
11
|
+
token = self.scan_for_year_name(token) unless token.type
|
12
|
+
token = self.scan_for_special_text(token) unless token.type
|
13
|
+
token = self.scan_for_units(token) unless token.type
|
14
|
+
end
|
15
|
+
tokens
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.scan_for_numbers(token)
|
19
|
+
regex = /\b(\d\d?)\b/
|
20
|
+
token.update(:number, token.word.gsub(regex,'\1').to_i, token.word.gsub(regex,'\1').to_i) if token.word =~ regex
|
21
|
+
token
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.scan_for_ordinal_names(token)
|
25
|
+
scanner = {/first/ => '1st',
|
26
|
+
/second/ => '2nd',
|
27
|
+
/third/ => '3rd',
|
28
|
+
/fourth/ => '4th',
|
29
|
+
/fifth/ => '5th',
|
30
|
+
/sixth/ => '6th',
|
31
|
+
/seventh/ => '7th',
|
32
|
+
/eighth/ => '8th',
|
33
|
+
/ninth/ => '9th',
|
34
|
+
/tenth/ => '10th',
|
35
|
+
/eleventh/ => '11th',
|
36
|
+
/twelfth/ => '12th',
|
37
|
+
/thirteenth/ => '13th',
|
38
|
+
/fourteenth/ => '14th',
|
39
|
+
/fifteenth/ => '15th',
|
40
|
+
/sixteenth/ => '16th',
|
41
|
+
/seventeenth/ => '17th',
|
42
|
+
/eighteenth/ => '18th',
|
43
|
+
/nineteenth/ => '19th',
|
44
|
+
/twentieth/ => '20th',
|
45
|
+
/thirtieth/ => '30th',
|
46
|
+
}
|
47
|
+
scanner.keys.each do |scanner_item|
|
48
|
+
if scanner_item =~ token.original
|
49
|
+
token.word = scanner[scanner_item]
|
50
|
+
token.update(:ordinal, scanner[scanner_item].ordinal_as_number, Tickle.days_in_month(Tickle.get_next_month(scanner[scanner_item].ordinal_as_number)))
|
51
|
+
end
|
52
|
+
end
|
53
|
+
token
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.scan_for_ordinals(token)
|
57
|
+
regex = /\b(\d*)(st|nd|rd|th)\b/
|
58
|
+
if token.original =~ regex
|
59
|
+
token.word = token.original
|
60
|
+
token.update(:ordinal, token.word.ordinal_as_number, Tickle.days_in_month(Tickle.get_next_month(token.word)))
|
61
|
+
end
|
62
|
+
token
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.scan_for_month_names(token)
|
66
|
+
scanner = {/^jan\.?(uary)?$/ => 1,
|
67
|
+
/^feb\.?(ruary)?$/ => 2,
|
68
|
+
/^mar\.?(ch)?$/ => 3,
|
69
|
+
/^apr\.?(il)?$/ => 4,
|
70
|
+
/^may$/ => 5,
|
71
|
+
/^jun\.?e?$/ => 6,
|
72
|
+
/^jul\.?y?$/ => 7,
|
73
|
+
/^aug\.?(ust)?$/ => 8,
|
74
|
+
/^sep\.?(t\.?|tember)?$/ => 9,
|
75
|
+
/^oct\.?(ober)?$/ => 10,
|
76
|
+
/^nov\.?(ember)?$/ => 11,
|
77
|
+
/^dec\.?(ember)?$/ => 12}
|
78
|
+
scanner.keys.each do |scanner_item|
|
79
|
+
token.update(:month_name, scanner[scanner_item], 30) if scanner_item =~ token.word
|
80
|
+
end
|
81
|
+
token
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.scan_for_day_names(token)
|
85
|
+
scanner = {/^m[ou]n(day)?$/ => :monday,
|
86
|
+
/^t(ue|eu|oo|u|)s(day)?$/ => :tuesday,
|
87
|
+
/^tue$/ => :tuesday,
|
88
|
+
/^we(dnes|nds|nns)day$/ => :wednesday,
|
89
|
+
/^wed$/ => :wednesday,
|
90
|
+
/^th(urs|ers)day$/ => :thursday,
|
91
|
+
/^thu$/ => :thursday,
|
92
|
+
/^fr[iy](day)?$/ => :friday,
|
93
|
+
/^sat(t?[ue]rday)?$/ => :saturday,
|
94
|
+
/^su[nm](day)?$/ => :sunday}
|
95
|
+
scanner.keys.each do |scanner_item|
|
96
|
+
token.update(:weekday, scanner[scanner_item], 7) if scanner_item =~ token.word
|
97
|
+
end
|
98
|
+
token
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.scan_for_year_name(token)
|
102
|
+
regex = /\b\d{4}\b/
|
103
|
+
token.update(:specific_year, token.original.gsub(regex,'\1'), 365) if token.original =~ regex
|
104
|
+
token
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.scan_for_special_text(token)
|
108
|
+
scanner = {/^other$/ => :other,
|
109
|
+
/^begin(ing|ning)?$/ => :beginning,
|
110
|
+
/^start$/ => :beginning,
|
111
|
+
/^end$/ => :end,
|
112
|
+
/^mid(d)?le$/ => :middle}
|
113
|
+
scanner.keys.each do |scanner_item|
|
114
|
+
token.update(:special, scanner[scanner_item], 7) if scanner_item =~ token.word
|
115
|
+
end
|
116
|
+
token
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.scan_for_units(token)
|
120
|
+
scanner = {/^year(ly)?s?$/ => {:type => :year, :interval => 365, :start => :today},
|
121
|
+
/^month(ly)?s?$/ => {:type => :month, :interval => 30, :start => :today},
|
122
|
+
/^fortnights?$/ => {:type => :fortnight, :interval => 365, :start => :today},
|
123
|
+
/^week(ly)?s?$/ => {:type => :week, :interval => 7, :start => :today},
|
124
|
+
/^weekends?$/ => {:type => :weekend, :interval => 7, :start => :saturday},
|
125
|
+
/^days?$/ => {:type => :day, :interval => 0, :start => :today},
|
126
|
+
/^daily?$/ => {:type => :day, :interval => 0, :start => :today}}
|
127
|
+
scanner.keys.each do |scanner_item|
|
128
|
+
if scanner_item =~ token.word
|
129
|
+
token.update(scanner[scanner_item][:type], scanner[scanner_item][:start], scanner[scanner_item][:interval]) if scanner_item =~ token.word
|
130
|
+
end
|
131
|
+
end
|
132
|
+
token
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
end
|
@@ -0,0 +1,323 @@
|
|
1
|
+
# Copyright (c) 2010 Joshua Lippiner
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
# a copy of this software and associated documentation files (the
|
5
|
+
# "Software"), to deal in the Software without restriction, including
|
6
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
# the following conditions:
|
10
|
+
#
|
11
|
+
# The above copyright notice and this permission notice shall be
|
12
|
+
# included in all copies or substantial portions of the Software.
|
13
|
+
#
|
14
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
22
|
+
module Tickle #:nodoc:
|
23
|
+
class << self #:nodoc:
|
24
|
+
# == Configuration options
|
25
|
+
#
|
26
|
+
# * +start+ - start date for future occurrences. Must be in valid date format.
|
27
|
+
# * +until+ - last date to run occurrences until. Must be in valid date format.
|
28
|
+
#
|
29
|
+
# Use by calling Tickle.parse and passing natural language with or without options.
|
30
|
+
#
|
31
|
+
# def get_next_occurrence
|
32
|
+
# results = Tickle.parse('every Wednesday starting June 1st until Dec 15th')
|
33
|
+
# return results[:next] if results
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
def parse(text, specified_options = {})
|
37
|
+
# get options and set defaults if necessary. Ability to set now is mostly for debugging
|
38
|
+
default_options = {:start => Time.now, :next_only => false, :until => nil, :now => Time.now}
|
39
|
+
options = default_options.merge specified_options
|
40
|
+
|
41
|
+
# ensure an expression was provided
|
42
|
+
raise(InvalidArgumentException, 'date expression is required') unless text
|
43
|
+
|
44
|
+
# ensure the specified options are valid
|
45
|
+
specified_options.keys.each do |key|
|
46
|
+
raise(InvalidArgumentException, "#{key} is not a valid option key.") unless default_options.keys.include?(key)
|
47
|
+
end
|
48
|
+
raise(InvalidArgumentException, ':start specified is not a valid datetime.') unless (is_date(specified_options[:start]) || Chronic.parse(specified_options[:start])) if specified_options[:start]
|
49
|
+
|
50
|
+
# check to see if a valid datetime was passed
|
51
|
+
return text if text.is_a?(Date) || text.is_a?(Time)
|
52
|
+
|
53
|
+
# check to see if this event starts some other time and reset now
|
54
|
+
event = scan_expression(text, options)
|
55
|
+
|
56
|
+
Tickle.dwrite("start: #{@start}, until: #{@until}, now: #{options[:now].to_date}")
|
57
|
+
|
58
|
+
# => ** this is mostly for testing. Bump by 1 day if today (or in the past for testing)
|
59
|
+
raise(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur in the past for a future event") if @start && @start.to_date < Date.today
|
60
|
+
raise(InvalidDateExpression, "the start date (#{@start.to_date}) cannot occur after the end date") if @until && @start.to_date > @until.to_date
|
61
|
+
|
62
|
+
# no need to guess at expression if the start_date is in the future
|
63
|
+
best_guess = nil
|
64
|
+
if @start.to_date > options[:now].to_date
|
65
|
+
best_guess = @start
|
66
|
+
else
|
67
|
+
# put the text into a normal format to ease scanning using Chronic
|
68
|
+
event = pre_filter(event)
|
69
|
+
|
70
|
+
# split into tokens
|
71
|
+
@tokens = base_tokenize(event)
|
72
|
+
|
73
|
+
# process each original word for implied word
|
74
|
+
post_tokenize
|
75
|
+
|
76
|
+
@tokens.each {|x| Tickle.dwrite("raw: #{x.inspect}")}
|
77
|
+
|
78
|
+
# scan the tokens with each token scanner
|
79
|
+
@tokens = Repeater.scan(@tokens)
|
80
|
+
|
81
|
+
# remove all tokens without a type
|
82
|
+
@tokens.reject! {|token| token.type.nil? }
|
83
|
+
|
84
|
+
# combine number and ordinals into single number
|
85
|
+
combine_multiple_numbers
|
86
|
+
|
87
|
+
@tokens.each {|x| Tickle.dwrite("processed: #{x.inspect}")}
|
88
|
+
|
89
|
+
# if we can't guess it maybe chronic can
|
90
|
+
best_guess = (guess || chronic_parse(event))
|
91
|
+
end
|
92
|
+
|
93
|
+
raise(InvalidDateExpression, "the next occurrence takes place after the end date specified") if @until && best_guess.to_date > @until.to_date
|
94
|
+
|
95
|
+
if !best_guess
|
96
|
+
return nil
|
97
|
+
elsif options[:next_only] != true
|
98
|
+
return {:next => best_guess.to_time, :expression => event.strip, :starting => @start, :until => @until}
|
99
|
+
else
|
100
|
+
return best_guess
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# scans the expression for a variety of natural formats, such as 'every thursday starting tomorrow until May 15th
|
105
|
+
def scan_expression(text, options)
|
106
|
+
starting = ending = nil
|
107
|
+
|
108
|
+
start_every_regex = /^(start(?:s|ing)?)\s(.*)(\s(?:every|each|\bon\b|repeat)(?:s|ing)?)(.*)/i
|
109
|
+
every_start_regex = /^(every|each|\bon\b|repeat(?:the)?)\s(.*)(\s(?:start)(?:s|ing)?)(.*)/i
|
110
|
+
start_ending_regex = /^(start(?:s|ing)?)\s(.*)(\s(?:\bend|until)(?:s|ing)?)(.*)/i
|
111
|
+
if text =~ start_every_regex
|
112
|
+
starting = text.match(start_every_regex)[2].strip
|
113
|
+
text = text.match(start_every_regex)[4].strip
|
114
|
+
event, ending = process_for_ending(text)
|
115
|
+
elsif text =~ every_start_regex
|
116
|
+
event = text.match(every_start_regex)[2].strip
|
117
|
+
text = text.match(every_start_regex)[4].strip
|
118
|
+
starting, ending = process_for_ending(text)
|
119
|
+
elsif text =~ start_ending_regex
|
120
|
+
starting = text.match(start_ending_regex)[2].strip
|
121
|
+
ending = text.match(start_ending_regex)[4].strip
|
122
|
+
event = 'day'
|
123
|
+
else
|
124
|
+
event, ending = process_for_ending(text)
|
125
|
+
end
|
126
|
+
|
127
|
+
# they gave a phrase so if we can't interpret then we need to raise an error
|
128
|
+
if starting
|
129
|
+
Tickle.dwrite("starting: #{starting}")
|
130
|
+
@start = chronic_parse(pre_filter(starting))
|
131
|
+
if @start
|
132
|
+
@start.to_time
|
133
|
+
else
|
134
|
+
raise(InvalidDateExpression,"the starting date expression \"#{starting}\" could not be interpretted")
|
135
|
+
end
|
136
|
+
else
|
137
|
+
@start = options[:start].to_time #rescue nil
|
138
|
+
end
|
139
|
+
|
140
|
+
if ending
|
141
|
+
@until = chronic_parse(pre_filter(ending))
|
142
|
+
if @until
|
143
|
+
@until.to_time
|
144
|
+
else
|
145
|
+
raise(InvalidDateExpression,"the ending date expression \"#{ending}\" could not be interpretted")
|
146
|
+
end
|
147
|
+
else
|
148
|
+
@until = options[:until].to_time rescue nil
|
149
|
+
end
|
150
|
+
|
151
|
+
@next = nil
|
152
|
+
|
153
|
+
return event
|
154
|
+
end
|
155
|
+
|
156
|
+
# process the remaining expression to see if an until, end, ending is specified
|
157
|
+
def process_for_ending(text)
|
158
|
+
regex = /^(.*)(\s(?:\bend|until)(?:s|ing)?)(.*)/i
|
159
|
+
if text =~ regex
|
160
|
+
return text.match(regex)[1], text.match(regex)[3]
|
161
|
+
else
|
162
|
+
return text, nil
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Normalize natural string removing prefix language
|
167
|
+
def pre_filter(text)
|
168
|
+
return nil unless text
|
169
|
+
|
170
|
+
text.gsub!(/every(\s)?/, '')
|
171
|
+
text.gsub!(/each(\s)?/, '')
|
172
|
+
text.gsub!(/repeat(s|ing)?(\s)?/, '')
|
173
|
+
text.gsub!(/on the(\s)?/, '')
|
174
|
+
text.gsub!(/([^\w\d\s])+/, '')
|
175
|
+
text.downcase.strip
|
176
|
+
text = normalize_us_holidays(text)
|
177
|
+
end
|
178
|
+
|
179
|
+
# Split the text on spaces and convert each word into
|
180
|
+
# a Token
|
181
|
+
def base_tokenize(text) #:nodoc:
|
182
|
+
text.split(' ').map { |word| Token.new(word) }
|
183
|
+
end
|
184
|
+
|
185
|
+
# normalizes each token
|
186
|
+
def post_tokenize
|
187
|
+
@tokens.each do |token|
|
188
|
+
token.word = normalize(token.original)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Clean up the specified input text by stripping unwanted characters,
|
193
|
+
# converting idioms to their canonical form, converting number words
|
194
|
+
# to numbers (three => 3), and converting ordinal words to numeric
|
195
|
+
# ordinals (third => 3rd)
|
196
|
+
def normalize(text) #:nodoc:
|
197
|
+
normalized_text = text.to_s.downcase
|
198
|
+
normalized_text = Numerizer.numerize(normalized_text)
|
199
|
+
normalized_text.gsub!(/['"\.]/, '')
|
200
|
+
normalized_text.gsub!(/([\/\-\,\@])/) { ' ' + $1 + ' ' }
|
201
|
+
normalized_text
|
202
|
+
end
|
203
|
+
|
204
|
+
# Converts natural language US Holidays into a date expression to be
|
205
|
+
# parsed.
|
206
|
+
def normalize_us_holidays(text) #:nodoc:
|
207
|
+
normalized_text = text.to_s.downcase
|
208
|
+
normalized_text.gsub!(/\bnew\syear'?s?(\s)?(day)?\b/, "january 1, #{next_appropriate_year(1, 1)}")
|
209
|
+
normalized_text.gsub!(/\bnew\syear'?s?(\s)?(eve)?\b/, "december 31, #{next_appropriate_year(12, 31)}")
|
210
|
+
normalized_text.gsub!(/\bm(artin\s)?l(uther\s)?k(ing)?(\sday)?\b/, 'third monday in january')
|
211
|
+
normalized_text.gsub!(/\binauguration(\sday)?\b/, 'january 20')
|
212
|
+
normalized_text.gsub!(/\bpresident'?s?(\sday)?\b/, 'third monday in february')
|
213
|
+
normalized_text.gsub!(/\bmemorial\sday\b/, '4th monday of may')
|
214
|
+
normalized_text.gsub!(/\bindepend(e|a)nce\sday\b/, "july 4, #{next_appropriate_year(7, 4)}")
|
215
|
+
normalized_text.gsub!(/\blabor\sday\b/, 'first monday in september')
|
216
|
+
normalized_text.gsub!(/\bcolumbus\sday\b/, 'second monday in october')
|
217
|
+
normalized_text.gsub!(/\bveterans?\sday\b/, "november 11, #{next_appropriate_year(11, 1)}")
|
218
|
+
normalized_text.gsub!(/\bthanksgiving(\sday)?\b/, 'fourth thursday in november')
|
219
|
+
normalized_text.gsub!(/\bchristmas\seve\b/, "december 24, #{next_appropriate_year(12, 24)}")
|
220
|
+
normalized_text.gsub!(/\bchristmas(\sday)?\b/, "december 25, #{next_appropriate_year(12, 25)}")
|
221
|
+
normalized_text.gsub!(/\bsuper\sbowl(\ssunday)?\b/, 'first sunday in february')
|
222
|
+
normalized_text.gsub!(/\bgroundhog(\sday)?\b/, "february 2, #{next_appropriate_year(2, 2)}")
|
223
|
+
normalized_text.gsub!(/\bvalentine'?s?(\sday)?\b/, "february 14, #{next_appropriate_year(2, 14)}")
|
224
|
+
normalized_text.gsub!(/\bs(ain)?t\spatrick'?s?(\sday)?\b/, "march 17, #{next_appropriate_year(3, 17)}")
|
225
|
+
normalized_text.gsub!(/\bapril\sfool'?s?(\sday)?\b/, "april 1, #{next_appropriate_year(4, 1)}")
|
226
|
+
normalized_text.gsub!(/\bearth\sday\b/, "april 22, #{next_appropriate_year(4, 22)}")
|
227
|
+
normalized_text.gsub!(/\barbor\sday\b/, 'fourth friday in april')
|
228
|
+
normalized_text.gsub!(/\bcinco\sde\smayo\b/, "may 5, #{next_appropriate_year(5, 5)}")
|
229
|
+
normalized_text.gsub!(/\bmother'?s?\sday\b/, 'second sunday in may')
|
230
|
+
normalized_text.gsub!(/\bflag\sday\b/, "june 14, #{next_appropriate_year(6, 14)}")
|
231
|
+
normalized_text.gsub!(/\bfather'?s?\sday\b/, 'third sunday in june')
|
232
|
+
normalized_text.gsub!(/\bhalloween\b/, "october 31, #{next_appropriate_year(10, 31)}")
|
233
|
+
normalized_text.gsub!(/\belection\sday\b/, 'second tuesday in november')
|
234
|
+
normalized_text.gsub!(/\bkwanzaa\b/, "january 1, #{next_appropriate_year(1, 1)}")
|
235
|
+
normalized_text
|
236
|
+
end
|
237
|
+
|
238
|
+
# Turns compound numbers, like 'twenty first' => 21
|
239
|
+
def combine_multiple_numbers
|
240
|
+
if [:number, :ordinal].all? {|type| token_types.include? type}
|
241
|
+
number = token_of_type(:number)
|
242
|
+
ordinal = token_of_type(:ordinal)
|
243
|
+
combined_original = "#{number.original} #{ordinal.original}"
|
244
|
+
Tickle.dwrite "number.start = #{number.start}"
|
245
|
+
Tickle.dwrite "number.start.to_s = #{number.start.to_s}"
|
246
|
+
Tickle.dwrite "number.start.to_s[0] = #{number.start.to_s[0]}"
|
247
|
+
combined_word = ([number.start.to_s.chars.first, ordinal.word].join(""))
|
248
|
+
combined_value = ([number.start.to_s.chars.first, ordinal.start.to_s].join(""))
|
249
|
+
new_number_token = Token.new(combined_original, combined_word, :ordinal, combined_value, 365)
|
250
|
+
@tokens.reject! {|token| (token.type == :number || token.type == :ordinal)}
|
251
|
+
@tokens << new_number_token
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# Returns an array of types for all tokens
|
256
|
+
def token_types
|
257
|
+
@tokens.map(&:type)
|
258
|
+
end
|
259
|
+
|
260
|
+
protected
|
261
|
+
|
262
|
+
# Returns the next available month based on the current day of the month.
|
263
|
+
# 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.
|
264
|
+
# However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month.
|
265
|
+
def get_next_month(number)
|
266
|
+
month = number.to_i < @start.day ? (@start.month == 12 ? 1 : @start.month + 1) : @start.month
|
267
|
+
end
|
268
|
+
|
269
|
+
def next_appropriate_year(month, day)
|
270
|
+
year = (Date.new(@start.year.to_i, month.to_i, day.to_i) == @start.to_date) ? @start.year + 1 : @start.year
|
271
|
+
return year
|
272
|
+
end
|
273
|
+
|
274
|
+
# Return the number of days in a specified month.
|
275
|
+
# If no month is specified, current month is used.
|
276
|
+
def days_in_month(month=nil)
|
277
|
+
month ||= Date.today.month
|
278
|
+
days_in_mon = Date.civil(Date.today.year, month, -1).day
|
279
|
+
end
|
280
|
+
|
281
|
+
private
|
282
|
+
|
283
|
+
# slightly modified chronic parser to ensure that the date found is in the future
|
284
|
+
# first we check to see if an explicit date was passed and, if so, dont do anything.
|
285
|
+
# if, however, a date expression was passed we evaluate and shift forward if needed
|
286
|
+
def chronic_parse(exp)
|
287
|
+
result = Chronic.parse(exp.ordinal_as_number)
|
288
|
+
result = Time.local(result.year + 1, result.month, result.day, result.hour, result.min, result.sec) if result && result.to_time < Time.now
|
289
|
+
Tickle.dwrite("Chronic.parse('#{exp}') # => #{result}")
|
290
|
+
result
|
291
|
+
end
|
292
|
+
|
293
|
+
end
|
294
|
+
|
295
|
+
class Token #:nodoc:
|
296
|
+
attr_accessor :original, :word, :type, :interval, :start
|
297
|
+
|
298
|
+
def initialize(original, word=nil, type=nil, start=nil, interval=nil)
|
299
|
+
@original = original
|
300
|
+
@word = word
|
301
|
+
@type = type
|
302
|
+
@interval = interval
|
303
|
+
@start = start
|
304
|
+
end
|
305
|
+
|
306
|
+
# Updates an existing token. Mostly used by the repeater class.
|
307
|
+
def update(type, start=nil, interval=nil)
|
308
|
+
@start = start
|
309
|
+
@type = type
|
310
|
+
@interval = interval
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
# This exception is raised if an invalid argument is provided to
|
315
|
+
# any of Tickle's methods
|
316
|
+
class InvalidArgumentException < Exception
|
317
|
+
end
|
318
|
+
|
319
|
+
# This exception is raised if there is an issue with the parsing
|
320
|
+
# output from the date expression provided
|
321
|
+
class InvalidDateExpression < Exception
|
322
|
+
end
|
323
|
+
end
|
data/lib/tickle.rb
ADDED
@@ -0,0 +1,214 @@
|
|
1
|
+
#=============================================================================
|
2
|
+
#
|
3
|
+
# Name: Tickle
|
4
|
+
# Author: Joshua Lippiner
|
5
|
+
# Purpose: Parse natural language into recuring intervals
|
6
|
+
#
|
7
|
+
#=============================================================================
|
8
|
+
|
9
|
+
|
10
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__)) # For use/testing when no gem is installed
|
11
|
+
|
12
|
+
|
13
|
+
|
14
|
+
require 'date'
|
15
|
+
require 'time'
|
16
|
+
require 'chronic'
|
17
|
+
|
18
|
+
class Symbol
|
19
|
+
def <=>(with)
|
20
|
+
return nil unless with.is_a? Symbol
|
21
|
+
to_s <=> with.to_s
|
22
|
+
end unless method_defined? :"<=>"
|
23
|
+
end
|
24
|
+
|
25
|
+
class Date
|
26
|
+
def to_date
|
27
|
+
self
|
28
|
+
end unless method_defined?(:to_date)
|
29
|
+
|
30
|
+
def to_time(form = :local)
|
31
|
+
Time.send("#{form}_time", year, month, day)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class Time
|
36
|
+
class << self
|
37
|
+
# Overriding case equality method so that it returns true for ActiveSupport::TimeWithZone instances
|
38
|
+
def ===(other)
|
39
|
+
other.is_a?(::Time)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Return the number of days in the given month.
|
43
|
+
# If no year is specified, it will use the current year.
|
44
|
+
def days_in_month(month, year = now.year)
|
45
|
+
return 29 if month == 2 && ::Date.gregorian_leap?(year)
|
46
|
+
COMMON_YEAR_DAYS_IN_MONTH[month]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns a new Time if requested year can be accommodated by Ruby's Time class
|
50
|
+
# (i.e., if year is within either 1970..2038 or 1902..2038, depending on system architecture);
|
51
|
+
# otherwise returns a DateTime
|
52
|
+
def time_with_datetime_fallback(utc_or_local, year, month=1, day=1, hour=0, min=0, sec=0, usec=0)
|
53
|
+
time = ::Time.send(utc_or_local, year, month, day, hour, min, sec, usec)
|
54
|
+
# This check is needed because Time.utc(y) returns a time object in the 2000s for 0 <= y <= 138.
|
55
|
+
time.year == year ? time : ::DateTime.civil_from_format(utc_or_local, year, month, day, hour, min, sec)
|
56
|
+
rescue
|
57
|
+
::DateTime.civil_from_format(utc_or_local, year, month, day, hour, min, sec)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Wraps class method +time_with_datetime_fallback+ with +utc_or_local+ set to <tt>:utc</tt>.
|
61
|
+
def utc_time(*args)
|
62
|
+
time_with_datetime_fallback(:utc, *args)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Wraps class method +time_with_datetime_fallback+ with +utc_or_local+ set to <tt>:local</tt>.
|
66
|
+
def local_time(*args)
|
67
|
+
time_with_datetime_fallback(:local, *args)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_date
|
72
|
+
Date.new(self.year, self.month, self.day)
|
73
|
+
end unless method_defined?(:to_date)
|
74
|
+
|
75
|
+
def to_time
|
76
|
+
self
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
require 'tickle/tickle'
|
81
|
+
require 'tickle/handler'
|
82
|
+
require 'tickle/repeater'
|
83
|
+
|
84
|
+
module Tickle #:nodoc:
|
85
|
+
VERSION = "0.1.7"
|
86
|
+
|
87
|
+
def self.debug=(val); @debug = val; end
|
88
|
+
|
89
|
+
def self.dwrite(msg, line_feed=nil)
|
90
|
+
(line_feed ? p(">> #{msg}") : puts(">> #{msg}")) if @debug
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.is_date(str)
|
94
|
+
begin
|
95
|
+
Date.parse(str.to_s)
|
96
|
+
return true
|
97
|
+
rescue Exception => e
|
98
|
+
return false
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
class Date #:nodoc:
|
104
|
+
# returns the days in the sending month
|
105
|
+
def days_in_month
|
106
|
+
d,m,y = mday,month,year
|
107
|
+
d += 1 while Date.valid_civil?(y,m,d)
|
108
|
+
d - 1
|
109
|
+
end
|
110
|
+
|
111
|
+
def bump(attr, amount=nil)
|
112
|
+
amount ||= 1
|
113
|
+
case attr
|
114
|
+
when :day then
|
115
|
+
self + amount
|
116
|
+
when :wday then
|
117
|
+
amount = Date::ABBR_DAYNAMES.index(amount) if amount.is_a?(String)
|
118
|
+
raise Exception, "specified day of week invalid. Use #{Date::ABBR_DAYNAMES}" unless amount
|
119
|
+
diff = (amount > self.wday) ? (amount - self.wday) : (7 - (self.wday - amount))
|
120
|
+
self + diff
|
121
|
+
when :week then
|
122
|
+
self + (7*amount)
|
123
|
+
when :month then
|
124
|
+
self>>amount
|
125
|
+
when :year then
|
126
|
+
Date.civil(self.year + amount, self.month, self.day)
|
127
|
+
else
|
128
|
+
raise Exception, "type \"#{attr}\" not supported."
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
class Time #:nodoc:
|
136
|
+
def bump(attr, amount=nil)
|
137
|
+
amount ||= 1
|
138
|
+
case attr
|
139
|
+
when :sec then
|
140
|
+
self + amount
|
141
|
+
when :min then
|
142
|
+
self + (amount * 60)
|
143
|
+
when :hour then
|
144
|
+
self + (amount * 60 * 60)
|
145
|
+
when :day then
|
146
|
+
self + (amount * 60 * 60 * 24)
|
147
|
+
when :wday then
|
148
|
+
amount = Time::RFC2822_DAY_NAME.index(amount) if amount.is_a?(String)
|
149
|
+
raise Exception, "specified day of week invalid. Use #{Time::RFC2822_DAY_NAME}" unless amount
|
150
|
+
diff = (amount > self.wday) ? (amount - self.wday) : (7 - (self.wday - amount))
|
151
|
+
self.bump(:day, diff)
|
152
|
+
when :week then
|
153
|
+
self + (amount * 60 * 60 * 24 * 7)
|
154
|
+
when :month then
|
155
|
+
d = self.to_date >> amount
|
156
|
+
Time.local(d.year, d.month, d.day, self.hour, self.min, self.sec)
|
157
|
+
when :year then
|
158
|
+
Time.local(self.year + amount, self.month, self.day, self.hour, self.min, self.sec)
|
159
|
+
else
|
160
|
+
raise Exception, "type \"#{attr}\" not supported."
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
#class NilClass
|
166
|
+
# def to_date
|
167
|
+
# return nil
|
168
|
+
# end unless method_defined?(:to_date)
|
169
|
+
#end
|
170
|
+
|
171
|
+
class String #:nodoc:
|
172
|
+
# returns true if the sending string is a text or numeric ordinal (e.g. first or 1st)
|
173
|
+
def is_ordinal?
|
174
|
+
scanner = %w{first second third fourth fifth sixth seventh eighth ninth tenth eleventh twelfth thirteenth fourteenth fifteenth sixteenth seventeenth eighteenth nineteenth twenty thirty thirtieth}
|
175
|
+
regex = /\b(\d*)(st|nd|rd|th)\b/
|
176
|
+
!(self =~ regex).nil? || scanner.include?(self.downcase)
|
177
|
+
end
|
178
|
+
|
179
|
+
def ordinal_as_number
|
180
|
+
return self unless self.is_ordinal?
|
181
|
+
scanner = {/first/ => '1st',
|
182
|
+
/second/ => '2nd',
|
183
|
+
/third/ => '3rd',
|
184
|
+
/fourth/ => '4th',
|
185
|
+
/fifth/ => '5th',
|
186
|
+
/sixth/ => '6th',
|
187
|
+
/seventh/ => '7th',
|
188
|
+
/eighth/ => '8th',
|
189
|
+
/ninth/ => '9th',
|
190
|
+
/tenth/ => '10th',
|
191
|
+
/eleventh/ => '11th',
|
192
|
+
/twelfth/ => '12th',
|
193
|
+
/thirteenth/ => '13th',
|
194
|
+
/fourteenth/ => '14th',
|
195
|
+
/fifteenth/ => '15th',
|
196
|
+
/sixteenth/ => '16th',
|
197
|
+
/seventeenth/ => '17th',
|
198
|
+
/eighteenth/ => '18th',
|
199
|
+
/nineteenth/ => '19th',
|
200
|
+
/twentieth/ => '20th',
|
201
|
+
/thirtieth/ => '30th',
|
202
|
+
}
|
203
|
+
result = self
|
204
|
+
scanner.keys.each {|scanner_item| result = scanner[scanner_item] if scanner_item =~ self}
|
205
|
+
return result.gsub(/\b(\d*)(st|nd|rd|th)\b/, '\1')
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
class Array #:nodoc:
|
210
|
+
# compares two arrays to determine if they both contain the same elements
|
211
|
+
def same?(y)
|
212
|
+
self.sort == y.sort
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
GITFLOW_VERSION=0.1.0
|
data/test/helper.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'shoulda'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
6
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
7
|
+
|
8
|
+
require File.join(File.dirname(__FILE__), '..', 'lib', 'tickle')
|
9
|
+
|
10
|
+
class Test::Unit::TestCase
|
11
|
+
end
|