└── src ├── api_config.yml.sample ├── converter.rb └── basecamp.rb /src/api_config.yml.sample: -------------------------------------------------------------------------------- 1 | api_key: 2 | api_secret: 3 | 4 | login: PODIO 5 | password: 6 | 7 | basecamp_url: 8 | basecamp_username: 9 | basecamp_password: -------------------------------------------------------------------------------- /src/converter.rb: -------------------------------------------------------------------------------- 1 | require 'podio' 2 | require 'basecamp' 3 | require 'yaml' 4 | 5 | API_CONFIG = YAML::load(File.open('api_config.yml')) #Podio API config 6 | api_config = API_CONFIG 7 | 8 | Basecamp.establish_connection!(api_config['basecamp_url'], api_config['basecamp_username'], api_config['basecamp_password'], true) 9 | @basecamp = Basecamp.new 10 | 11 | Podio.setup(:api_key => api_config['api_key'], :api_secret => api_config['api_secret'], :debug => true) 12 | Podio.client.authenticate_with_credentials(api_config['login'], api_config['password']) 13 | 14 | def date_converter(date, s) 15 | if s == true 16 | date.to_s[5..-1].gsub!(/ /, '-')+ " 00:00:00" 17 | else 18 | date = date.to_s[0..-5] 19 | end 20 | date 21 | end 22 | 23 | def choose_podio_org 24 | puts 'This script will create one space/project. 25 | Choose the Organization for these spaces now' 26 | Podio::Organization.find_all.each do |org| 27 | puts "\t#{org['org_id']}: #{org['name']}" 28 | end 29 | puts "Org id: " 30 | org_id = gets.chomp 31 | end 32 | 33 | 34 | 35 | def create_user(user, space_id) 36 | Podio::Contact.create_space_contact(space_id, { 37 | :external_id => user['id'].to_s, 38 | :name => "#{user['first-name']} #{user['last-name']}", 39 | :mail => [user['email-address']]}) 40 | 41 | end 42 | 43 | def create_users(project, space_id) 44 | bcusers = @basecamp.people(project.company.id, project.id).inject({}) {|users, user| 45 | users[user['email-address']] = user 46 | users 47 | } 48 | users = Podio::Contact.find_all_for_space(space_id, {:exclude_self => false, 49 | :contact_type => ""}).inject({}) { |users, user| 50 | unless user['mail'].nil? 51 | user['mail'].each do |mail| 52 | if bcusers.has_key?(mail) 53 | users[bcusers[mail]['id']] = user 54 | bcusers.delete(mail) 55 | end 56 | end 57 | end 58 | users 59 | } 60 | bcusers.each do |mail, user| 61 | users[user['id']] = create_user(user, space_id) 62 | end 63 | users 64 | end 65 | 66 | def import_milestones(project) 67 | @basecamp.milestones(project.id).inject({}) {|hash, m| 68 | items = Podio::Item.find_all_by_external_id(@apps['Milestones']['app_id'], m['id']) 69 | if items.count <= 0 #Check doesn't exist 70 | if @users.has_key?(m['responsible-party-id'].to_i) 71 | val = [{:value => @users[m['responsible-party-id']]['profile_id']}] 72 | else 73 | val = [] 74 | end 75 | 76 | id = Podio::Item.create(@apps['Milestones']['app_id'], {:external_id=>m['id'].to_s, 'fields'=>[ 77 | {:external_id=>'title', :values=>[{:value=>m['title']}]}, 78 | {:external_id=>'whens-it-due', :values=>[{'start'=>date_converter(m['created-on'], false)}]}, 79 | {:external_id=>'whos-responsible', :values=>val}]}) 80 | comments = @basecamp.milestone_comments(m['id']) 81 | unless comments.nil? 82 | comments.each { |c| 83 | Podio::Comment.create('item', id.to_s, {:external_id => c[:id].to_s, :value =>"#{c[:body]}\n\nBy: #{@users[c[:author].to_i]['name']}"})} 84 | end 85 | hash[m['id']] = {:item=>m, :podio_id=>id} 86 | else 87 | hash[m['id']] = {:item=>m, :podio_id=>items.all[0]['item_id']} 88 | end 89 | hash 90 | } 91 | end 92 | 93 | def create_or_update(app_id, external_item_id) 94 | if Podio::Item.find_all_by_external_id(app_id, external_item_id).count <= 0 95 | Podio::Item.create(app_id, external_item_id, yield) 96 | else 97 | Podio::Item.update(app_id, external_item_id, yield) 98 | end 99 | end 100 | 101 | 102 | def import_messages(project, milestones) 103 | Basecamp::Message.archive(project_id=project.id).each do |m| 104 | m = Basecamp::Message.find(m.id) 105 | 106 | if Podio::Item.find_all_by_external_id(@apps['Messages']['app_id'], m.id).count <= 0 #Check doesn't exist 107 | unless m.milestone_id == 0 108 | val = [{:value=>milestones[m.milestone_id][:podio_id]}] 109 | else 110 | val = [] 111 | end 112 | if @users.has_key?(m.author_id) 113 | val2 = [{:value => @users[m.author_id]['profile_id']}] 114 | else 115 | val2 = [] 116 | end 117 | 118 | id = Podio::Item.create(@apps['Messages']['app_id'], {:external_id=>m.id.to_s, 'fields'=>[ 119 | {:external_id=>'title', :values=>[{:value=>m.title}]}, 120 | {:external_id=>'body', :values=>[{:value=>m.body}]}, 121 | {:external_id=>'originally-posted', :values=>[:start=>date_converter(m.posted_on, false)]}, 122 | {:external_id=>'categories', :values=>[{:value=>Basecamp::Category.find(m.category_id).name}]}, 123 | {:external_id=>'milestone', :values=>val}, 124 | {:external_id=>'author', :values=>val2} 125 | ]}) 126 | 127 | unless m.comments.nil? 128 | m.comments.each do |c| 129 | Podio::Comment.create('item', id.to_s, {:external_id => c[:id].to_s, 130 | :value =>"#{c[:body]}\n\nBy: #{@users[c[:author].to_i]['name']}"}) 131 | end 132 | end 133 | end 134 | end 135 | end 136 | 137 | 138 | org_id = choose_podio_org().to_i 139 | def import_all(org_id) 140 | spaces = Podio::Space.find_all_for_org(org_id).inject({}) {|obj, x| 141 | obj[x['name']]=x 142 | obj 143 | } 144 | 145 | Basecamp::Project.find(:all).each {|project| 146 | if !spaces.has_key?(project.name) 147 | puts project.name+' not in Podio yet' 148 | spaces[project.name]= Podio::Space.create( 149 | {'org_id'=>org_id, 'name'=>project.name, 150 | 'post_on_new_app' => false, 'post_on_new_member' => false } 151 | ) 152 | space = spaces[project.name] 153 | 154 | else 155 | puts "Already in Podio" 156 | puts project.name 157 | space = spaces[project.name] 158 | end 159 | @apps = Podio::Application.find_all_for_space(space['space_id']).inject({}) { 160 | |hash,app| 161 | if app['status'] == 'active' 162 | hash[app['config']['name']] = app 163 | end 164 | hash 165 | } 166 | unless @apps.has_key?('Messages') 167 | puts "Install these apps to the space first: https://podio.com/store/app/510-todo-list, https://podio.com/store/app/508-milestones, https://podio.com/store/app/509-messages" 168 | end 169 | @users = create_users(project, space['space_id']) 170 | 171 | milestones = import_milestones(project) 172 | import_messages(project, milestones) 173 | #import_tasks(project, milestones) 174 | } 175 | end 176 | import_all(org_id) -------------------------------------------------------------------------------- /src/basecamp.rb: -------------------------------------------------------------------------------- 1 | require 'net/https' 2 | require 'yaml' 3 | require 'date' 4 | require 'time' 5 | 6 | begin 7 | require 'xmlsimple' 8 | rescue LoadError 9 | begin 10 | require 'rubygems' 11 | require 'xmlsimple' 12 | rescue LoadError 13 | abort <<-ERROR 14 | The 'xml-simple' library could not be loaded. If you have RubyGems installed 15 | you can install xml-simple by doing "gem install xml-simple". 16 | ERROR 17 | end 18 | end 19 | 20 | begin 21 | require 'active_resource' 22 | rescue LoadError 23 | begin 24 | require 'rubygems' 25 | require 'active_resource' 26 | rescue LoadError 27 | abort <<-ERROR 28 | The 'activeresource' library could not be loaded. If you have RubyGems 29 | installed you can install ActiveResource by doing "gem install activeresource". 30 | ERROR 31 | end 32 | end 33 | 34 | # = A Ruby library for working with the Basecamp web-services API. 35 | # 36 | # For more information about the Basecamp web-services API, visit: 37 | # 38 | # http://developer.37signals.com/basecamp 39 | # 40 | # NOTE: not all of Basecamp's web-services are accessible via REST. This 41 | # library provides access to RESTful services via ActiveResource. Services not 42 | # yet upgraded to REST are accessed via the Basecamp class. Continue reading 43 | # for more details. 44 | # 45 | # 46 | # == Establishing a Connection 47 | # 48 | # The first thing you need to do is establish a connection to Basecamp. This 49 | # requires your Basecamp site address and your login credentials. Example: 50 | # 51 | # Basecamp.establish_connection!('you.grouphub.com', 'username', 'password') 52 | # 53 | # This is the same whether you're accessing using the ActiveResource interface, 54 | # or the legacy interface. 55 | # 56 | # 57 | # == Using the REST interface via ActiveResource 58 | # 59 | # The REST interface is accessed via ActiveResource, a popular Ruby library 60 | # that implements object-relational mapping for REST web-services. For more 61 | # information on working with ActiveResource, see: 62 | # 63 | # * http://api.rubyonrails.org/files/activeresource/README.html 64 | # * http://api.rubyonrails.org/classes/ActiveResource/Base.html 65 | # 66 | # 67 | # === Finding a Resource 68 | # 69 | # Find a specific resource using the +find+ method. Attributes of the resource 70 | # are available as instance methods on the resulting object. For example, to 71 | # find a message with the ID of 8675309 and access its title attribute, you 72 | # would do the following: 73 | # 74 | # m = Basecamp::Message.find(8675309) 75 | # m.title # => 'Jenny' 76 | # 77 | # To find all messages for a given project, use find(:all), passing the 78 | # project_id as a parameter to find. Example: 79 | # 80 | # messages = Basecamp::Message.find(:all, params => { :project_id => 1037 }) 81 | # messages.size # => 25 82 | # 83 | # 84 | # === Creating a Resource 85 | # 86 | # Create a resource by making a new instance of that resource, setting its 87 | # attributes, and saving it. If the resource requires a prefix to identify 88 | # it (as is the case with resources that belong to a sub-resource, such as a 89 | # project), it should be specified when instantiating the object. Examples: 90 | # 91 | # m = Basecamp::Message.new(:project_id => 1037) 92 | # m.category_id = 7301 93 | # m.title = 'Message in a bottle' 94 | # m.body = 'Another lonely day, with no one here but me' 95 | # m.save # => true 96 | # 97 | # c = Basecamp::Comment.new(:post_id => 25874) 98 | # c.body = 'Did you get those TPS reports?' 99 | # c.save # => true 100 | # 101 | # You can also create a resource using the +create+ method, which will create 102 | # and save it in one step. Example: 103 | # 104 | # Basecamp::TodoItem.create(:todo_list_id => 3422, :contents => 'Do it') 105 | # 106 | # 107 | # === Updating a Resource 108 | # 109 | # To update a resource, first find it by its id, change its attributes, and 110 | # save it. Example: 111 | # 112 | # m = Basecamp::Message.find(8675309) 113 | # m.body = 'Changed' 114 | # m.save # => true 115 | # 116 | # 117 | # === Deleting a Resource 118 | # 119 | # To delete a resource, use the +delete+ method with the ID of the resource 120 | # you want to delete. Example: 121 | # 122 | # Basecamp::Message.delete(1037) 123 | # 124 | # 125 | # === Attaching Files to a Resource 126 | # 127 | # If the resource accepts file attachments, the +attachments+ parameter should 128 | # be an array of Basecamp::Attachment objects. Example: 129 | # 130 | # a1 = Basecamp::Attachment.create('primary', File.read('primary.doc')) 131 | # a2 = Basecamp::Attachment.create('another', File.read('another.doc')) 132 | # 133 | # m = Basecamp::Message.new(:project_id => 1037) 134 | # ... 135 | # m.attachments = [a1, a2] 136 | # m.save # => true 137 | # 138 | # 139 | # = Using the non-REST inteface 140 | # 141 | # The non-REST interface is accessed via instance methods on the Basecamp 142 | # class. Ensure you've established a connection, then create a new Basecamp 143 | # instance and call methods on it. Object attributes are accessible as methods. 144 | # Example: 145 | # 146 | # session = Basecamp.new 147 | # person = session.person(93832) # => # 148 | # person.first_name # => "Jason" 149 | # 150 | class Basecamp 151 | class Connection #:nodoc: 152 | def initialize(master) 153 | @master = master 154 | @connection = Net::HTTP.new(master.site, master.use_ssl ? 443 : 80) 155 | @connection.use_ssl = master.use_ssl 156 | @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if master.use_ssl 157 | end 158 | 159 | def post(path, body, headers = {}) 160 | request = Net::HTTP::Post.new(path, headers.merge('Accept' => 'application/xml')) 161 | request.basic_auth(@master.user, @master.password) 162 | @connection.request(request, body) 163 | end 164 | 165 | def get(path, headers = {}) 166 | request = Net::HTTP::Get.new(path, headers.merge('Accept' => 'application/xml')) 167 | request.basic_auth(@master.user, @master.password) 168 | @connection.request(request) 169 | end 170 | end 171 | 172 | class Resource < ActiveResource::Base #:nodoc: 173 | class << self 174 | def parent_resources(*parents) 175 | @parent_resources = parents 176 | end 177 | 178 | def element_name 179 | name.split(/::/).last.underscore 180 | end 181 | 182 | def prefix_source 183 | if @parent_resources 184 | @parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id" }.join + '/' 185 | else 186 | '/' 187 | end 188 | end 189 | 190 | def prefix(options = {}) 191 | if options.any? 192 | options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}" }.join + '/' 193 | else 194 | '/' 195 | end 196 | end 197 | end 198 | 199 | def prefix_options 200 | id ? {} : super 201 | end 202 | end 203 | 204 | class Project < Resource 205 | end 206 | 207 | class Company < Resource 208 | parent_resources :project 209 | 210 | def self.on_project(project_id, options = {}) 211 | find(:all, :params => options.merge(:project_id => project_id)) 212 | end 213 | end 214 | 215 | # == Creating different types of categories 216 | # 217 | # The type parameter is required when creating a category. For exampe, to 218 | # create an attachment category for a particular project: 219 | # 220 | # c = Basecamp::Category.new(:project_id => 1037) 221 | # c.type = 'attachment' 222 | # c.name = 'Pictures' 223 | # c.save # => true 224 | # 225 | class Category < Resource 226 | parent_resources :project 227 | 228 | def self.all(project_id, options = {}) 229 | find(:all, :params => options.merge(:project_id => project_id)) 230 | end 231 | 232 | def self.post_categories(project_id, options = {}) 233 | find(:all, :params => options.merge(:project_id => project_id, :type => 'post')) 234 | end 235 | 236 | def self.attachment_categories(project_id, options = {}) 237 | find(:all, :params => options.merge(:project_id => project_id, :type => 'attachment')) 238 | end 239 | end 240 | 241 | class Message < Resource 242 | parent_resources :project 243 | set_element_name 'post' 244 | 245 | # Returns the most recent 25 messages in the given project (and category, 246 | # if specified). If you need to retrieve older messages, use the archive 247 | # method instead. Example: 248 | # 249 | # Basecamp::Message.recent(1037) 250 | # Basecamp::Message.recent(1037, :category_id => 7301) 251 | # 252 | def self.recent(project_id, options = {}) 253 | find(:all, :params => options.merge(:project_id => project_id)) 254 | end 255 | 256 | # Returns a summary of all messages in the given project (and category, if 257 | # specified). The summary is simply the title and category of the message, 258 | # as well as the number of attachments (if any). Example: 259 | # 260 | # Basecamp::Message.archive(1037) 261 | # Basecamp::Message.archive(1037, :category_id => 7301) 262 | # 263 | def self.archive(project_id, options = {}) 264 | find(:all, :params => options.merge(:project_id => project_id), :from => :archive) 265 | end 266 | 267 | def comments 268 | response = Basecamp.connection.get("/posts/#{id}/comments.xml") 269 | xml = XmlSimple.xml_in(response.body) 270 | if xml['count'].to_i != 0 271 | comments = xml['comment'].map {|c| 272 | {:author=>c['author-id'].first['content'], :body => c['body'].join('
'), 273 | :id => c['id'].first['content'].to_i} 274 | } 275 | else 276 | comments = nil 277 | end 278 | comments 279 | end 280 | end 281 | 282 | # == Creating comments for multiple resources 283 | # 284 | # Comments can be created for messages, milestones, and to-dos, identified 285 | # by the post_id, milestone_id, and todo_item_id 286 | # params respectively. 287 | # 288 | # For example, to create a comment on the message with id #8675309: 289 | # 290 | # c = Basecamp::Comment.new(:post_id => 8675309) 291 | # c.body = 'Great tune' 292 | # c.save # => true 293 | # 294 | # Similarly, to create a comment on a milestone: 295 | # 296 | # c = Basecamp::Comment.new(:milestone_id => 8473647) 297 | # c.body = 'Is this done yet?' 298 | # c.save # => true 299 | # 300 | class Comment < Resource 301 | parent_resources :post, :milestone, :todo_item 302 | end 303 | 304 | class TodoList < Resource 305 | parent_resources :project 306 | 307 | # Returns all lists for a project. If complete is true, only completed lists 308 | # are returned. If complete is false, only uncompleted lists are returned. 309 | def self.all(project_id, complete = nil) 310 | filter = case complete 311 | when nil then "all" 312 | when true then "finished" 313 | when false then "pending" 314 | else raise ArgumentError, "invalid value for `complete'" 315 | end 316 | 317 | find(:all, :params => { :project_id => project_id, :filter => filter }) 318 | end 319 | 320 | def todo_items(options = {}) 321 | @todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id)) 322 | end 323 | end 324 | 325 | class TodoItem < Resource 326 | parent_resources :todo_list 327 | 328 | def todo_list(options = {}) 329 | @todo_list ||= TodoList.find(todo_list_id, options) 330 | end 331 | 332 | def time_entries(options = {}) 333 | @time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id)) 334 | end 335 | 336 | def comments(options = {}) 337 | response = Basecamp.connection.get("/todo_items/#{id}/comments.xml") 338 | xml = XmlSimple.xml_in(response.body) 339 | if xml['count'].to_i != 0 340 | @comments ||= xml['comment'].map {|c| 341 | # p c 342 | {:author=>c['author-id'].first['content'], :body => c['body'].join('
')} 343 | } 344 | else 345 | comments ||= nil 346 | end 347 | comments 348 | end 349 | 350 | def complete! 351 | put(:complete) 352 | end 353 | 354 | def uncomplete! 355 | put(:uncomplete) 356 | end 357 | end 358 | 359 | class TimeEntry < Resource 360 | parent_resources :project, :todo_item 361 | 362 | def self.all(project_id, page = 0) 363 | find(:all, :params => { :project_id => project_id, :page => page }) 364 | end 365 | 366 | def self.report(options={}) 367 | find(:all, :from => :report, :params => options) 368 | end 369 | end 370 | 371 | class Category < Resource 372 | parent_resources :project 373 | end 374 | 375 | class Attachment 376 | attr_accessor :id, :filename, :content 377 | 378 | def self.create(filename, content) 379 | returning new(filename, content) do |attachment| 380 | attachment.save 381 | end 382 | end 383 | 384 | def get 385 | resp = Basecamp.connection.get(@url).body 386 | end 387 | 388 | def initialize(resp) 389 | @filename = resp['name'] 390 | @url = resp['download_url'] 391 | end 392 | 393 | def attributes 394 | { :file => id, :original_filename => filename } 395 | end 396 | 397 | def to_xml(options = {}) 398 | { :file => attributes }.to_xml(options) 399 | end 400 | 401 | def inspect 402 | to_s 403 | end 404 | 405 | def save 406 | response = Basecamp.connection.post('/upload', content, 'Content-Type' => 'application/octet-stream') 407 | 408 | if response.code == '200' 409 | self.id = Hash.from_xml(response.body)['upload']['id'] 410 | true 411 | else 412 | raise "Could not save attachment: #{response.message} (#{response.code})" 413 | end 414 | end 415 | end 416 | 417 | class Record #:nodoc: 418 | attr_reader :type 419 | 420 | def initialize(type, hash) 421 | #p type, hash 422 | @type, @hash = type, hash 423 | end 424 | 425 | def [](name) 426 | name = dashify(name) 427 | 428 | case @hash[name] 429 | when Hash then 430 | @hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array)) 431 | @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } 432 | else 433 | Record.new(name, @hash[name]) 434 | end 435 | else 436 | @hash[name] 437 | end 438 | end 439 | 440 | def id 441 | @hash['id'] 442 | end 443 | 444 | def attributes 445 | @hash.keys 446 | end 447 | 448 | def respond_to?(sym) 449 | super || @hash.has_key?(dashify(sym)) 450 | end 451 | 452 | def method_missing(sym, *args) 453 | if args.empty? && !block_given? && respond_to?(sym) 454 | self[sym] 455 | else 456 | super 457 | end 458 | end 459 | 460 | def to_s 461 | "\#" 462 | end 463 | 464 | def inspect 465 | to_s 466 | end 467 | 468 | private 469 | 470 | def dashify(name) 471 | name.to_s.tr("_", "-") 472 | end 473 | end 474 | 475 | attr_accessor :use_xml 476 | 477 | class << self 478 | attr_reader :site, :user, :password, :use_ssl 479 | 480 | def establish_connection!(site, user, password, use_ssl = false) 481 | @site = site 482 | @user = user 483 | @password = password 484 | @use_ssl = use_ssl 485 | 486 | Resource.user = user 487 | Resource.password = password 488 | Resource.site = (use_ssl ? "https" : "http") + "://" + site 489 | 490 | @connection = Connection.new(self) 491 | end 492 | 493 | def connection 494 | @connection || raise('No connection established') 495 | end 496 | 497 | def get_token 498 | response = @connection.get('/me.xml') 499 | xml = XmlSimple.xml_in(response.body) 500 | xml['token'][0] 501 | end 502 | end 503 | 504 | def initialize 505 | @use_xml = false 506 | end 507 | 508 | # ========================================================================== 509 | # PEOPLE 510 | # ========================================================================== 511 | 512 | # Return an array of the people in the given company. If the project-id is 513 | # given, only people who have access to the given project will be returned. 514 | def people(company_id, project_id=nil) 515 | url = project_id ? "/projects/#{project_id}" : "" 516 | url << "/contacts/people/#{company_id}" 517 | records "person", url 518 | end 519 | 520 | # Return information about the person with the given id 521 | def person(id) 522 | record "/contacts/person/#{id}" 523 | end 524 | 525 | # ========================================================================== 526 | # MILESTONES 527 | # ========================================================================== 528 | 529 | # Returns a list of all milestones for the given project, optionally filtered 530 | # by whether they are completed, late, or upcoming. 531 | def milestones(project_id, find = 'all') 532 | records "milestone", "/projects/#{project_id}/milestones/list", :find => find 533 | end 534 | def milestone_comments(id) 535 | response = Basecamp.connection.get("/milestones/#{id}/comments.xml") 536 | xml = XmlSimple.xml_in(response.body) 537 | if xml['count'].to_i != 0 538 | comments = xml['comment'].map {|c| 539 | {:author=>c['author-id'].first['content'], :body => c['body'].join('
'), 540 | :id => c['id'].first['content'].to_i} 541 | } 542 | else 543 | comments = nil 544 | end 545 | comments 546 | end 547 | 548 | # Create a new milestone for the given project. +data+ must be hash of the 549 | # values to set, including +title+, +deadline+, +responsible_party+, and 550 | # +notify+. 551 | def create_milestone(project_id, data) 552 | create_milestones(project_id, [data]).first 553 | end 554 | 555 | # As #create_milestone, but can create multiple milestones in a single 556 | # request. The +milestones+ parameter must be an array of milestone values as 557 | # described in #create_milestone. 558 | def create_milestones(project_id, milestones) 559 | records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones 560 | end 561 | 562 | # Updates an existing milestone. 563 | def update_milestone(id, data, move = false, move_off_weekends = false) 564 | record "/milestones/update/#{id}", :milestone => data, 565 | :move_upcoming_milestones => move, 566 | :move_upcoming_milestones_off_weekends => move_off_weekends 567 | end 568 | 569 | # Destroys the milestone with the given id. 570 | def delete_milestone(id) 571 | record "/milestones/delete/#{id}" 572 | end 573 | 574 | # Complete the milestone with the given id 575 | def complete_milestone(id) 576 | record "/milestones/complete/#{id}" 577 | end 578 | 579 | # Uncomplete the milestone with the given id 580 | def uncomplete_milestone(id) 581 | record "/milestones/uncomplete/#{id}" 582 | end 583 | 584 | private 585 | 586 | # Make a raw web-service request to Basecamp. This will return a Hash of 587 | # Arrays of the response, and may seem a little odd to the uninitiated. 588 | def request(path, parameters = {}) 589 | response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type) 590 | 591 | if response.code.to_i / 100 == 2 592 | result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true) 593 | typecast_value(result) 594 | else 595 | raise "#{response.message} (#{response.code})" 596 | end 597 | end 598 | 599 | # A convenience method for wrapping the result of a query in a Record 600 | # object. This assumes that the result is a singleton, not a collection. 601 | def record(path, parameters={}) 602 | result = request(path, parameters) 603 | (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil 604 | end 605 | 606 | # A convenience method for wrapping the result of a query in Record 607 | # objects. This assumes that the result is a collection--any singleton 608 | # result will be wrapped in an array. 609 | def records(node, path, parameters={}) 610 | result = request(path, parameters).values.first or return [] 611 | result = result[node] or return [] 612 | result = [result] unless Array === result 613 | result.map { |row| Record.new(node, row) } 614 | end 615 | 616 | def convert_body(body) 617 | body = use_xml ? body.to_legacy_xml : body.to_yaml 618 | end 619 | 620 | def content_type 621 | use_xml ? "application/xml" : "application/x-yaml" 622 | end 623 | 624 | def typecast_value(value) 625 | case value 626 | when Hash 627 | if value.has_key?("__content__") 628 | content = translate_entities(value["__content__"]).strip 629 | case value["type"] 630 | when "integer" then content.to_i 631 | when "boolean" then content == "true" 632 | when "datetime" then Time.parse(content) 633 | when "date" then Date.parse(content) 634 | else content 635 | end 636 | # a special case to work-around a bug in XmlSimple. When you have an empty 637 | # tag that has an attribute, XmlSimple will not add the __content__ key 638 | # to the returned hash. Thus, we check for the presense of the 'type' 639 | # attribute to look for empty, typed tags, and simply return nil for 640 | # their value. 641 | elsif value.keys == %w(type) 642 | nil 643 | elsif value["nil"] == "true" 644 | nil 645 | # another special case, introduced by the latest rails, where an array 646 | # type now exists. This is parsed by XmlSimple as a two-key hash, where 647 | # one key is 'type' and the other is the actual array value. 648 | elsif value.keys.length == 2 && value["type"] == "array" 649 | value.delete("type") 650 | typecast_value(value) 651 | else 652 | value.empty? ? nil : value.inject({}) do |h,(k,v)| 653 | h[k] = typecast_value(v) 654 | h 655 | end 656 | end 657 | when Array 658 | value.map! { |i| typecast_value(i) } 659 | case value.length 660 | when 0 then nil 661 | when 1 then value.first 662 | else value 663 | end 664 | else 665 | raise "can't typecast #{value.inspect}" 666 | end 667 | end 668 | 669 | def translate_entities(value) 670 | value.gsub(/</, "<"). 671 | gsub(/>/, ">"). 672 | gsub(/"/, '"'). 673 | gsub(/'/, "'"). 674 | gsub(/&/, "&") 675 | end 676 | end 677 | 678 | # A minor hack to let Xml-Simple serialize symbolic keys in hashes 679 | class Symbol 680 | def [](*args) 681 | to_s[*args] 682 | end 683 | end 684 | 685 | class Hash 686 | def to_legacy_xml 687 | XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true) 688 | end 689 | end 690 | --------------------------------------------------------------------------------