Klondike-basecamper 1.0.2

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 (6) hide show
  1. data/LICENSE +22 -0
  2. data/README +109 -0
  3. data/bin/track +278 -0
  4. data/lib/basecamp.rb +499 -0
  5. data/lib/basecamper.rb +369 -0
  6. metadata +65 -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.
@@ -0,0 +1,278 @@
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
+
84
+ if time = tracker.log_time(duration, message, project)
85
+ puts "Logged time:"
86
+ puts display(time)
87
+ else
88
+ error! "Couldn't log time; make sure the project name is spelled right."
89
+ end
90
+ end
91
+
92
+ def start
93
+ if tracker.started?
94
+ puts "Already tracking time."
95
+ status
96
+ exit
97
+ elsif tracker.paused?
98
+ return pause
99
+ end
100
+
101
+ start_time = nil
102
+ project = nil
103
+ if ARGV.any?
104
+ start_time = ARGV.shift if ARGV[0].to_time
105
+ project = project_name(ARGV.shift) if ARGV.any?
106
+ tracker.set!("current_project", project) if project
107
+ end
108
+
109
+ error! "No project specified. Specify a project name as the last argument, or set a default project in config.yml." unless tracker.current_project
110
+
111
+ tracker.start!(start_time)
112
+ puts "Started tracking time for #{tracker.current_project} at #{tracker.start_time.strftime("%I:%M%p").downcase}."
113
+ end
114
+
115
+ def cancel
116
+ error! "Tracker not started. Use the 'log' command to log a complete time." unless tracker.started? or tracker.paused?
117
+ tracker.cancel!
118
+ puts "Canceled time tracking for #{tracker.current_project}."
119
+ end
120
+
121
+ def pause
122
+ if tracker.paused?
123
+ tracker.start!
124
+ puts "Resumed tracking time at #{tracker.start_time.strftime("%I:%M%p").downcase} with #{tracker.minutes_elapsed} minutes elapsed."
125
+ elsif tracker.started?
126
+ tracker.pause!
127
+ puts "Paused tracking time with #{tracker.minutes_elapsed} minutes elapsed."
128
+ else
129
+ error! "Tracker not started."
130
+ end
131
+ end
132
+
133
+ def stop
134
+ usage! if ARGV.empty?
135
+ error! "Tracker not started. Use the 'log' command to log a complete time." unless tracker.started?
136
+
137
+ end_time = ARGV.shift if ARGV[0].to_time
138
+ message = ARGV.shift
139
+ usage! if message.blank?
140
+
141
+ error! "No project specified. Specify a project name as the last argument, or set a default project in config.yml." unless tracker.current_project
142
+
143
+ time = tracker.stop!(message, end_time)
144
+
145
+ if time
146
+ puts "Logged time:"
147
+ puts display(time)
148
+ else
149
+ error! "Couldn't log time; make sure the project name is spelled right."
150
+ end
151
+ end
152
+
153
+ def times
154
+ if tracker.times.empty?
155
+ puts "No times recorded today."
156
+ return
157
+ end
158
+
159
+ puts "Today's times:"
160
+
161
+ projects = tracker.times.map {|time| time.project_id}.uniq
162
+ sums = {}
163
+ projects.each do |id|
164
+ sums[id] = tracker.times.select {|time| time.project_id == id}.map {|time| time.hours.to_f}.sum
165
+ end
166
+ total_sum = sums.values.sum
167
+
168
+ tracker.times.reverse_each {|time| puts display(time)}
169
+ puts
170
+ puts "Totals:"
171
+ projects.each {|id| puts " #{tracker.project_name(id)}: #{sums[id]} hours"}
172
+ puts
173
+ puts " All projects: #{total_sum} hours"
174
+ end
175
+
176
+ def undo
177
+ error! "No times recorded." unless tracker.times.any?
178
+
179
+ time = tracker.delete_time(ARGV.shift)
180
+ puts "Deleted time:"
181
+ puts display(time)
182
+ end
183
+
184
+ def status
185
+ if tracker.configured?
186
+ puts "Tracker configured correctly, Basecamp communication online."
187
+ else
188
+ puts "Tracker not configured correctly, Basecamp communication offline."
189
+ end
190
+
191
+ puts "Current project: #{tracker.current_project || "[No project set.]"}"
192
+
193
+ if tracker.started?
194
+ puts "\nTracking started:\n #{tracker.start_time.strftime("%I:%M%p")} (#{tracker.minutes_elapsed} minutes elapsed)"
195
+ elsif tracker.paused?
196
+ puts "\nTracking paused:\n #{tracker.minutes_elapsed} minutes elapsed."
197
+ else
198
+ puts "\nNot currently tracking time."
199
+ end
200
+ end
201
+
202
+ def help
203
+ usage!(ARGV.shift) if ARGV[0].in?(COMMANDS)
204
+
205
+ puts "usage: track <subcommand> [args]"
206
+ puts "help: track help <subcommand>"
207
+ puts "\nProject names can be a beginning fragment."
208
+ puts "\nAvailable subcommands:"
209
+ COMMANDS.sort.each {|command| puts " #{command}"}
210
+ end
211
+
212
+
213
+ # helper
214
+
215
+ def usage!(command = nil)
216
+ puts "Usage:"
217
+ USAGE[command || @command].each {|msg| puts " #{msg}"}
218
+ puts
219
+ exit
220
+ end
221
+
222
+ def error!(msg = nil)
223
+ puts msg if msg
224
+ puts
225
+ exit
226
+ end
227
+
228
+ def display(time)
229
+ " #{time.created_at.strftime("%I:%M%p").downcase} ##{time.id} [#{tracker.project_name(time.project_id)}] #{time.hours} - #{time.description}"
230
+ end
231
+
232
+ def tracker
233
+ @tracker ||= Basecamper.new
234
+ end
235
+
236
+ def project_name(fragment)
237
+ return if fragment.blank? or tracker.projects.nil?
238
+ tracker.projects.values.find {|name| name == fragment or name =~ /^#{fragment}/i}
239
+ end
240
+
241
+ def variables!
242
+ puts "Variables:"
243
+ puts " url\t\tBasecamp URL. (e.g. thoughtbot.clientsection.com)"
244
+ puts " username\tBasecamp username."
245
+ puts " password\tBasecamp password."
246
+ puts " ssl\t\tWhether your Basecamp uses SSL (https)."
247
+ puts " rounding\tRound time to the nearest ___ minutes. Set to 0 to disable."
248
+ puts
249
+ exit
250
+ end
251
+
252
+ def configuration!
253
+ puts "Configuration:"
254
+ puts " url\t\t#{tracker.config.url}"
255
+ puts " username\t#{tracker.config.user_name}"
256
+ puts " password\t#{tracker.config.password}"
257
+ puts " ssl\t\t#{tracker.config.use_ssl}"
258
+ puts " rounding\t#{tracker.config.rounding}"
259
+ puts
260
+ exit
261
+ end
262
+
263
+ def unconfigured!
264
+ puts
265
+ puts "Tracker not configured correctly, cannot communicate with Basecamp."
266
+ puts
267
+ exit
268
+ end
269
+
270
+
271
+ @command = ARGV[0].in?(COMMANDS) ? ARGV.shift : 'help'
272
+
273
+ requires_connection = COMMANDS.reject {|c| c.in? ["help", "set", "configure"]}
274
+ unconfigured! if @command.in?(requires_connection) and !tracker.configured?
275
+
276
+ puts
277
+ self.send(@command)
278
+ puts
@@ -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
@@ -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)
67
+ project_id = project_id(project || current_project)
68
+ return unless project_id
69
+
70
+ save @basecamp.log_time(project_id, @person_id, Time.now, 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,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: Klondike-basecamper
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Eric Mill
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-07-08 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: xml-simple
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: "0"
23
+ version:
24
+ description:
25
+ email: kprojection@gmail.com
26
+ executables:
27
+ - track
28
+ extensions: []
29
+
30
+ extra_rdoc_files: []
31
+
32
+ files:
33
+ - README
34
+ - LICENSE
35
+ - bin/track
36
+ - lib/basecamp.rb
37
+ - lib/basecamper.rb
38
+ has_rdoc: false
39
+ homepage: http://github.com/Klondike/basecamper/
40
+ post_install_message:
41
+ rdoc_options: []
42
+
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: "0"
50
+ version:
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: "0"
56
+ version:
57
+ requirements: []
58
+
59
+ rubyforge_project:
60
+ rubygems_version: 1.2.0
61
+ signing_key:
62
+ specification_version: 2
63
+ summary: Command line interface to tracking time on Basecamp.
64
+ test_files: []
65
+