Simple Ruby script to sync Basecamp and Things to-dos.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 <tt>post_id</tt>, <tt>milestone_id</tt>, and <tt>todo_item_id</tt> | |
# 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(/</, "<"). | |
gsub(/>/, ">"). | |
gsub(/"/, '"'). | |
gsub(/'/, "'"). | |
gsub(/&/, "&") | |
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment