lapluviosilla-tickle 0.1.8

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