I had this conversation on Twitter yesterday.
That's fine. 37Signals is known for saying no to things in the interest of a better product. A migrator for BCX isn't exactly something that would add enough value to the product to warrant spending their time on it.
It's cool.
Don't worry, I got this
I built one. It only took a couple hours to throw together and clean up. It's super basic, but it did the job for me. It handles todolists with their todos and comments, but no attachments.
require 'httparty' | |
require 'active_support/all' | |
module PartyPooper | |
def get(url, options = {}) | |
self.class.get("/#{account_id}/api/v1/#{url}", options.merge(extras)) | |
rescue => boom | |
puts boom.message | |
retry | |
end | |
def post(url, data, options = {}) | |
self.class.post("/#{account_id}/api/v1/#{url}", options.merge(extras(post_headers)).merge(body: data.to_json)) | |
rescue => boom | |
puts boom.message | |
retry | |
end | |
def put(url, data, options = {}) | |
self.class.put("/#{account_id}/api/v1/#{url}", options.merge(extras(post_headers)).merge(body: data.to_json)) | |
rescue => boom | |
puts boom.message | |
retry | |
end | |
def extras(h = headers) | |
{ basic_auth: basic_auth, headers: h } | |
end | |
def basic_auth | |
{ username: username, password: password } | |
end | |
def headers | |
{ 'User-Agent' => user_agent } | |
end | |
def post_headers | |
headers.merge('Content-Type' => 'application/json') | |
end | |
end | |
Client = Struct.new(:username, :password, :user_agent, :account_id, :project_id) do | |
include HTTParty | |
include PartyPooper | |
base_uri 'https://basecamp.com' | |
def todo_lists | |
get("/projects/#{project_id}/todolists.json") | |
end | |
def completed_todo_lists | |
get("/projects/#{project_id}/todolists/completed.json") | |
end | |
def todo_list(id) | |
get("/projects/#{project_id}/todolists/#{id}.json") | |
end | |
def create_todo_list(data) | |
post("/projects/#{project_id}/todolists.json", data) | |
end | |
def todos(list) | |
get("/projects/#{project_id}/todolists/#{list}.json") | |
end | |
def todo(id) | |
get("/projects/#{project_id}/todos/#{id}.json") | |
end | |
def create_todo(list, data) | |
post("/projects/#{project_id}/todolists/#{list}/todos.json", data) | |
end | |
def complete_todo(id) | |
put("/projects/#{project_id}/todos/#{id}.json", completed: true) | |
end | |
def create_todo_comment(id, data) | |
post("/projects/#{project_id}/todos/#{id}/comments.json", data) | |
end | |
end | |
Migrator = Struct.new(:from, :to, :log) do | |
def migrate | |
lists.each { |list| migrate_list(list) } | |
end | |
def migrate_list(list) | |
log.info("Migrating #{list['name']}") | |
destination = to.create_todo_list(list.slice('name')) | |
todos_from_list(list).each { |todo| migrate_todo(todo, destination) } | |
end | |
def migrate_todo(todo, destination) | |
log.info("\tMigrating todo #{todo['content']}") | |
todo = from.todo(todo['id']) | |
ctodo = to.create_todo(destination['id'], todo.slice('content')) | |
comments = comments_from_todo(todo) | |
if comments.present? | |
log.info("\tMigrating #{comments.count} comments") | |
comments.each { |comment| migrate_comment(comment, ctodo) } | |
end | |
complete_todo(ctodo) if todo['completed'] | |
end | |
def complete_todo(todo) | |
log.info("\tCompleting #{todo['content']}") | |
to.complete_todo(todo['id']) | |
end | |
def comments_from_todo(todo) | |
Array(todo['comments']) | |
end | |
def migrate_comment(comment, todo) | |
to.create_todo_comment(todo['id'], comment.slice('content')) | |
end | |
def lists | |
from.completed_todo_lists.reverse + from.todo_lists.reverse | |
end | |
def todos_from_list(list) | |
todos = from.todo_list(list['id'])['todos'] | |
Array(todos['remaining']) + Array(todos['completed']) | |
end | |
end |
require 'logger' | |
require_relative 'client' | |
username = 'username' | |
password = 'password' | |
user_agent = 'BCX Mover (email@example.com)' | |
from_account_id = 1234 | |
from_project_id = 2345 | |
to_account_id = 5678 | |
to_project_id = 6789 | |
from = Client.new(username, password, user_agent, from_account_id, from_project_id) | |
to = Client.new(username, password, user_agent, to_account_id, to_project_id) | |
log = Logger.new(STDOUT) | |
log.formatter = Logger::Formatter.new | |
Migrator.new(from, to, log).migrate |
It's not my best code, but whatever it works. The rescue
/retry
stuff was a last minute eye roller because I got an SSL error of sorts. Just keep an eye on it if it gets out of hand. Completed lists are migrated as well, so you have a record of that stuff too.
It migrated my 800+ todos in my Basecamp Next project to Basecamp Personal in about 24 minutes. Now I can save some bucks in the long run.
Happy migrating!