Simple Ruby script to sync Basecamp and Things to-dos.

sixone74
2011-11-15T08:57:39Z

.sync_things.yaml

| ---
| url: yoursubdomain.basecamphq.com
| username: your_api_token
| password: X
| current_user: your_current_user_id (pull it out of your edit URL)
| basecamp_project: your_project_id (in URL)

.sync_things.yaml

| ---
| url: yoursubdomain.basecamphq.com
| username: your_api_token
| password: X
| current_user: your_current_user_id (pull it out of your edit URL)
| basecamp_project: your_project_id (in URL)

basecamp.rb

| require 'net/https'
| require 'yaml'
| require 'date'
| require 'time'
|
| begin
| require 'xmlsimple'
| rescue LoadError
| begin
| require 'rubygems'
| require 'xmlsimple'
| rescue LoadError
| abort <<-ERROR
| The 'xml-simple' library could not be loaded. If you have RubyGems installed
| you can install xml-simple by doing "gem install xml-simple".
| ERROR
| end
| end
|
| begin
| require 'active_resource'
| rescue LoadError
| begin
| require 'rubygems'
| require 'active_resource'
| rescue LoadError
| abort <<-ERROR
| The 'active_resource' library could not be loaded. If you have RubyGems
| installed you can install ActiveResource by doing "gem install activeresource".
| ERROR
| end
| end
|
| # = A Ruby library for working with the Basecamp web-services API.
| #
| # For more information about the Basecamp web-services API, visit:
| #
| # http://developer.37signals.com/basecamp
| #
| # NOTE: not all of Basecamp's web-services are accessible via REST. This
| # library provides access to RESTful services via ActiveResource. Services not
| # yet upgraded to REST are accessed via the Basecamp class. Continue reading
| # for more details.
| #
| #
| # == Establishing a Connection
| #
| # The first thing you need to do is establish a connection to Basecamp. This
| # requires your Basecamp site address and your login credentials. Example:
| #
| # Basecamp.establish_connection!('you.grouphub.com', 'username', 'password')
| #
| # This is the same whether you're accessing using the ActiveResource interface,
| # or the legacy interface.
| #
| #
| # == Using the REST interface via ActiveResource
| #
| # The REST interface is accessed via ActiveResource, a popular Ruby library
| # that implements object-relational mapping for REST web-services. For more
| # information on working with ActiveResource, see:
| #
| # * http://api.rubyonrails.org/files/activeresource/README.html
| # * http://api.rubyonrails.org/classes/ActiveResource/Base.html
| #
| #
| # === Finding a Resource
| #
| # Find a specific resource using the +find+ method. Attributes of the resource
| # are available as instance methods on the resulting object. For example, to
| # find a message with the ID of 8675309 and access its title attribute, you
| # would do the following:
| #
| # m = Basecamp::Message.find(8675309)
| # m.title # => 'Jenny'
| #
| # To find all messages for a given project, use find(:all), passing the
| # project_id as a parameter to find. Example:
| #
| # messages = Basecamp::Message.find(:all, params => { :project_id => 1037 })
| # messages.size # => 25
| #
| #
| # === Creating a Resource
| #
| # Create a resource by making a new instance of that resource, setting its
| # attributes, and saving it. If the resource requires a prefix to identify
| # it (as is the case with resources that belong to a sub-resource, such as a
| # project), it should be specified when instantiating the object. Examples:
| #
| # m = Basecamp::Message.new(:project_id => 1037)
| # m.category_id = 7301
| # m.title = 'Message in a bottle'
| # m.body = 'Another lonely day, with no one here but me'
| # m.save # => true
| #
| # c = Basecamp::Comment.new(:post_id => 25874)
| # c.body = 'Did you get those TPS reports?'
| # c.save # => true
| #
| # You can also create a resource using the +create+ method, which will create
| # and save it in one step. Example:
| #
| # Basecamp::TodoItem.create(:todo_list_id => 3422, :contents => 'Do it')
| #
| #
| # === Updating a Resource
| #
| # To update a resource, first find it by its id, change its attributes, and
| # save it. Example:
| #
| # m = Basecamp::Message.find(8675309)
| # m.body = 'Changed'
| # m.save # => true
| #
| #
| # === Deleting a Resource
| #
| # To delete a resource, use the +delete+ method with the ID of the resource
| # you want to delete. Example:
| #
| # Basecamp::Message.delete(1037)
| #
| #
| # === Attaching Files to a Resource
| #
| # If the resource accepts file attachments, the +attachments+ parameter should
| # be an array of Basecamp::Attachment objects. Example:
| #
| # a1 = Basecamp::Attachment.create('primary', File.read('primary.doc'))
| # a2 = Basecamp::Attachment.create('another', File.read('another.doc'))
| #
| # m = Basecamp::Message.new(:project_id => 1037)
| # ...
| # m.attachments = [a1, a2]
| # m.save # => true
| #
| #
| # = Using the non-REST inteface
| #
| # The non-REST interface is accessed via instance methods on the Basecamp
| # class. Ensure you've established a connection, then create a new Basecamp
| # instance and call methods on it. Object attributes are accessible as methods.
| # Example:
| #
| # session = Basecamp.new
| # person = session.person(93832) # => #<Record(person)..>
| # person.first_name # => "Jason"
| #
| class Basecamp
| class Connection #:nodoc:
| def initialize(master)
| @master = master
| @connection = Net::HTTP.new(master.site, master.use_ssl ? 443 : 80)
| @connection.use_ssl = master.use_ssl
| @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if master.use_ssl
| end
|
| def post(path, body, headers = {})
| request = Net::HTTP::Post.new(path, headers.merge('Accept' => 'application/xml'))
| request.basic_auth(@master.user, @master.password)
| @connection.request(request, body)
| end
| end
|
| class Resource < ActiveResource::Base #:nodoc:
| #self.format = ActiveResource::Formats::JSONFormat # for 6.1 capability
| self.format = :xml
| class << self
| def parent_resources(*parents)
| @parent_resources = parents
| end
|
| def element_name
| name.split(/::/).last.underscore
| end
|
| def prefix_source
| if @parent_resources
| @parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id" }.join + '/'
| else
| '/'
| end
| end
|
| def prefix(options = {})
| if options.any?
| options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}" }.join + '/'
| else
| '/'
| end
| end
| end
|
| def prefix_options
| id ? {} : super
| end
| end
|
| class Account < Resource
| end
|
| class People < Resource
| end
|
| class Project < Resource
| end
|
| class Company < Resource
| parent_resources :project
|
| def self.on_project(project_id, options = {})
| find(:all, :params => options.merge(:project_id => project_id))
| end
| end
|
| # == Creating different types of categories
| #
| # The type parameter is required when creating a category. For exampe, to
| # create an attachment category for a particular project:
| #
| # c = Basecamp::Category.new(:project_id => 1037)
| # c.type = 'attachment'
| # c.name = 'Pictures'
| # c.save # => true
| #
| class Category < Resource
| parent_resources :project
|
| def self.all(project_id, options = {})
| find(:all, :params => options.merge(:project_id => project_id))
| end
|
| def self.post_categories(project_id, options = {})
| find(:all, :params => options.merge(:project_id => project_id, :type => 'post'))
| end
|
| def self.attachment_categories(project_id, options = {})
| find(:all, :params => options.merge(:project_id => project_id, :type => 'attachment'))
| end
| end
|
| class Message < Resource
| parent_resources :project
| set_element_name 'post'
|
| # Returns the most recent 25 messages in the given project (and category,
| # if specified). If you need to retrieve older messages, use the archive
| # method instead. Example:
| #
| # Basecamp::Message.recent(1037)
| # Basecamp::Message.recent(1037, :category_id => 7301)
| #
| def self.recent(project_id, options = {})
| find(:all, :params => options.merge(:project_id => project_id))
| end
|
| # Returns a summary of all messages in the given project (and category, if
| # specified). The summary is simply the title and category of the message,
| # as well as the number of attachments (if any). Example:
| #
| # Basecamp::Message.archive(1037)
| # Basecamp::Message.archive(1037, :category_id => 7301)
| #
| def self.archive(project_id, options = {})
| find(:all, :params => options.merge(:project_id => project_id), :from => :archive)
| end
|
| def comments(options = {})
| @comments ||= Comment.find(:all, :params => options.merge(:post_id => id))
| end
| end
|
| # == Creating comments for multiple resources
| #
| # Comments can be created for messages, milestones, and to-dos, identified
| # by the post_id , milestone_id , and todo_item_id
| # params respectively.
| #
| # For example, to create a comment on the message with id #8675309:
| #
| # c = Basecamp::Comment.new(:post_id => 8675309)
| # c.body = 'Great tune'
| # c.save # => true
| #
| # Similarly, to create a comment on a milestone:
| #
| # c = Basecamp::Comment.new(:milestone_id => 8473647)
| # c.body = 'Is this done yet?'
| # c.save # => true
| #
| class Comment < Resource
| parent_resources :todo_item
| end
|
| class TodoList < Resource
| parent_resources :project
|
| # Returns all lists for a project. If complete is true, only completed lists
| # are returned. If complete is false, only uncompleted lists are returned.
| def self.all(project_id, complete = nil)
| filter = case complete
| when nil then "all"
| when true then "finished"
| when false then "pending"
| else raise ArgumentError, "invalid value for `complete'"
| end
|
| find(:all, :params => { :project_id => project_id, :filter => filter })
| end
|
| def todo_items(options = {})
| @todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id))
| end
| end
|
| class TodoItem < Resource
| parent_resources :todo_list
|
| def todo_list(options = {})
| @todo_list ||= TodoList.find(todo_list_id, options)
| end
|
| def time_entries(options = {})
| @time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id))
| end
|
| def comments(options = {})
| @comments ||= Comment.find(:all, :params => options.merge(:todo_item_id => id))
| end
|
| def complete!
| put(:complete)
| end
|
| def uncomplete!
| put(:uncomplete)
| end
| end
|
| class TimeEntry < Resource
| parent_resources :project, :todo_item
|
| def self.all(project_id, page = 0)
| find(:all, :params => { :project_id => project_id, :page => page })
| end
|
| def self.report(options={})
| find(:all, :from => :report, :params => options)
| end
| end
|
| class Category < Resource
| parent_resources :project
| end
|
| class Attachment
| attr_accessor :id, :filename, :content
|
| def self.create(filename, content)
| returning new(filename, content) do |attachment|
| attachment.save
| end
| end
|
| def initialize(filename, content)
| @filename, @content = filename, content
| end
|
| def attributes
| { :file => id, :original_filename => filename }
| end
|
| def to_xml(options = {})
| { :file => attributes }.to_xml(options)
| end
|
| def inspect
| to_s
| end
|
| def save
| response = Basecamp.connection.post('/upload', content, 'Content-Type' => 'application/octet-stream')
|
| if response.code == '200'
| self.id = Hash.from_xml(response.body)['upload']['id']
| true
| else
| raise "Could not save attachment: #{response.message} (#{response.code})"
| end
| end
| end
|
| class Record #:nodoc:
| attr_reader :type
|
| def initialize(type, hash)
| @type, @hash = type, hash
| end
|
| def [](name)
| name = dashify(name)
|
| case @hash[name]
| when Hash then
| @hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array))
| @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) }
| else
| Record.new(name, @hash[name])
| end
| else
| @hash[name]
| end
| end
|
| def id
| @hash['id']
| end
|
| def attributes
| @hash.keys
| end
|
| def respond_to?(sym)
| super || @hash.has_key?(dashify(sym))
| end
|
| def method_missing(sym, *args)
| if args.empty? && !block_given? && respond_to?(sym)
| self[sym]
| else
| super
| end
| end
|
| def to_s
| "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
| end
|
| def inspect
| to_s
| end
|
| private
|
| def dashify(name)
| name.to_s.tr("_", "-")
| end
| end
|
| attr_accessor :use_xml
|
| class << self
| attr_reader :site, :user, :password, :use_ssl
|
| def establish_connection!(site, user, password, use_ssl = false)
| @site = site
| @user = user
| @password = password
| @use_ssl = use_ssl
|
| Resource.user = user
| Resource.password = password
| Resource.site = (use_ssl ? "https" : "http") + "://" + site
|
| @connection = Connection.new(self)
| end
|
| def connection
| @connection || raise('No connection established')
| end
| end
|
| def initialize
| @use_xml = false
| end
|
| # ==========================================================================
| # PEOPLE
| # ==========================================================================
|
| # Return an array of the people in the given company. If the project-id is
| # given, only people who have access to the given project will be returned.
| def people(company_id, project_id=nil)
| url = project_id ? "/projects/#{project_id}" : ""
| url << "/contacts/people/#{company_id}"
| records "person", url
| end
|
| # Return information about the person with the given id
| def person(id)
| record "/contacts/person/#{id}"
| end
|
| # ==========================================================================
| # MILESTONES
| # ==========================================================================
|
| # Returns a list of all milestones for the given project, optionally filtered
| # by whether they are completed, late, or upcoming.
| def milestones(project_id, find = 'all')
| records "milestone", "/projects/#{project_id}/milestones/list", :find => find
| end
|
| # Create a new milestone for the given project. +data+ must be hash of the
| # values to set, including +title+, +deadline+, +responsible_party+, and
| # +notify+.
| def create_milestone(project_id, data)
| create_milestones(project_id, [data]).first
| end
|
| # As #create_milestone, but can create multiple milestones in a single
| # request. The +milestones+ parameter must be an array of milestone values as
| # described in #create_milestone.
| def create_milestones(project_id, milestones)
| records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
| end
|
| # Updates an existing milestone.
| def update_milestone(id, data, move = false, move_off_weekends = false)
| record "/milestones/update/#{id}", :milestone => data,
| :move_upcoming_milestones => move,
| :move_upcoming_milestones_off_weekends => move_off_weekends
| end
|
| # Destroys the milestone with the given id.
| def delete_milestone(id)
| record "/milestones/delete/#{id}"
| end
|
| # Complete the milestone with the given id
| def complete_milestone(id)
| record "/milestones/complete/#{id}"
| end
|
| # Uncomplete the milestone with the given id
| def uncomplete_milestone(id)
| record "/milestones/uncomplete/#{id}"
| end
|
| private
|
| # Make a raw web-service request to Basecamp. This will return a Hash of
| # Arrays of the response, and may seem a little odd to the uninitiated.
| def request(path, parameters = {})
| response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type)
|
| if response.code.to_i / 100 == 2
| result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true)
| typecast_value(result)
| else
| raise "#{response.message} (#{response.code})"
| end
| end
|
| # A convenience method for wrapping the result of a query in a Record
| # object. This assumes that the result is a singleton, not a collection.
| def record(path, parameters={})
| result = request(path, parameters)
| (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
| end
|
| # A convenience method for wrapping the result of a query in Record
| # objects. This assumes that the result is a collection--any singleton
| # result will be wrapped in an array.
| def records(node, path, parameters={})
| result = request(path, parameters).values.first or return []
| result = result[node] or return []
| result = [result] unless Array === result
| result.map { |row| Record.new(node, row) }
| end
|
| def convert_body(body)
| body = use_xml ? body.to_legacy_xml : body.to_yaml
| end
|
| def content_type
| use_xml ? "application/xml" : "application/x-yaml"
| end
|
| def typecast_value(value)
| case value
| when Hash
| if value.has_key?("__content__")
| content = translate_entities(value["__content__"]).strip
| case value["type"]
| when "integer" then content.to_i
| when "boolean" then content == "true"
| when "datetime" then Time.parse(content)
| when "date" then Date.parse(content)
| else content
| end
| # a special case to work-around a bug in XmlSimple. When you have an empty
| # tag that has an attribute, XmlSimple will not add the __content__ key
| # to the returned hash. Thus, we check for the presense of the 'type'
| # attribute to look for empty, typed tags, and simply return nil for
| # their value.
| elsif value.keys == %w(type)
| nil
| elsif value["nil"] == "true"
| nil
| # another special case, introduced by the latest rails, where an array
| # type now exists. This is parsed by XmlSimple as a two-key hash, where
| # one key is 'type' and the other is the actual array value.
| elsif value.keys.length == 2 && value["type"] == "array"
| value.delete("type")
| typecast_value(value)
| else
| value.empty? ? nil : value.inject({}) do |h,(k,v)|
| h[k] = typecast_value(v)
| h
| end
| end
| when Array
| value.map! { |i| typecast_value(i) }
| case value.length
| when 0 then nil
| when 1 then value.first
| else value
| end
| else
| raise "can't typecast #{value.inspect}"
| end
| end
|
| def translate_entities(value)
| value.gsub(/&lt;/, "<").
| gsub(/&gt;/, ">").
| gsub(/&quot;/, '"').
| gsub(/&apos;/, "'").
| gsub(/&amp;/, "&")
| end
| end
|
| # A minor hack to let Xml-Simple serialize symbolic keys in hashes
| class Symbol
| def [](*args)
| to_s[*args]
| end
| end
|
| class Hash
| def to_legacy_xml
| XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
| end
| end

sync_things.rb

| #!/usr/bin/env ruby
| #
| # Biggest problem with this is that it checks everything. Needs
| # to be adjusted to only check N days and/or N tasks on Basecamp.
| #
| # Also has a problem in that Completed always wins. If you have a
| # task marked at completed, then mark it as open again on just one
| # side, it'll mark the other as completed if you run the sync again.
| #
| # All that said, it provides a basic, very rudimentary sync.
| #
| # TODO:
| # - Check Logbook for items if they don't exist in their current
| # current location in Things.
| # - Control how many items to actually check for syncing purposes
| # - Use local cache to determine what syncing has happened in the
| # past and try to be smart about future syncs.
|
| require 'basecamp' # you need to d/l the basecamp.rb file from Basecamp
| require 'appscript' # gem install rb-appscript
| require 'yaml'
|
| conf = YAML.load(File.open(ENV['HOME'] + "/.sync_things.yaml"))
| Basecamp.establish_connection!(conf['url'], conf['username'], conf['password'], true)
|
| bucket_names = {
| :inbox => 1,
| :today => 2,
| :next => 3,
| :someday => 5,
| }
|
| basecamp_bucket = {}
| on_basecamp = {}
|
|
| lists = Basecamp::TodoList.all(conf['basecamp_project'])
| lists.each do |list|
| name = list.name.downcase.to_sym
| basecamp_bucket[name] = {} if basecamp_bucket[name].nil?
| on_basecamp[name] = [] if on_basecamp[name].nil?
| list.todo_items.each do |item|
| begin
| if item.responsible_party_id == conf['current_user']
| on_basecamp[name] << item.content
| basecamp_bucket[name][item.content] = item
| end
| rescue
| # I know, I know...
| end
| end
| end
|
| things = Appscript.app('Things')
| bucket_names.each do |name,key|
| things.lists[key].to_dos.get.each do |to_do|
| idx = on_basecamp[name].index(to_do.name.get) unless on_basecamp[name].nil? # not sure why, but this is sometimes nil
| if !idx.nil?
| puts "attempting to sync [#{to_do.name.get}]"
|
| # check to see if its been completed on Basecamp
| if basecamp_bucket[name][to_do.name.get].completed
| puts " - marking local as completed"
| to_do.status.set(:completed)
| else if to_do.status.get == :completed
| puts " - marking remote as completed"
| basecamp_bucket[name][to_do.name.get].complete!
| end end
|
| on_basecamp[name].delete(to_do.name.get)
| end
| end
| end
|
| # now start adding from basecamp to things
| on_basecamp.each do |bucket_name, entries|
| entries.each do |entry|
| puts "adding new entry [#{entry}]"
| things.lists[bucket_names[bucket_name]].make(
| :new => :to_do,
| :with_properties => {
| :name => entry
| }
| )
| end
| end
|