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.
- data/LICENSE +22 -0
- data/README +109 -0
- data/bin/track +278 -0
- data/lib/basecamp.rb +499 -0
- data/lib/basecamper.rb +369 -0
- 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.
|
data/bin/track
ADDED
|
@@ -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
|
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(/</, "<").
|
|
471
|
+
gsub(/>/, ">").
|
|
472
|
+
gsub(/"/, '"').
|
|
473
|
+
gsub(/'/, "'").
|
|
474
|
+
gsub(/&/, "&")
|
|
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)
|
|
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
|
+
|