Permalink
Browse files

Initial commit

  • Loading branch information...
0 parents commit da905d12a9cb098dedf1ac90b8ccac73aedfab21 @tpope committed Sep 19, 2010
3 .gitignore
@@ -0,0 +1,3 @@
+.bundle
+Gemfile.lock
+pkg/*
2 Gemfile
@@ -0,0 +1,2 @@
+source :gemcutter
+gemspec
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2008 Tim Pope
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
53 README.markdown
@@ -0,0 +1,53 @@
+Rumember
+========
+
+Ruby API and command line client for [Remember The
+Milk](http://www.rememberthemilk.com/).
+
+## Command line usage
+
+The sole motivation for this project was a quick way to capture to-dos
+from the command line. As such, I've chosen a chosen a very short
+command name of `ru` (something I'd normally never allow myself to do).
+All arguments are joined with spaces and used to invoke Remember The
+Milk's [Smart Add](http://www.rememberthemilk.com/services/smartadd/)
+feature.
+
+ ru buy milk #errand
+
+Browser based authentication is triggered the first time `ru` is run,
+and the resulting token is cached in `~/.rtm.yml`.
+
+I was originally planning to add support for the full range of
+operations possible in Remember The Milk, but after pondering the
+interface, this seems unlikely. I just can't imagine myself forgoing
+the web interface in favor of something like:
+
+ ru --complete 142857 # Ain't gonna happen
+
+## API Usage
+
+The API is a bit more fleshed out than the command line interface, but
+still incomplete, under-documented, and under-tested (I have additional
+integration tests I won't publish because they are specific to my RTM
+account). You'll need to familiarize yourself with [Remember The Milk's
+API](http://www.rememberthemilk.com/services/api/). In particular, you
+need to understand what a timeline is.
+
+ interface = Rumember.new(api_key, shared_secret)
+ interface = Rumember.new # Uses built in credentials
+ interface.dispatch('test.echo')
+
+ account = interface.account(auth_token)
+ account = interface.account # browser based and cached
+ account = Rumember.account # Rumember.new.account shortcut
+
+ timeline = account.timeline # cached
+ timeline = account.new_timeline # fresh each time
+
+ timeline.smart_add('buy milk #errand')
+
+ list = timeline.lists.first
+ task = list.tasks.first
+ transaction = task.complete
+ transaction.undo
17 Rakefile
@@ -0,0 +1,17 @@
+begin; require 'rubygems'; rescue LoadError; end
+require 'rake'
+require 'rake/gempackagetask'
+
+spec = eval(File.read(File.join(File.dirname(__FILE__),'rumember.gemspec')))
+Rake::GemPackageTask.new(spec) do |p|
+ p.gem_spec = spec
+end
+
+begin
+ require 'spec/rake/spectask'
+ Spec::Rake::SpecTask.new(:spec) do |t|
+ t.spec_files = FileList["spec/**/*_spec.rb"]
+ end
+ task :default => :spec
+rescue LoadError
+end
6 bin/ru
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+
+$:.unshift(File.join(File.dirname(File.dirname(__FILE__)),'lib'))
+require 'rumember'
+
+Rumember.run(ARGV)
162 lib/rumember.rb
@@ -0,0 +1,162 @@
+class Rumember
+
+ class Error < RuntimeError
+ end
+
+ class ResponseError < Error
+ attr_accessor :code
+ end
+
+ API_KEY = '36f62f69fba7135e8049adbe307ff9ba'
+ SHARED_SECRET = '0c33513097c09be4'
+
+ module Dispatcher
+
+ def dispatch(method, params = {})
+ parent.dispatch(method, self.params.merge(params))
+ end
+
+ def transaction_dispatch(*args)
+ response = dispatch(*args)
+ yield response if block_given?
+ Transaction.new(self, response)
+ end
+
+ def lists
+ dispatch('lists.getList')['lists']['list'].map do |list|
+ List.new(self, list)
+ end
+ end
+
+ def locations
+ dispatch('locations.getList')['locations']['location'].map do |list|
+ Location.new(self, list)
+ end
+ end
+
+ end
+
+ def self.run(argv)
+ if argv.empty?
+ puts "Logged in as #{account.username}"
+ else
+ account.smart_add(argv.join(' '))
+ end
+ rescue Error
+ $stderr.puts "#$!"
+ exit 1
+ rescue Interrupt
+ $stderr.puts "Interrupted!"
+ exit 130
+ end
+
+ attr_reader :api_key, :shared_secret
+
+ def initialize(api_key = API_KEY, shared_secret = SHARED_SECRET)
+ @api_key = api_key
+ @shared_secret = shared_secret
+ end
+
+ def api_sig(params)
+ require 'digest/md5'
+ Digest::MD5.hexdigest(
+ shared_secret + params.sort_by {|k,v| k.to_s}.join
+ )
+ end
+
+ def sign(params)
+ params = params.merge('api_key' => api_key)
+ params.update('api_sig' => api_sig(params))
+ end
+
+ def params(params)
+ require 'cgi'
+ sign(params).map do |k,v|
+ "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}"
+ end.join('&')
+ end
+
+ def auth_url(perms = :delete, extra = {})
+ "http://rememberthemilk.com/services/auth?" +
+ params({'perms' => perms}.merge(extra))
+ end
+
+ def authenticate
+ require 'launchy'
+ frob = dispatch('auth.getFrob')['frob']
+ Launchy.open(auth_url(:delete, 'frob' => frob))
+ first = true
+ begin
+ dispatch('auth.getToken', 'frob' => frob)['auth']
+ rescue ResponseError => e
+ puts e.message unless first
+ puts 'Press enter when authentication is complete'
+ $stdin.gets
+ first = false
+ retry
+ end
+ end
+
+ def reconfigure
+ token = authenticate['token']
+ File.open(self.class.config_file,'w') do |f|
+ f.puts "auth_token: #{token}"
+ end
+ end
+
+ def self.config_file
+ File.expand_path('~/.rtm.yml')
+ end
+
+ def account(auth_token = nil)
+ if auth_token
+ Account.new(self, auth_token)
+ else
+ require 'yaml'
+ @account ||=
+ begin
+ reconfigure unless File.exist?(self.class.config_file)
+ t = YAML.load(File.read(self.class.config_file))['auth_token']
+ account(t)
+ end
+ end
+ end
+
+ alias autoconfigure account
+
+ def self.account
+ @account ||= new.account
+ end
+
+ def url(params)
+ "http://api.rememberthemilk.com/services/rest?#{params(params)}"
+ end
+
+ def dispatch(method, params = {})
+ require 'json'
+ require 'open-uri'
+ raw = open(url(params.merge('method' => "rtm.#{method}", 'format' => 'json'))).read
+ rsp = JSON.parse(raw)['rsp']
+ case rsp['stat']
+ when 'fail'
+ error = ResponseError.new(rsp['err']['msg'])
+ error.code = rsp['err']['code']
+ error.set_backtrace caller
+ raise error
+ when 'ok'
+ rsp.delete('stat')
+ rsp
+ else
+ raise ResponseError.new(rsp.inspect)
+ end
+ end
+
+ autoload :Abstract, 'rumember/abstract'
+ autoload :Account, 'rumember/account'
+ autoload :Timeline, 'rumember/timeline'
+ autoload :Transaction, 'rumember/transaction'
+ autoload :List, 'rumember/list'
+ autoload :Location, 'rumember/location'
+ autoload :Task, 'rumember/task'
+
+end
51 lib/rumember/abstract.rb
@@ -0,0 +1,51 @@
+class Rumember
+ class Abstract
+
+ include Dispatcher
+ attr_reader :parent
+
+ def initialize(parent, attributes)
+ @parent = parent
+ @attributes = attributes
+ end
+
+ def params
+ {}
+ end
+
+ def self.reader(*methods, &block)
+ methods.each do |method|
+ define_method(method) do
+ value = @attributes[method.to_s]
+ if block && !value.nil?
+ yield value
+ else
+ value
+ end
+ end
+ end
+ end
+
+ def self.integer_reader(*methods)
+ reader(*methods) do |value|
+ Integer(value)
+ end
+ end
+
+ def self.boolean_reader(*methods)
+ reader(*methods) do |value|
+ value == '1' ? true : false
+ end
+ methods.each do |method|
+ alias_method "#{method}?", method
+ end
+ end
+
+ def self.time_reader(*methods)
+ reader(*methods) do |value|
+ Time.parse(value) unless value.empty?
+ end
+ end
+
+ end
+end
41 lib/rumember/account.rb
@@ -0,0 +1,41 @@
+class Rumember
+ class Account
+
+ include Dispatcher
+ attr_reader :auth_token
+
+ def initialize(interface, auth_token)
+ @interface = interface
+ @auth_token = auth_token
+ end
+
+ def parent
+ @interface
+ end
+
+ def params
+ {'auth_token' => auth_token}
+ end
+
+ def new_timeline
+ Timeline.new(self)
+ end
+
+ def timeline
+ @timeline ||= new_timeline
+ end
+
+ def smart_add(name)
+ timeline.smart_add(name)
+ end
+
+ def username
+ @username ||= dispatch('auth.checkToken')['auth']['user']['username']
+ end
+
+ def inspect
+ "#<#{self.class.inspect}: #{username}>"
+ end
+
+ end
+end
20 lib/rumember/list.rb
@@ -0,0 +1,20 @@
+class Rumember
+ class List < Abstract
+
+ reader :name, :filter
+ boolean_reader :archived, :deleted, :locked, :smart
+ integer_reader :id, :position
+ alias to_s name
+
+ def params
+ {'list_id' => id}
+ end
+
+ def tasks
+ dispatch('tasks.getList')['tasks']['list']['taskseries'].map do |ts|
+ Task.new(self, ts)
+ end
+ end
+
+ end
+end
11 lib/rumember/location.rb
@@ -0,0 +1,11 @@
+class Rumember
+ class Location < Abstract
+ integer_reader :id, :zoom
+ reader :name, :address
+ boolean_reader :viewable
+ reader :latitude, :longitude do |l|
+ Float(l)
+ end
+ alias to_s name
+ end
+end
96 lib/rumember/task.rb
@@ -0,0 +1,96 @@
+class Rumember
+ class Task < Abstract
+ integer_reader :id, :location_id
+ time_reader :created, :modified
+ reader :name, :source, :url
+ alias taskseries_id id
+
+ def task_id
+ Integer(@attributes['task']['id'])
+ end
+
+ def tags
+ if @attributes['tags'].empty?
+ []
+ else
+ Array(@attributes['tags']['tag'])
+ end
+ end
+
+ def params
+ {'taskseries_id' => taskseries_id, 'task_id' => task_id}
+ end
+
+ def transaction_dispatch(*args)
+ super(*args) do |response|
+ @attributes = response['list']['taskseries']
+ end
+ end
+
+ def delete
+ transaction_dispatch('tasks.delete')
+ end
+
+ def complete
+ transaction_dispatch('tasks.complete')
+ end
+
+ def uncomplete
+ transaction_dispatch('tasks.uncomplete')
+ end
+
+ def postpone
+ transaction_dispatch('tasks.postpone')
+ end
+
+ def add_tags(tags)
+ transaction_dispatch('tasks.addTags', 'tags' => tags)
+ end
+
+ def remove_tags(tags)
+ transaction_dispatch('tasks.removeTags', 'tags' => tags)
+ end
+
+ def raise_priority
+ transaction_dispatch('tasks.movePriority', 'direction' => 'up')
+ end
+
+ def lower_priority
+ transaction_dispatch('tasks.movePriority', 'direction' => 'down')
+ end
+
+ def move_to(list)
+ transaction_dispatch(
+ 'tasks.moveTo',
+ 'from_list_id' => parent.id,
+ 'to_list_id' => list.id
+ ).tap do
+ @parent = list
+ end
+ end
+
+ %w(Estimate Name Priority Recurrence URL).each do |attr|
+ define_method("set_#{attr.downcase}") do |value|
+ transaction_dispatch("tasks.set#{attr}", attr.downcase => value)
+ end
+ alias_method "#{attr.downcase}=", "set_#{attr.downcase}"
+ end
+
+ def set_tags(tags)
+ transaction_dispatch('tasks.setTags', 'tags' => Array(tags).join(' '))
+ end
+ alias tags= set_tags
+
+ def set_due_date(url)
+ transaction_dispatch('tasks.setDueDate', 'url' => url)
+ end
+ alias due_date= set_due_date
+
+ def set_location(id)
+ id = id.location_id if id.respond_to?(:location_id)
+ transaction_dispatch('tasks.setLocation', id)
+ end
+ alias location= set_location
+
+ end
+end
20 lib/rumember/timeline.rb
@@ -0,0 +1,20 @@
+class Rumember
+ class Timeline
+
+ include Dispatcher
+ attr_reader :id, :parent
+
+ def initialize(parent, id = nil)
+ @parent = parent
+ @id = (id || parent.dispatch('timelines.create').fetch('timeline')).to_i
+ end
+
+ def params
+ {'timeline' => id}
+ end
+
+ def smart_add(name)
+ dispatch('tasks.add', 'parse' => 1, 'name' => name)
+ end
+ end
+end
34 lib/rumember/transaction.rb
@@ -0,0 +1,34 @@
+class Rumember
+ class Transaction < Abstract
+
+ def id
+ Integer(@attributes['transaction']['id'])
+ end
+
+ def undoable?
+ @attributes['transaction']['undoable'] == '1'
+ end
+
+ def undone?
+ !!@undone
+ end
+
+ def response
+ @attributes
+ end
+
+ def params
+ {'transaction_id' => id}
+ end
+
+ def undo
+ if undone? || !undoable?
+ false
+ else
+ dispatch('transactions.undo')
+ @undone = true
+ end
+ end
+
+ end
+end
21 rumember.gemspec
@@ -0,0 +1,21 @@
+Gem::Specification.new do |s|
+ s.name = "rumember"
+ s.version = "0.0.0"
+
+ s.summary = "Remember The Milk Ruby API and command line client"
+ s.authors = ["Tim Pope"]
+ s.email = "code@tpope.n"+'et'
+ s.homepage = "http://github.com/tpope/rumember"
+ s.default_executable = "ru"
+ s.executables = ["ru"]
+ s.files = [
+ "README.rdoc",
+ "MIT-LICENSE",
+ "rumember.gemspec",
+ "bin/ru",
+ "lib/rumember.rb",
+ ]
+ s.add_runtime_dependency("json", ["~> 1.4.0"])
+ s.add_runtime_dependency("launchy", ["~> 0.3.0"])
+ s.add_development_dependency("rspec", ["~> 1.3.0"])
+end
33 spec/rumember/timeline_spec.rb
@@ -0,0 +1,33 @@
+require File.expand_path(File.dirname(__FILE__) + '/../../spec/spec_helper')
+
+describe Rumember::Timeline do
+
+ let :interface do
+ stub.as_null_object
+ end
+
+ let :account do
+ Rumember::Account.new(interface, 'key')
+ end
+
+ subject do
+ Rumember::Timeline.new(account, 3)
+ end
+
+ its(:parent) { should == account }
+ its(:params) { should == { 'timeline' => 3 } }
+
+ describe '#smart_add' do
+ it 'should dispatch rtm.tasks.add' do
+ interface.should_receive(:dispatch).with(
+ 'tasks.add',
+ 'auth_token' => 'key',
+ 'name' => 'buy milk',
+ 'parse' => 1,
+ 'timeline' => 3
+ )
+ subject.smart_add('buy milk')
+ end
+ end
+
+end
51 spec/rumember/transaction_spec.rb
@@ -0,0 +1,51 @@
+require File.expand_path(File.dirname(__FILE__) + '/../../spec/spec_helper')
+
+describe Rumember::Transaction do
+
+ let :response do
+ { 'transaction' => { 'id' => '1', 'undoable' => '1' } }
+ end
+
+ let :parent do
+ stub.as_null_object
+ end
+
+ subject do
+ Rumember::Transaction.new(parent, response)
+ end
+
+ its(:parent) { should == parent }
+ its(:response) { should == response }
+ its(:id) { should == 1 }
+ its(:undoable?) { should be_true }
+ its(:params) { should == {'transaction_id' => 1} }
+
+ describe '#undo' do
+ context 'when the transaction is not undoable' do
+ let :response do
+ { 'transaction' => { 'id' => '1', 'undoable' => '0' } }
+ end
+
+ it 'should not dispatch rtm.transactions.undo' do
+ subject.should_not_receive(:dispatch).with('transactions.undo')
+ subject.undo
+ end
+
+ it 'should return false' do
+ subject.undo.should be_false
+ end
+ end
+
+ context 'when the transaction is undoable' do
+ it 'should dispatch rtm.transactions.undo' do
+ subject.should_receive(:dispatch).with('transactions.undo')
+ subject.undo
+ end
+
+ it 'should return true' do
+ subject.undo.should be_true
+ end
+ end
+ end
+
+end
14 spec/rumember_spec.rb
@@ -0,0 +1,14 @@
+require File.expand_path(File.dirname(__FILE__) + '/../spec/spec_helper')
+
+describe Rumember do
+ subject do
+ Rumember.new('abc123', 'BANANAS')
+ end
+
+ describe '#api_sig' do
+ it 'should MD5 the concatenated sorted parameters' do
+ subject.api_sig(:yxz => 'foo', :feg => 'bar', :abc => 'baz').should ==
+ '82044aae4dd676094f23f1ec152159ba'
+ end
+ end
+end
7 spec/spec_helper.rb
@@ -0,0 +1,7 @@
+$LOAD_PATH.unshift(File.join(File.dirname(File.dirname(__FILE__)),'lib'))
+require 'rumember'
+begin; require 'rubygems'; rescue LoadError; end
+require 'spec'
+
+Spec::Runner.configure do |config|
+end

0 comments on commit da905d1

Please sign in to comment.