brandonvalentine-basecamper 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. data/LICENSE +22 -0
  2. data/README +109 -0
  3. data/bin/track +284 -0
  4. data/lib/basecamp.rb +499 -0
  5. data/lib/basecamper.rb +369 -0
  6. metadata +66 -0
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2007 Eric Mill
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,109 @@
1
+ ===============================================================================
2
+ Basecamper - Command line time tracker for Basecamp
3
+ ===============================================================================
4
+
5
+ Basecamper is a command line interface to log and manage your times on your
6
+ Basecamp. It uses and extends the Basecamp API Ruby wrapper. Perhaps in the
7
+ future it will do more than time tracking. This time is not now.
8
+
9
+
10
+ === Install ===
11
+
12
+ gem sources -a http://gems.github.com
13
+ sudo gem install Klondike-basecamper
14
+
15
+ === Usage ===
16
+
17
+ Configure the tracker with your Basecamp URL, login details, and whether the
18
+ Basecamp uses SSL. This will take a while as it caches project information
19
+ from your Basecamp. Example:
20
+
21
+ track configure example.clientsection.com username password true
22
+
23
+ Set a project as "current", so any times logged are assumed to be meant for
24
+ that project.
25
+
26
+ track project "Johnson Industries"
27
+ track project joh
28
+
29
+ Log a time by giving the duration, or by giving two times. You can optionally
30
+ specify the project to log time to, which won't change the default project.
31
+
32
+ track log 0.25 "Log message"
33
+ track log :15 "Log message" "Johnson Industries"
34
+ track log 2:30p 5:30pm "Log message"
35
+ track log 10:00 1:00 "Log message" joh
36
+
37
+ In the last case (10:00 to 1:00), the tracker will assume that the 2nd time
38
+ is later than the 1st one, and calculate it as 3 hours, not -9.
39
+
40
+ Log time by starting a timer. If you don't specify a starting time, it's
41
+ assumed you're starting now. You can optionally specify a project, which
42
+ will change the default project.
43
+
44
+ track start
45
+ track start "Johnson Industries"
46
+ track start 3:15
47
+ track start 3:15 "Johnson Industries"
48
+
49
+ Stop the timer to log elapsed time. If you don't specify an ending time, it's
50
+ assumed you're stopping now.
51
+
52
+ track stop "Log message"
53
+ track stop 5:15pm "Log message"
54
+
55
+ Pause and unpause the timer.
56
+
57
+ track pause
58
+
59
+ Cancel all time tracking and reset counters to 0, if the timer is running or
60
+ paused.
61
+
62
+ track cancel
63
+
64
+ List times logged that day, including totals:
65
+
66
+ track times
67
+
68
+ Delete a logged time from Basecamp with "undo". If you don't specify an
69
+ ID, it's assumed you want to delete the last logged time.
70
+
71
+ track undo
72
+ track undo 6861536
73
+
74
+ Set a variable, such as the minute increment to round times to, or any Basecamp
75
+ authorization credentials.
76
+
77
+ track set rounding 15
78
+
79
+ See the list of available projects to track time against.
80
+
81
+ track projects
82
+
83
+ See whether the tracker is configured correctly, the current project, and
84
+ if/when the timer was started or paused:
85
+
86
+ track status
87
+
88
+ See a general or command-specific help message:
89
+
90
+ track help
91
+ track help log
92
+
93
+
94
+ == General ==
95
+
96
+ * Project names can be entered as starting fragments. For example, if
97
+ "Johnson Industries" was the only project beginning with "joh", you could
98
+ reference it as "joh" (e.g. "track project joh"). If more than one
99
+ project starts with a fragment, the first one that matches is chosen.
100
+
101
+ * Times:
102
+ - Valid formats for times of day are: 10:00, 9:00pm, 23:30, 8, 1am, 10p
103
+ - Invalid formats for times of day are: 1230, 1120pm, 22:00am
104
+
105
+ * When calculating elapsed time, minutes are rounded up to be integers, and
106
+ then rounded to the nearest 15 minute increment, by default. So a 1-minute
107
+ time will be logged as 15, 15 as 15, 16 as 30, etc. Use the 'set' command
108
+ to change the increment that times are rounded to. Times logged using the
109
+ 'log' command will not be rounded.
data/bin/track ADDED
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'rubygems'
4
+ require 'basecamper'
5
+
6
+
7
+ USAGE = {
8
+ "configure" => ["track configure [url username password ssl? [project-name]]"],
9
+ "set" => ["track set [key value]"],
10
+ "project" => ["track project project-name"],
11
+ "projects" => ["track projects"],
12
+ "log" => ["track log start-time end-time message [project-name]", "track log duration message [project-name]"],
13
+ "undo" => ["track undo [todo-id]"],
14
+ "status" => ["track status"],
15
+ "times" => ["track times"],
16
+ "help" => ["track help [subcommand]"],
17
+ "start" => ["track start [project-name]", "track start start-time [project-name]"],
18
+ "stop" => ["track stop [end-time] message"],
19
+ "cancel" => ["track cancel"],
20
+ "pause" => ["track pause"],
21
+ }
22
+ COMMANDS = USAGE.keys
23
+
24
+
25
+ # commands
26
+
27
+ def configure
28
+ configuration! if ARGV.empty?
29
+
30
+ url = ARGV.shift
31
+ user_name = ARGV.shift
32
+ password = ARGV.shift
33
+ use_ssl = (ARGV.shift == "true")
34
+
35
+ tracker.configure!(url, user_name, password, use_ssl)
36
+ configuration!
37
+ end
38
+
39
+ def project
40
+ usage! if ARGV.empty?
41
+ tracker.set!("current_project", project_name(ARGV.shift))
42
+ puts "Set current project to #{tracker.current_project}."
43
+ end
44
+
45
+ def projects
46
+ puts "Projects:"
47
+ tracker.projects.values.map {|v| v.capitalize_all}.sort.each {|name| puts " #{name}"}
48
+ end
49
+
50
+ def set
51
+ variables! if ARGV.empty?
52
+ usage! if ARGV[1].blank?
53
+
54
+ exceptions = {"ssl" => "use_ssl", "username" => "user_name"}
55
+ key, value = [ARGV.shift, ARGV.shift]
56
+
57
+ tracker.set!(exceptions[key] || key, value)
58
+ puts "Set #{key} to #{value}."
59
+ end
60
+
61
+ def log
62
+ usage! if ARGV[0].blank? or ARGV[1].blank?
63
+
64
+ if ARGV[0].to_time and ARGV[1].to_time
65
+ start_time = ARGV.shift.to_time
66
+ end_time = ARGV.shift.to_time
67
+ # for example, if the times given are "10:00" and "1:30", assume the meridian changed
68
+ if start_time > end_time
69
+ if start_time - end_time < (12*60*60)
70
+ end_time += (12*60*60)
71
+ elsif start_time - end_time < (24*60*60)
72
+ end_time += (24*60*60)
73
+ end
74
+ end
75
+
76
+ duration = ":#{end_time.minutes_since(start_time).round_to(tracker.config.rounding.to_i)}"
77
+ else
78
+ duration = ARGV.shift
79
+ end
80
+
81
+ message = ARGV.shift
82
+ project = project_name(ARGV.shift)
83
+ date = mydate(ARGV.shift)
84
+
85
+ if time = tracker.log_time(duration, message, project, date)
86
+ puts "Logged time:"
87
+ puts display(time)
88
+ else
89
+ error! "Couldn't log time; make sure the project name is spelled right."
90
+ end
91
+ end
92
+
93
+ def start
94
+ if tracker.started?
95
+ puts "Already tracking time."
96
+ status
97
+ exit
98
+ elsif tracker.paused?
99
+ return pause
100
+ end
101
+
102
+ start_time = nil
103
+ project = nil
104
+ if ARGV.any?
105
+ start_time = ARGV.shift if ARGV[0].to_time
106
+ project = project_name(ARGV.shift) if ARGV.any?
107
+ tracker.set!("current_project", project) if project
108
+ end
109
+
110
+ error! "No project specified. Specify a project name as the last argument, or set a default project in config.yml." unless tracker.current_project
111
+
112
+ tracker.start!(start_time)
113
+ puts "Started tracking time for #{tracker.current_project} at #{tracker.start_time.strftime("%I:%M%p").downcase}."
114
+ end
115
+
116
+ def cancel
117
+ error! "Tracker not started. Use the 'log' command to log a complete time." unless tracker.started? or tracker.paused?
118
+ tracker.cancel!
119
+ puts "Canceled time tracking for #{tracker.current_project}."
120
+ end
121
+
122
+ def pause
123
+ if tracker.paused?
124
+ tracker.start!
125
+ puts "Resumed tracking time at #{tracker.start_time.strftime("%I:%M%p").downcase} with #{tracker.minutes_elapsed} minutes elapsed."
126
+ elsif tracker.started?
127
+ tracker.pause!
128
+ puts "Paused tracking time with #{tracker.minutes_elapsed} minutes elapsed."
129
+ else
130
+ error! "Tracker not started."
131
+ end
132
+ end
133
+
134
+ def stop
135
+ usage! if ARGV.empty?
136
+ error! "Tracker not started. Use the 'log' command to log a complete time." unless tracker.started?
137
+
138
+ end_time = ARGV.shift if ARGV[0].to_time
139
+ message = ARGV.shift
140
+ usage! if message.blank?
141
+
142
+ error! "No project specified. Specify a project name as the last argument, or set a default project in config.yml." unless tracker.current_project
143
+
144
+ time = tracker.stop!(message, end_time)
145
+
146
+ if time
147
+ puts "Logged time:"
148
+ puts display(time)
149
+ else
150
+ error! "Couldn't log time; make sure the project name is spelled right."
151
+ end
152
+ end
153
+
154
+ def times
155
+ if tracker.times.empty?
156
+ puts "No times recorded today."
157
+ return
158
+ end
159
+
160
+ puts "Today's times:"
161
+
162
+ projects = tracker.times.map {|time| time.project_id}.uniq
163
+ sums = {}
164
+ projects.each do |id|
165
+ sums[id] = tracker.times.select {|time| time.project_id == id}.map {|time| time.hours.to_f}.sum
166
+ end
167
+ total_sum = sums.values.sum
168
+
169
+ tracker.times.reverse_each {|time| puts display(time)}
170
+ puts
171
+ puts "Totals:"
172
+ projects.each {|id| puts " #{tracker.project_name(id)}: #{sums[id]} hours"}
173
+ puts
174
+ puts " All projects: #{total_sum} hours"
175
+ end
176
+
177
+ def undo
178
+ error! "No times recorded." unless tracker.times.any?
179
+
180
+ time = tracker.delete_time(ARGV.shift)
181
+ puts "Deleted time:"
182
+ puts display(time)
183
+ end
184
+
185
+ def status
186
+ if tracker.configured?
187
+ puts "Tracker configured correctly, Basecamp communication online."
188
+ else
189
+ puts "Tracker not configured correctly, Basecamp communication offline."
190
+ end
191
+
192
+ puts "Current project: #{tracker.current_project || "[No project set.]"}"
193
+
194
+ if tracker.started?
195
+ puts "\nTracking started:\n #{tracker.start_time.strftime("%I:%M%p")} (#{tracker.minutes_elapsed} minutes elapsed)"
196
+ elsif tracker.paused?
197
+ puts "\nTracking paused:\n #{tracker.minutes_elapsed} minutes elapsed."
198
+ else
199
+ puts "\nNot currently tracking time."
200
+ end
201
+ end
202
+
203
+ def help
204
+ usage!(ARGV.shift) if ARGV[0].in?(COMMANDS)
205
+
206
+ puts "usage: track <subcommand> [args]"
207
+ puts "help: track help <subcommand>"
208
+ puts "\nProject names can be a beginning fragment."
209
+ puts "\nAvailable subcommands:"
210
+ COMMANDS.sort.each {|command| puts " #{command}"}
211
+ end
212
+
213
+
214
+ # helper
215
+
216
+ def usage!(command = nil)
217
+ puts "Usage:"
218
+ USAGE[command || @command].each {|msg| puts " #{msg}"}
219
+ puts
220
+ exit
221
+ end
222
+
223
+ def error!(msg = nil)
224
+ puts msg if msg
225
+ puts
226
+ exit
227
+ end
228
+
229
+ def display(time)
230
+ " #{time.created_at.strftime("%I:%M%p").downcase} ##{time.id} [#{tracker.project_name(time.project_id)}] #{time.hours} - #{time.description}"
231
+ end
232
+
233
+ def tracker
234
+ @tracker ||= Basecamper.new
235
+ end
236
+
237
+ def project_name(fragment)
238
+ return if fragment.blank? or tracker.projects.nil?
239
+ tracker.projects.values.find {|name| name == fragment or name =~ /^#{fragment}/i}
240
+ end
241
+
242
+ def mydate(date)
243
+ return if date.blank? or !date.to_time?
244
+ date.to_time
245
+ end
246
+
247
+ def variables!
248
+ puts "Variables:"
249
+ puts " url\t\tBasecamp URL. (e.g. thoughtbot.clientsection.com)"
250
+ puts " username\tBasecamp username."
251
+ puts " password\tBasecamp password."
252
+ puts " ssl\t\tWhether your Basecamp uses SSL (https)."
253
+ puts " rounding\tRound time to the nearest ___ minutes. Set to 0 to disable."
254
+ puts
255
+ exit
256
+ end
257
+
258
+ def configuration!
259
+ puts "Configuration:"
260
+ puts " url\t\t#{tracker.config.url}"
261
+ puts " username\t#{tracker.config.user_name}"
262
+ puts " password\t#{tracker.config.password}"
263
+ puts " ssl\t\t#{tracker.config.use_ssl}"
264
+ puts " rounding\t#{tracker.config.rounding}"
265
+ puts
266
+ exit
267
+ end
268
+
269
+ def unconfigured!
270
+ puts
271
+ puts "Tracker not configured correctly, cannot communicate with Basecamp."
272
+ puts
273
+ exit
274
+ end
275
+
276
+
277
+ @command = ARGV[0].in?(COMMANDS) ? ARGV.shift : 'help'
278
+
279
+ requires_connection = COMMANDS.reject {|c| c.in? ["help", "set", "configure"]}
280
+ unconfigured! if @command.in?(requires_connection) and !tracker.configured?
281
+
282
+ puts
283
+ self.send(@command)
284
+ puts
data/lib/basecamp.rb ADDED
@@ -0,0 +1,499 @@
1
+ # The "official" (but public domain) Basecamp Ruby wrapper can be downloaded from:
2
+ # http://developer.37signals.com/basecamp/basecamp.rb
3
+
4
+ # Modified by Eric Mill
5
+ # - Changed 'xml-simple' to 'xmlsimple', the lib name has changed to match the gem name
6
+ # - Added some debug output
7
+
8
+ DEBUG = false
9
+
10
+ # the following are all standard ruby libraries
11
+
12
+ require 'net/https'
13
+ require 'yaml'
14
+ require 'date'
15
+ require 'time'
16
+
17
+ require 'rubygems'
18
+ require 'xmlsimple'
19
+
20
+ # An interface to the Basecamp web-services API. Usage is straightforward:
21
+ #
22
+ # session = Basecamp.new('your.basecamp.com', 'username', 'password')
23
+ # puts "projects: #{session.projects.length}"
24
+ class Basecamp
25
+
26
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
27
+ class Record #:nodoc:
28
+ attr_reader :type
29
+
30
+ def initialize(type, hash)
31
+ @type = type
32
+ @hash = hash
33
+ end
34
+
35
+ def [](name)
36
+ name = dashify(name)
37
+ case @hash[name]
38
+ when Hash then
39
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
40
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
41
+ Record.new(name, @hash[name])
42
+ else @hash[name]
43
+ end
44
+ end
45
+
46
+ def id
47
+ @hash["id"]
48
+ end
49
+
50
+ def attributes
51
+ @hash.keys
52
+ end
53
+
54
+ def respond_to?(sym)
55
+ super || @hash.has_key?(dashify(sym))
56
+ end
57
+
58
+ def method_missing(sym, *args)
59
+ if args.empty? && !block_given? && respond_to?(sym)
60
+ self[sym]
61
+ else
62
+ super
63
+ end
64
+ end
65
+
66
+ def to_s
67
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
68
+ end
69
+
70
+ def inspect
71
+ to_s
72
+ end
73
+
74
+ private
75
+
76
+ def dashify(name)
77
+ name.to_s.tr("_", "-")
78
+ end
79
+ end
80
+
81
+ # A wrapper to represent a file that should be uploaded. This is used so that
82
+ # the form/multi-part encoder knows when to encode a field as a file, versus
83
+ # when to encode it as a simple field.
84
+ class FileUpload
85
+ attr_reader :filename, :content
86
+
87
+ def initialize(filename, content)
88
+ @filename = filename
89
+ @content = content
90
+ end
91
+ end
92
+
93
+ attr_accessor :use_xml
94
+
95
+ # Connects
96
+ def initialize(url, user_name, password, use_ssl = false)
97
+ @use_xml = false
98
+ @user_name, @password = user_name, password
99
+ connect!(url, use_ssl)
100
+ end
101
+
102
+ # Return the list of all accessible projects.
103
+ def projects
104
+ records "project", "/project/list"
105
+ end
106
+
107
+ # Returns the list of message categories for the given project
108
+ def message_categories(project_id)
109
+ records "post-category", "/projects/#{project_id}/post_categories"
110
+ end
111
+
112
+ # Returns the list of file categories for the given project
113
+ def file_categories(project_id)
114
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
115
+ end
116
+
117
+ # Return information for the company with the given id
118
+ def company(id)
119
+ record "/contacts/company/#{id}"
120
+ end
121
+
122
+ # Return an array of the people in the given company. If the project-id is
123
+ # given, only people who have access to the given project will be returned.
124
+ def people(company_id, project_id=nil)
125
+ url = project_id ? "/projects/#{project_id}" : ""
126
+ url << "/contacts/people/#{company_id}"
127
+ records "person", url
128
+ end
129
+
130
+ # Return information about the person with the given id
131
+ def person(id)
132
+ record "/contacts/person/#{id}"
133
+ end
134
+
135
+ # Return information about the message(s) with the given id(s). The API
136
+ # limits you to requesting 25 messages at a time, so if you need to get more
137
+ # than that, you'll need to do it in multiple requests.
138
+ def message(*ids)
139
+ result = records("post", "/msg/get/#{ids.join(",")}")
140
+ result.length == 1 ? result.first : result
141
+ end
142
+
143
+ # Returns a summary of all messages in the given project (and category, if
144
+ # specified). The summary is simply the title and category of the message,
145
+ # as well as the number of attachments (if any).
146
+ def message_list(project_id, category_id=nil)
147
+ url = "/projects/#{project_id}/msg"
148
+ url << "/cat/#{category_id}" if category_id
149
+ url << "/archive"
150
+
151
+ records "post", url
152
+ end
153
+
154
+ # Create a new message in the given project. The +message+ parameter should
155
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
156
+ # should be notified of the post.
157
+ #
158
+ # If you want to add attachments to the message, the +attachments+ parameter
159
+ # should be an array of hashes, where each has has a :name key (optional),
160
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
161
+ # instance.
162
+ #
163
+ # msg = session.post_message(158141,
164
+ # { :title => "Requirements",
165
+ # :body => "Here are the requirements documents you asked for.",
166
+ # :category_id => 2301121 },
167
+ # [john.id, martha.id],
168
+ # [ { :name => "Primary Requirements",
169
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
170
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
171
+ def post_message(project_id, message, notify=[], attachments=[])
172
+ prepare_attachments(attachments)
173
+ record "/projects/#{project_id}/msg/create",
174
+ :post => message,
175
+ :notify => notify,
176
+ :attachments => attachments
177
+ end
178
+
179
+ # Edit the message with the given id. The +message+ parameter should
180
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
181
+ # should be notified of the post.
182
+ #
183
+ # The +attachments+ parameter, if used, should be the same as described for
184
+ # #post_message.
185
+ def update_message(id, message, notify=[], attachments=[])
186
+ prepare_attachments(attachments)
187
+ record "/msg/update/#{id}",
188
+ :post => message,
189
+ :notify => notify,
190
+ :attachments => attachments
191
+ end
192
+
193
+ # Deletes the message with the given id, and returns it.
194
+ def delete_message(id)
195
+ record "/msg/delete/#{id}"
196
+ end
197
+
198
+ # Return a list of the comments for the specified message.
199
+ def comments(post_id)
200
+ records "comment", "/msg/comments/#{post_id}"
201
+ end
202
+
203
+ # Retrieve a specific comment
204
+ def comment(id)
205
+ record "/msg/comment/#{id}"
206
+ end
207
+
208
+ # Add a new comment to a message. +comment+ must be a hash describing the
209
+ # comment. You can add attachments to the comment, too, by giving them in
210
+ # an array. See the #post_message method for a description of how to do that.
211
+ def create_comment(post_id, comment, attachments=[])
212
+ prepare_attachments(attachments)
213
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
214
+ :attachments => attachments
215
+ end
216
+
217
+ # Update the given comment. Attachments follow the same format as #post_message.
218
+ def update_comment(id, comment, attachments=[])
219
+ prepare_attachments(attachments)
220
+ record "/msg/update_comment", :comment_id => id,
221
+ :comment => comment, :attachments => attachments
222
+ end
223
+
224
+ # Deletes (and returns) the given comment.
225
+ def delete_comment(id)
226
+ record "/msg/delete_comment/#{id}"
227
+ end
228
+
229
+ # =========================================================================
230
+ # TODO LISTS AND ITEMS
231
+ # =========================================================================
232
+
233
+ # Marks the given item completed.
234
+ def complete_item(id)
235
+ record "/todos/complete_item/#{id}"
236
+ end
237
+
238
+ # Marks the given item uncompleted.
239
+ def uncomplete_item(id)
240
+ record "/todos/uncomplete_item/#{id}"
241
+ end
242
+
243
+ # Creates a new to-do item.
244
+ def create_item(list_id, content, responsible_party=nil, notify=true)
245
+ record "/todos/create_item/#{list_id}",
246
+ :content => content, :responsible_party => responsible_party,
247
+ :notify => notify
248
+ end
249
+
250
+ # Creates a new list using the given hash of list metadata.
251
+ def create_list(project_id, list)
252
+ record "/projects/#{project_id}/todos/create_list", list
253
+ end
254
+
255
+ # Deletes the given item from it's parent list.
256
+ def delete_item(id)
257
+ record "/todos/delete_item/#{id}"
258
+ end
259
+
260
+ # Deletes the given list and all of its items.
261
+ def delete_list(id)
262
+ record "/todos/delete_list/#{id}"
263
+ end
264
+
265
+ # Retrieves the specified list, and all of its items.
266
+ def get_list(id)
267
+ record "/todos/list/#{id}"
268
+ end
269
+
270
+ # Return all lists for a project. If complete is true, only completed lists
271
+ # are returned. If complete is false, only uncompleted lists are returned.
272
+ def lists(project_id, complete=nil)
273
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
274
+ end
275
+
276
+ # Repositions an item to be at the given position in its list
277
+ def move_item(id, to)
278
+ record "/todos/move_item/#{id}", :to => to
279
+ end
280
+
281
+ # Repositions a list to be at the given position in its project
282
+ def move_list(id, to)
283
+ record "/todos/move_list/#{id}", :to => to
284
+ end
285
+
286
+ # Updates the given item
287
+ def update_item(id, content, responsible_party=nil, notify=true)
288
+ record "/todos/update_item/#{id}",
289
+ :item => { :content => content }, :responsible_party => responsible_party,
290
+ :notify => notify
291
+ end
292
+
293
+ # Updates the given list's metadata
294
+ def update_list(id, list)
295
+ record "/todos/update_list/#{id}", :list => list
296
+ end
297
+
298
+ # =========================================================================
299
+ # MILESTONES
300
+ # =========================================================================
301
+
302
+ # Complete the milestone with the given id
303
+ def complete_milestone(id)
304
+ record "/milestones/complete/#{id}"
305
+ end
306
+
307
+ # Create a new milestone for the given project. +data+ must be hash of the
308
+ # values to set, including +title+, +deadline+, +responsible_party+, and
309
+ # +notify+.
310
+ def create_milestone(project_id, data)
311
+ create_milestones(project_id, [data]).first
312
+ end
313
+
314
+ # As #create_milestone, but can create multiple milestones in a single
315
+ # request. The +milestones+ parameter must be an array of milestone values as
316
+ # descrbed in #create_milestone.
317
+ def create_milestones(project_id, milestones)
318
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
319
+ end
320
+
321
+ # Destroys the milestone with the given id.
322
+ def delete_milestone(id)
323
+ record "/milestones/delete/#{id}"
324
+ end
325
+
326
+ # Returns a list of all milestones for the given project, optionally filtered
327
+ # by whether they are completed, late, or upcoming.
328
+ def milestones(project_id, find="all")
329
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
330
+ end
331
+
332
+ # Uncomplete the milestone with the given id
333
+ def uncomplete_milestone(id)
334
+ record "/milestones/uncomplete/#{id}"
335
+ end
336
+
337
+ # Updates an existing milestone.
338
+ def update_milestone(id, data, move=false, move_off_weekends=false)
339
+ record "/milestones/update/#{id}", :milestone => data,
340
+ :move_upcoming_milestones => move,
341
+ :move_upcoming_milestones_off_weekends => move_off_weekends
342
+ end
343
+
344
+ # Make a raw web-service request to Basecamp. This will return a Hash of
345
+ # Arrays of the response, and may seem a little odd to the uninitiated.
346
+ def request(path, parameters = {}, second_try = false)
347
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
348
+
349
+ if DEBUG
350
+ puts "Reponse:"
351
+ puts response.body
352
+ end
353
+
354
+ if response.code.to_i / 100 == 2
355
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
356
+ 'contentkey' => '__content__', 'forcecontent' => true)
357
+ typecast_value(result)
358
+ elsif response.code == "302" && !second_try
359
+ connect!(@url, !@use_ssl)
360
+ request(path, parameters, true)
361
+ else
362
+ raise "#{response.message} (#{response.code})"
363
+ end
364
+ end
365
+
366
+ # A convenience method for wrapping the result of a query in a Record
367
+ # object. This assumes that the result is a singleton, not a collection.
368
+ def record(path, parameters={})
369
+ result = request(path, parameters)
370
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
371
+ end
372
+
373
+ # A convenience method for wrapping the result of a query in Record
374
+ # objects. This assumes that the result is a collection--any singleton
375
+ # result will be wrapped in an array.
376
+ def records(node, path, parameters={})
377
+ result = request(path, parameters).values.first or return []
378
+ result = result[node] or return []
379
+ result = [result] unless Array === result
380
+ result.map { |row| Record.new(node, row) }
381
+ end
382
+
383
+ private
384
+
385
+ def connect!(url, use_ssl)
386
+ @use_ssl = use_ssl
387
+ @url = url
388
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
389
+ @connection.use_ssl = @use_ssl
390
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
391
+ end
392
+
393
+ def convert_body(body)
394
+ body = use_xml ? body.to_xml : body.to_yaml
395
+ end
396
+
397
+ def content_type
398
+ use_xml ? "application/xml" : "application/x-yaml"
399
+ end
400
+
401
+ def post(path, body, header={})
402
+ if DEBUG
403
+ puts "POSTing:"
404
+ p YAML.load(body)
405
+ end
406
+
407
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
408
+ request.basic_auth(@user_name, @password)
409
+ @connection.request(request, body)
410
+ end
411
+
412
+ def store_file(contents)
413
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
414
+ 'Accept' => 'application/xml')
415
+
416
+ if response.code == "200"
417
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
418
+ return result["upload"]["id"]
419
+ else
420
+ raise "Could not store file: #{response.message} (#{response.code})"
421
+ end
422
+ end
423
+
424
+ def typecast_value(value)
425
+ case value
426
+ when Hash
427
+ if value.has_key?("__content__")
428
+ content = translate_entities(value["__content__"]).strip
429
+ case value["type"]
430
+ when "integer" then content.to_i
431
+ when "boolean" then content == "true"
432
+ when "datetime" then Time.parse(content)
433
+ when "date" then Date.parse(content)
434
+ else content
435
+ end
436
+ # a special case to work-around a bug in XmlSimple. When you have an empty
437
+ # tag that has an attribute, XmlSimple will not add the __content__ key
438
+ # to the returned hash. Thus, we check for the presense of the 'type'
439
+ # attribute to look for empty, typed tags, and simply return nil for
440
+ # their value.
441
+ elsif value.keys == %w(type)
442
+ nil
443
+ elsif value["nil"] == "true"
444
+ nil
445
+ # another special case, introduced by the latest rails, where an array
446
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
447
+ # one key is 'type' and the other is the actual array value.
448
+ elsif value.keys.length == 2 && value["type"] == "array"
449
+ value.delete("type")
450
+ typecast_value(value)
451
+ else
452
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
453
+ h[k] = typecast_value(v)
454
+ h
455
+ end
456
+ end
457
+ when Array
458
+ value.map! { |i| typecast_value(i) }
459
+ case value.length
460
+ when 0 then nil
461
+ when 1 then value.first
462
+ else value
463
+ end
464
+ else
465
+ raise "can't typecast #{value.inspect}"
466
+ end
467
+ end
468
+
469
+ def translate_entities(value)
470
+ value.gsub(/&lt;/, "<").
471
+ gsub(/&gt;/, ">").
472
+ gsub(/&quot;/, '"').
473
+ gsub(/&apos;/, "'").
474
+ gsub(/&amp;/, "&")
475
+ end
476
+
477
+ def prepare_attachments(list)
478
+ (list || []).each do |data|
479
+ upload = data[:file]
480
+ id = store_file(upload.content)
481
+ data[:file] = { :file => id,
482
+ :content_type => "application/octet-stream",
483
+ :original_filename => upload.filename }
484
+ end
485
+ end
486
+ end
487
+
488
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
489
+ class Symbol
490
+ def [](*args)
491
+ to_s[*args]
492
+ end
493
+ end
494
+
495
+ class Hash
496
+ def to_xml
497
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
498
+ end
499
+ end
data/lib/basecamper.rb ADDED
@@ -0,0 +1,369 @@
1
+ require 'fileutils'
2
+ require 'basecamp'
3
+
4
+ class Basecamper
5
+
6
+ attr_reader :basecamp, :projects, :times, :person_id, :config
7
+
8
+ def initialize
9
+ initialize_data!
10
+
11
+ # need to make booleans a special case and transform there here forevermore
12
+ ["use_ssl"].each do |key|
13
+ @config[key] = true if @config[key] == "true"
14
+ @config[key] = false if @config[key] == "false"
15
+ end
16
+ # defaults
17
+ {"rounding" => 15, "minutes_logged" => 0}.each_pair {|key, value| @config[key] ||= value}
18
+
19
+ @basecamp = Basecamp.new @config.url, @config.user_name, @config.password, @config.use_ssl
20
+
21
+ # set up a couple of attr_readers
22
+ @person_id = @config.person_id
23
+ @projects = @config.projects
24
+ end
25
+
26
+
27
+ # main API
28
+
29
+ def configure!(url, user_name, password, use_ssl)
30
+ write_config("url" => url, "user_name" => user_name, "password" => password, "use_ssl" => use_ssl)
31
+ test_basecamp!
32
+ end
33
+
34
+ def set!(key, value)
35
+ write_config(key.to_s => value)
36
+ test_basecamp! if key.in? ["url", "user_name", "password", "use_ssl"]
37
+ end
38
+
39
+ def start!(start_time = nil)
40
+ return if started?
41
+
42
+ write_config("start_time" => (start_time.to_time || Time.now))
43
+ end
44
+
45
+ def stop!(message, end_time = nil)
46
+ return unless started?
47
+
48
+ minutes = minutes_elapsed(end_time).round_to(@config.rounding.to_i)
49
+
50
+ write_config("start_time" => nil, "minutes_logged" => 0)
51
+ log_time(":#{minutes}", message)
52
+ end
53
+
54
+ def cancel!
55
+ return unless started? or paused?
56
+
57
+ write_config("start_time" => nil, "minutes_logged" => 0)
58
+ end
59
+
60
+ def pause!
61
+ return unless started?
62
+
63
+ write_config("start_time" => nil, "minutes_logged" => minutes_elapsed)
64
+ end
65
+
66
+ def log_time(duration, message, project = nil, date = Time.now)
67
+ project_id = project_id(project || current_project)
68
+ return unless project_id
69
+
70
+ save @basecamp.log_time(project_id, @person_id, date, duration, message)
71
+ end
72
+
73
+ def delete_time(time_id = nil)
74
+ time = time_id ? find_time(time_id) : @times.first
75
+ return unless time
76
+
77
+ @basecamp.delete_time(time.id, time.project_id)
78
+ @times.delete time
79
+ write_times
80
+
81
+ time
82
+ end
83
+
84
+ # secondary API
85
+
86
+ def current_project
87
+ @config.current_project.capitalize_all
88
+ end
89
+
90
+ def configured?
91
+ @config.configured
92
+ end
93
+
94
+ def test_basecamp!
95
+ @basecamp = Basecamp.new(@config.url, @config.user_name, @config.password, @config.use_ssl)
96
+ write_config("configured" => @basecamp.test_auth)
97
+ sync_basecamp if configured?
98
+ end
99
+
100
+ def sync_basecamp
101
+ puts "Initializing, may take a few seconds..."
102
+ $stdout.flush if $stdout and $stdout.respond_to? :flush
103
+ get_person(@config.user_name)
104
+ get_projects
105
+ end
106
+
107
+ def started?
108
+ !@config.start_time.nil?
109
+ end
110
+
111
+ def paused?
112
+ !started? and (@config.minutes_logged > 0)
113
+ end
114
+
115
+ def minutes_elapsed(end_time = nil)
116
+ if started?
117
+ @config.minutes_logged + (end_time || Time.now).minutes_since(start_time)
118
+ else
119
+ @config.minutes_logged
120
+ end
121
+ end
122
+
123
+ def start_time
124
+ @config.start_time
125
+ end
126
+
127
+ def project_name(project_id)
128
+ @projects[project_id].capitalize_all
129
+ end
130
+
131
+ def project_id(name)
132
+ @projects.invert[name.to_s.downcase]
133
+ end
134
+
135
+ def save(record)
136
+ return unless record
137
+ record = record.to_hash
138
+ record.created_at = Time.now
139
+ @times.unshift record
140
+ prune_times
141
+ write_times
142
+ @times.first
143
+ end
144
+
145
+ def inspect
146
+ config = @config.dup
147
+ config.projects = config.projects.values.map {|name| name.capitalize_all}.join(", ")
148
+ config.to_yaml.gsub(/^---/, "")
149
+ end
150
+
151
+ private
152
+
153
+ def initialize_data!
154
+ FileUtils.mkdir data_path if !File.exists? data_path
155
+ if File.exists?(config_file)
156
+ @config = YAML.load_file(config_file)
157
+ else
158
+ @config = {}
159
+ write_config
160
+ end
161
+
162
+ if File.exists?(times_file)
163
+ @times = YAML.load_file(times_file)
164
+ else
165
+ @times = []
166
+ write_times
167
+ end
168
+ true
169
+ end
170
+
171
+ def config_file
172
+ File.join data_path, "config.yml"
173
+ end
174
+
175
+ def times_file
176
+ File.join data_path, "times.yml"
177
+ end
178
+
179
+ def data_path
180
+ File.join ENV['HOME'], ".basecamper"
181
+ end
182
+
183
+ def get_projects
184
+ @projects = {}
185
+ @basecamp.projects.each {|project| @projects[project.id] = project.name.downcase}
186
+ write_config("projects" => @projects)
187
+ end
188
+
189
+ def get_person(user_name)
190
+ if record = basecamp.person(user_name)
191
+ @person_id = record.id
192
+ write_config("person_id" => @person_id)
193
+ end
194
+ end
195
+
196
+ def find_time(id)
197
+ @times.find {|time| time.id.to_s == id}
198
+ end
199
+
200
+ # keep only today's times
201
+ def prune_times
202
+ now = Time.now.strftime("%Y-%m-%d")
203
+ @times.reject! {|time| time["date"].strftime("%Y-%m-%d") != now}
204
+ end
205
+
206
+ def write_config(params = {})
207
+ params.each_pair {|key, value| @config[key] = value}
208
+ File.open(config_file, "w") {|file| file.write(@config.to_yaml)}
209
+ end
210
+
211
+ def write_times
212
+ File.open(times_file, "w") {|file| file.write(@times.to_yaml)}
213
+ end
214
+
215
+
216
+ end
217
+
218
+
219
+ # Basecamp wrapper extensions
220
+
221
+ class Basecamp
222
+
223
+ def log_time(project_id, person_id, date, hours, description = nil, todo_item_id = nil)
224
+ entry = {"project_id" => project_id, "person_id" => person_id, "date" => date.to_s, "hours" => hours.to_s}
225
+ entry["description"] = description if description
226
+ entry["todo_item_id"] = todo_item_id if todo_item_id
227
+ record "/time/save_entry", :entry => entry
228
+ end
229
+
230
+ def delete_time(id, project_id)
231
+ record "/projects/#{project_id}/time/delete_entry/#{id}"
232
+ end
233
+
234
+ # overrides existing #person to accept an ID or a user_name - IDs of 0 will be interpreted as a user_name
235
+ def person(identifier)
236
+ if identifier.is_a? Fixnum or identifier.to_i > 0
237
+ record "/contacts/person/#{identifier}"
238
+ else # identifier is a username
239
+ all_people.find {|person| person["user-name"] == identifier}
240
+ end
241
+ end
242
+
243
+ def companies
244
+ records "company", "/contacts/companies"
245
+ end
246
+
247
+ # Fetches all people from all companies and prunes for uniqueness
248
+ def all_people
249
+ companies.map do |company|
250
+ records "person", "/contacts/people/#{company.id}"
251
+ end.flatten
252
+ end
253
+
254
+ # tests credentials
255
+ def test_auth
256
+ begin
257
+ projects
258
+ true
259
+ rescue
260
+ false
261
+ end
262
+ end
263
+
264
+ class Record
265
+ def to_hash
266
+ hash = {}
267
+ self.attributes.each do |attr|
268
+ hash[attr.undashify] = self[attr]
269
+ end
270
+ hash
271
+ end
272
+ end
273
+
274
+ end
275
+
276
+
277
+ # Core extensions
278
+
279
+ class String
280
+ def to_time
281
+ matches = self.match(/^(\d\d?)(:\d\d)?(am?|pm?)?$/i)
282
+ return unless matches
283
+
284
+ hour = matches[1].to_i
285
+ minutes = matches[2] ? matches[2].gsub(/:/, "").to_i : 0
286
+ meridian = matches[3]
287
+
288
+ return unless hour > 0
289
+ hour += 12 if meridian =~ /^p/ and hour < 12
290
+ return unless hour <= 24
291
+
292
+ now = Time.now
293
+ Time.mktime(now.year, now.month, now.day, hour, minutes)
294
+ end
295
+
296
+ def capitalize_all(separator = " ")
297
+ split(" ").map {|s| s.capitalize}.join(" ")
298
+ end
299
+
300
+ def blank?
301
+ empty?
302
+ end
303
+
304
+ def dashify
305
+ self.tr '_', '-'
306
+ end
307
+
308
+ def undashify
309
+ self.tr '-', '_'
310
+ end
311
+ end
312
+
313
+ class Time
314
+ def to_time
315
+ self
316
+ end
317
+
318
+ def minutes_since(time)
319
+ ((self - time) / 60).ceil
320
+ end
321
+ end
322
+
323
+ class NilClass
324
+ def blank?
325
+ true
326
+ end
327
+
328
+ def method_missing(method, *args)
329
+ self
330
+ end
331
+ end
332
+
333
+ class Hash
334
+ def id
335
+ method_missing :id
336
+ end
337
+
338
+ def method_missing(method, *args)
339
+ if method.to_s =~ /=$/
340
+ self[method.to_s.tr('=','')] = args.first
341
+ else
342
+ self[method.to_s]
343
+ end
344
+ end
345
+ end
346
+
347
+ class Object
348
+ def in?(collection)
349
+ collection.include? self
350
+ end
351
+ end
352
+
353
+ class Fixnum
354
+ def round_to(minutes)
355
+ return self unless minutes > 0
356
+
357
+ if self > 0 and self % minutes == 0
358
+ self
359
+ else
360
+ self + (minutes - (self % minutes))
361
+ end
362
+ end
363
+ end
364
+
365
+ class Array
366
+ def sum
367
+ inject {|sum, x| sum + x}
368
+ end
369
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: brandonvalentine-basecamper
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.5
5
+ platform: ruby
6
+ authors:
7
+ - Brandon D. Valentine
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-02-13 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: xml-simple
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ description:
26
+ email: brandon@brandonvalentine.com
27
+ executables:
28
+ - track
29
+ extensions: []
30
+
31
+ extra_rdoc_files: []
32
+
33
+ files:
34
+ - README
35
+ - LICENSE
36
+ - bin/track
37
+ - lib/basecamp.rb
38
+ - lib/basecamper.rb
39
+ has_rdoc: false
40
+ homepage: http://github.com/brandonvalentine/basecamper/
41
+ post_install_message:
42
+ rdoc_options: []
43
+
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: "0"
51
+ version:
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: "0"
57
+ version:
58
+ requirements: []
59
+
60
+ rubyforge_project:
61
+ rubygems_version: 1.2.0
62
+ signing_key:
63
+ specification_version: 2
64
+ summary: Command line interface to tracking time on Basecamp.
65
+ test_files: []
66
+