thartmx 0.1.9
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.
- data/LICENSE +344 -0
- data/README.rdoc +302 -0
- data/Rakefile +57 -0
- data/VERSION +1 -0
- data/bin/rrtm +40 -0
- data/lib/thartmx.rb +268 -0
- data/lib/thartmx_lib.rb +744 -0
- data/test/helper.rb +10 -0
- data/test/test_thartm.rb +7 -0
- metadata +103 -0
data/lib/thartmx_lib.rb
ADDED
@@ -0,0 +1,744 @@
|
|
1
|
+
# This file is part of the RTM Ruby API Wrapper.
|
2
|
+
#
|
3
|
+
# The RTM Ruby API Wrapper is free software; you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as
|
5
|
+
# published by the Free Software Foundation; either version 2 of the
|
6
|
+
# License, or (at your option) any later version.
|
7
|
+
#
|
8
|
+
# The RTM Ruby API Wrapper is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with the RTM Ruby API Wrapper; if not, write to the Free Software
|
15
|
+
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
16
|
+
#
|
17
|
+
# (c) 2006, QuantumFoam.org, Inc.
|
18
|
+
|
19
|
+
|
20
|
+
#Modified by thamayor, mail: thamayor at gmail dot com
|
21
|
+
#my private rtm key is inside this file..
|
22
|
+
|
23
|
+
#this file is intended to be used with my rtm command line interface
|
24
|
+
|
25
|
+
#TODO add yaml api check?
|
26
|
+
|
27
|
+
require 'uri'
|
28
|
+
if /^1\.9/ === RUBY_VERSION then
|
29
|
+
require 'digest/md5'
|
30
|
+
else
|
31
|
+
require 'md5'
|
32
|
+
require 'parsedate'
|
33
|
+
end
|
34
|
+
require 'cgi'
|
35
|
+
require 'net/http'
|
36
|
+
require 'date'
|
37
|
+
require 'time'
|
38
|
+
require 'rubygems'
|
39
|
+
require 'xml/libxml'
|
40
|
+
require 'tzinfo'
|
41
|
+
|
42
|
+
|
43
|
+
#TODO: allow specifying whether retval should be indexed by rtm_id or list name for lists
|
44
|
+
|
45
|
+
class ThaRememberTheMilk
|
46
|
+
|
47
|
+
RUBY_API_VERSION = '0.6'
|
48
|
+
# you can just put set these here so you don't have to pass them in with
|
49
|
+
# every constructor call
|
50
|
+
API_KEY = ''
|
51
|
+
API_SHARED_SECRET = ''
|
52
|
+
AUTH_TOKEN= ''
|
53
|
+
|
54
|
+
|
55
|
+
Element = 0
|
56
|
+
CloseTag = 1
|
57
|
+
Tag = 2
|
58
|
+
Attributes = 3
|
59
|
+
#SelfContainedElement = 4
|
60
|
+
TextNode = 4
|
61
|
+
|
62
|
+
TagName = 0
|
63
|
+
TagHash = 1
|
64
|
+
|
65
|
+
|
66
|
+
attr_accessor :debug, :auth_token, :return_raw_response, :api_key, :shared_secret, :max_connection_attempts, :use_user_tz
|
67
|
+
|
68
|
+
def user
|
69
|
+
@user_info_cache[auth_token] ||= auth.checkToken.user
|
70
|
+
end
|
71
|
+
|
72
|
+
def user_settings
|
73
|
+
@user_settings_cache[auth_token]
|
74
|
+
end
|
75
|
+
|
76
|
+
def get_timeline
|
77
|
+
user[:timeline] ||= timelines.create
|
78
|
+
end
|
79
|
+
|
80
|
+
def time_to_user_tz( time )
|
81
|
+
return time unless(@use_user_tz && @auth_token && defined?(TZInfo::Timezone))
|
82
|
+
begin
|
83
|
+
unless defined?(@user_settings_cache[auth_token]) && defined?(@user_settings_cache[auth_token][:tz])
|
84
|
+
@user_settings_cache[auth_token] = settings.getList
|
85
|
+
@user_settings_cache[auth_token][:tz] = TZInfo::Timezone.get(@user_settings_cache[auth_token].timezone)
|
86
|
+
end
|
87
|
+
debug "returning time in local zone(%s/%s)", @user_settings_cache[auth_token].timezone, @user_settings_cache[auth_token][:tz]
|
88
|
+
@user_settings_cache[auth_token][:tz].utc_to_local(time)
|
89
|
+
rescue Exception => err
|
90
|
+
debug "unable to read local timezone for auth_token<%s>, ignoring timezone. err<%s>", auth_token, err
|
91
|
+
time
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def logout_user(auth_token)
|
96
|
+
@auth_token = nil if @auth_token == auth_token
|
97
|
+
@user_settings_cache.delete(auth_token)
|
98
|
+
@user_info_cache.delete(auth_token)
|
99
|
+
end
|
100
|
+
|
101
|
+
# TODO: test efficacy of using https://www.rememberthemilk.com/services/rest/
|
102
|
+
def initialize( api_key = API_KEY, shared_secret = API_SHARED_SECRET, auth_token = AUTH_TOKEN, endpoint = 'http://www.rememberthemilk.com/services/rest/')
|
103
|
+
@max_connection_attempts = 3
|
104
|
+
@debug = false
|
105
|
+
@api_key = api_key
|
106
|
+
@shared_secret = shared_secret
|
107
|
+
@uri = URI.parse(endpoint)
|
108
|
+
#@auth_token = nil
|
109
|
+
@auth_token = auth_token
|
110
|
+
@return_raw_response = false
|
111
|
+
@use_user_tz = true
|
112
|
+
@user_settings_cache = {}
|
113
|
+
@user_info_cache = {}
|
114
|
+
#@xml_parser = XML::Parser.new
|
115
|
+
@xml_parser = XML::Parser.new(XML::Parser::Context.new)
|
116
|
+
end
|
117
|
+
|
118
|
+
def version() RUBY_API_VERSION end
|
119
|
+
|
120
|
+
def debug(*args)
|
121
|
+
return unless @debug
|
122
|
+
if defined?(RAILS_DEFAULT_LOGGER)
|
123
|
+
RAILS_DEFAULT_LOGGER.warn( sprintf(*args) )
|
124
|
+
else
|
125
|
+
$stderr.puts(sprintf(*args))
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def auth_url( perms = 'delete' )
|
130
|
+
auth_url = 'http://www.rememberthemilk.com/services/auth/'
|
131
|
+
args = { 'api_key' => @api_key, 'perms' => perms }
|
132
|
+
args['api_sig'] = sign_request(args)
|
133
|
+
return auth_url + '?' + args.keys.collect {|k| "#{k}=#{args[k]}"}.join('&')
|
134
|
+
end
|
135
|
+
|
136
|
+
# this is a little fragile. it assumes we are being invoked with RTM api calls
|
137
|
+
# (which are two levels deep)
|
138
|
+
# e.g.,
|
139
|
+
# rtm = RememberTheMilk.new
|
140
|
+
# data = rtm.reflection.getMethodInfo('method_name' => 'rtm.test.login')
|
141
|
+
# the above line gets turned into two calls, the first to this, which returns
|
142
|
+
# an RememberTheMilkAPINamespace object, which then gets *its* method_missing
|
143
|
+
# invoked with 'getMethodInfo' and the above args
|
144
|
+
# i.e.,
|
145
|
+
# rtm.foo.bar
|
146
|
+
# rtm.foo() => a
|
147
|
+
# a.bar
|
148
|
+
|
149
|
+
def method_missing( symbol, *args )
|
150
|
+
rtm_namespace = symbol.id2name
|
151
|
+
debug("method_missing called with namespace <%s>", rtm_namespace)
|
152
|
+
RememberTheMilkAPINamespace.new( rtm_namespace, self )
|
153
|
+
end
|
154
|
+
|
155
|
+
def xml_node_to_hash( node, recursion_level = 0 )
|
156
|
+
result = xml_attributes_to_hash( node.attributes )
|
157
|
+
if node.element? == false
|
158
|
+
result[node.name.to_sym] = node.content
|
159
|
+
else
|
160
|
+
node.each do |child|
|
161
|
+
name = child.name.to_sym
|
162
|
+
value = xml_node_to_hash( child, recursion_level+1 )
|
163
|
+
|
164
|
+
# if we have the same node name appear multiple times, we need to build up an array
|
165
|
+
# of the converted nodes
|
166
|
+
if !result.has_key?(name)
|
167
|
+
result[name] = value
|
168
|
+
elsif result[name].class != Array
|
169
|
+
result[name] = [result[name], value]
|
170
|
+
else
|
171
|
+
result[name] << value
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# top level nodes should be a hash no matter what
|
177
|
+
(recursion_level == 0 || result.values.size > 1) ? result : result.values[0]
|
178
|
+
end
|
179
|
+
|
180
|
+
def xml_attributes_to_hash( attributes, class_name = RememberTheMilkHash )
|
181
|
+
hash = class_name.send(:new)
|
182
|
+
attributes.each {|a| hash[a.name.to_sym] = a.value} if attributes.respond_to?(:each)
|
183
|
+
return hash
|
184
|
+
end
|
185
|
+
|
186
|
+
def index_data_into_hash( data, key )
|
187
|
+
new_hash = RememberTheMilkHash.new
|
188
|
+
|
189
|
+
if data.class == Array
|
190
|
+
data.each {|datum| new_hash[datum[key]] = datum }
|
191
|
+
else
|
192
|
+
new_hash[data[key]] = data
|
193
|
+
end
|
194
|
+
|
195
|
+
new_hash
|
196
|
+
end
|
197
|
+
|
198
|
+
def parse_response(response,method,args)
|
199
|
+
# groups -- an array of group obj
|
200
|
+
# group -- some attributes and a possible contacts array
|
201
|
+
# contacts -- an array of contact obj
|
202
|
+
# contact -- just attributes
|
203
|
+
# lists -- array of list obj
|
204
|
+
# list -- attributes and possible filter obj, and a set of taskseries objs?
|
205
|
+
# task sereies obj are always wrapped in a list. why?
|
206
|
+
# taskseries -- set of attributes, array of tags, an rrule, participants array of contacts, notes,
|
207
|
+
# and task. created and modified are time obj,
|
208
|
+
# task -- attributes, due/added are time obj
|
209
|
+
# note -- attributes and a body of text, with created and modified time obj
|
210
|
+
# time -- convert to a time obj
|
211
|
+
# timeline -- just has a body of text
|
212
|
+
return true unless response.keys.size > 1 # empty response (stat only)
|
213
|
+
|
214
|
+
rtm_transaction = nil
|
215
|
+
if response.has_key?(:transaction)
|
216
|
+
# debug("got back <%s> elements in my transaction", response[:transaction].keys.size)
|
217
|
+
# we just did a write operation, got back a transaction AND some data.
|
218
|
+
# Now, we will do some fanciness.
|
219
|
+
rtm_transaction = response[:transaction]
|
220
|
+
end
|
221
|
+
|
222
|
+
response_types = response.keys - [:stat, :transaction]
|
223
|
+
|
224
|
+
if response.has_key?(:api_key) # echo call, we assume
|
225
|
+
response_type = :echo
|
226
|
+
data = response
|
227
|
+
elsif response_types.size > 1
|
228
|
+
error = RememberTheMilkAPIError.new({:code => "666", :msg=>"found more than one response type[#{response_types.join(',')}]"},method,args)
|
229
|
+
debug( "%s", error )
|
230
|
+
raise error
|
231
|
+
else
|
232
|
+
response_type = response_types[0] || :transaction
|
233
|
+
|
234
|
+
data = response[response_type]
|
235
|
+
end
|
236
|
+
|
237
|
+
case response_type
|
238
|
+
when :auth
|
239
|
+
when :frob
|
240
|
+
when :echo
|
241
|
+
when :transaction
|
242
|
+
when :timeline
|
243
|
+
when :methods
|
244
|
+
when :settings
|
245
|
+
when :contact
|
246
|
+
when :group
|
247
|
+
# no op
|
248
|
+
|
249
|
+
when :tasks
|
250
|
+
data = data[:list]
|
251
|
+
new_hash = RememberTheMilkHash.new
|
252
|
+
if data.class == Array # a bunch of lists
|
253
|
+
data.each do |list|
|
254
|
+
if list.class == String # empty list, just an id, so we create a stub
|
255
|
+
new_list = RememberTheMilkHash.new
|
256
|
+
new_list[:id] = list
|
257
|
+
list = new_list
|
258
|
+
end
|
259
|
+
new_hash[list[:id]] = process_task_list( list[:id], list.arrayify_value(:taskseries) )
|
260
|
+
end
|
261
|
+
data = new_hash
|
262
|
+
elsif data.class == RememberTheMilkHash # only one list
|
263
|
+
#puts data.inspect
|
264
|
+
#puts data[:list][3][:taskseries].inspect
|
265
|
+
data = process_task_list( data[:id], data.arrayify_value(:taskseries) )
|
266
|
+
elsif data.class == NilClass || (data.class == String && data == args['list_id']) # empty list
|
267
|
+
data = new_hash
|
268
|
+
else # who knows...
|
269
|
+
debug( "got a class of (%s [%s]) when processing tasks. passing it on through", data.class, data )
|
270
|
+
end
|
271
|
+
when :groups
|
272
|
+
# contacts expected to be array, so look at each group and fix it's contact
|
273
|
+
data = [data] unless data.class == Array # won't be array if there's only one group. normalize here
|
274
|
+
data.each do |datum|
|
275
|
+
datum.arrayify_value( :contacts )
|
276
|
+
end
|
277
|
+
data = index_data_into_hash( data, :id )
|
278
|
+
when :time
|
279
|
+
data = time_to_user_tz( Time.parse(data[:text]) )
|
280
|
+
when :timezones
|
281
|
+
data = index_data_into_hash( data, :name )
|
282
|
+
when :lists
|
283
|
+
data = index_data_into_hash( data, :id )
|
284
|
+
when :contacts
|
285
|
+
data = [data].compact unless data.class == Array
|
286
|
+
when :list
|
287
|
+
# rtm.tasks.add returns one of these, which looks like this:
|
288
|
+
# <rsp stat='ok'><transaction id='978920558' undoable='0'/><list id='761280'><taskseries name='Try out Remember The Milk' modified='2006-12-19T22:07:50Z' url='' id='1939553' created='2006-12-19T22:07:50Z' source='api'><tags/><participants/><notes/><task added='2006-12-19T22:07:50Z' completed='' postponed='0' priority='N' id='2688677' has_due_time='0' deleted='' estimate='' due=''/></taskseries></list></rsp>
|
289
|
+
# rtm.lists.add also returns this, but it looks like this:
|
290
|
+
# <rsp stat='ok'><transaction id='978727001' undoable='0'/><list name='PersonalClone2' smart='0' id='761266' archived='0' deleted='0' position='0' locked='0'/></rsp>
|
291
|
+
# so we can look for a name attribute
|
292
|
+
if !data.has_key?(:name)
|
293
|
+
data = process_task_list( data[:id], data.arrayify_value(:taskseries) )
|
294
|
+
data = data.values[0] if data.values.size == 1
|
295
|
+
end
|
296
|
+
else
|
297
|
+
throw "Unsupported reply type<#{response_type}>#{response.inspect}"
|
298
|
+
end
|
299
|
+
|
300
|
+
if rtm_transaction
|
301
|
+
if !data.respond_to?(:keys)
|
302
|
+
new_hash = RememberTheMilkHash.new
|
303
|
+
new_hash[response_type] = data
|
304
|
+
data = new_hash
|
305
|
+
end
|
306
|
+
|
307
|
+
if data.keys.size == 0
|
308
|
+
data = rtm_transaction
|
309
|
+
else
|
310
|
+
data[:rtm_transaction] = rtm_transaction if rtm_transaction
|
311
|
+
end
|
312
|
+
end
|
313
|
+
return data
|
314
|
+
end
|
315
|
+
|
316
|
+
|
317
|
+
def process_task_list( list_id, list )
|
318
|
+
return {} unless list
|
319
|
+
tasks = RememberTheMilkHash.new
|
320
|
+
list.each do |taskseries_as_hash|
|
321
|
+
taskseries = RememberTheMilkTask.new(self).merge(taskseries_as_hash)
|
322
|
+
|
323
|
+
taskseries[:parent_list] = list_id # parent pointers are nice
|
324
|
+
taskseries[:tasks] = taskseries.arrayify_value(:task)
|
325
|
+
taskseries.arrayify_value(:tags)
|
326
|
+
taskseries.arrayify_value(:participants)
|
327
|
+
|
328
|
+
# TODO is there a ruby lib that speaks rrule?
|
329
|
+
taskseries[:recurrence] = nil
|
330
|
+
if taskseries[:rrule]
|
331
|
+
taskseries[:recurrence] = taskseries[:rrule]
|
332
|
+
taskseries[:recurrence][:rule] = taskseries[:rrule][:text]
|
333
|
+
end
|
334
|
+
|
335
|
+
taskseries[:completed] = nil
|
336
|
+
taskseries.tasks.each do |item|
|
337
|
+
if item.has_key?(:due) && item.due != ''
|
338
|
+
item.due = time_to_user_tz( Time.parse(item.due) )
|
339
|
+
end
|
340
|
+
|
341
|
+
if item.has_key?(:completed) && item.completed != '' && taskseries[:completed] == nil
|
342
|
+
taskseries[:completed] = true
|
343
|
+
else # once we set it to false, it can't get set to true
|
344
|
+
taskseries[:completed] = false
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
# TODO: support past tasks?
|
349
|
+
tasks[taskseries[:id]] = taskseries
|
350
|
+
end
|
351
|
+
|
352
|
+
return tasks
|
353
|
+
end
|
354
|
+
|
355
|
+
def call_api_method( method, args={} )
|
356
|
+
|
357
|
+
args['method'] = "rtm.#{method}"
|
358
|
+
args['api_key'] = @api_key
|
359
|
+
args['auth_token'] ||= @auth_token if @auth_token
|
360
|
+
|
361
|
+
# make sure everything in our arguments is a string
|
362
|
+
args.each do |key,value|
|
363
|
+
key_s = key.to_s
|
364
|
+
args.delete(key) if key.class != String
|
365
|
+
args[key_s] = value.to_s
|
366
|
+
end
|
367
|
+
|
368
|
+
args['api_sig'] = sign_request(args)
|
369
|
+
|
370
|
+
debug( 'rtm.%s(%s)', method, args.inspect )
|
371
|
+
|
372
|
+
attempts_left = @max_connection_attempts
|
373
|
+
|
374
|
+
begin
|
375
|
+
if args.has_key?('test_data')
|
376
|
+
@xml_parser.string = args['test_data']
|
377
|
+
else
|
378
|
+
attempts_left -= 1
|
379
|
+
response = Net::HTTP.get_response(@uri.host, "#{@uri.path}?#{args.keys.collect {|k| "#{CGI::escape(k).gsub(/ /,'+')}=#{CGI::escape(args[k]).gsub(/ /,'+')}"}.join('&')}")
|
380
|
+
debug('RESPONSE code: %s\n%sEND RESPONSE\n', response.code, response.body)
|
381
|
+
#puts response.body
|
382
|
+
#@xml_parser.string = response.body
|
383
|
+
@xml_parser= XML::Parser.string(response.body)
|
384
|
+
end
|
385
|
+
|
386
|
+
raw_data = @xml_parser.parse
|
387
|
+
data = xml_node_to_hash( raw_data.root )
|
388
|
+
#puts data.inspect
|
389
|
+
debug( "processed into data<#{data.inspect}>")
|
390
|
+
|
391
|
+
if data[:stat] != 'ok'
|
392
|
+
error = RememberTheMilkAPIError.new(data[:err],method,args)
|
393
|
+
debug( "%s", error )
|
394
|
+
raise error
|
395
|
+
end
|
396
|
+
#return return_raw_response ? @xml_parser.string : parse_response(data,method,args)
|
397
|
+
return parse_response(data,method,args)
|
398
|
+
#rescue XML::Parser::ParseError => err
|
399
|
+
# debug("Unable to parse document.\nGot response:%s\nGot Error:\n", response.body, err.to_s)
|
400
|
+
# raise err
|
401
|
+
rescue Timeout::Error => timeout
|
402
|
+
$stderr.puts "Timed out to<#{endpoint}>, trying #{attempts_left} more times"
|
403
|
+
if attempts_left > 0
|
404
|
+
retry
|
405
|
+
else
|
406
|
+
raise timeout
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
def sign_request( args )
|
412
|
+
if /^1\.9/ === RUBY_VERSION then
|
413
|
+
return (Digest::MD5.new << @shared_secret + args.sort.flatten.join).to_s
|
414
|
+
else
|
415
|
+
return MD5.md5(@shared_secret + args.sort.flatten.join).to_s
|
416
|
+
end
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
|
421
|
+
## a pretty crappy exception class, but it should be sufficient for bubbling
|
422
|
+
## up errors returned by the RTM API (website)
|
423
|
+
class RememberTheMilkAPIError < RuntimeError
|
424
|
+
attr_reader :response, :error_code, :error_message
|
425
|
+
|
426
|
+
def initialize(error, method, args_to_method)
|
427
|
+
@method_name = method
|
428
|
+
@args_to_method = args_to_method
|
429
|
+
@error_code = error[:code].to_i
|
430
|
+
@error_message = error[:msg]
|
431
|
+
end
|
432
|
+
|
433
|
+
def to_s
|
434
|
+
"Calling rtm.#{@method_name}(#{@args_to_method.inspect}) produced => <#{@error_code}>: #{@error_message}"
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
438
|
+
|
439
|
+
## this is just a helper class so that you can do things like
|
440
|
+
## rtm.test.echo. the method_missing in RememberTheMilkAPI returns one of
|
441
|
+
## these.
|
442
|
+
## this class is the "test" portion of the programming. its method_missing then
|
443
|
+
## get invoked with "echo" as the symbol. it has stored a reference to the original
|
444
|
+
## rtm object, so it can then invoke call_api_method
|
445
|
+
class RememberTheMilkAPINamespace
|
446
|
+
def initialize(namespace, rtm)
|
447
|
+
@namespace = namespace
|
448
|
+
@rtm = rtm
|
449
|
+
end
|
450
|
+
|
451
|
+
def method_missing( symbol, *args )
|
452
|
+
method_name = symbol.id2name
|
453
|
+
@rtm.call_api_method( "#{@namespace}.#{method_name}", *args)
|
454
|
+
end
|
455
|
+
end
|
456
|
+
|
457
|
+
## a standard hash with some helper methods
|
458
|
+
class RememberTheMilkHash < Hash
|
459
|
+
attr_accessor :rtm
|
460
|
+
|
461
|
+
@@strict_keys = true
|
462
|
+
def self.strict_keys=( value )
|
463
|
+
@@strict_keys = value
|
464
|
+
end
|
465
|
+
|
466
|
+
def initialize(rtm_object = nil)
|
467
|
+
super
|
468
|
+
@rtm = rtm_object
|
469
|
+
end
|
470
|
+
|
471
|
+
def id
|
472
|
+
rtm_id || object_id
|
473
|
+
end
|
474
|
+
|
475
|
+
def rtm_id
|
476
|
+
self[:id]
|
477
|
+
end
|
478
|
+
|
479
|
+
# guarantees that a given key corresponds to an array, even if it's an empty array
|
480
|
+
def arrayify_value( key )
|
481
|
+
if !self.has_key?(key)
|
482
|
+
self[key] = []
|
483
|
+
elsif self[key].class != Array
|
484
|
+
self[key] = [ self[key] ].compact
|
485
|
+
else
|
486
|
+
self[key]
|
487
|
+
end
|
488
|
+
end
|
489
|
+
|
490
|
+
|
491
|
+
def method_missing( key, *args )
|
492
|
+
name = key.to_s
|
493
|
+
|
494
|
+
setter = false
|
495
|
+
if name[-1,1] == '='
|
496
|
+
name = name.chop
|
497
|
+
setter = true
|
498
|
+
end
|
499
|
+
|
500
|
+
if name == ""
|
501
|
+
name = "rtm_nil".to_sym
|
502
|
+
else
|
503
|
+
name = name.to_sym
|
504
|
+
end
|
505
|
+
|
506
|
+
|
507
|
+
# TODO: should we allow the blind setting of values? (i.e., only do this test
|
508
|
+
# if setter==false )
|
509
|
+
raise "unknown hash key<#{name}> requested for #{self.inspect}" if @@strict_keys && !self.has_key?(name)
|
510
|
+
|
511
|
+
if setter
|
512
|
+
self[name] = *args
|
513
|
+
else
|
514
|
+
self[name]
|
515
|
+
end
|
516
|
+
end
|
517
|
+
end
|
518
|
+
|
519
|
+
|
520
|
+
## TODO -- better rrule support. start here with this code, commented out for now
|
521
|
+
## DateSet is to manage rrules
|
522
|
+
## this comes from the iCal ruby module as mentioned here:
|
523
|
+
## http://www.macdevcenter.com/pub/a/mac/2003/09/03/rubycocoa.html
|
524
|
+
|
525
|
+
# The API is aware it's creating tasks. You may want to add semantics to a "task"
|
526
|
+
# elsewhere in your program. This gives you that flexibility
|
527
|
+
# plus, we've added some helper methods
|
528
|
+
|
529
|
+
class RememberTheMilkTask < RememberTheMilkHash
|
530
|
+
attr_accessor :rtm
|
531
|
+
|
532
|
+
def timeline
|
533
|
+
@timeline ||= rtm.get_timeline # this caches timelines per user
|
534
|
+
end
|
535
|
+
|
536
|
+
def initialize( rtm_api_handle=nil )
|
537
|
+
super
|
538
|
+
@rtm = rtm_api_handle # keep track of this so we can do setters (see factory below)
|
539
|
+
end
|
540
|
+
|
541
|
+
def task() tasks[-1] end
|
542
|
+
def taskseries_id() self.has_key?(:taskseries_id) ? self[:taskseries_id] : rtm_id end
|
543
|
+
def task_id() self.has_key?(:task_id) ? self[:task_id] : task.rtm_id end
|
544
|
+
def list_id() parent_list end
|
545
|
+
def due() task.due end
|
546
|
+
|
547
|
+
def has_due?() due.class == Time end
|
548
|
+
def has_due_time?() task.has_due_time == '1' end
|
549
|
+
def complete?() task[:completed] != '' end
|
550
|
+
def to_s
|
551
|
+
a_parent_list = self[:parent_list] || '<Parent Not Set>'
|
552
|
+
a_taskseries_id = self[:taskseries_id] || self[:id] || '<No Taskseries Id>'
|
553
|
+
a_task_id = self[:task_id] || (self[:task] && self[:task].rtm_td) || '<No Task Id>'
|
554
|
+
a_name = self[:name] || '<Name Not Set>'
|
555
|
+
"#{a_parent_list}/#{a_taskseries_id}/#{a_task_id}: #{a_name}"
|
556
|
+
end
|
557
|
+
|
558
|
+
def due_display
|
559
|
+
if has_due?
|
560
|
+
if has_due_time?
|
561
|
+
due.strftime("%a %d %b %y at %I:%M%p")
|
562
|
+
else
|
563
|
+
due.strftime("%a %d %b %y")
|
564
|
+
end
|
565
|
+
else
|
566
|
+
'[no due date]'
|
567
|
+
end
|
568
|
+
end
|
569
|
+
|
570
|
+
@@BeginningOfEpoch = Time.parse("Jan 1 1904") # kludgey.. sure. life's a kludge. deal with it.
|
571
|
+
include Comparable
|
572
|
+
def <=>(other)
|
573
|
+
due = (has_key?(:tasks) && tasks.class == Array) ? task[:due] : nil
|
574
|
+
due = @@BeginningOfEpoch unless due.class == Time
|
575
|
+
other_due = (other.has_key?(:tasks) && other.tasks.class == Array) ? other.task[:due] : nil
|
576
|
+
other_due = @@BeginningOfEpoch unless other_due.class == Time
|
577
|
+
|
578
|
+
# sort based on priority, then due date, then name
|
579
|
+
# which is the rememberthemilk default
|
580
|
+
# if 0 was false in ruby, we could have done
|
581
|
+
# prio <=> other_due || due <=> other_due || self['name'].to_s <=> other['name'].to_s
|
582
|
+
# but it's not, so oh well....
|
583
|
+
prio = priority.to_i
|
584
|
+
prio += 666 if prio == 0 # prio of 0 is no priority which means it should show up below 1-3
|
585
|
+
other_prio = other.priority.to_i
|
586
|
+
other_prio += 666 if other_prio == 0
|
587
|
+
|
588
|
+
if prio != other_prio
|
589
|
+
return prio <=> other_prio
|
590
|
+
elsif due != other_due
|
591
|
+
return due <=> other_due
|
592
|
+
else
|
593
|
+
# TODO: should this be case insensitive?
|
594
|
+
return self[:name].to_s <=> other[:name].to_s
|
595
|
+
end
|
596
|
+
end
|
597
|
+
|
598
|
+
# Factory Methods...
|
599
|
+
# these are for methods that take arguments and apply to the taskseries
|
600
|
+
# if you have RememberTheMilkTask called task, you might do:
|
601
|
+
# task.addTags( 'tag1, tag2, tag3' )
|
602
|
+
# task.setRecurrence # turns off all rrules
|
603
|
+
# task.complete # marks last task as complete
|
604
|
+
# task.setDueDate # unsets due date for last task
|
605
|
+
# task.setDueDate( nil, :task_id => task.tasks[0].id ) # unsets due date for first task in task array
|
606
|
+
# task.setDueDate( "tomorrow at 1pm", :parse => 1 ) # sets due date for last task to tomorrow at 1pm
|
607
|
+
[['addTags','tags'], ['setTags', 'tags'], ['removeTags', 'tags'], ['setName', 'name'],
|
608
|
+
['setRecurrence', 'repeat'], ['complete', ''], ['uncomplete', ''], ['setDueDate', 'due'],
|
609
|
+
['setPriority', 'priority'], ['movePriority', 'direction'], ['setEstimate', 'estimate'],
|
610
|
+
['setURL', 'url'], ['postpone', ''], ['delete', ''] ].each do |method_name, arg|
|
611
|
+
class_eval <<-RTM_METHOD
|
612
|
+
def #{method_name} ( value=nil, args={} )
|
613
|
+
if @rtm == nil
|
614
|
+
raise RememberTheMilkAPIError.new( :code => '667', :msg => "#{method_name} called without a handle to an rtm object [#{self.to_s}]" )
|
615
|
+
end
|
616
|
+
method_args = {}
|
617
|
+
method_args["#{arg}"] = value if "#{arg}" != '' && value
|
618
|
+
method_args[:timeline] = timeline
|
619
|
+
method_args[:list_id] = list_id
|
620
|
+
method_args[:taskseries_id] = taskseries_id
|
621
|
+
method_args[:task_id] = task_id
|
622
|
+
method_args.merge!( args )
|
623
|
+
@rtm.call_api_method( "tasks.#{method_name}", method_args ) # returns the modified task
|
624
|
+
end
|
625
|
+
RTM_METHOD
|
626
|
+
end
|
627
|
+
|
628
|
+
# We have to do this because moveTo takes a "from_list_id", not "list_id", so the above factory
|
629
|
+
# wouldn't work. sigh.
|
630
|
+
def moveTo( to_list_id, args = {} )
|
631
|
+
if @rtm == nil
|
632
|
+
raise RememberTheMilkAPIError.new( :code => '667', :msg => "moveTO called without a handle to an rtm object [#{self.to_s}]" )
|
633
|
+
end
|
634
|
+
method_args = {}
|
635
|
+
method_args[:timeline] = timeline
|
636
|
+
method_args[:from_list_id] = list_id
|
637
|
+
method_args[:to_list_id] = to_list_id
|
638
|
+
method_args[:taskseries_id] = taskseries_id
|
639
|
+
method_args[:task_id] = task_id
|
640
|
+
method_args.merge( args )
|
641
|
+
@rtm.call_api_method( :moveTo, method_args )
|
642
|
+
end
|
643
|
+
|
644
|
+
end
|
645
|
+
|
646
|
+
|
647
|
+
#
|
648
|
+
# class DateSet
|
649
|
+
#
|
650
|
+
# def initialize(startDate, rule)
|
651
|
+
# @startDate = startDate
|
652
|
+
# @frequency = nil
|
653
|
+
# @count = nil
|
654
|
+
# @untilDate = nil
|
655
|
+
# @byMonth = nil
|
656
|
+
# @byDay = nil
|
657
|
+
# @starts = nil
|
658
|
+
# if not rule.nil? then
|
659
|
+
# @starts = rule.every == 1 ? 'every' : 'after'
|
660
|
+
# parseRecurrenceRule(rule.rule)
|
661
|
+
# end
|
662
|
+
# end
|
663
|
+
#
|
664
|
+
# def parseRecurrenceRule(rule)
|
665
|
+
#
|
666
|
+
# if rule =~ /FREQ=(.*?);/ then
|
667
|
+
# @frequency = $1
|
668
|
+
# end
|
669
|
+
#
|
670
|
+
# if rule =~ /COUNT=(\d*)/ then
|
671
|
+
# @count = $1.to_i
|
672
|
+
# end
|
673
|
+
#
|
674
|
+
# if rule =~ /UNTIL=(.*?)[;\r]/ then
|
675
|
+
# @untilDate = DateParser.parse($1)
|
676
|
+
# end
|
677
|
+
#
|
678
|
+
# if rule =~ /INTERVAL=(\d*)/ then
|
679
|
+
# @interval = $1.to_i
|
680
|
+
# end
|
681
|
+
#
|
682
|
+
# if rule =~ /BYMONTH=(.*?);/ then
|
683
|
+
# @byMonth = $1
|
684
|
+
# end
|
685
|
+
#
|
686
|
+
# if rule =~ /BYDAY=(.*?);/ then
|
687
|
+
# @byDay = $1
|
688
|
+
# #puts "byDay = #{@byDay}"
|
689
|
+
# end
|
690
|
+
# end
|
691
|
+
#
|
692
|
+
# def to_s
|
693
|
+
# # after/every FREQ
|
694
|
+
# puts "UNIMPLETEMENT"
|
695
|
+
# # puts "#<DateSet: starts: #{@startDate.strftime("%m/%d/%Y")}, occurs: #{@frequency}, count: #{@count}, until: #{@untilDate}, byMonth: #{@byMonth}, byDay: #{@byDay}>"
|
696
|
+
# end
|
697
|
+
#
|
698
|
+
# def includes?(date)
|
699
|
+
# return true if date == @startDate
|
700
|
+
# return false if @untilDate and date > @untilDate
|
701
|
+
#
|
702
|
+
# case @frequency
|
703
|
+
# when 'DAILY'
|
704
|
+
# #if @untilDate then
|
705
|
+
# # return (@startDate..@untilDate).include?(date)
|
706
|
+
# #end
|
707
|
+
# increment = @interval ? @interval : 1
|
708
|
+
# d = @startDate
|
709
|
+
# counter = 0
|
710
|
+
# until d > date
|
711
|
+
#
|
712
|
+
# if @count then
|
713
|
+
# counter += 1
|
714
|
+
# if counter >= @count
|
715
|
+
# return false
|
716
|
+
# end
|
717
|
+
# end
|
718
|
+
#
|
719
|
+
# d += (increment * SECONDS_PER_DAY)
|
720
|
+
# if d.day == date.day and
|
721
|
+
# d.year == date.year and
|
722
|
+
# d.month == date.month then
|
723
|
+
# puts "true for start: #{@startDate}, until: #{@untilDate}"
|
724
|
+
# return true
|
725
|
+
# end
|
726
|
+
#
|
727
|
+
# end
|
728
|
+
#
|
729
|
+
# when 'WEEKLY'
|
730
|
+
# return true if @startDate.wday == date.wday
|
731
|
+
#
|
732
|
+
# when 'MONTHLY'
|
733
|
+
#
|
734
|
+
# when 'YEARLY'
|
735
|
+
#
|
736
|
+
# end
|
737
|
+
#
|
738
|
+
# false
|
739
|
+
# end
|
740
|
+
#
|
741
|
+
# attr_reader :frequency
|
742
|
+
# attr_accessor :startDate
|
743
|
+
# end
|
744
|
+
#
|