taskmapper-basecamp 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. data/.document +5 -0
  2. data/.travis.yml +4 -0
  3. data/Gemfile +15 -0
  4. data/Gemfile.lock +52 -0
  5. data/LICENSE +20 -0
  6. data/README.md +63 -0
  7. data/Rakefile +41 -0
  8. data/VERSION +1 -0
  9. data/lib/basecamp/basecamp.rb +655 -0
  10. data/lib/provider/basecamp.rb +39 -0
  11. data/lib/provider/comment.rb +63 -0
  12. data/lib/provider/project.rb +94 -0
  13. data/lib/provider/ticket.rb +162 -0
  14. data/lib/taskmapper-basecamp.rb +5 -0
  15. data/spec/comments_spec.rb +103 -0
  16. data/spec/fixtures/comments/74197051.json +1 -0
  17. data/spec/fixtures/comments/74197051.xml +14 -0
  18. data/spec/fixtures/comments/74197096.json +1 -0
  19. data/spec/fixtures/comments/74197096.xml +14 -0
  20. data/spec/fixtures/comments.json +1 -0
  21. data/spec/fixtures/comments.xml +47 -0
  22. data/spec/fixtures/project_count.json +1 -0
  23. data/spec/fixtures/project_count.xml +5 -0
  24. data/spec/fixtures/projects/5220065.json +1 -0
  25. data/spec/fixtures/projects/5220065.xml +16 -0
  26. data/spec/fixtures/projects/create.json +1 -0
  27. data/spec/fixtures/projects/create.xml +2 -0
  28. data/spec/fixtures/projects.json +1 -0
  29. data/spec/fixtures/projects.xml +19 -0
  30. data/spec/fixtures/todo_items/62509330_todo_item.xml +15 -0
  31. data/spec/fixtures/todo_list_9972756.xml +14 -0
  32. data/spec/fixtures/todo_list_with_items.json +1 -0
  33. data/spec/fixtures/todo_list_with_items.xml +3477 -0
  34. data/spec/fixtures/todo_lists/9972756_items.json +1 -0
  35. data/spec/fixtures/todo_lists/9972756_items.xml +93 -0
  36. data/spec/fixtures/todo_lists/9973518_items.json +1 -0
  37. data/spec/fixtures/todo_lists/9973518_items.xml +98 -0
  38. data/spec/fixtures/todo_lists/create.json +1 -0
  39. data/spec/fixtures/todo_lists/create.xml +14 -0
  40. data/spec/fixtures/todo_lists.json +1 -0
  41. data/spec/fixtures/todo_lists.xml +29 -0
  42. data/spec/projects_spec.rb +83 -0
  43. data/spec/spec.opts +1 -0
  44. data/spec/spec_helper.rb +15 -0
  45. data/spec/taskmapper-basecamp_spec.rb +20 -0
  46. data/spec/tickets_spec.rb +87 -0
  47. data/taskmapper-basecamp.gemspec +100 -0
  48. metadata +201 -0
@@ -0,0 +1,655 @@
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 'active_resource' 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) # => #<Record(person)..>
148
+ # person.first_name # => "Jason"
149
+ #
150
+ class BasecampAPI
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
+ end
165
+
166
+ class Resource < ActiveResource::Base #:nodoc:
167
+ #self.format = ActiveResource::Formats::JSONFormat # for 6.1 capability
168
+ self.format = :xml
169
+ class << self
170
+ def parent_resources(*parents)
171
+ @parent_resources = parents
172
+ end
173
+
174
+ def element_name
175
+ name.split(/::/).last.underscore
176
+ end
177
+
178
+ def prefix_source
179
+ if @parent_resources
180
+ @parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id" }.join + '/'
181
+ else
182
+ '/'
183
+ end
184
+ end
185
+
186
+ def prefix(options = {})
187
+ if options.any?
188
+ options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}" }.join + '/'
189
+ else
190
+ '/'
191
+ end
192
+ end
193
+ end
194
+
195
+ # Commented because it was causing a Missing prefix error when updating a TodoItem
196
+ #def prefix_options
197
+ # id ? {} : super
198
+ #end
199
+ end
200
+
201
+ class Account < Resource
202
+ end
203
+
204
+ class People < Resource
205
+ end
206
+
207
+ class Project < Resource
208
+ end
209
+
210
+ class Company < Resource
211
+ parent_resources :project
212
+
213
+ def self.on_project(project_id, options = {})
214
+ find(:all, :params => options.merge(:project_id => project_id))
215
+ end
216
+ end
217
+
218
+ # == Creating different types of categories
219
+ #
220
+ # The type parameter is required when creating a category. For exampe, to
221
+ # create an attachment category for a particular project:
222
+ #
223
+ # c = Basecamp::Category.new(:project_id => 1037)
224
+ # c.type = 'attachment'
225
+ # c.name = 'Pictures'
226
+ # c.save # => true
227
+ #
228
+ class Category < Resource
229
+ parent_resources :project
230
+
231
+ def self.all(project_id, options = {})
232
+ find(:all, :params => options.merge(:project_id => project_id))
233
+ end
234
+
235
+ def self.post_categories(project_id, options = {})
236
+ find(:all, :params => options.merge(:project_id => project_id, :type => 'post'))
237
+ end
238
+
239
+ def self.attachment_categories(project_id, options = {})
240
+ find(:all, :params => options.merge(:project_id => project_id, :type => 'attachment'))
241
+ end
242
+ end
243
+
244
+ class Message < Resource
245
+ parent_resources :project
246
+ set_element_name 'post'
247
+
248
+ # Returns the most recent 25 messages in the given project (and category,
249
+ # if specified). If you need to retrieve older messages, use the archive
250
+ # method instead. Example:
251
+ #
252
+ # Basecamp::Message.recent(1037)
253
+ # Basecamp::Message.recent(1037, :category_id => 7301)
254
+ #
255
+ def self.recent(project_id, options = {})
256
+ find(:all, :params => options.merge(:project_id => project_id))
257
+ end
258
+
259
+ # Returns a summary of all messages in the given project (and category, if
260
+ # specified). The summary is simply the title and category of the message,
261
+ # as well as the number of attachments (if any). Example:
262
+ #
263
+ # Basecamp::Message.archive(1037)
264
+ # Basecamp::Message.archive(1037, :category_id => 7301)
265
+ #
266
+ def self.archive(project_id, options = {})
267
+ find(:all, :params => options.merge(:project_id => project_id), :from => :archive)
268
+ end
269
+
270
+ def comments(options = {})
271
+ @comments ||= Comment.find(:all, :params => options.merge(:post_id => id))
272
+ end
273
+ end
274
+
275
+ # == Creating comments for multiple resources
276
+ #
277
+ # Comments can be created for messages, milestones, and to-dos, identified
278
+ # by the <tt>post_id</tt>, <tt>milestone_id</tt>, and <tt>todo_item_id</tt>
279
+ # params respectively.
280
+ #
281
+ # For example, to create a comment on the message with id #8675309:
282
+ #
283
+ # c = Basecamp::Comment.new(:post_id => 8675309)
284
+ # c.body = 'Great tune'
285
+ # c.save # => true
286
+ #
287
+ # Similarly, to create a comment on a milestone:
288
+ #
289
+ # c = Basecamp::Comment.new(:milestone_id => 8473647)
290
+ # c.body = 'Is this done yet?'
291
+ # c.save # => true
292
+ #
293
+ class Comment < Resource
294
+ parent_resources :todo_item
295
+ end
296
+
297
+ class TodoList < Resource
298
+ parent_resources :project
299
+
300
+ # Returns all lists for a project. If complete is true, only completed lists
301
+ # are returned. If complete is false, only uncompleted lists are returned.
302
+ def self.all(project_id, complete = nil)
303
+ filter = case complete
304
+ when nil then "all"
305
+ when true then "finished"
306
+ when false then "pending"
307
+ else raise ArgumentError, "invalid value for `complete'"
308
+ end
309
+
310
+ find(:all, :params => { :project_id => project_id, :filter => filter })
311
+ end
312
+
313
+ def todo_items(options = {})
314
+ @todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id))
315
+ end
316
+ end
317
+
318
+ # This resource is to address GET /todo_lists.xml?responsible_party=#{id}
319
+ # To retrieve Todo lists with items inside in one request
320
+ class TodoListWithItems < Resource
321
+ def self.element_name
322
+ "todo_lists"
323
+ end
324
+ end
325
+
326
+ class TodoItem < Resource
327
+ parent_resources :todo_list
328
+
329
+ def todo_list(options = {})
330
+ @todo_list ||= TodoList.find(todo_list_id, options)
331
+ end
332
+
333
+ def time_entries(options = {})
334
+ @time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id))
335
+ end
336
+
337
+ def comments(options = {})
338
+ @comments ||= Comment.find(:all, :params => options.merge(:todo_item_id => id))
339
+ end
340
+
341
+ def complete!
342
+ put(:complete)
343
+ end
344
+
345
+ def uncomplete!
346
+ put(:uncomplete)
347
+ end
348
+ end
349
+
350
+ class TimeEntry < Resource
351
+ parent_resources :project, :todo_item
352
+
353
+ def self.all(project_id, page = 0)
354
+ find(:all, :params => { :project_id => project_id, :page => page })
355
+ end
356
+
357
+ def self.report(options={})
358
+ find(:all, :from => :report, :params => options)
359
+ end
360
+ end
361
+
362
+ class Category < Resource
363
+ parent_resources :project
364
+ end
365
+
366
+ class Attachment
367
+ attr_accessor :id, :filename, :content
368
+
369
+ def self.create(filename, content)
370
+ returning new(filename, content) do |attachment|
371
+ attachment.save
372
+ end
373
+ end
374
+
375
+ def initialize(filename, content)
376
+ @filename, @content = filename, content
377
+ end
378
+
379
+ def attributes
380
+ { :file => id, :original_filename => filename }
381
+ end
382
+
383
+ def to_xml(options = {})
384
+ { :file => attributes }.to_xml(options)
385
+ end
386
+
387
+ def inspect
388
+ to_s
389
+ end
390
+
391
+ def save
392
+ response = Basecamp.connection.post('/upload', content, 'Content-Type' => 'application/octet-stream')
393
+
394
+ if response.code == '200'
395
+ self.id = Hash.from_xml(response.body)['upload']['id']
396
+ true
397
+ else
398
+ raise "Could not save attachment: #{response.message} (#{response.code})"
399
+ end
400
+ end
401
+ end
402
+
403
+ class Record #:nodoc:
404
+ attr_reader :type
405
+
406
+ def initialize(type, hash)
407
+ @type, @hash = type, hash
408
+ end
409
+
410
+ def [](name)
411
+ name = dashify(name)
412
+
413
+ case @hash[name]
414
+ when Hash then
415
+ @hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array))
416
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) }
417
+ else
418
+ Record.new(name, @hash[name])
419
+ end
420
+ else
421
+ @hash[name]
422
+ end
423
+ end
424
+
425
+ def id
426
+ @hash['id']
427
+ end
428
+
429
+ def attributes
430
+ @hash.keys
431
+ end
432
+
433
+ def respond_to?(sym)
434
+ super || @hash.has_key?(dashify(sym))
435
+ end
436
+
437
+ def method_missing(sym, *args)
438
+ if args.empty? && !block_given? && respond_to?(sym)
439
+ self[sym]
440
+ else
441
+ super
442
+ end
443
+ end
444
+
445
+ def to_s
446
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
447
+ end
448
+
449
+ def inspect
450
+ to_s
451
+ end
452
+
453
+ private
454
+
455
+ def dashify(name)
456
+ name.to_s.tr("_", "-")
457
+ end
458
+ end
459
+
460
+ attr_accessor :use_xml
461
+
462
+ class << self
463
+ attr_reader :site, :user, :password, :use_ssl
464
+
465
+ def establish_connection!(site, user, password, use_ssl = false)
466
+ @site = site
467
+ @user = user
468
+ @password = password
469
+ @use_ssl = use_ssl
470
+
471
+ Resource.user = user
472
+ Resource.password = password
473
+ Resource.site = (use_ssl ? "https" : "http") + "://" + site
474
+
475
+ @connection = Connection.new(self)
476
+ end
477
+
478
+ def connection
479
+ @connection || raise('No connection established')
480
+ end
481
+ end
482
+
483
+ def initialize
484
+ @use_xml = false
485
+ end
486
+
487
+ # ==========================================================================
488
+ # PEOPLE
489
+ # ==========================================================================
490
+
491
+ # Return an array of the people in the given company. If the project-id is
492
+ # given, only people who have access to the given project will be returned.
493
+ def people(company_id, project_id=nil)
494
+ url = project_id ? "/projects/#{project_id}" : ""
495
+ url << "/contacts/people/#{company_id}"
496
+ records "person", url
497
+ end
498
+
499
+ # Return information about the person with the given id
500
+ def person(id)
501
+ record "/contacts/person/#{id}"
502
+ end
503
+
504
+ # ==========================================================================
505
+ # MILESTONES
506
+ # ==========================================================================
507
+
508
+ # Returns a list of all milestones for the given project, optionally filtered
509
+ # by whether they are completed, late, or upcoming.
510
+ def milestones(project_id, find = 'all')
511
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
512
+ end
513
+
514
+ # Create a new milestone for the given project. +data+ must be hash of the
515
+ # values to set, including +title+, +deadline+, +responsible_party+, and
516
+ # +notify+.
517
+ def create_milestone(project_id, data)
518
+ create_milestones(project_id, [data]).first
519
+ end
520
+
521
+ # As #create_milestone, but can create multiple milestones in a single
522
+ # request. The +milestones+ parameter must be an array of milestone values as
523
+ # described in #create_milestone.
524
+ def create_milestones(project_id, milestones)
525
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
526
+ end
527
+
528
+ # Updates an existing milestone.
529
+ def update_milestone(id, data, move = false, move_off_weekends = false)
530
+ record "/milestones/update/#{id}", :milestone => data,
531
+ :move_upcoming_milestones => move,
532
+ :move_upcoming_milestones_off_weekends => move_off_weekends
533
+ end
534
+
535
+ # Destroys the milestone with the given id.
536
+ def delete_milestone(id)
537
+ record "/milestones/delete/#{id}"
538
+ end
539
+
540
+ # Complete the milestone with the given id
541
+ def complete_milestone(id)
542
+ record "/milestones/complete/#{id}"
543
+ end
544
+
545
+ # Uncomplete the milestone with the given id
546
+ def uncomplete_milestone(id)
547
+ record "/milestones/uncomplete/#{id}"
548
+ end
549
+
550
+ private
551
+
552
+ # Make a raw web-service request to Basecamp. This will return a Hash of
553
+ # Arrays of the response, and may seem a little odd to the uninitiated.
554
+ def request(path, parameters = {})
555
+ response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type)
556
+
557
+ if response.code.to_i / 100 == 2
558
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true)
559
+ typecast_value(result)
560
+ else
561
+ raise "#{response.message} (#{response.code})"
562
+ end
563
+ end
564
+
565
+ # A convenience method for wrapping the result of a query in a Record
566
+ # object. This assumes that the result is a singleton, not a collection.
567
+ def record(path, parameters={})
568
+ result = request(path, parameters)
569
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
570
+ end
571
+
572
+ # A convenience method for wrapping the result of a query in Record
573
+ # objects. This assumes that the result is a collection--any singleton
574
+ # result will be wrapped in an array.
575
+ def records(node, path, parameters={})
576
+ result = request(path, parameters).values.first or return []
577
+ result = result[node] or return []
578
+ result = [result] unless Array === result
579
+ result.map { |row| Record.new(node, row) }
580
+ end
581
+
582
+ def convert_body(body)
583
+ body = use_xml ? body.to_legacy_xml : body.to_yaml
584
+ end
585
+
586
+ def content_type
587
+ use_xml ? "application/xml" : "application/x-yaml"
588
+ end
589
+
590
+ def typecast_value(value)
591
+ case value
592
+ when Hash
593
+ if value.has_key?("__content__")
594
+ content = translate_entities(value["__content__"]).strip
595
+ case value["type"]
596
+ when "integer" then content.to_i
597
+ when "boolean" then content == "true"
598
+ when "datetime" then Time.parse(content)
599
+ when "date" then Date.parse(content)
600
+ else content
601
+ end
602
+ # a special case to work-around a bug in XmlSimple. When you have an empty
603
+ # tag that has an attribute, XmlSimple will not add the __content__ key
604
+ # to the returned hash. Thus, we check for the presense of the 'type'
605
+ # attribute to look for empty, typed tags, and simply return nil for
606
+ # their value.
607
+ elsif value.keys == %w(type)
608
+ nil
609
+ elsif value["nil"] == "true"
610
+ nil
611
+ # another special case, introduced by the latest rails, where an array
612
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
613
+ # one key is 'type' and the other is the actual array value.
614
+ elsif value.keys.length == 2 && value["type"] == "array"
615
+ value.delete("type")
616
+ typecast_value(value)
617
+ else
618
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
619
+ h[k] = typecast_value(v)
620
+ h
621
+ end
622
+ end
623
+ when Array
624
+ value.map! { |i| typecast_value(i) }
625
+ case value.length
626
+ when 0 then nil
627
+ when 1 then value.first
628
+ else value
629
+ end
630
+ else
631
+ raise "can't typecast #{value.inspect}"
632
+ end
633
+ end
634
+
635
+ def translate_entities(value)
636
+ value.gsub(/&lt;/, "<").
637
+ gsub(/&gt;/, ">").
638
+ gsub(/&quot;/, '"').
639
+ gsub(/&apos;/, "'").
640
+ gsub(/&amp;/, "&")
641
+ end
642
+ end
643
+
644
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
645
+ class Symbol
646
+ def [](*args)
647
+ to_s[*args]
648
+ end
649
+ end
650
+
651
+ class Hash
652
+ def to_legacy_xml
653
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
654
+ end
655
+ end
@@ -0,0 +1,39 @@
1
+ module TaskMapper::Provider
2
+ # This is the Basecamp Provider for taskmapper
3
+ module Basecamp
4
+ include TaskMapper::Provider::Base
5
+
6
+ # This is for cases when you want to instantiate using TaskMapper::Provider::Basecamp.new(auth)
7
+ def self.new(auth = {})
8
+ TaskMapper.new(:basecamp, auth)
9
+ end
10
+
11
+ def authorize(auth = {})
12
+ auth[:ssl] = true
13
+ @authentication ||= TaskMapper::Authenticator.new(auth)
14
+ auth = @authentication
15
+ if (auth.domain.nil? and auth.subdomain.nil?) or (auth.token.nil? and (auth.username.nil? and auth.password.nil?))
16
+ raise "Please provide at least an domain and token or username and password)"
17
+ end
18
+ unless auth.token.nil?
19
+ auth.username = auth.token
20
+ auth.password = 'Basecamp'
21
+ end
22
+ if auth.domain.nil? and auth.subdomain
23
+ auth.domain = (auth.subdomain.include?('.') ? auth.subdomain : auth.subdomain + '.basecamphq.com')
24
+ end
25
+ BasecampAPI.establish_connection!(auth.domain, auth.username, auth.password, auth.ssl)
26
+ end
27
+
28
+ def valid?
29
+ begin
30
+ !project_count = BasecampAPI::People.find(:me).nil?
31
+ rescue
32
+ false
33
+ end
34
+ end
35
+
36
+ end
37
+ end
38
+
39
+