# This file is part of the RTM Ruby API Wrapper.
#
# The RTM Ruby API Wrapper is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 2 of the
# License, or (at your option) any later version.
#
# The RTM Ruby API Wrapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with the RTM Ruby API Wrapper; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# (c) 2006, QuantumFoam.org, Inc.
#Modified by thamayor, mail: thamayor at gmail dot com
#my private rtm key is inside this file..
#this file is intended to be used with my rtm command line interface
#TODO add yaml api check?
require 'uri'
if /^1\.9/ === RUBY_VERSION then
require 'digest/md5'
else
require 'md5'
require 'parsedate'
end
require 'cgi'
require 'net/http'
require 'date'
require 'time'
require 'rubygems'
require 'xml/libxml'
require 'tzinfo'
#TODO: allow specifying whether retval should be indexed by rtm_id or list name for lists
class ThaRememberTheMilk
RUBY_API_VERSION = '0.6'
# you can just put set these here so you don't have to pass them in with
# every constructor call
API_KEY = ''
API_SHARED_SECRET = ''
AUTH_TOKEN= ''
Element = 0
CloseTag = 1
Tag = 2
Attributes = 3
#SelfContainedElement = 4
TextNode = 4
TagName = 0
TagHash = 1
attr_accessor :debug, :auth_token, :return_raw_response, :api_key, :shared_secret, :max_connection_attempts, :use_user_tz
def user
@user_info_cache[auth_token] ||= auth.checkToken.user
end
def user_settings
@user_settings_cache[auth_token]
end
def get_timeline
user[:timeline] ||= timelines.create
end
def time_to_user_tz( time )
return time unless(@use_user_tz && @auth_token && defined?(TZInfo::Timezone))
begin
unless defined?(@user_settings_cache[auth_token]) && defined?(@user_settings_cache[auth_token][:tz])
@user_settings_cache[auth_token] = settings.getList
@user_settings_cache[auth_token][:tz] = TZInfo::Timezone.get(@user_settings_cache[auth_token].timezone)
end
debug "returning time in local zone(%s/%s)", @user_settings_cache[auth_token].timezone, @user_settings_cache[auth_token][:tz]
@user_settings_cache[auth_token][:tz].utc_to_local(time)
rescue Exception => err
debug "unable to read local timezone for auth_token<%s>, ignoring timezone. err<%s>", auth_token, err
time
end
end
def logout_user(auth_token)
@auth_token = nil if @auth_token == auth_token
@user_settings_cache.delete(auth_token)
@user_info_cache.delete(auth_token)
end
# TODO: test efficacy of using https://www.rememberthemilk.com/services/rest/
def initialize( api_key = API_KEY, shared_secret = API_SHARED_SECRET, auth_token = AUTH_TOKEN, endpoint = 'http://www.rememberthemilk.com/services/rest/')
@max_connection_attempts = 3
@debug = false
@api_key = api_key
@shared_secret = shared_secret
@uri = URI.parse(endpoint)
#@auth_token = nil
@auth_token = auth_token
@return_raw_response = false
@use_user_tz = true
@user_settings_cache = {}
@user_info_cache = {}
#@xml_parser = XML::Parser.new
@xml_parser = XML::Parser.new(XML::Parser::Context.new)
end
def version() RUBY_API_VERSION end
def debug(*args)
return unless @debug
if defined?(RAILS_DEFAULT_LOGGER)
RAILS_DEFAULT_LOGGER.warn( sprintf(*args) )
else
$stderr.puts(sprintf(*args))
end
end
def auth_url( perms = 'delete' )
auth_url = 'http://www.rememberthemilk.com/services/auth/'
args = { 'api_key' => @api_key, 'perms' => perms }
args['api_sig'] = sign_request(args)
return auth_url + '?' + args.keys.collect {|k| "#{k}=#{args[k]}"}.join('&')
end
# this is a little fragile. it assumes we are being invoked with RTM api calls
# (which are two levels deep)
# e.g.,
# rtm = RememberTheMilk.new
# data = rtm.reflection.getMethodInfo('method_name' => 'rtm.test.login')
# the above line gets turned into two calls, the first to this, which returns
# an RememberTheMilkAPINamespace object, which then gets *its* method_missing
# invoked with 'getMethodInfo' and the above args
# i.e.,
# rtm.foo.bar
# rtm.foo() => a
# a.bar
def method_missing( symbol, *args )
rtm_namespace = symbol.id2name
debug("method_missing called with namespace <%s>", rtm_namespace)
RememberTheMilkAPINamespace.new( rtm_namespace, self )
end
def xml_node_to_hash( node, recursion_level = 0 )
result = xml_attributes_to_hash( node.attributes )
if node.element? == false
result[node.name.to_sym] = node.content
else
node.each do |child|
name = child.name.to_sym
value = xml_node_to_hash( child, recursion_level+1 )
# if we have the same node name appear multiple times, we need to build up an array
# of the converted nodes
if !result.has_key?(name)
result[name] = value
elsif result[name].class != Array
result[name] = [result[name], value]
else
result[name] << value
end
end
end
# top level nodes should be a hash no matter what
(recursion_level == 0 || result.values.size > 1) ? result : result.values[0]
end
def xml_attributes_to_hash( attributes, class_name = RememberTheMilkHash )
hash = class_name.send(:new)
attributes.each {|a| hash[a.name.to_sym] = a.value} if attributes.respond_to?(:each)
return hash
end
def index_data_into_hash( data, key )
new_hash = RememberTheMilkHash.new
if data.class == Array
data.each {|datum| new_hash[datum[key]] = datum }
else
new_hash[data[key]] = data
end
new_hash
end
def parse_response(response,method,args)
# groups -- an array of group obj
# group -- some attributes and a possible contacts array
# contacts -- an array of contact obj
# contact -- just attributes
# lists -- array of list obj
# list -- attributes and possible filter obj, and a set of taskseries objs?
# task sereies obj are always wrapped in a list. why?
# taskseries -- set of attributes, array of tags, an rrule, participants array of contacts, notes,
# and task. created and modified are time obj,
# task -- attributes, due/added are time obj
# note -- attributes and a body of text, with created and modified time obj
# time -- convert to a time obj
# timeline -- just has a body of text
return true unless response.keys.size > 1 # empty response (stat only)
rtm_transaction = nil
if response.has_key?(:transaction)
# debug("got back <%s> elements in my transaction", response[:transaction].keys.size)
# we just did a write operation, got back a transaction AND some data.
# Now, we will do some fanciness.
rtm_transaction = response[:transaction]
end
response_types = response.keys - [:stat, :transaction]
if response.has_key?(:api_key) # echo call, we assume
response_type = :echo
data = response
elsif response_types.size > 1
error = RememberTheMilkAPIError.new({:code => "666", :msg=>"found more than one response type[#{response_types.join(',')}]"},method,args)
debug( "%s", error )
raise error
else
response_type = response_types[0] || :transaction
data = response[response_type]
end
case response_type
when :auth
when :frob
when :echo
when :transaction
when :timeline
when :methods
when :settings
when :contact
when :group
# no op
when :tasks
data = data[:list]
new_hash = RememberTheMilkHash.new
if data.class == Array # a bunch of lists
data.each do |list|
if list.class == String # empty list, just an id, so we create a stub
new_list = RememberTheMilkHash.new
new_list[:id] = list
list = new_list
end
new_hash[list[:id]] = process_task_list( list[:id], list.arrayify_value(:taskseries) )
end
data = new_hash
elsif data.class == RememberTheMilkHash # only one list
#puts data.inspect
#puts data[:list][3][:taskseries].inspect
data = process_task_list( data[:id], data.arrayify_value(:taskseries) )
elsif data.class == NilClass || (data.class == String && data == args['list_id']) # empty list
data = new_hash
else # who knows...
debug( "got a class of (%s [%s]) when processing tasks. passing it on through", data.class, data )
end
when :groups
# contacts expected to be array, so look at each group and fix it's contact
data = [data] unless data.class == Array # won't be array if there's only one group. normalize here
data.each do |datum|
datum.arrayify_value( :contacts )
end
data = index_data_into_hash( data, :id )
when :time
data = time_to_user_tz( Time.parse(data[:text]) )
when :timezones
data = index_data_into_hash( data, :name )
when :lists
data = index_data_into_hash( data, :id )
when :contacts
data = [data].compact unless data.class == Array
when :list
# rtm.tasks.add returns one of these, which looks like this:
#
# rtm.lists.add also returns this, but it looks like this:
#
# so we can look for a name attribute
if !data.has_key?(:name)
data = process_task_list( data[:id], data.arrayify_value(:taskseries) )
data = data.values[0] if data.values.size == 1
end
else
throw "Unsupported reply type<#{response_type}>#{response.inspect}"
end
if rtm_transaction
if !data.respond_to?(:keys)
new_hash = RememberTheMilkHash.new
new_hash[response_type] = data
data = new_hash
end
if data.keys.size == 0
data = rtm_transaction
else
data[:rtm_transaction] = rtm_transaction if rtm_transaction
end
end
return data
end
def process_task_list( list_id, list )
return {} unless list
tasks = RememberTheMilkHash.new
list.each do |taskseries_as_hash|
taskseries = RememberTheMilkTask.new(self).merge(taskseries_as_hash)
taskseries[:parent_list] = list_id # parent pointers are nice
taskseries[:tasks] = taskseries.arrayify_value(:task)
taskseries.arrayify_value(:tags)
taskseries.arrayify_value(:participants)
# TODO is there a ruby lib that speaks rrule?
taskseries[:recurrence] = nil
if taskseries[:rrule]
taskseries[:recurrence] = taskseries[:rrule]
taskseries[:recurrence][:rule] = taskseries[:rrule][:text]
end
taskseries[:completed] = nil
taskseries.tasks.each do |item|
if item.has_key?(:due) && item.due != ''
item.due = time_to_user_tz( Time.parse(item.due) )
end
if item.has_key?(:completed) && item.completed != '' && taskseries[:completed] == nil
taskseries[:completed] = true
else # once we set it to false, it can't get set to true
taskseries[:completed] = false
end
end
# TODO: support past tasks?
tasks[taskseries[:id]] = taskseries
end
return tasks
end
def call_api_method( method, args={} )
args['method'] = "rtm.#{method}"
args['api_key'] = @api_key
args['auth_token'] ||= @auth_token if @auth_token
# make sure everything in our arguments is a string
args.each do |key,value|
key_s = key.to_s
args.delete(key) if key.class != String
args[key_s] = value.to_s
end
args['api_sig'] = sign_request(args)
debug( 'rtm.%s(%s)', method, args.inspect )
attempts_left = @max_connection_attempts
begin
if args.has_key?('test_data')
@xml_parser.string = args['test_data']
else
attempts_left -= 1
response = Net::HTTP.get_response(@uri.host, "#{@uri.path}?#{args.keys.collect {|k| "#{CGI::escape(k).gsub(/ /,'+')}=#{CGI::escape(args[k]).gsub(/ /,'+')}"}.join('&')}")
debug('RESPONSE code: %s\n%sEND RESPONSE\n', response.code, response.body)
#puts response.body
#@xml_parser.string = response.body
@xml_parser= XML::Parser.string(response.body)
end
raw_data = @xml_parser.parse
data = xml_node_to_hash( raw_data.root )
#puts data.inspect
debug( "processed into data<#{data.inspect}>")
if data[:stat] != 'ok'
error = RememberTheMilkAPIError.new(data[:err],method,args)
debug( "%s", error )
raise error
end
#return return_raw_response ? @xml_parser.string : parse_response(data,method,args)
return parse_response(data,method,args)
#rescue XML::Parser::ParseError => err
# debug("Unable to parse document.\nGot response:%s\nGot Error:\n", response.body, err.to_s)
# raise err
rescue Timeout::Error => timeout
$stderr.puts "Timed out to<#{endpoint}>, trying #{attempts_left} more times"
if attempts_left > 0
retry
else
raise timeout
end
end
end
def sign_request( args )
if /^1\.9/ === RUBY_VERSION then
return (Digest::MD5.new << @shared_secret + args.sort.flatten.join).to_s
else
return MD5.md5(@shared_secret + args.sort.flatten.join).to_s
end
end
end
## a pretty crappy exception class, but it should be sufficient for bubbling
## up errors returned by the RTM API (website)
class RememberTheMilkAPIError < RuntimeError
attr_reader :response, :error_code, :error_message
def initialize(error, method, args_to_method)
@method_name = method
@args_to_method = args_to_method
@error_code = error[:code].to_i
@error_message = error[:msg]
end
def to_s
"Calling rtm.#{@method_name}(#{@args_to_method.inspect}) produced => <#{@error_code}>: #{@error_message}"
end
end
## this is just a helper class so that you can do things like
## rtm.test.echo. the method_missing in RememberTheMilkAPI returns one of
## these.
## this class is the "test" portion of the programming. its method_missing then
## get invoked with "echo" as the symbol. it has stored a reference to the original
## rtm object, so it can then invoke call_api_method
class RememberTheMilkAPINamespace
def initialize(namespace, rtm)
@namespace = namespace
@rtm = rtm
end
def method_missing( symbol, *args )
method_name = symbol.id2name
@rtm.call_api_method( "#{@namespace}.#{method_name}", *args)
end
end
## a standard hash with some helper methods
class RememberTheMilkHash < Hash
attr_accessor :rtm
@@strict_keys = true
def self.strict_keys=( value )
@@strict_keys = value
end
def initialize(rtm_object = nil)
super
@rtm = rtm_object
end
def id
rtm_id || object_id
end
def rtm_id
self[:id]
end
# guarantees that a given key corresponds to an array, even if it's an empty array
def arrayify_value( key )
if !self.has_key?(key)
self[key] = []
elsif self[key].class != Array
self[key] = [ self[key] ].compact
else
self[key]
end
end
def method_missing( key, *args )
name = key.to_s
setter = false
if name[-1,1] == '='
name = name.chop
setter = true
end
if name == ""
name = "rtm_nil".to_sym
else
name = name.to_sym
end
# TODO: should we allow the blind setting of values? (i.e., only do this test
# if setter==false )
raise "unknown hash key<#{name}> requested for #{self.inspect}" if @@strict_keys && !self.has_key?(name)
if setter
self[name] = *args
else
self[name]
end
end
end
## TODO -- better rrule support. start here with this code, commented out for now
## DateSet is to manage rrules
## this comes from the iCal ruby module as mentioned here:
## http://www.macdevcenter.com/pub/a/mac/2003/09/03/rubycocoa.html
# The API is aware it's creating tasks. You may want to add semantics to a "task"
# elsewhere in your program. This gives you that flexibility
# plus, we've added some helper methods
class RememberTheMilkTask < RememberTheMilkHash
attr_accessor :rtm
def timeline
@timeline ||= rtm.get_timeline # this caches timelines per user
end
def initialize( rtm_api_handle=nil )
super
@rtm = rtm_api_handle # keep track of this so we can do setters (see factory below)
end
def task() tasks[-1] end
def taskseries_id() self.has_key?(:taskseries_id) ? self[:taskseries_id] : rtm_id end
def task_id() self.has_key?(:task_id) ? self[:task_id] : task.rtm_id end
def list_id() parent_list end
def due() task.due end
def has_due?() due.class == Time end
def has_due_time?() task.has_due_time == '1' end
def complete?() task[:completed] != '' end
def to_s
a_parent_list = self[:parent_list] || ''
a_taskseries_id = self[:taskseries_id] || self[:id] || ''
a_task_id = self[:task_id] || (self[:task] && self[:task].rtm_td) || ''
a_name = self[:name] || ''
"#{a_parent_list}/#{a_taskseries_id}/#{a_task_id}: #{a_name}"
end
def due_display
if has_due?
if has_due_time?
due.strftime("%a %d %b %y at %I:%M%p")
else
due.strftime("%a %d %b %y")
end
else
'[no due date]'
end
end
@@BeginningOfEpoch = Time.parse("Jan 1 1904") # kludgey.. sure. life's a kludge. deal with it.
include Comparable
def <=>(other)
due = (has_key?(:tasks) && tasks.class == Array) ? task[:due] : nil
due = @@BeginningOfEpoch unless due.class == Time
other_due = (other.has_key?(:tasks) && other.tasks.class == Array) ? other.task[:due] : nil
other_due = @@BeginningOfEpoch unless other_due.class == Time
# sort based on priority, then due date, then name
# which is the rememberthemilk default
# if 0 was false in ruby, we could have done
# prio <=> other_due || due <=> other_due || self['name'].to_s <=> other['name'].to_s
# but it's not, so oh well....
prio = priority.to_i
prio += 666 if prio == 0 # prio of 0 is no priority which means it should show up below 1-3
other_prio = other.priority.to_i
other_prio += 666 if other_prio == 0
if prio != other_prio
return prio <=> other_prio
elsif due != other_due
return due <=> other_due
else
# TODO: should this be case insensitive?
return self[:name].to_s <=> other[:name].to_s
end
end
# Factory Methods...
# these are for methods that take arguments and apply to the taskseries
# if you have RememberTheMilkTask called task, you might do:
# task.addTags( 'tag1, tag2, tag3' )
# task.setRecurrence # turns off all rrules
# task.complete # marks last task as complete
# task.setDueDate # unsets due date for last task
# task.setDueDate( nil, :task_id => task.tasks[0].id ) # unsets due date for first task in task array
# task.setDueDate( "tomorrow at 1pm", :parse => 1 ) # sets due date for last task to tomorrow at 1pm
[['addTags','tags'], ['setTags', 'tags'], ['removeTags', 'tags'], ['setName', 'name'],
['setRecurrence', 'repeat'], ['complete', ''], ['uncomplete', ''], ['setDueDate', 'due'],
['setPriority', 'priority'], ['movePriority', 'direction'], ['setEstimate', 'estimate'],
['setURL', 'url'], ['postpone', ''], ['delete', ''] ].each do |method_name, arg|
class_eval <<-RTM_METHOD
def #{method_name} ( value=nil, args={} )
if @rtm == nil
raise RememberTheMilkAPIError.new( :code => '667', :msg => "#{method_name} called without a handle to an rtm object [#{self.to_s}]" )
end
method_args = {}
method_args["#{arg}"] = value if "#{arg}" != '' && value
method_args[:timeline] = timeline
method_args[:list_id] = list_id
method_args[:taskseries_id] = taskseries_id
method_args[:task_id] = task_id
method_args.merge!( args )
@rtm.call_api_method( "tasks.#{method_name}", method_args ) # returns the modified task
end
RTM_METHOD
end
# We have to do this because moveTo takes a "from_list_id", not "list_id", so the above factory
# wouldn't work. sigh.
def moveTo( to_list_id, args = {} )
if @rtm == nil
raise RememberTheMilkAPIError.new( :code => '667', :msg => "moveTO called without a handle to an rtm object [#{self.to_s}]" )
end
method_args = {}
method_args[:timeline] = timeline
method_args[:from_list_id] = list_id
method_args[:to_list_id] = to_list_id
method_args[:taskseries_id] = taskseries_id
method_args[:task_id] = task_id
method_args.merge( args )
@rtm.call_api_method( :moveTo, method_args )
end
end
#
# class DateSet
#
# def initialize(startDate, rule)
# @startDate = startDate
# @frequency = nil
# @count = nil
# @untilDate = nil
# @byMonth = nil
# @byDay = nil
# @starts = nil
# if not rule.nil? then
# @starts = rule.every == 1 ? 'every' : 'after'
# parseRecurrenceRule(rule.rule)
# end
# end
#
# def parseRecurrenceRule(rule)
#
# if rule =~ /FREQ=(.*?);/ then
# @frequency = $1
# end
#
# if rule =~ /COUNT=(\d*)/ then
# @count = $1.to_i
# end
#
# if rule =~ /UNTIL=(.*?)[;\r]/ then
# @untilDate = DateParser.parse($1)
# end
#
# if rule =~ /INTERVAL=(\d*)/ then
# @interval = $1.to_i
# end
#
# if rule =~ /BYMONTH=(.*?);/ then
# @byMonth = $1
# end
#
# if rule =~ /BYDAY=(.*?);/ then
# @byDay = $1
# #puts "byDay = #{@byDay}"
# end
# end
#
# def to_s
# # after/every FREQ
# puts "UNIMPLETEMENT"
# # puts "#"
# end
#
# def includes?(date)
# return true if date == @startDate
# return false if @untilDate and date > @untilDate
#
# case @frequency
# when 'DAILY'
# #if @untilDate then
# # return (@startDate..@untilDate).include?(date)
# #end
# increment = @interval ? @interval : 1
# d = @startDate
# counter = 0
# until d > date
#
# if @count then
# counter += 1
# if counter >= @count
# return false
# end
# end
#
# d += (increment * SECONDS_PER_DAY)
# if d.day == date.day and
# d.year == date.year and
# d.month == date.month then
# puts "true for start: #{@startDate}, until: #{@untilDate}"
# return true
# end
#
# end
#
# when 'WEEKLY'
# return true if @startDate.wday == date.wday
#
# when 'MONTHLY'
#
# when 'YEARLY'
#
# end
#
# false
# end
#
# attr_reader :frequency
# attr_accessor :startDate
# end
#