dotime 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt ADDED
@@ -0,0 +1,13 @@
1
+ == 0.0.3 / 2006-12-23
2
+
3
+ * Added to RubyForge
4
+ * Fixing bug which appeared when more than 9 todo items are present and always started the todo whose position was the first digit pressed (when starting/stopping a todo)
5
+ * Adding textual indications of what's happening when loading initial projects/lists
6
+
7
+ == 0.0.2 / 2006-09-18
8
+
9
+ * Feature addition & bug fixes
10
+
11
+ == 0.0.1 / 2006-09-12
12
+
13
+ * Initial version
data/Manifest.txt ADDED
@@ -0,0 +1,8 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.txt
4
+ Rakefile
5
+ bin/dotime
6
+ lib/do_time.rb
7
+ lib/basecamp.rb
8
+ test/test_do_time.rb
data/README.txt ADDED
@@ -0,0 +1,81 @@
1
+ DoTime
2
+ by Saimon Moore
3
+ http://rubyforge.org/projects/dotime/
4
+
5
+ == DESCRIPTION:
6
+
7
+ This is a cheap time tracking substitute for those of us who can't afford the
8
+ extra cash (or believe all basecamp projects require time tracking) for
9
+ Basecamp (http://www.basecamphq.com) plans
10
+ with the time tracking feature.
11
+
12
+ How it works:
13
+
14
+ DoTime cheats by keeping track of the time worked on a todo within the todo itself.
15
+ It specifies a text format to keep track of the elapsed hours and then just updates it as necessary.
16
+
17
+ == FEATURES/PROBLEMS:
18
+
19
+ * easy to use single-key menu navigation
20
+ * start/stop timers (even in parallel but that's naughty ;) by selecting a todo's position in the list
21
+ * 'space' key start/stops next available todo
22
+ * 'enter' key completes currently running todo
23
+ * complete a specific todo if more than one todo is currently running
24
+ * change project/todo lists at any time
25
+ * refresh currently selected list to get changes from server
26
+ * Automatically filters projects/lists & todos for the current logged in user.
27
+ * elapsed time indication per todo/per list
28
+
29
+ == TODOS:
30
+ * Show total elapsed time per project
31
+ * Show running timer for running tasks.
32
+ * Update tasks every x minutes
33
+ * For future bc api versions allow all
34
+ * people involved in a project to access tasks.
35
+ * (This will make find_logged_in_user redundant)
36
+ * Refactor code to get todo-list with todos
37
+ * Make OS independant (Linux & Macs work, check Windows)
38
+ * Make space bar also do:
39
+ * - start last running task if not completed (missing, it now just starts running the first task again)
40
+ * - start next task if previous one completed (not tested)
41
+ * Add 'working' indicator between long waits
42
+
43
+ == SYNOPSYS:
44
+
45
+ dotime basecamp_login basecamp_password
46
+
47
+ (Then just follow the menu options)
48
+
49
+ == REQUIREMENTS:
50
+
51
+ * xml-simple >= 1.0.8
52
+ * highline >= 1.2.1
53
+
54
+ == INSTALL:
55
+
56
+ * sudo gem install dotime -y
57
+
58
+ == LICENSE:
59
+
60
+ (The MIT License)
61
+
62
+ Copyright (c) 2006 Saimon Moore
63
+
64
+ Permission is hereby granted, free of charge, to any person obtaining
65
+ a copy of this software and associated documentation files (the
66
+ 'Software'), to deal in the Software without restriction, including
67
+ without limitation the rights to use, copy, modify, merge, publish,
68
+ distribute, sublicense, and/or sell copies of the Software, and to
69
+ permit persons to whom the Software is furnished to do so, subject to
70
+ the following conditions:
71
+
72
+ The above copyright notice and this permission notice shall be
73
+ included in all copies or substantial portions of the Software.
74
+
75
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
76
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
77
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
78
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
79
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
80
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
81
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ #:mode=ruby:
2
+
3
+ require 'rubygems'
4
+ require 'hoe'
5
+ require './lib/do_time.rb'
6
+
7
+ Hoe.new('dotime', DoTime::VERSION) do |p|
8
+ p.rubyforge_name = 'dotime'
9
+ p.author = 'Saimon Moore'
10
+ p.summary = 'Cheap time tracking for Basecamp todo lists'
11
+ p.description = p.paragraphs_of('README.txt', 2..5).join("\n\n")
12
+ p.url = p.paragraphs_of('README.txt', 0).first.split(/\n/)[1..-1]
13
+ p.changes = p.paragraphs_of('History.txt', 0..1).join("\n\n")
14
+ p.extra_deps = [["xml-simple", ">= 1.0.8"],["highline", ">= 1.2.1"]]
15
+ p.spec_extras = {
16
+ :autorequire => "do_time",
17
+ :bindir => "bin", # Use these for applications.
18
+ :executables => ["dotime"],
19
+ :default_executable => "dotime"
20
+ }
21
+ end
data/bin/dotime ADDED
@@ -0,0 +1,50 @@
1
+ #:mode=ruby:
2
+ OPTIONS = {
3
+ :login => nil,
4
+ :password => nil,
5
+ :use_ssl => false
6
+ }
7
+
8
+ ARGV.options do |opts|
9
+ opts.banner = "Usage: dotime [options]"
10
+
11
+ opts.separator ""
12
+
13
+ opts.on <<-EOF
14
+ Description:
15
+ This is a cheap time tracking substitute for those of us who can't afford the
16
+ extra cash (or believe all basecamp projects require time tracking) for
17
+ Basecamp (http://www.basecamphq.com) plans
18
+ without the time tracking feature.
19
+
20
+ How it works:
21
+
22
+ DoTime cheats by keeping track of the time done on a todo within the todo itself.
23
+ It specifies a format to keep track of the elapsed hours and then just updates it as necessary.
24
+
25
+ DoTime is very easy to use. Just follow the menu options.
26
+
27
+
28
+ Examples:
29
+ dotime -u my_basecamp_login -p my_basecamp_password
30
+ EOF
31
+
32
+ opts.on(" Required:")
33
+
34
+ opts.on("-u", "--user=login", "A Basecamp account login", String) { |OPTIONS[:login]| }
35
+ opts.on("-p", "--password=password", "A Basecamp account password", String) { |OPTIONS[:password]| }
36
+
37
+ opts.separator ""
38
+ opts.separator "Options:"
39
+
40
+ opts.on("-s", "--[no-]ssl", "Use SSL when connecting (Default: #{OPTIONS[:use_ssl]})", String) { |OPTIONS[:use_ssl]| }
41
+ opts.on("-h", "--help", "Show this help message.") { puts opts; exit }
42
+
43
+ opts.parse!
44
+ end
45
+
46
+ unless OPTIONS[:login] && OPTIONS[:password]
47
+ puts ARGV.options; exit
48
+ else
49
+ DoTime.new([OPTIONS[:login], OPTIONS[:password], OPTIONS[:use_ssl]]).run
50
+ end
data/lib/basecamp.rb ADDED
@@ -0,0 +1,475 @@
1
+ # the following are all standard ruby libraries
2
+ require 'net/https'
3
+ require 'yaml'
4
+ require 'date'
5
+ require 'time'
6
+
7
+ begin
8
+ require 'xmlsimple'
9
+ rescue LoadError
10
+ begin
11
+ require 'rubygems'
12
+ require_gem 'xml-simple'
13
+ rescue LoadError
14
+ abort <<-ERROR
15
+ The 'xml-simple' library could not be loaded. If you have RubyGems installed
16
+ you can install xml-simple by doing "gem install xml-simple".
17
+ ERROR
18
+ end
19
+ end
20
+
21
+ # An interface to the Basecamp web-services API. Usage is straightforward:
22
+ #
23
+ # session = Basecamp.new('your.basecamp.com', 'username', 'password')
24
+ # puts "projects: #{session.projects.length}"
25
+ class Basecamp
26
+
27
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
28
+ class Record #:nodoc:
29
+ attr_reader :type
30
+
31
+ def initialize(type, hash)
32
+ @type = type
33
+ @hash = hash
34
+ end
35
+
36
+ def [](name)
37
+ name = dashify(name)
38
+ case @hash[name]
39
+ when Hash then
40
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
41
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
42
+ Record.new(name, @hash[name])
43
+ else @hash[name]
44
+ end
45
+ end
46
+
47
+ def id
48
+ @hash["id"]
49
+ end
50
+
51
+ def attributes
52
+ @hash.keys
53
+ end
54
+
55
+ def respond_to?(sym)
56
+ super || @hash.has_key?(dashify(sym))
57
+ end
58
+
59
+ def method_missing(sym, *args)
60
+ if args.empty? && !block_given? && respond_to?(sym)
61
+ self[sym]
62
+ else
63
+ super
64
+ end
65
+ end
66
+
67
+ def to_s
68
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
69
+ end
70
+
71
+ def inspect
72
+ to_s
73
+ end
74
+
75
+ private
76
+
77
+ def dashify(name)
78
+ name.to_s.tr("_", "-")
79
+ end
80
+ end
81
+
82
+ # A wrapper to represent a file that should be uploaded. This is used so that
83
+ # the form/multi-part encoder knows when to encode a field as a file, versus
84
+ # when to encode it as a simple field.
85
+ class FileUpload
86
+ attr_reader :filename, :content
87
+
88
+ def initialize(filename, content)
89
+ @filename = filename
90
+ @content = content
91
+ end
92
+ end
93
+
94
+ attr_accessor :use_xml
95
+
96
+ # Connects
97
+ def initialize(url, user_name, password, use_ssl = false)
98
+ @use_xml = false
99
+ @user_name, @password = user_name, password
100
+ connect!(url, use_ssl)
101
+ end
102
+
103
+ # Return the list of all accessible projects.
104
+ def projects
105
+ records "project", "/project/list"
106
+ end
107
+
108
+ # Returns the list of message categories for the given project
109
+ def message_categories(project_id)
110
+ records "post-category", "/projects/#{project_id}/post_categories"
111
+ end
112
+
113
+ # Returns the list of file categories for the given project
114
+ def file_categories(project_id)
115
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
116
+ end
117
+
118
+ # Return information for the company with the given id
119
+ def company(id)
120
+ record "/contacts/company/#{id}"
121
+ end
122
+
123
+ # Return an array of the people in the given company. If the project-id is
124
+ # given, only people who have access to the given project will be returned.
125
+ def people(company_id, project_id=nil)
126
+ url = project_id ? "/projects/#{project_id}" : ""
127
+ url << "/contacts/people/#{company_id}"
128
+ records "person", url
129
+ end
130
+
131
+ # Return information about the person with the given id
132
+ def person(id)
133
+ record "/contacts/person/#{id}"
134
+ end
135
+
136
+ # Return information about the message(s) with the given id(s). The API
137
+ # limits you to requesting 25 messages at a time, so if you need to get more
138
+ # than that, you'll need to do it in multiple requests.
139
+ def message(*ids)
140
+ result = records("post", "/msg/get/#{ids.join(",")}")
141
+ result.length == 1 ? result.first : result
142
+ end
143
+
144
+ # Returns a summary of all messages in the given project (and category, if
145
+ # specified). The summary is simply the title and category of the message,
146
+ # as well as the number of attachments (if any).
147
+ def message_list(project_id, category_id=nil)
148
+ url = "/projects/#{project_id}/msg"
149
+ url << "/cat/#{category_id}" if category_id
150
+ url << "/archive"
151
+
152
+ records "post", url
153
+ end
154
+
155
+ # Create a new message in the given project. The +message+ parameter should
156
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
157
+ # should be notified of the post.
158
+ #
159
+ # If you want to add attachments to the message, the +attachments+ parameter
160
+ # should be an array of hashes, where each has has a :name key (optional),
161
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
162
+ # instance.
163
+ #
164
+ # msg = session.post_message(158141,
165
+ # { :title => "Requirements",
166
+ # :body => "Here are the requirements documents you asked for.",
167
+ # :category_id => 2301121 },
168
+ # [john.id, martha.id],
169
+ # [ { :name => "Primary Requirements",
170
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
171
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
172
+ def post_message(project_id, message, notify=[], attachments=[])
173
+ prepare_attachments(attachments)
174
+ record "/projects/#{project_id}/msg/create",
175
+ :post => message,
176
+ :notify => notify,
177
+ :attachments => attachments
178
+ end
179
+
180
+ # Edit the message with the given id. The +message+ parameter should
181
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
182
+ # should be notified of the post.
183
+ #
184
+ # The +attachments+ parameter, if used, should be the same as described for
185
+ # #post_message.
186
+ def update_message(id, message, notify=[], attachments=[])
187
+ prepare_attachments(attachments)
188
+ record "/msg/update/#{id}",
189
+ :post => message,
190
+ :notify => notify,
191
+ :attachments => attachments
192
+ end
193
+
194
+ # Deletes the message with the given id, and returns it.
195
+ def delete_message(id)
196
+ record "/msg/delete/#{id}"
197
+ end
198
+
199
+ # Return a list of the comments for the specified message.
200
+ def comments(post_id)
201
+ records "comment", "/msg/comments/#{post_id}"
202
+ end
203
+
204
+ # Retrieve a specific comment
205
+ def comment(id)
206
+ record "/msg/comment/#{id}"
207
+ end
208
+
209
+ # Add a new comment to a message. +comment+ must be a hash describing the
210
+ # comment. You can add attachments to the comment, too, by giving them in
211
+ # an array. See the #post_message method for a description of how to do that.
212
+ def create_comment(post_id, comment, attachments=[])
213
+ prepare_attachments(attachments)
214
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
215
+ :attachments => attachments
216
+ end
217
+
218
+ # Update the given comment. Attachments follow the same format as #post_message.
219
+ def update_comment(id, comment, attachments=[])
220
+ prepare_attachments(attachments)
221
+ record "/msg/update_comment", :comment_id => id,
222
+ :comment => comment, :attachments => attachments
223
+ end
224
+
225
+ # Deletes (and returns) the given comment.
226
+ def delete_comment(id)
227
+ record "/msg/delete_comment/#{id}"
228
+ end
229
+
230
+ # =========================================================================
231
+ # TODO LISTS AND ITEMS
232
+ # =========================================================================
233
+
234
+ # Marks the given item completed.
235
+ def complete_item(id)
236
+ record "/todos/complete_item/#{id}"
237
+ end
238
+
239
+ # Marks the given item uncompleted.
240
+ def uncomplete_item(id)
241
+ record "/todos/uncomplete_item/#{id}"
242
+ end
243
+
244
+ # Creates a new to-do item.
245
+ def create_item(list_id, content, responsible_party=nil, notify=true)
246
+ record "/todos/create_item/#{list_id}",
247
+ :content => content, :responsible_party => responsible_party,
248
+ :notify => notify
249
+ end
250
+
251
+ # Creates a new list using the given hash of list metadata.
252
+ def create_list(project_id, list)
253
+ record "/projects/#{project_id}/todos/create_list", list
254
+ end
255
+
256
+ # Deletes the given item from it's parent list.
257
+ def delete_item(id)
258
+ record "/todos/delete_item/#{id}"
259
+ end
260
+
261
+ # Deletes the given list and all of its items.
262
+ def delete_list(id)
263
+ record "/todos/delete_list/#{id}"
264
+ end
265
+
266
+ # Retrieves the specified list, and all of its items.
267
+ def get_list(id)
268
+ record "/todos/list/#{id}"
269
+ end
270
+
271
+ # Return all lists for a project. If complete is true, only completed lists
272
+ # are returned. If complete is false, only uncompleted lists are returned.
273
+ def lists(project_id, complete=nil)
274
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
275
+ end
276
+
277
+ # Repositions an item to be at the given position in its list
278
+ def move_item(id, to)
279
+ record "/todos/move_item/#{id}", :to => to
280
+ end
281
+
282
+ # Repositions a list to be at the given position in its project
283
+ def move_list(id, to)
284
+ record "/todos/move_list/#{id}", :to => to
285
+ end
286
+
287
+ # Updates the given item
288
+ def update_item(id, content, responsible_party=nil, notify=true)
289
+ record "/todos/update_item/#{id}",
290
+ :item => { :content => content }, :responsible_party => responsible_party,
291
+ :notify => notify
292
+ end
293
+
294
+ # Updates the given list's metadata
295
+ def update_list(id, list)
296
+ record "/todos/update_list/#{id}", :list => list
297
+ end
298
+
299
+ # =========================================================================
300
+ # MILESTONES
301
+ # =========================================================================
302
+
303
+ # Complete the milestone with the given id
304
+ def complete_milestone(id)
305
+ record "/milestones/complete/#{id}"
306
+ end
307
+
308
+ # Create a new milestone for the given project. +data+ must be hash of the
309
+ # values to set, including +title+, +deadline+, +responsible_party+, and
310
+ # +notify+.
311
+ def create_milestone(project_id, data)
312
+ create_milestones(project_id, [data]).first
313
+ end
314
+
315
+ # As #create_milestone, but can create multiple milestones in a single
316
+ # request. The +milestones+ parameter must be an array of milestone values as
317
+ # descrbed in #create_milestone.
318
+ def create_milestones(project_id, milestones)
319
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
320
+ end
321
+
322
+ # Destroys the milestone with the given id.
323
+ def delete_milestone(id)
324
+ record "/milestones/delete/#{id}"
325
+ end
326
+
327
+ # Returns a list of all milestones for the given project, optionally filtered
328
+ # by whether they are completed, late, or upcoming.
329
+ def milestones(project_id, find="all")
330
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
331
+ end
332
+
333
+ # Uncomplete the milestone with the given id
334
+ def uncomplete_milestone(id)
335
+ record "/milestones/uncomplete/#{id}"
336
+ end
337
+
338
+ # Updates an existing milestone.
339
+ def update_milestone(id, data, move=false, move_off_weekends=false)
340
+ record "/milestones/update/#{id}", :milestone => data,
341
+ :move_upcoming_milestones => move,
342
+ :move_upcoming_milestones_off_weekends => move_off_weekends
343
+ end
344
+
345
+ # Make a raw web-service request to Basecamp. This will return a Hash of
346
+ # Arrays of the response, and may seem a little odd to the uninitiated.
347
+ def request(path, parameters = {}, second_try = false)
348
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
349
+
350
+ if response.code.to_i / 100 == 2
351
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
352
+ 'contentkey' => '__content__', 'forcecontent' => true)
353
+ typecast_value(result)
354
+ elsif response.code == "302" && !second_try
355
+ connect!(@url, !@use_ssl)
356
+ request(path, parameters, true)
357
+ else
358
+ raise "#{response.message} (#{response.code})"
359
+ end
360
+ end
361
+
362
+ # A convenience method for wrapping the result of a query in a Record
363
+ # object. This assumes that the result is a singleton, not a collection.
364
+ def record(path, parameters={})
365
+ result = request(path, parameters)
366
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
367
+ end
368
+
369
+ # A convenience method for wrapping the result of a query in Record
370
+ # objects. This assumes that the result is a collection--any singleton
371
+ # result will be wrapped in an array.
372
+ def records(node, path, parameters={})
373
+ result = request(path, parameters).values.first or return []
374
+ result = result[node] or return []
375
+ result = [result] unless Array === result
376
+ result.map { |row| Record.new(node, row) }
377
+ end
378
+
379
+ private
380
+
381
+ def connect!(url, use_ssl)
382
+ @use_ssl = use_ssl
383
+ @url = url
384
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
385
+ @connection.use_ssl = @use_ssl
386
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
387
+ end
388
+
389
+ def convert_body(body)
390
+ body = use_xml ? body.to_xml : body.to_yaml
391
+ end
392
+
393
+ def content_type
394
+ use_xml ? "application/xml" : "application/x-yaml"
395
+ end
396
+
397
+ def post(path, body, header={})
398
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
399
+ request.basic_auth(@user_name, @password)
400
+ @connection.request(request, body)
401
+ end
402
+
403
+ def store_file(contents)
404
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
405
+ 'Accept' => 'application/xml')
406
+
407
+ if response.code == "200"
408
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
409
+ return result["upload"]["id"]
410
+ else
411
+ raise "Could not store file: #{response.message} (#{response.code})"
412
+ end
413
+ end
414
+
415
+ def typecast_value(value)
416
+ case value
417
+ when Hash
418
+ if value.has_key?("__content__")
419
+ content = translate_entities(value["__content__"]).strip
420
+ case value["type"]
421
+ when "integer" then content.to_i
422
+ when "boolean" then content == "true"
423
+ when "datetime" then Time.parse(content)
424
+ when "date" then Date.parse(content)
425
+ else content
426
+ end
427
+ else
428
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
429
+ h[k] = typecast_value(v)
430
+ h
431
+ end
432
+ end
433
+ when Array
434
+ value.map! { |i| typecast_value(i) }
435
+ case value.length
436
+ when 0 then nil
437
+ when 1 then value.first
438
+ else value
439
+ end
440
+ else
441
+ raise "can't typecast #{value.inspect}"
442
+ end
443
+ end
444
+
445
+ def translate_entities(value)
446
+ value.gsub(/&lt;/, "<").
447
+ gsub(/&gt;/, ">").
448
+ gsub(/&quot;/, '"').
449
+ gsub(/&apos;/, "'").
450
+ gsub(/&amp;/, "&")
451
+ end
452
+
453
+ def prepare_attachments(list)
454
+ (list || []).each do |data|
455
+ upload = data[:file]
456
+ id = store_file(upload.content)
457
+ data[:file] = { :file => id,
458
+ :content_type => "application/octet-stream",
459
+ :original_filename => upload.filename }
460
+ end
461
+ end
462
+ end
463
+
464
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
465
+ class Symbol
466
+ def [](*args)
467
+ to_s[*args]
468
+ end
469
+ end
470
+
471
+ class Hash
472
+ def to_xml
473
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
474
+ end
475
+ end
data/lib/do_time.rb ADDED
@@ -0,0 +1,544 @@
1
+ begin
2
+ require 'rubygems'
3
+ require "highline/import"
4
+ require "highline/system_extensions"
5
+ rescue LoadError
6
+ begin
7
+ require "highline/system_extensions"
8
+ require "highline/import"
9
+ rescue LoadError
10
+ abort <<-ERROR
11
+ The 'highline' library could not be loaded. If you have RubyGems installed
12
+ you can install HighLine by doing "gem install highline".
13
+ ERROR
14
+ end
15
+ end
16
+
17
+ require File.dirname(__FILE__) + '/basecamp'
18
+ require 'optparse'
19
+
20
+ class DoTime
21
+ VERSION = '0.0.3'
22
+
23
+ include HighLine::SystemExtensions
24
+
25
+ ENTER_KEY = 10
26
+ SPACE_KEY = 32
27
+
28
+ BASIC_ACTIONS = [
29
+ ['p', 'Select a different project','Select from a list of the active projects belonging to owner'],
30
+ ['l', 'Select a different list','Select a different todo list from those available in the current projects'],
31
+ ['r', 'Refresh list','Restart session and refresh status of todos in current list'],
32
+ ['q', 'Quit DoTime', 'Just exit this app']
33
+ ]
34
+
35
+ ACTIVE_LIST_ACTIONS = [
36
+ ['1-9/01-99', 'Start/Stop todo timer','Press the number of the todo to start/stop timing it. If more than 9 items in list use 01-09.'],
37
+ ['c', 'Complete todo','Complete a running todo e.g. type c3 to complete todo no 3.'],
38
+ [ENTER_KEY, 'Complete running todo','Completes the only running todo. Otherwise starts next available todo'],
39
+ [SPACE_KEY, 'Stop running todo','Stop timing the only running todo. Otherwise starts next available todo']
40
+ ]
41
+
42
+ attr_accessor :login, :password, :use_ssl, :session, :current_owner, :current_project, :valid_lists, :current_list, :running_todos
43
+
44
+ def initialize(args)
45
+ @login, @password, @use_ssl = *args
46
+ init_bc_session
47
+ @valid_lists = []
48
+ @running_todos = {}
49
+ end
50
+
51
+ #Entry point to DoTime
52
+ def run
53
+ clear_screen
54
+ unless @current_project
55
+ select_project
56
+ end
57
+
58
+ no_valid_lists if @valid_lists.empty?
59
+
60
+ if !@current_list && (@valid_lists && !@valid_lists.empty?)
61
+ select_list
62
+ end
63
+
64
+ make_selections
65
+ end
66
+
67
+ protected
68
+
69
+ #Starts a new Basecamp API session
70
+ def init_bc_session
71
+ @session = Basecamp.new('webtypes.projectpath.com',@login, @password, @use_ssl)
72
+ end
73
+
74
+ #Print no valid lists warning
75
+ def no_valid_lists
76
+ say "<%=color('No valid lists for that user! Select either another owner or project.',:red, :bold)%>"
77
+ end
78
+
79
+ #Main key selection loop
80
+ #Loops until quit is selected performing all actions
81
+ def make_selections
82
+ if @current_list
83
+ actions = ACTIVE_LIST_ACTIONS + BASIC_ACTIONS
84
+ else
85
+ actions = BASIC_ACTIONS
86
+ end
87
+
88
+ while /q/i !~ (char = show_todos_and_select_action(actions)).chr
89
+ char = char.chr unless [ENTER_KEY, SPACE_KEY].include?(char)
90
+ case char
91
+ when /p/i
92
+ clear_screen
93
+ select_project
94
+ select_list
95
+ when /l/i
96
+ clear_screen
97
+ select_list
98
+ when /c/i
99
+ char = get_character.chr
100
+ position = get_position(char)
101
+ clear_screen
102
+ complete_todo(position)
103
+ when /r/i
104
+ clear_screen
105
+ refresh_and_print
106
+ when ENTER_KEY
107
+ clear_screen
108
+ complete_running_todo
109
+ when SPACE_KEY
110
+ clear_screen
111
+ stop_running_todo
112
+ puts "SPACE"
113
+ when /\d/
114
+ position = get_position(char)
115
+ clear_screen
116
+ start_stop_todo(position)
117
+ else
118
+ say "<%=color('Unsupported key...',:red, :bold)%>" and sleep 1
119
+ clear_screen
120
+ end
121
+ end
122
+ say_goodbye
123
+ end
124
+
125
+ def get_position(first_digit)
126
+ second_digit = nil
127
+ case all_todos.size
128
+ when 10..99
129
+ second_digit = get_character.chr
130
+ else
131
+ end
132
+ unless second_digit
133
+ position = first_digit
134
+ else
135
+ position = first_digit == 0 ? "0#{second_digit}" : "#{first_digit}#{second_digit}"
136
+ end
137
+ position
138
+ end
139
+
140
+ def say_goodbye
141
+ puts
142
+ puts "Bye..."
143
+ end
144
+
145
+ #Complete a running todo if only one running
146
+ #else starts next available todo
147
+ def complete_running_todo
148
+ unless @running_todos.size == 1
149
+ puts "Starting next available..."
150
+ start_next_available_todo
151
+ return
152
+ end
153
+ stop_timing_todo(find_todo_by_id(@running_todos.keys.first), true)
154
+ end
155
+
156
+ #Stop timing a running todo if only one running
157
+ #else starts next available todo
158
+ def stop_running_todo
159
+ unless @running_todos.size == 1
160
+ puts "Starting next available..."
161
+ start_next_available_todo
162
+ return
163
+ end
164
+ stop_timing_todo(find_todo_by_id(@running_todos.keys.first), false)
165
+ end
166
+
167
+ #Start timing next available todo
168
+ #i.e. First todo in list that is not running
169
+ def start_next_available_todo
170
+ next_todo = find_next_available_todo
171
+ unless next_todo
172
+ say "<%=color('No available todos to start...',:red, :bold)%>" and return
173
+ end
174
+ start_timing_todo(next_todo)
175
+ end
176
+
177
+ #Searches list of valid todos and returns first todo that is not running
178
+ #Note: todos list is ordered by the todo 'position' attribute
179
+ def find_next_available_todo
180
+ all_todos.find do |todo|
181
+ !@running_todos.has_key?(todo.id)
182
+ end
183
+ end
184
+
185
+ #Find a todo within the currently active list by its 'id' attribute
186
+ def find_todo_by_id(id)
187
+ all_todos.find do |todo|
188
+ todo.id == id
189
+ end
190
+ end
191
+
192
+ #Prints the list of todos, the available actions
193
+ #and waits for a user response
194
+ #Returns the key pressed.
195
+ def show_todos_and_select_action(actions)
196
+ print_todos
197
+ print_actions(actions)
198
+ get_character
199
+ end
200
+
201
+ #Start or stop a todo found via it's position in the list
202
+ #Toggles timer for the selected todo
203
+ def start_stop_todo(position)
204
+ selected_todo = all_todos[position.to_i - 1]
205
+ unless selected_todo
206
+ say "<%=color('Not available',:red,:bold)%>"
207
+ return
208
+ end
209
+ unless @running_todos[selected_todo.id]
210
+ start_timing_todo(selected_todo)
211
+ else
212
+ stop_timing_todo(selected_todo, false)
213
+ end
214
+ end
215
+
216
+ #Prints the available user actions
217
+ #Each action is bound to a specific key (case insensitive)
218
+ def print_actions(actions)
219
+
220
+ say "<%=color('Actions',:blue, :bold, :underline)%>"
221
+ puts
222
+ actions.each do |option|
223
+ key = key_to_char(option[0])
224
+ title = option[1]
225
+ desc = option[2]
226
+
227
+ say "'#{key}' - #{title} (<%=color('#{desc}',:cyan)%>)"
228
+ end
229
+
230
+ puts
231
+ print 'Choose action? >'
232
+ end
233
+
234
+ #Displays the text character of a key code
235
+ def key_to_char(key)
236
+ case key
237
+ when 10
238
+ 'ENTER'
239
+ when 32
240
+ 'SPACE'
241
+ else
242
+ key
243
+ end
244
+ end
245
+
246
+ #Returns abbreviated version of the todo's 'content' attribute
247
+ def brief_todo(todo)
248
+ todo['content'][0..20]
249
+ end
250
+
251
+ #Complete a todo whose 'id' attribute is supplied.
252
+ def complete_todo(id)
253
+ clear_screen
254
+ selected_todo = all_todos[id.to_i - 1]
255
+ unless selected_todo
256
+ say "<%=color('#{id} doesn\\'t correspond to a todo in the current list',:red, :bold)%>"
257
+ return
258
+ end
259
+
260
+ unless selected_todo && @running_todos.has_key?(selected_todo.id)
261
+ say "<%=color('Timer hasn\\'t started for \"#{brief_todo(selected_todo)}\"',:red, :bold)%>"
262
+ return
263
+ end
264
+ stop_timing_todo(selected_todo, true)
265
+ end
266
+
267
+ #Add a timer to the supplied todo
268
+ def start_timing_todo(todo)
269
+ @running_todos[todo.id] = Time.now
270
+ say "<%=color('Timer started for \"#{brief_todo(todo)}\"',:green, :bold)%>"
271
+ end
272
+
273
+ #Stop the timer on a selected todo
274
+ #By adding a complete argument you can specify
275
+ #wether the todo is completed or not.
276
+ def stop_timing_todo(todo, complete = false)
277
+ elapsed_time = Time.now - @running_todos.delete(todo.id)
278
+ update_todo_with_elapsed_time(todo, elapsed_time, complete)
279
+ say "<%=color('Stopped timer for \"#{brief_todo(todo)}\"',:green, :bold)%>"
280
+ say "<%=color('Elapsed time: #{time(elapsed_time)}',:black, :bold)%>"
281
+ puts
282
+ refresh
283
+ end
284
+
285
+ #Pretty prints the supplied time (in seconds)
286
+ #If a minute or more, minutes are used
287
+ #If an hour or more, hours are used
288
+ def time(time)
289
+ case time
290
+ when 0..59
291
+ "#{time} seconds"
292
+ when 60..3599
293
+ "#{time/60} minutes"
294
+ else
295
+ "#{time/3600} hours"
296
+ end
297
+ end
298
+
299
+ #Filter out any non-active projects
300
+ def active_projects(cache = true)
301
+ say "Getting projects ..."
302
+ @active_projects = nil unless cache
303
+ @active_projects ||= @session.projects.select do |project|
304
+ print "."
305
+ project['status'] == 'active'
306
+ end
307
+ clear_screen
308
+ @active_projects
309
+ end
310
+
311
+ #Find project && select valid lists for current owner
312
+ def select_project
313
+ bc_projects = active_projects
314
+ @current_project = nil
315
+ @current_project = bc_projects.first if bc_projects.size == 1
316
+ @current_project ||= bc_projects[select_from("Select a project?", bc_projects.collect {|p| p['name']})[1]]
317
+
318
+ clear_screen
319
+ unless @current_owner
320
+ find_logged_in_user
321
+ clear_screen
322
+ end
323
+
324
+ select_valid_lists
325
+ end
326
+
327
+ #Filter out any of current owner's lists
328
+ #that do not have at least one todo that isn't complete
329
+ def select_valid_lists
330
+ say "Selecting valid lists ... <%=color('please wait...',:black, :bold)%>"
331
+ @valid_lists.clear
332
+ bc_lists = @session.lists(@current_project.id, false)
333
+
334
+ #find lists with todos that belong to person and aren't completed
335
+ bc_lists.each do |bc_list|
336
+ print "."
337
+ list = @session.get_list(bc_list.id)
338
+ if list['todo-items'].is_a? Array
339
+ any = list['todo-items'].any? do |todo|
340
+ todo['responsible-party-id'] == @current_owner.id && !todo['completed']
341
+ end
342
+ @valid_lists << list if any
343
+ else
344
+ @valid_lists << list if list['todo-items'] && list['todo-items']['todo-item']['responsible-party-id'] == @current_owner.id && !list['todo-items']['todo-item']['completed']
345
+ end
346
+ end
347
+ clear_screen
348
+ end
349
+
350
+ #Select current todo list to work with
351
+ def select_list
352
+ @current_list = nil
353
+ if @valid_lists && !@valid_lists.empty?
354
+ @current_list = @valid_lists.first if @valid_lists.size == 1
355
+ @current_list ||= @valid_lists[select_from("Select a todo list?", @valid_lists.collect {|l| l['name']})[1]]
356
+ clear_screen
357
+ print_todos
358
+ else
359
+ clear_screen
360
+ no_valid_lists
361
+ end
362
+ end
363
+
364
+ #Find the logged in users Basecamp api person record
365
+ #TODO: Deprecate if available in future basecampe api versions
366
+ def find_logged_in_user
367
+ say "Finding logged in user ..."
368
+ category = @session.message_categories(@current_project.id).first
369
+ fake_message = @session.post_message(@current_project.id,
370
+ { :title => "[TEMP] #{Time.now}",
371
+ :body => "Must be deleted!",
372
+ :category_id => category.id })
373
+ @current_owner = @session.person(fake_message['author-id'])
374
+ @session.delete_message(fake_message.id)
375
+ @current_owner
376
+ end
377
+
378
+ #Returns all valid todos sorted by their 'position' attribute
379
+ #irrespective of wether they've timers running or not
380
+ def all_todos
381
+ sort_todos(list_valid_todos)
382
+ end
383
+
384
+ #Returns a list of the currently running todos
385
+ def running_todos
386
+ all_todos.select {|t| @running_todos.keys.include?(t.id)}
387
+ end
388
+
389
+ #Returns a list of all the todos that are currently not running
390
+ def not_running_todos
391
+ all_todos - running_todos
392
+ end
393
+
394
+ #Recreate list of valid todos from currently active todo list
395
+ #By valid todos I mean, todos that are still to be completed
396
+ def list_valid_todos
397
+ valid_todos = []
398
+ if @current_list['todo-items'].is_a? Array
399
+ @current_list['todo-items'].each do |todo|
400
+ valid_todos << todo if todo['responsible-party-id'] == @current_owner.id && !todo['completed']
401
+ end
402
+ else
403
+ valid_todos << @current_list['todo-items']['todo-item'] if @current_list['todo-items']['todo-item']['responsible-party-id'] == @current_owner.id && !@current_list['todo-items']['todo-item']['completed']
404
+ end
405
+ valid_todos
406
+ end
407
+
408
+
409
+ #Sort todos by their 'position' attribute
410
+ def sort_todos(todos)
411
+ todos.sort {|x,y| x['position'] <=> y['position']}
412
+ end
413
+
414
+ #Print status list for all valid todos
415
+ def print_todos
416
+ print_todos_for(all_todos)
417
+ end
418
+
419
+ #Reinitialise the connection to the Basecamp api
420
+ #and refresh the currently active list
421
+ def refresh
422
+ init_bc_session
423
+ @current_list = @session.get_list(@current_list.id)
424
+ end
425
+
426
+ #Print list of todos showing their current status (Running or not) and elapsed times.
427
+ def print_todos_for(list)
428
+ clear_screen
429
+ say "[<%= color('#{@current_owner['first-name'].to_s}', :bold)%> <%= color('#{@current_owner['last-name'].to_s}',:bold)%>] Project: <%= color('#{@current_project['name'].to_s}',:bold)%>"
430
+ say "Todo's for list: <%=color('#{@current_list['name'].to_s}', :green, :bold)%> (Total elapsed time: #{time(calc_total_elapsed_time*3600)}):"
431
+ puts
432
+ list.each_with_index do |todo, i|
433
+ hours = extract_elapsed_time_from(todo)
434
+ running = is_todo_running?(todo) ? "[Running]" : ""
435
+ say "<%=color('#{(i + 1).to_s}',:black, :bold)%>. - #{todo['content'].gsub(/(\[.*\])/,'').strip}"
436
+ say "<%=color('Elapsed',:black, :bold)%>: #{time(hours*3600)} <%= color('#{running}',:red, :bold)%>"
437
+ puts
438
+ end
439
+ puts
440
+ puts
441
+ end
442
+
443
+ #Calculate the total elapsed time for the currently active todo list
444
+ def calc_total_elapsed_time
445
+ time = 0.0
446
+ all_todos.each do |todo|
447
+ time += extract_elapsed_time_from(todo)
448
+ end
449
+ time
450
+ end
451
+
452
+ #Determines wether the supplied todo has a running timer or not
453
+ def is_todo_running?(todo)
454
+ @running_todos.has_key?(todo.id)
455
+ end
456
+
457
+ #Extract the elapsed time from the todos 'content' attribute
458
+ #Should have a string of text at the end of the content in the
459
+ #following format: [[0.1]]
460
+ def extract_elapsed_time_from(todo)
461
+ time = has_time_tracker?(todo) ? has_time_tracker?(todo)[1].to_f : 0.0
462
+ end
463
+
464
+ #Determines wether the todo's 'content' attribute has the time tracking pattern: [[0.1]]
465
+ def has_time_tracker?(todo)
466
+ todo['content'].match(/\[\[(.*)\]\]/)
467
+ end
468
+
469
+ #Update a todo's 'content' attribute with the elapsed time.
470
+ #By passing in the 'complete' argument can toggle wether todo is completed
471
+ #or not
472
+ def update_todo_with_elapsed_time(todo, elapsed_time, complete = false)
473
+ if has_time_tracker?(todo)
474
+ previous_elapsed_time = extract_elapsed_time_from(todo)
475
+ new_elapsed_time = previous_elapsed_time + time_for_tracker(elapsed_time)
476
+ update_todo_with(todo, update_content_with(todo['content'], new_elapsed_time))
477
+ complete_item(todo) and puts "Completing" if complete
478
+ else
479
+ add_time_tracker_to(todo, time_for_tracker(elapsed_time))
480
+ complete_item(todo) and puts "Completing" if complete
481
+ end
482
+ end
483
+
484
+ #Actual Basecamp api method to complete a todo
485
+ def complete_item(todo)
486
+ @session.complete_item(todo.id)
487
+ end
488
+
489
+ #Add time tracking format string to todo content
490
+ def add_time_tracker_to(todo, elapsed_time)
491
+ content = todo['content']
492
+ content << " [[#{elapsed_time}]]"
493
+ update_todo_with(todo, content)
494
+ end
495
+
496
+ #Replaces string with format '[[1.1]]' with wlapsed time
497
+ def update_content_with(content, elapsed_time)
498
+ content.gsub(/\[\[(.*)\]\]/, "[[#{elapsed_time}]]")
499
+ end
500
+
501
+ #Perform list item update via BaseCamp api
502
+ def update_todo_with(todo, content)
503
+ @session.update_item(todo.id, content, @current_owner.id, false)
504
+ end
505
+
506
+ #Calculate time in hours for seconds argument
507
+ def time_for_tracker(elapsed_time)
508
+ (((elapsed_time/3600.0) * 100).round)/100.0
509
+ end
510
+
511
+ #Clear screen via OS clear command
512
+ #TODO: System specific???
513
+ def clear_screen
514
+ system("clear")
515
+ end
516
+
517
+ # Choose from a list of options. +question+ is a prompt displayed
518
+ # above the list. +list+ is a list of option strings.
519
+ # Returns the pair [option_name, option_index].
520
+ def select_from(question, list)
521
+ say "<%=color('#{question.strip}',:bold)%>"
522
+ list.each_with_index do |item, index|
523
+ puts " #{index+1}. #{item}"
524
+ end
525
+ puts " q - quit"
526
+ print "> "
527
+ while /q/i !~ (key = get_character.chr)
528
+ case key.to_i
529
+ when 1..list.size
530
+ result = key.to_i - 1
531
+ return list[result], result
532
+ else
533
+ nil
534
+ end
535
+ end
536
+ say_goodbye
537
+ exit!
538
+ end
539
+
540
+ def refresh_and_print
541
+ refresh
542
+ print_todos
543
+ end
544
+ end
File without changes
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ rubygems_version: 0.9.0
3
+ specification_version: 1
4
+ name: dotime
5
+ version: !ruby/object:Gem::Version
6
+ version: 0.0.3
7
+ date: 2006-12-24 00:00:00 +00:00
8
+ summary: Cheap time tracking for Basecamp todo lists
9
+ require_paths:
10
+ - lib
11
+ email: ryand-ruby@zenspider.com
12
+ homepage: " by Saimon Moore"
13
+ rubyforge_project: dotime
14
+ description: "This is a cheap time tracking substitute for those of us who can't afford the extra cash (or believe all basecamp projects require time tracking) for Basecamp (http://www.basecamphq.com) plans with the time tracking feature. How it works: DoTime cheats by keeping track of the time worked on a todo within the todo itself. It specifies a text format to keep track of the elapsed hours and then just updates it as necessary. == FEATURES/PROBLEMS:"
15
+ autorequire: do_time
16
+ default_executable: dotime
17
+ bindir: bin
18
+ has_rdoc: true
19
+ required_ruby_version: !ruby/object:Gem::Version::Requirement
20
+ requirements:
21
+ - - ">"
22
+ - !ruby/object:Gem::Version
23
+ version: 0.0.0
24
+ version:
25
+ platform: ruby
26
+ signing_key:
27
+ cert_chain:
28
+ post_install_message:
29
+ authors:
30
+ - Saimon Moore
31
+ files:
32
+ - History.txt
33
+ - Manifest.txt
34
+ - README.txt
35
+ - Rakefile
36
+ - bin/dotime
37
+ - lib/do_time.rb
38
+ - lib/basecamp.rb
39
+ - test/test_do_time.rb
40
+ test_files:
41
+ - test/test_do_time.rb
42
+ rdoc_options: []
43
+
44
+ extra_rdoc_files: []
45
+
46
+ executables:
47
+ - dotime
48
+ extensions: []
49
+
50
+ requirements: []
51
+
52
+ dependencies:
53
+ - !ruby/object:Gem::Dependency
54
+ name: xml-simple
55
+ version_requirement:
56
+ version_requirements: !ruby/object:Gem::Version::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.0.8
61
+ version:
62
+ - !ruby/object:Gem::Dependency
63
+ name: highline
64
+ version_requirement:
65
+ version_requirements: !ruby/object:Gem::Version::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 1.2.1
70
+ version: