basecamp 0.0.5 → 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +45 -16
- data/lib/basecamp.rb +17 -504
- data/lib/basecamp/base.rb +116 -0
- data/lib/basecamp/connection.rb +20 -0
- data/lib/basecamp/hash.rb +5 -0
- data/lib/basecamp/record.rb +56 -0
- data/lib/basecamp/resource.rb +31 -0
- data/lib/basecamp/resources/attachment.rb +36 -0
- data/lib/basecamp/resources/category.rb +25 -0
- data/lib/basecamp/resources/comment.rb +22 -0
- data/lib/basecamp/resources/company.rb +7 -0
- data/lib/basecamp/resources/message.rb +30 -0
- data/lib/basecamp/resources/milestone.rb +45 -0
- data/lib/basecamp/resources/person.rb +8 -0
- data/lib/basecamp/resources/project.rb +5 -0
- data/lib/basecamp/resources/time_entry.rb +11 -0
- data/lib/basecamp/resources/todo_item.rb +23 -0
- data/lib/basecamp/resources/todo_list.rb +20 -0
- data/lib/basecamp/symbol.rb +6 -0
- metadata +20 -3
data/README.rdoc
CHANGED
@@ -15,8 +15,21 @@ for more details.
|
|
15
15
|
|
16
16
|
== Installation
|
17
17
|
|
18
|
+
Install the gem
|
19
|
+
|
18
20
|
gem install basecamp
|
19
21
|
|
22
|
+
Include the system gems and require the library in your script
|
23
|
+
|
24
|
+
require 'rubygems'
|
25
|
+
require 'basecamp'
|
26
|
+
|
27
|
+
=== Dependencies
|
28
|
+
|
29
|
+
* activeresource >= 2.3.0
|
30
|
+
* xml-simple
|
31
|
+
* oauth2
|
32
|
+
|
20
33
|
== Establishing a Connection
|
21
34
|
|
22
35
|
The first thing you need to do is establish a connection to Basecamp. This
|
@@ -27,13 +40,6 @@ requires your Basecamp site address and your login credentials. Example:
|
|
27
40
|
This is the same whether you're accessing using the ActiveResource interface,
|
28
41
|
or the legacy interface.
|
29
42
|
|
30
|
-
== Getting API token
|
31
|
-
|
32
|
-
If you only have username/password you can get the API token to use in future calls.
|
33
|
-
|
34
|
-
Basecamp.establish_connection!('you.grouphub.com', 'username', 'password')
|
35
|
-
Basecamp.get_token
|
36
|
-
|
37
43
|
== Using the REST interface via ActiveResource
|
38
44
|
|
39
45
|
The REST interface is accessed via ActiveResource, a popular Ruby library
|
@@ -43,7 +49,6 @@ information on working with ActiveResource, see:
|
|
43
49
|
* http://api.rubyonrails.org/files/activeresource/README.html
|
44
50
|
* http://api.rubyonrails.org/classes/ActiveResource/Base.html
|
45
51
|
|
46
|
-
|
47
52
|
=== Finding a Resource
|
48
53
|
|
49
54
|
Find a specific resource using the +find+ method. Attributes of the resource
|
@@ -60,6 +65,21 @@ project_id as a parameter to find. Example:
|
|
60
65
|
messages = Basecamp::Message.find(:all, params => { :project_id => 1037 })
|
61
66
|
messages.size # => 25
|
62
67
|
|
68
|
+
To get the current logged in user:
|
69
|
+
|
70
|
+
Basecamp::Person.me
|
71
|
+
|
72
|
+
Note: You can access the API token using this object. This is useful if you only have username/password and you want to use the API token in future calls:
|
73
|
+
|
74
|
+
Basecamp::Person.me.token
|
75
|
+
|
76
|
+
To get all people by company:
|
77
|
+
|
78
|
+
Basecamp::Person.find(:all, :params => {:company_id => company.id})
|
79
|
+
|
80
|
+
To get all people by project:
|
81
|
+
|
82
|
+
Basecamp::Person.find(:all, :params => {:project_id => project.id})
|
63
83
|
|
64
84
|
=== Creating a Resource
|
65
85
|
|
@@ -115,20 +135,29 @@ be an array of Basecamp::Attachment objects. Example:
|
|
115
135
|
m.attachments = [a1, a2]
|
116
136
|
m.save # => true
|
117
137
|
|
138
|
+
=== Milestones
|
139
|
+
|
140
|
+
Is not implemented as an active resource, it uses the non-REST interface.
|
141
|
+
Browse the source (lib/basecamp/resources/milestone) to see all available methods.
|
142
|
+
|
143
|
+
For example, to list all milestones in a project:
|
118
144
|
|
119
|
-
=
|
145
|
+
milestones = Basecamp::Milestone.list(1037)
|
146
|
+
milestones.first.title # => "The Milestone"
|
120
147
|
|
121
|
-
|
122
|
-
class. Ensure you've established a connection, then create a new Basecamp
|
123
|
-
instance and call methods on it. Object attributes are accessible as methods.
|
124
|
-
Example:
|
148
|
+
= Using the non-REST interface
|
125
149
|
|
126
|
-
|
127
|
-
|
150
|
+
You can access other resources not included in this wrapper yet using "record" and "records".
|
151
|
+
|
152
|
+
person = Basecamp.record("/contacts/person/93832")
|
128
153
|
person.first_name # => "Jason"
|
129
154
|
|
155
|
+
people_in_company = Basecamp.records("person", "/contacts/people/85")
|
156
|
+
people_in_company.first.first_name # => "Jason"
|
157
|
+
|
130
158
|
== Contributors
|
131
159
|
|
132
160
|
* jamesarosen
|
133
161
|
* defeated
|
134
|
-
* justinbarry
|
162
|
+
* justinbarry
|
163
|
+
* fmiopensource
|
data/lib/basecamp.rb
CHANGED
@@ -5,507 +5,20 @@ require 'time'
|
|
5
5
|
require 'active_resource'
|
6
6
|
require 'xmlsimple'
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
request.basic_auth(@master.user, @master.password)
|
26
|
-
@connection.request(request)
|
27
|
-
end
|
28
|
-
end
|
29
|
-
|
30
|
-
class Resource < ActiveResource::Base #:nodoc:
|
31
|
-
class << self
|
32
|
-
def parent_resources(*parents)
|
33
|
-
@parent_resources = parents
|
34
|
-
end
|
35
|
-
|
36
|
-
def element_name
|
37
|
-
name.split(/::/).last.underscore
|
38
|
-
end
|
39
|
-
|
40
|
-
def prefix_source
|
41
|
-
if @parent_resources
|
42
|
-
@parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id" }.join + '/'
|
43
|
-
else
|
44
|
-
'/'
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
def prefix(options = {})
|
49
|
-
if options.any?
|
50
|
-
options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}" }.join + '/'
|
51
|
-
else
|
52
|
-
'/'
|
53
|
-
end
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
def prefix_options
|
58
|
-
id ? {} : super
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
class Project < Resource
|
63
|
-
def time_entries(options = {})
|
64
|
-
@time_entries ||= TimeEntry.find(:all, :params => options.merge(:project_id => id))
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
class Company < Resource
|
69
|
-
parent_resources :project
|
70
|
-
|
71
|
-
def self.on_project(project_id, options = {})
|
72
|
-
find(:all, :params => options.merge(:project_id => project_id))
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
# == Creating different types of categories
|
77
|
-
#
|
78
|
-
# The type parameter is required when creating a category. For exampe, to
|
79
|
-
# create an attachment category for a particular project:
|
80
|
-
#
|
81
|
-
# c = Basecamp::Category.new(:project_id => 1037)
|
82
|
-
# c.type = 'attachment'
|
83
|
-
# c.name = 'Pictures'
|
84
|
-
# c.save # => true
|
85
|
-
#
|
86
|
-
class Category < Resource
|
87
|
-
parent_resources :project
|
88
|
-
|
89
|
-
def self.all(project_id, options = {})
|
90
|
-
find(:all, :params => options.merge(:project_id => project_id))
|
91
|
-
end
|
92
|
-
|
93
|
-
def self.post_categories(project_id, options = {})
|
94
|
-
find(:all, :params => options.merge(:project_id => project_id, :type => 'post'))
|
95
|
-
end
|
96
|
-
|
97
|
-
def self.attachment_categories(project_id, options = {})
|
98
|
-
find(:all, :params => options.merge(:project_id => project_id, :type => 'attachment'))
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
class Message < Resource
|
103
|
-
parent_resources :project
|
104
|
-
set_element_name 'post'
|
105
|
-
|
106
|
-
# Returns the most recent 25 messages in the given project (and category,
|
107
|
-
# if specified). If you need to retrieve older messages, use the archive
|
108
|
-
# method instead. Example:
|
109
|
-
#
|
110
|
-
# Basecamp::Message.recent(1037)
|
111
|
-
# Basecamp::Message.recent(1037, :category_id => 7301)
|
112
|
-
#
|
113
|
-
def self.recent(project_id, options = {})
|
114
|
-
find(:all, :params => options.merge(:project_id => project_id))
|
115
|
-
end
|
116
|
-
|
117
|
-
# Returns a summary of all messages in the given project (and category, if
|
118
|
-
# specified). The summary is simply the title and category of the message,
|
119
|
-
# as well as the number of attachments (if any). Example:
|
120
|
-
#
|
121
|
-
# Basecamp::Message.archive(1037)
|
122
|
-
# Basecamp::Message.archive(1037, :category_id => 7301)
|
123
|
-
#
|
124
|
-
def self.archive(project_id, options = {})
|
125
|
-
find(:all, :params => options.merge(:project_id => project_id), :from => :archive)
|
126
|
-
end
|
127
|
-
|
128
|
-
def comments(options = {})
|
129
|
-
@comments ||= Comment.find(:all, :params => options.merge(:post_id => id))
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
|
-
# == Creating comments for multiple resources
|
134
|
-
#
|
135
|
-
# Comments can be created for messages, milestones, and to-dos, identified
|
136
|
-
# by the <tt>post_id</tt>, <tt>milestone_id</tt>, and <tt>todo_item_id</tt>
|
137
|
-
# params respectively.
|
138
|
-
#
|
139
|
-
# For example, to create a comment on the message with id #8675309:
|
140
|
-
#
|
141
|
-
# c = Basecamp::Comment.new(:post_id => 8675309)
|
142
|
-
# c.body = 'Great tune'
|
143
|
-
# c.save # => true
|
144
|
-
#
|
145
|
-
# Similarly, to create a comment on a milestone:
|
146
|
-
#
|
147
|
-
# c = Basecamp::Comment.new(:milestone_id => 8473647)
|
148
|
-
# c.body = 'Is this done yet?'
|
149
|
-
# c.save # => true
|
150
|
-
#
|
151
|
-
class Comment < Resource
|
152
|
-
parent_resources :post, :milestone, :todo_item
|
153
|
-
end
|
154
|
-
|
155
|
-
class TodoList < Resource
|
156
|
-
parent_resources :project
|
157
|
-
|
158
|
-
# Returns all lists for a project. If complete is true, only completed lists
|
159
|
-
# are returned. If complete is false, only uncompleted lists are returned.
|
160
|
-
def self.all(project_id, complete = nil)
|
161
|
-
filter = case complete
|
162
|
-
when nil then "all"
|
163
|
-
when true then "finished"
|
164
|
-
when false then "pending"
|
165
|
-
else raise ArgumentError, "invalid value for `complete'"
|
166
|
-
end
|
167
|
-
|
168
|
-
find(:all, :params => { :project_id => project_id, :filter => filter })
|
169
|
-
end
|
170
|
-
|
171
|
-
def todo_items(options = {})
|
172
|
-
@todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id))
|
173
|
-
end
|
174
|
-
end
|
175
|
-
|
176
|
-
class TodoItem < Resource
|
177
|
-
parent_resources :todo_list
|
178
|
-
|
179
|
-
def todo_list(options = {})
|
180
|
-
@todo_list ||= TodoList.find(todo_list_id, options)
|
181
|
-
end
|
182
|
-
|
183
|
-
def time_entries(options = {})
|
184
|
-
@time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id))
|
185
|
-
end
|
186
|
-
|
187
|
-
def comments(options = {})
|
188
|
-
@comments ||= Comment.find(:all, :params => options.merge(:todo_item_id => id))
|
189
|
-
end
|
190
|
-
|
191
|
-
def complete!
|
192
|
-
put(:complete)
|
193
|
-
end
|
194
|
-
|
195
|
-
def uncomplete!
|
196
|
-
put(:uncomplete)
|
197
|
-
end
|
198
|
-
end
|
199
|
-
|
200
|
-
class TimeEntry < Resource
|
201
|
-
parent_resources :project, :todo_item
|
202
|
-
|
203
|
-
def self.all(project_id, page = 0)
|
204
|
-
find(:all, :params => { :project_id => project_id, :page => page })
|
205
|
-
end
|
206
|
-
|
207
|
-
def self.report(options={})
|
208
|
-
find(:all, :from => :report, :params => options)
|
209
|
-
end
|
210
|
-
end
|
211
|
-
|
212
|
-
class Category < Resource
|
213
|
-
parent_resources :project
|
214
|
-
end
|
215
|
-
|
216
|
-
class Attachment
|
217
|
-
attr_accessor :id, :filename, :content
|
218
|
-
|
219
|
-
def self.create(filename, content)
|
220
|
-
returning new(filename, content) do |attachment|
|
221
|
-
attachment.save
|
222
|
-
end
|
223
|
-
end
|
224
|
-
|
225
|
-
def initialize(filename, content)
|
226
|
-
@filename, @content = filename, content
|
227
|
-
end
|
228
|
-
|
229
|
-
def attributes
|
230
|
-
{ :file => id, :original_filename => filename }
|
231
|
-
end
|
232
|
-
|
233
|
-
def to_xml(options = {})
|
234
|
-
{ :file => attributes }.to_xml(options)
|
235
|
-
end
|
236
|
-
|
237
|
-
def inspect
|
238
|
-
to_s
|
239
|
-
end
|
240
|
-
|
241
|
-
def save
|
242
|
-
response = Basecamp.connection.post('/upload', content, 'Content-Type' => 'application/octet-stream')
|
243
|
-
|
244
|
-
if response.code == '200'
|
245
|
-
self.id = Hash.from_xml(response.body)['upload']['id']
|
246
|
-
true
|
247
|
-
else
|
248
|
-
raise "Could not save attachment: #{response.message} (#{response.code})"
|
249
|
-
end
|
250
|
-
end
|
251
|
-
end
|
252
|
-
|
253
|
-
class Record #:nodoc:
|
254
|
-
attr_reader :type
|
255
|
-
|
256
|
-
def initialize(type, hash)
|
257
|
-
@type, @hash = type, hash
|
258
|
-
end
|
259
|
-
|
260
|
-
def [](name)
|
261
|
-
name = dashify(name)
|
262
|
-
|
263
|
-
case @hash[name]
|
264
|
-
when Hash then
|
265
|
-
@hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array))
|
266
|
-
@hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) }
|
267
|
-
else
|
268
|
-
Record.new(name, @hash[name])
|
269
|
-
end
|
270
|
-
else
|
271
|
-
@hash[name]
|
272
|
-
end
|
273
|
-
end
|
274
|
-
|
275
|
-
def id
|
276
|
-
@hash['id']
|
277
|
-
end
|
278
|
-
|
279
|
-
def attributes
|
280
|
-
@hash.keys
|
281
|
-
end
|
282
|
-
|
283
|
-
def respond_to?(sym)
|
284
|
-
super || @hash.has_key?(dashify(sym))
|
285
|
-
end
|
286
|
-
|
287
|
-
def method_missing(sym, *args)
|
288
|
-
if args.empty? && !block_given? && respond_to?(sym)
|
289
|
-
self[sym]
|
290
|
-
else
|
291
|
-
super
|
292
|
-
end
|
293
|
-
end
|
294
|
-
|
295
|
-
def to_s
|
296
|
-
"\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
|
297
|
-
end
|
298
|
-
|
299
|
-
def inspect
|
300
|
-
to_s
|
301
|
-
end
|
302
|
-
|
303
|
-
private
|
304
|
-
|
305
|
-
def dashify(name)
|
306
|
-
name.to_s.tr("_", "-")
|
307
|
-
end
|
308
|
-
end
|
309
|
-
|
310
|
-
attr_accessor :use_xml
|
311
|
-
|
312
|
-
class << self
|
313
|
-
attr_reader :site, :user, :password, :use_ssl
|
314
|
-
|
315
|
-
def establish_connection!(site, user, password, use_ssl = false)
|
316
|
-
@site = site
|
317
|
-
@user = user
|
318
|
-
@password = password
|
319
|
-
@use_ssl = use_ssl
|
320
|
-
|
321
|
-
Resource.user = user
|
322
|
-
Resource.password = password
|
323
|
-
Resource.site = (use_ssl ? "https" : "http") + "://" + site
|
324
|
-
|
325
|
-
@connection = Connection.new(self)
|
326
|
-
end
|
327
|
-
|
328
|
-
def connection
|
329
|
-
@connection || raise('No connection established')
|
330
|
-
end
|
331
|
-
|
332
|
-
def get_token
|
333
|
-
response = @connection.get('/me.xml')
|
334
|
-
xml = XmlSimple.xml_in(response.body)
|
335
|
-
xml['token'][0]
|
336
|
-
end
|
337
|
-
end
|
338
|
-
|
339
|
-
def initialize
|
340
|
-
@use_xml = false
|
341
|
-
end
|
342
|
-
|
343
|
-
# ==========================================================================
|
344
|
-
# PEOPLE
|
345
|
-
# ==========================================================================
|
346
|
-
|
347
|
-
# Return an array of the people in the given company. If the project-id is
|
348
|
-
# given, only people who have access to the given project will be returned.
|
349
|
-
def people(company_id, project_id=nil)
|
350
|
-
url = project_id ? "/projects/#{project_id}" : ""
|
351
|
-
url << "/contacts/people/#{company_id}"
|
352
|
-
records "person", url
|
353
|
-
end
|
354
|
-
|
355
|
-
# Return information about the person with the given id
|
356
|
-
def person(id)
|
357
|
-
record "/contacts/person/#{id}"
|
358
|
-
end
|
359
|
-
|
360
|
-
# ==========================================================================
|
361
|
-
# MILESTONES
|
362
|
-
# ==========================================================================
|
363
|
-
|
364
|
-
# Returns a list of all milestones for the given project, optionally filtered
|
365
|
-
# by whether they are completed, late, or upcoming.
|
366
|
-
def milestones(project_id, find = 'all')
|
367
|
-
records "milestone", "/projects/#{project_id}/milestones/list", :find => find
|
368
|
-
end
|
369
|
-
|
370
|
-
# Create a new milestone for the given project. +data+ must be hash of the
|
371
|
-
# values to set, including +title+, +deadline+, +responsible_party+, and
|
372
|
-
# +notify+.
|
373
|
-
def create_milestone(project_id, data)
|
374
|
-
create_milestones(project_id, [data]).first
|
375
|
-
end
|
376
|
-
|
377
|
-
# As #create_milestone, but can create multiple milestones in a single
|
378
|
-
# request. The +milestones+ parameter must be an array of milestone values as
|
379
|
-
# described in #create_milestone.
|
380
|
-
def create_milestones(project_id, milestones)
|
381
|
-
records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
|
382
|
-
end
|
383
|
-
|
384
|
-
# Updates an existing milestone.
|
385
|
-
def update_milestone(id, data, move = false, move_off_weekends = false)
|
386
|
-
record "/milestones/update/#{id}", :milestone => data,
|
387
|
-
:move_upcoming_milestones => move,
|
388
|
-
:move_upcoming_milestones_off_weekends => move_off_weekends
|
389
|
-
end
|
390
|
-
|
391
|
-
# Destroys the milestone with the given id.
|
392
|
-
def delete_milestone(id)
|
393
|
-
record "/milestones/delete/#{id}"
|
394
|
-
end
|
395
|
-
|
396
|
-
# Complete the milestone with the given id
|
397
|
-
def complete_milestone(id)
|
398
|
-
record "/milestones/complete/#{id}"
|
399
|
-
end
|
400
|
-
|
401
|
-
# Uncomplete the milestone with the given id
|
402
|
-
def uncomplete_milestone(id)
|
403
|
-
record "/milestones/uncomplete/#{id}"
|
404
|
-
end
|
405
|
-
|
406
|
-
private
|
407
|
-
|
408
|
-
# Make a raw web-service request to Basecamp. This will return a Hash of
|
409
|
-
# Arrays of the response, and may seem a little odd to the uninitiated.
|
410
|
-
def request(path, parameters = {})
|
411
|
-
response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type)
|
412
|
-
|
413
|
-
if response.code.to_i / 100 == 2
|
414
|
-
result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true)
|
415
|
-
typecast_value(result)
|
416
|
-
else
|
417
|
-
raise "#{response.message} (#{response.code})"
|
418
|
-
end
|
419
|
-
end
|
420
|
-
|
421
|
-
# A convenience method for wrapping the result of a query in a Record
|
422
|
-
# object. This assumes that the result is a singleton, not a collection.
|
423
|
-
def record(path, parameters={})
|
424
|
-
result = request(path, parameters)
|
425
|
-
(result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
|
426
|
-
end
|
427
|
-
|
428
|
-
# A convenience method for wrapping the result of a query in Record
|
429
|
-
# objects. This assumes that the result is a collection--any singleton
|
430
|
-
# result will be wrapped in an array.
|
431
|
-
def records(node, path, parameters={})
|
432
|
-
result = request(path, parameters).values.first or return []
|
433
|
-
result = result[node] or return []
|
434
|
-
result = [result] unless Array === result
|
435
|
-
result.map { |row| Record.new(node, row) }
|
436
|
-
end
|
437
|
-
|
438
|
-
def convert_body(body)
|
439
|
-
body = use_xml ? body.to_legacy_xml : body.to_yaml
|
440
|
-
end
|
441
|
-
|
442
|
-
def content_type
|
443
|
-
use_xml ? "application/xml" : "application/x-yaml"
|
444
|
-
end
|
445
|
-
|
446
|
-
def typecast_value(value)
|
447
|
-
case value
|
448
|
-
when Hash
|
449
|
-
if value.has_key?("__content__")
|
450
|
-
content = translate_entities(value["__content__"]).strip
|
451
|
-
case value["type"]
|
452
|
-
when "integer" then content.to_i
|
453
|
-
when "boolean" then content == "true"
|
454
|
-
when "datetime" then Time.parse(content)
|
455
|
-
when "date" then Date.parse(content)
|
456
|
-
else content
|
457
|
-
end
|
458
|
-
# a special case to work-around a bug in XmlSimple. When you have an empty
|
459
|
-
# tag that has an attribute, XmlSimple will not add the __content__ key
|
460
|
-
# to the returned hash. Thus, we check for the presense of the 'type'
|
461
|
-
# attribute to look for empty, typed tags, and simply return nil for
|
462
|
-
# their value.
|
463
|
-
elsif value.keys == %w(type)
|
464
|
-
nil
|
465
|
-
elsif value["nil"] == "true"
|
466
|
-
nil
|
467
|
-
# another special case, introduced by the latest rails, where an array
|
468
|
-
# type now exists. This is parsed by XmlSimple as a two-key hash, where
|
469
|
-
# one key is 'type' and the other is the actual array value.
|
470
|
-
elsif value.keys.length == 2 && value["type"] == "array"
|
471
|
-
value.delete("type")
|
472
|
-
typecast_value(value)
|
473
|
-
else
|
474
|
-
value.empty? ? nil : value.inject({}) do |h,(k,v)|
|
475
|
-
h[k] = typecast_value(v)
|
476
|
-
h
|
477
|
-
end
|
478
|
-
end
|
479
|
-
when Array
|
480
|
-
value.map! { |i| typecast_value(i) }
|
481
|
-
case value.length
|
482
|
-
when 0 then nil
|
483
|
-
when 1 then value.first
|
484
|
-
else value
|
485
|
-
end
|
486
|
-
else
|
487
|
-
raise "can't typecast #{value.inspect}"
|
488
|
-
end
|
489
|
-
end
|
490
|
-
|
491
|
-
def translate_entities(value)
|
492
|
-
value.gsub(/</, "<").
|
493
|
-
gsub(/>/, ">").
|
494
|
-
gsub(/"/, '"').
|
495
|
-
gsub(/'/, "'").
|
496
|
-
gsub(/&/, "&")
|
497
|
-
end
|
498
|
-
end
|
499
|
-
|
500
|
-
# A minor hack to let Xml-Simple serialize symbolic keys in hashes
|
501
|
-
class Symbol
|
502
|
-
def [](*args)
|
503
|
-
to_s[*args]
|
504
|
-
end
|
505
|
-
end
|
506
|
-
|
507
|
-
class Hash
|
508
|
-
def to_legacy_xml
|
509
|
-
XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
|
510
|
-
end
|
511
|
-
end
|
8
|
+
require 'basecamp/base'
|
9
|
+
require 'basecamp/connection'
|
10
|
+
require 'basecamp/hash'
|
11
|
+
require 'basecamp/record'
|
12
|
+
require 'basecamp/resource'
|
13
|
+
require 'basecamp/symbol'
|
14
|
+
require 'basecamp/resources/attachment'
|
15
|
+
require 'basecamp/resources/category'
|
16
|
+
require 'basecamp/resources/comment'
|
17
|
+
require 'basecamp/resources/company'
|
18
|
+
require 'basecamp/resources/message'
|
19
|
+
require 'basecamp/resources/milestone'
|
20
|
+
require 'basecamp/resources/person'
|
21
|
+
require 'basecamp/resources/project'
|
22
|
+
require 'basecamp/resources/time_entry'
|
23
|
+
require 'basecamp/resources/todo_item'
|
24
|
+
require 'basecamp/resources/todo_list'
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module Basecamp
|
2
|
+
class << self
|
3
|
+
attr_accessor :use_xml
|
4
|
+
attr_reader :site, :user, :password, :use_ssl
|
5
|
+
|
6
|
+
def establish_connection!(site, user, password, use_ssl = false)
|
7
|
+
@site = site
|
8
|
+
@user = user
|
9
|
+
@password = password
|
10
|
+
@use_ssl = use_ssl
|
11
|
+
|
12
|
+
Resource.user = user
|
13
|
+
Resource.password = password
|
14
|
+
Resource.site = (use_ssl ? "https" : "http") + "://" + site
|
15
|
+
|
16
|
+
@connection = Connection.new(self)
|
17
|
+
end
|
18
|
+
|
19
|
+
def connection
|
20
|
+
@connection || raise('No connection established')
|
21
|
+
end
|
22
|
+
|
23
|
+
# Make a raw web-service request to Basecamp. This will return a Hash of
|
24
|
+
# Arrays of the response, and may seem a little odd to the uninitiated.
|
25
|
+
def request(path, parameters = {})
|
26
|
+
response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type)
|
27
|
+
|
28
|
+
if response.code.to_i / 100 == 2
|
29
|
+
result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true)
|
30
|
+
typecast_value(result)
|
31
|
+
else
|
32
|
+
raise "#{response.message} (#{response.code})"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# A convenience method for wrapping the result of a query in a Record
|
37
|
+
# object. This assumes that the result is a singleton, not a collection.
|
38
|
+
def record(path, parameters={})
|
39
|
+
result = request(path, parameters)
|
40
|
+
(result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
|
41
|
+
end
|
42
|
+
|
43
|
+
# A convenience method for wrapping the result of a query in Record
|
44
|
+
# objects. This assumes that the result is a collection--any singleton
|
45
|
+
# result will be wrapped in an array.
|
46
|
+
def records(node, path, parameters={})
|
47
|
+
result = request(path, parameters).values.first or return []
|
48
|
+
result = result[node] or return []
|
49
|
+
result = [result] unless Array === result
|
50
|
+
result.map { |row| Record.new(node, row) }
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def convert_body(body)
|
56
|
+
body = use_xml ? body.to_legacy_xml : body.to_yaml
|
57
|
+
end
|
58
|
+
|
59
|
+
def content_type
|
60
|
+
use_xml ? "application/xml" : "application/x-yaml"
|
61
|
+
end
|
62
|
+
|
63
|
+
def typecast_value(value)
|
64
|
+
case value
|
65
|
+
when Hash
|
66
|
+
if value.has_key?("__content__")
|
67
|
+
content = translate_entities(value["__content__"]).strip
|
68
|
+
case value["type"]
|
69
|
+
when "integer" then content.to_i
|
70
|
+
when "boolean" then content == "true"
|
71
|
+
when "datetime" then Time.parse(content)
|
72
|
+
when "date" then Date.parse(content)
|
73
|
+
else content
|
74
|
+
end
|
75
|
+
# a special case to work-around a bug in XmlSimple. When you have an empty
|
76
|
+
# tag that has an attribute, XmlSimple will not add the __content__ key
|
77
|
+
# to the returned hash. Thus, we check for the presense of the 'type'
|
78
|
+
# attribute to look for empty, typed tags, and simply return nil for
|
79
|
+
# their value.
|
80
|
+
elsif value.keys == %w(type)
|
81
|
+
nil
|
82
|
+
elsif value["nil"] == "true"
|
83
|
+
nil
|
84
|
+
# another special case, introduced by the latest rails, where an array
|
85
|
+
# type now exists. This is parsed by XmlSimple as a two-key hash, where
|
86
|
+
# one key is 'type' and the other is the actual array value.
|
87
|
+
elsif value.keys.length == 2 && value["type"] == "array"
|
88
|
+
value.delete("type")
|
89
|
+
typecast_value(value)
|
90
|
+
else
|
91
|
+
value.empty? ? nil : value.inject({}) do |h,(k,v)|
|
92
|
+
h[k] = typecast_value(v)
|
93
|
+
h
|
94
|
+
end
|
95
|
+
end
|
96
|
+
when Array
|
97
|
+
value.map! { |i| typecast_value(i) }
|
98
|
+
case value.length
|
99
|
+
when 0 then nil
|
100
|
+
when 1 then value.first
|
101
|
+
else value
|
102
|
+
end
|
103
|
+
else
|
104
|
+
raise "can't typecast #{value.inspect}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def translate_entities(value)
|
109
|
+
value.gsub(/</, "<").
|
110
|
+
gsub(/>/, ">").
|
111
|
+
gsub(/"/, '"').
|
112
|
+
gsub(/'/, "'").
|
113
|
+
gsub(/&/, "&")
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Basecamp; class Connection
|
2
|
+
def initialize(master)
|
3
|
+
@master = master
|
4
|
+
@connection = Net::HTTP.new(master.site, master.use_ssl ? 443 : 80)
|
5
|
+
@connection.use_ssl = master.use_ssl
|
6
|
+
@connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if master.use_ssl
|
7
|
+
end
|
8
|
+
|
9
|
+
def post(path, body, headers = {})
|
10
|
+
request = Net::HTTP::Post.new(path, headers.merge('Accept' => 'application/xml'))
|
11
|
+
request.basic_auth(@master.user, @master.password)
|
12
|
+
@connection.request(request, body)
|
13
|
+
end
|
14
|
+
|
15
|
+
def get(path, headers = {})
|
16
|
+
request = Net::HTTP::Get.new(path, headers.merge('Accept' => 'application/xml'))
|
17
|
+
request.basic_auth(@master.user, @master.password)
|
18
|
+
@connection.request(request)
|
19
|
+
end
|
20
|
+
end; end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Basecamp; class Record
|
2
|
+
attr_reader :type
|
3
|
+
|
4
|
+
def initialize(type, hash)
|
5
|
+
@type, @hash = type, hash
|
6
|
+
end
|
7
|
+
|
8
|
+
def [](name)
|
9
|
+
name = dashify(name)
|
10
|
+
|
11
|
+
case @hash[name]
|
12
|
+
when Hash then
|
13
|
+
@hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array))
|
14
|
+
@hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) }
|
15
|
+
else
|
16
|
+
Record.new(name, @hash[name])
|
17
|
+
end
|
18
|
+
else
|
19
|
+
@hash[name]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def id
|
24
|
+
@hash['id']
|
25
|
+
end
|
26
|
+
|
27
|
+
def attributes
|
28
|
+
@hash.keys
|
29
|
+
end
|
30
|
+
|
31
|
+
def respond_to?(sym)
|
32
|
+
super || @hash.has_key?(dashify(sym))
|
33
|
+
end
|
34
|
+
|
35
|
+
def method_missing(sym, *args)
|
36
|
+
if args.empty? && !block_given? && respond_to?(sym)
|
37
|
+
self[sym]
|
38
|
+
else
|
39
|
+
super
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_s
|
44
|
+
"\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
|
45
|
+
end
|
46
|
+
|
47
|
+
def inspect
|
48
|
+
to_s
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def dashify(name)
|
54
|
+
name.to_s.tr("_", "-")
|
55
|
+
end
|
56
|
+
end; end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Basecamp; class Resource < ActiveResource::Base
|
2
|
+
class << self
|
3
|
+
def parent_resources(*parents)
|
4
|
+
@parent_resources = parents
|
5
|
+
end
|
6
|
+
|
7
|
+
def element_name
|
8
|
+
name.split(/::/).last.underscore
|
9
|
+
end
|
10
|
+
|
11
|
+
def prefix_source
|
12
|
+
if @parent_resources
|
13
|
+
@parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id" }.join + '/'
|
14
|
+
else
|
15
|
+
'/'
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def prefix(options = {})
|
20
|
+
if options.any?
|
21
|
+
options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}" }.join + '/'
|
22
|
+
else
|
23
|
+
'/'
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def prefix_options
|
29
|
+
id ? {} : super
|
30
|
+
end
|
31
|
+
end; end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Basecamp; class Attachment
|
2
|
+
attr_accessor :id, :filename, :content
|
3
|
+
|
4
|
+
def self.create(filename, content)
|
5
|
+
returning new(filename, content) do |attachment|
|
6
|
+
attachment.save
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(filename, content)
|
11
|
+
@filename, @content = filename, content
|
12
|
+
end
|
13
|
+
|
14
|
+
def attributes
|
15
|
+
{ :file => id, :original_filename => filename }
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_xml(options = {})
|
19
|
+
{ :file => attributes }.to_xml(options)
|
20
|
+
end
|
21
|
+
|
22
|
+
def inspect
|
23
|
+
to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
def save
|
27
|
+
response = Basecamp.connection.post('/upload', content, 'Content-Type' => 'application/octet-stream')
|
28
|
+
|
29
|
+
if response.code == '200'
|
30
|
+
self.id = Hash.from_xml(response.body)['upload']['id']
|
31
|
+
true
|
32
|
+
else
|
33
|
+
raise "Could not save attachment: #{response.message} (#{response.code})"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end; end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# == Creating different types of categories
|
2
|
+
#
|
3
|
+
# The type parameter is required when creating a category. For exampe, to
|
4
|
+
# create an attachment category for a particular project:
|
5
|
+
#
|
6
|
+
# c = Basecamp::Category.new(:project_id => 1037)
|
7
|
+
# c.type = 'attachment'
|
8
|
+
# c.name = 'Pictures'
|
9
|
+
# c.save # => true
|
10
|
+
#
|
11
|
+
module Basecamp; class Category < Basecamp::Resource
|
12
|
+
parent_resources :project
|
13
|
+
|
14
|
+
def self.all(project_id, options = {})
|
15
|
+
find(:all, :params => options.merge(:project_id => project_id))
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.post_categories(project_id, options = {})
|
19
|
+
find(:all, :params => options.merge(:project_id => project_id, :type => 'post'))
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.attachment_categories(project_id, options = {})
|
23
|
+
find(:all, :params => options.merge(:project_id => project_id, :type => 'attachment'))
|
24
|
+
end
|
25
|
+
end; end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# == Creating comments for multiple resources
|
2
|
+
#
|
3
|
+
# Comments can be created for messages, milestones, and to-dos, identified
|
4
|
+
# by the <tt>post_id</tt>, <tt>milestone_id</tt>, and <tt>todo_item_id</tt>
|
5
|
+
# params respectively.
|
6
|
+
#
|
7
|
+
# For example, to create a comment on the message with id #8675309:
|
8
|
+
#
|
9
|
+
# c = Basecamp::Comment.new(:post_id => 8675309)
|
10
|
+
# c.body = 'Great tune'
|
11
|
+
# c.save # => true
|
12
|
+
#
|
13
|
+
# Similarly, to create a comment on a milestone:
|
14
|
+
#
|
15
|
+
# c = Basecamp::Comment.new(:milestone_id => 8473647)
|
16
|
+
# c.body = 'Is this done yet?'
|
17
|
+
# c.save # => true
|
18
|
+
#
|
19
|
+
|
20
|
+
module Basecamp; class Comment < Basecamp::Resource
|
21
|
+
parent_resources :post, :milestone, :todo_item
|
22
|
+
end; end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Basecamp; class Message < Basecamp::Resource
|
2
|
+
parent_resources :project
|
3
|
+
set_element_name 'post'
|
4
|
+
|
5
|
+
# Returns the most recent 25 messages in the given project (and category,
|
6
|
+
# if specified). If you need to retrieve older messages, use the archive
|
7
|
+
# method instead. Example:
|
8
|
+
#
|
9
|
+
# Basecamp::Message.recent(1037)
|
10
|
+
# Basecamp::Message.recent(1037, :category_id => 7301)
|
11
|
+
#
|
12
|
+
def self.recent(project_id, options = {})
|
13
|
+
find(:all, :params => options.merge(:project_id => project_id))
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns a summary of all messages in the given project (and category, if
|
17
|
+
# specified). The summary is simply the title and category of the message,
|
18
|
+
# as well as the number of attachments (if any). Example:
|
19
|
+
#
|
20
|
+
# Basecamp::Message.archive(1037)
|
21
|
+
# Basecamp::Message.archive(1037, :category_id => 7301)
|
22
|
+
#
|
23
|
+
def self.archive(project_id, options = {})
|
24
|
+
find(:all, :params => options.merge(:project_id => project_id), :from => :archive)
|
25
|
+
end
|
26
|
+
|
27
|
+
def comments(options = {})
|
28
|
+
@comments ||= Comment.find(:all, :params => options.merge(:post_id => id))
|
29
|
+
end
|
30
|
+
end; end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Basecamp; class Milestone
|
2
|
+
class << self
|
3
|
+
# Returns a list of all milestones for the given project, optionally filtered
|
4
|
+
# by whether they are completed, late, or upcoming.
|
5
|
+
def list(project_id, find = 'all')
|
6
|
+
Basecamp.records "milestone", "/projects/#{project_id}/milestones/list", :find => find
|
7
|
+
end
|
8
|
+
|
9
|
+
# Create a new milestone for the given project. +data+ must be hash of the
|
10
|
+
# values to set, including +title+, +deadline+, +responsible_party+, and
|
11
|
+
# +notify+.
|
12
|
+
def create(project_id, data)
|
13
|
+
create_milestones(project_id, [data]).first
|
14
|
+
end
|
15
|
+
|
16
|
+
# As #create_milestone, but can create multiple milestones in a single
|
17
|
+
# request. The +milestones+ parameter must be an array of milestone values as
|
18
|
+
# described in #create_milestone.
|
19
|
+
def create_milestones(project_id, milestones)
|
20
|
+
Basecamp.records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
|
21
|
+
end
|
22
|
+
|
23
|
+
# Updates an existing milestone.
|
24
|
+
def update(id, data, move = false, move_off_weekends = false)
|
25
|
+
Basecamp.record "/milestones/update/#{id}", :milestone => data,
|
26
|
+
:move_upcoming_milestones => move,
|
27
|
+
:move_upcoming_milestones_off_weekends => move_off_weekends
|
28
|
+
end
|
29
|
+
|
30
|
+
# Destroys the milestone with the given id.
|
31
|
+
def delete(id)
|
32
|
+
Basecamp.record "/milestones/delete/#{id}"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Complete the milestone with the given id
|
36
|
+
def complete(id)
|
37
|
+
Basecamp.record "/milestones/complete/#{id}"
|
38
|
+
end
|
39
|
+
|
40
|
+
# Uncomplete the milestone with the given id
|
41
|
+
def uncomplete(id)
|
42
|
+
Basecamp.record "/milestones/uncomplete/#{id}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end; end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Basecamp; class TimeEntry < Basecamp::Resource
|
2
|
+
parent_resources :project, :todo_item
|
3
|
+
|
4
|
+
def self.all(project_id, page = 0)
|
5
|
+
find(:all, :params => { :project_id => project_id, :page => page })
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.report(options={})
|
9
|
+
find(:all, :from => :report, :params => options)
|
10
|
+
end
|
11
|
+
end; end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Basecamp; class TodoItem < Basecamp::Resource
|
2
|
+
parent_resources :todo_list
|
3
|
+
|
4
|
+
def todo_list(options = {})
|
5
|
+
@todo_list ||= TodoList.find(todo_list_id, options)
|
6
|
+
end
|
7
|
+
|
8
|
+
def time_entries(options = {})
|
9
|
+
@time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id))
|
10
|
+
end
|
11
|
+
|
12
|
+
def comments(options = {})
|
13
|
+
@comments ||= Comment.find(:all, :params => options.merge(:todo_item_id => id))
|
14
|
+
end
|
15
|
+
|
16
|
+
def complete!
|
17
|
+
put(:complete)
|
18
|
+
end
|
19
|
+
|
20
|
+
def uncomplete!
|
21
|
+
put(:uncomplete)
|
22
|
+
end
|
23
|
+
end; end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Basecamp; class TodoList < Basecamp::Resource
|
2
|
+
parent_resources :project
|
3
|
+
|
4
|
+
# Returns all lists for a project. If complete is true, only completed lists
|
5
|
+
# are returned. If complete is false, only uncompleted lists are returned.
|
6
|
+
def self.all(project_id, complete = nil)
|
7
|
+
filter = case complete
|
8
|
+
when nil then "all"
|
9
|
+
when true then "finished"
|
10
|
+
when false then "pending"
|
11
|
+
else raise ArgumentError, "invalid value for `complete'"
|
12
|
+
end
|
13
|
+
|
14
|
+
find(:all, :params => { :project_id => project_id, :filter => filter })
|
15
|
+
end
|
16
|
+
|
17
|
+
def todo_items(options = {})
|
18
|
+
@todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id))
|
19
|
+
end
|
20
|
+
end; end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: basecamp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 19
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 0
|
9
|
-
-
|
10
|
-
version: 0.0.
|
9
|
+
- 6
|
10
|
+
version: 0.0.6
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Anibal Cucco
|
@@ -134,6 +134,23 @@ extensions: []
|
|
134
134
|
extra_rdoc_files: []
|
135
135
|
|
136
136
|
files:
|
137
|
+
- lib/basecamp/base.rb
|
138
|
+
- lib/basecamp/connection.rb
|
139
|
+
- lib/basecamp/hash.rb
|
140
|
+
- lib/basecamp/record.rb
|
141
|
+
- lib/basecamp/resource.rb
|
142
|
+
- lib/basecamp/resources/attachment.rb
|
143
|
+
- lib/basecamp/resources/category.rb
|
144
|
+
- lib/basecamp/resources/comment.rb
|
145
|
+
- lib/basecamp/resources/company.rb
|
146
|
+
- lib/basecamp/resources/message.rb
|
147
|
+
- lib/basecamp/resources/milestone.rb
|
148
|
+
- lib/basecamp/resources/person.rb
|
149
|
+
- lib/basecamp/resources/project.rb
|
150
|
+
- lib/basecamp/resources/time_entry.rb
|
151
|
+
- lib/basecamp/resources/todo_item.rb
|
152
|
+
- lib/basecamp/resources/todo_list.rb
|
153
|
+
- lib/basecamp/symbol.rb
|
137
154
|
- lib/basecamp.rb
|
138
155
|
- README.rdoc
|
139
156
|
has_rdoc: true
|