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