Klondike-basecamper 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|