dotime 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +13 -0
- data/Manifest.txt +8 -0
- data/README.txt +81 -0
- data/Rakefile +21 -0
- data/bin/dotime +50 -0
- data/lib/basecamp.rb +475 -0
- data/lib/do_time.rb +544 -0
- data/test/test_do_time.rb +0 -0
- metadata +70 -0
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
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(/</, "<").
|
447
|
+
gsub(/>/, ">").
|
448
|
+
gsub(/"/, '"').
|
449
|
+
gsub(/'/, "'").
|
450
|
+
gsub(/&/, "&")
|
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:
|