dotime 0.0.3

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/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: