harvest-reaper 0.1.1
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.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +6 -0
- data/assets/reaper_config_template.html +316 -0
- data/bin/console +14 -0
- data/bin/reaper +13 -0
- data/bin/setup +8 -0
- data/lib/.DS_Store +0 -0
- data/lib/reaper.rb +818 -0
- data/lib/reaper/version.rb +3 -0
- data/reaper.gemspec +46 -0
- metadata +147 -0
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "reaper"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/reaper
ADDED
data/bin/setup
ADDED
data/lib/.DS_Store
ADDED
Binary file
|
data/lib/reaper.rb
ADDED
@@ -0,0 +1,818 @@
|
|
1
|
+
lib = File.expand_path('..', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
|
4
|
+
require "reaper/version"
|
5
|
+
require 'socket'
|
6
|
+
require 'net/http'
|
7
|
+
require 'json'
|
8
|
+
require 'yaml'
|
9
|
+
require 'date'
|
10
|
+
require 'thor'
|
11
|
+
require 'terminal-table'
|
12
|
+
require 'ruby-progressbar'
|
13
|
+
|
14
|
+
module Reaper
|
15
|
+
class Error < StandardError; end
|
16
|
+
# Your code goes here...
|
17
|
+
|
18
|
+
HARVEST_CLIENT_ID = 'c4CbEqRlWx1ziSITWP03BwjN'
|
19
|
+
|
20
|
+
LOCAL_SERVER_PORT = 31390
|
21
|
+
|
22
|
+
LOGIN_FILE_PATH = File.join(Dir.home, '.reaper')
|
23
|
+
|
24
|
+
CONFIG_FILE_PATH = File.join(Dir.home, '.reaper_config')
|
25
|
+
|
26
|
+
module_function
|
27
|
+
|
28
|
+
def openWebpage(link)
|
29
|
+
system("open", link)
|
30
|
+
end
|
31
|
+
|
32
|
+
def request(endpoint)
|
33
|
+
uri = URI("https://api.harvestapp.com/v2/#{endpoint}")
|
34
|
+
req = Net::HTTP::Get.new(uri)
|
35
|
+
req['Accept'] = "application/json"
|
36
|
+
req['Authorization'] = "Bearer #{$token}"
|
37
|
+
req['Harvest-Account-ID'] = $acc_id
|
38
|
+
|
39
|
+
begin
|
40
|
+
res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) { |http|
|
41
|
+
http.request(req)
|
42
|
+
}
|
43
|
+
rescue
|
44
|
+
abort 'Cannot send request. Please check your network connection and try again.'
|
45
|
+
end
|
46
|
+
|
47
|
+
if res.kind_of? Net::HTTPSuccess
|
48
|
+
JSON.parse res.body
|
49
|
+
else
|
50
|
+
nil
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def request_delete(endpoint)
|
55
|
+
uri = URI("https://api.harvestapp.com/v2/#{endpoint}")
|
56
|
+
req = Net::HTTP::Delete.new(uri)
|
57
|
+
req['Accept'] = "application/json"
|
58
|
+
req['Content-Type'] = "application/json"
|
59
|
+
req['Authorization'] = "Bearer #{$token}"
|
60
|
+
req['Harvest-Account-ID'] = $acc_id
|
61
|
+
res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) { |http|
|
62
|
+
http.request(req)
|
63
|
+
}
|
64
|
+
|
65
|
+
if res.kind_of? Net::HTTPSuccess
|
66
|
+
JSON.parse res.body
|
67
|
+
else
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def check_auth
|
73
|
+
abort('Cannot find cached login info. Please run `reaper login` first.') unless restore_login_info
|
74
|
+
end
|
75
|
+
|
76
|
+
def restore_login_info
|
77
|
+
return false if !File.exist? LOGIN_FILE_PATH
|
78
|
+
login_data = YAML.load File.read(LOGIN_FILE_PATH)
|
79
|
+
if login_data[:harvest_login]
|
80
|
+
login = login_data[:harvest_login]
|
81
|
+
token = login[:token]
|
82
|
+
account_id = login[:account_id]
|
83
|
+
user_id = login[:user_id]
|
84
|
+
|
85
|
+
if token && !token.empty? &&
|
86
|
+
account_id && account_id.is_a?(Integer) &&
|
87
|
+
user_id && user_id.is_a?(Integer)
|
88
|
+
$token = token
|
89
|
+
$acc_id = account_id
|
90
|
+
$user_id = user_id
|
91
|
+
return true
|
92
|
+
end
|
93
|
+
end
|
94
|
+
false
|
95
|
+
end
|
96
|
+
|
97
|
+
def load_config
|
98
|
+
return false if !File.exist? CONFIG_FILE_PATH
|
99
|
+
$config = YAML.load File.read(CONFIG_FILE_PATH)
|
100
|
+
true
|
101
|
+
end
|
102
|
+
|
103
|
+
def show_config(config)
|
104
|
+
puts "Reaper Configuration (version #{$config[:version]})"
|
105
|
+
puts ''
|
106
|
+
|
107
|
+
title = 'Global Settings'
|
108
|
+
rows = []
|
109
|
+
rows << ['Daily working hours negative offset', "#{$config[:daily_negative_offset]} hour(s)"]
|
110
|
+
rows << ['Daily working hours positive offset', "#{$config[:daily_positive_offset]} hour(s)"]
|
111
|
+
table = Terminal::Table.new :title => title, :rows => rows
|
112
|
+
puts table
|
113
|
+
|
114
|
+
puts ''
|
115
|
+
|
116
|
+
title = 'Projects & Tasks Settings'
|
117
|
+
headers = ['Project', 'Task', 'Percentage']
|
118
|
+
|
119
|
+
rows = []
|
120
|
+
$config[:tasks].each do |task|
|
121
|
+
desc = "[#{task[:pcode]}] #{task[:pname]} (#{task[:client]})"
|
122
|
+
rows << [desc.scan(/.{1,30}/).join("\n"), task[:tname], "#{(task[:percentage] * 100).round}%"]
|
123
|
+
end
|
124
|
+
|
125
|
+
table = Terminal::Table.new :title => title, :headings => headers, :rows => rows
|
126
|
+
table.style = { :all_separators => true }
|
127
|
+
puts table
|
128
|
+
end
|
129
|
+
|
130
|
+
def list_projects
|
131
|
+
check_auth
|
132
|
+
|
133
|
+
puts "Fetching your project list..."
|
134
|
+
response = request 'users/me/project_assignments'
|
135
|
+
puts "Cannot fetch your project list, please try agian later." unless response
|
136
|
+
|
137
|
+
puts ''
|
138
|
+
|
139
|
+
projects = response['project_assignments']
|
140
|
+
# puts JSON.pretty_generate projects
|
141
|
+
|
142
|
+
projects = projects.map do |p|
|
143
|
+
pcode = p['project']['code']
|
144
|
+
pname = p['project']['name']
|
145
|
+
desc = "[#{pcode}] #{pname}"
|
146
|
+
tasks = p['task_assignments']
|
147
|
+
|
148
|
+
{
|
149
|
+
:project_id => p['project']['id'],
|
150
|
+
:project_name => pname,
|
151
|
+
:project_code => pcode,
|
152
|
+
:desc => desc,
|
153
|
+
:client => p['client']['name'],
|
154
|
+
:tasks => tasks.map do |t|
|
155
|
+
{
|
156
|
+
'tid' => t['task']['id'],
|
157
|
+
'name' => t['task']['name']
|
158
|
+
}
|
159
|
+
end
|
160
|
+
}
|
161
|
+
end
|
162
|
+
|
163
|
+
max_chars = (projects.max_by { |p| p[:desc].length })[:desc].length
|
164
|
+
|
165
|
+
clients = projects.group_by { |p| p[:client] }.to_h.sort.to_h
|
166
|
+
|
167
|
+
clients.each do |k, v|
|
168
|
+
puts k
|
169
|
+
puts '-' * max_chars
|
170
|
+
v.sort_by! { |p| p[:project_code] }
|
171
|
+
v.each do |p|
|
172
|
+
puts p[:desc]
|
173
|
+
end
|
174
|
+
puts ''
|
175
|
+
end
|
176
|
+
|
177
|
+
puts "Total: #{projects.size}"
|
178
|
+
|
179
|
+
clients
|
180
|
+
end
|
181
|
+
|
182
|
+
def start_config_server(global_settings, projects, added_tasks)
|
183
|
+
puts 'Launching the configuration page in your browser...'
|
184
|
+
|
185
|
+
server = TCPServer.new LOCAL_SERVER_PORT
|
186
|
+
|
187
|
+
while session = server.accept
|
188
|
+
request = session.gets
|
189
|
+
|
190
|
+
next unless request
|
191
|
+
|
192
|
+
if match = request.match(/\/reaper-config\s+/i)
|
193
|
+
template_path = File.join root, 'assets/reaper_config_template.html'
|
194
|
+
|
195
|
+
template = File.read(template_path)
|
196
|
+
.sub('"{{RPR_GLOBAL_SETTINGS}}"', "JSON.parse('#{global_settings.to_json}')")
|
197
|
+
.sub('"{{RPR_PROJECTS}}"', "JSON.parse('#{projects.to_json}')")
|
198
|
+
.sub('"{{RPR_ADDED_TASKS}}"', "JSON.parse('#{added_tasks.to_json}')")
|
199
|
+
|
200
|
+
session.print "HTTP/1.1 200\r\n"
|
201
|
+
session.print "Content-Type: text/html\r\n"
|
202
|
+
session.print "\r\n"
|
203
|
+
session.print template
|
204
|
+
elsif match = request.match(/\/submitTimeEntries\?([\S]+)\s+HTTP/i)
|
205
|
+
params = match.captures.first
|
206
|
+
|
207
|
+
raw_config = params.split('&').map { |arg| arg.split '=' }.to_h
|
208
|
+
|
209
|
+
max_index = raw_config.keys.select { |e| e =~ /p\d+/ }
|
210
|
+
.map { |e| e[1..-1].to_i }
|
211
|
+
.max
|
212
|
+
|
213
|
+
tasks = []
|
214
|
+
(max_index + 1).times do |n|
|
215
|
+
tasks << {
|
216
|
+
:pid => raw_config["p#{n}"].to_i,
|
217
|
+
:tid => raw_config["t#{n}"].to_i,
|
218
|
+
:percentage => raw_config["pct#{n}"].to_i / 100.0
|
219
|
+
}
|
220
|
+
end
|
221
|
+
|
222
|
+
tasks.each do |t|
|
223
|
+
proj = projects.select { |p| p['pid'] == t[:pid] }.first
|
224
|
+
if proj
|
225
|
+
task = proj['tasks'].select { |it| it['tid'] == t[:tid] }.first
|
226
|
+
t[:pcode] = proj['code']
|
227
|
+
t[:pname] = proj['name']
|
228
|
+
t[:client] = proj['client']
|
229
|
+
t[:tname] = task['name']
|
230
|
+
end
|
231
|
+
|
232
|
+
abort 'Something went wrong' unless proj && task
|
233
|
+
end
|
234
|
+
|
235
|
+
$config = {
|
236
|
+
:version => '0.1.0',
|
237
|
+
:daily_negative_offset => raw_config['no'].to_i,
|
238
|
+
:daily_positive_offset => raw_config['po'].to_i,
|
239
|
+
:tasks => tasks
|
240
|
+
}
|
241
|
+
|
242
|
+
session.close
|
243
|
+
break
|
244
|
+
end
|
245
|
+
|
246
|
+
session.close
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
def root
|
251
|
+
File.expand_path '../', File.dirname(__FILE__)
|
252
|
+
end
|
253
|
+
|
254
|
+
class Config < Thor
|
255
|
+
desc "show", "Show your Reaper configuration"
|
256
|
+
def show
|
257
|
+
if Reaper.load_config
|
258
|
+
Reaper.show_config $config
|
259
|
+
else
|
260
|
+
puts "No Reaper configuration is found"
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
desc "update", "Set or update your Reaper configuration"
|
265
|
+
def update
|
266
|
+
projs = Reaper.list_projects
|
267
|
+
abort unless projs
|
268
|
+
|
269
|
+
if Reaper.load_config
|
270
|
+
global_settings = {
|
271
|
+
'noffset' => $config[:daily_negative_offset],
|
272
|
+
'poffset' => $config[:daily_positive_offset]
|
273
|
+
}
|
274
|
+
else
|
275
|
+
global_settings = {
|
276
|
+
'noffset' => 0,
|
277
|
+
'poffset' => 0
|
278
|
+
}
|
279
|
+
end
|
280
|
+
|
281
|
+
projs_js = projs.values.flatten.map do |p|
|
282
|
+
{
|
283
|
+
'pid' => p[:project_id],
|
284
|
+
'code' => p[:project_code],
|
285
|
+
'name' => p[:project_name],
|
286
|
+
'client' => p[:client],
|
287
|
+
'tasks' => p[:tasks]
|
288
|
+
}
|
289
|
+
end
|
290
|
+
|
291
|
+
added_tasks = []
|
292
|
+
if $config
|
293
|
+
added_tasks = $config[:tasks].map do |t|
|
294
|
+
{
|
295
|
+
'pid': t[:pid],
|
296
|
+
'tid': t[:tid],
|
297
|
+
'pct': t[:percentage],
|
298
|
+
}
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
puts ''
|
303
|
+
Reaper.openWebpage("http://localhost:#{LOCAL_SERVER_PORT}/reaper-config");
|
304
|
+
Reaper.start_config_server(global_settings, projs_js, added_tasks)
|
305
|
+
|
306
|
+
if $config
|
307
|
+
File.write(Reaper::CONFIG_FILE_PATH, $config.to_yaml)
|
308
|
+
|
309
|
+
puts ''
|
310
|
+
Reaper.show_config $config
|
311
|
+
puts ''
|
312
|
+
|
313
|
+
puts 'Reaper configuration successfully updated'
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
desc "delete", "Delete your Reaper configuration"
|
318
|
+
def delete
|
319
|
+
if Reaper.load_config
|
320
|
+
File.delete Reaper::CONFIG_FILE_PATH
|
321
|
+
puts "Reaper configuration successfully deleted"
|
322
|
+
else
|
323
|
+
puts "No Reaper configuration is found"
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
class Main < Thor
|
329
|
+
|
330
|
+
$token = nil
|
331
|
+
$acc_id = nil
|
332
|
+
$user_id = nil
|
333
|
+
$config = nil
|
334
|
+
|
335
|
+
desc "test", "test"
|
336
|
+
def test
|
337
|
+
a = {"no"=>"0", "po"=>"0", "p0"=>"20769370", "t0"=>"11683694", "pct0"=>"56", "p1"=>"20500923", "t1"=>"11728114", "pct1"=>"22", "p2"=>"20500918", "t2"=>"11728114", "pct2"=>"22"}
|
338
|
+
puts a
|
339
|
+
|
340
|
+
max_index = a.keys.select { |e| e =~ /p\d+/ }
|
341
|
+
.map { |e| e[1..-1] }
|
342
|
+
.max
|
343
|
+
|
344
|
+
puts max_index
|
345
|
+
end
|
346
|
+
|
347
|
+
desc "login", "Login to your Harvest account to authorize Reaper"
|
348
|
+
def login
|
349
|
+
openWebpage "https://id.getharvest.com/oauth2/authorize?client_id=#{HARVEST_CLIENT_ID}&response_type=token"
|
350
|
+
|
351
|
+
start_login_server
|
352
|
+
|
353
|
+
if $token
|
354
|
+
me = request 'users/me'
|
355
|
+
$user_id = me['id']
|
356
|
+
puts "Harvest user ID: #{$user_id}"
|
357
|
+
|
358
|
+
login_data = { :harvest_login =>
|
359
|
+
{ :token => $token, :account_id => $acc_id, :user_id => $user_id }
|
360
|
+
}
|
361
|
+
|
362
|
+
File.write(LOGIN_FILE_PATH, login_data.to_yaml)
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
desc "account", "View your current Harvest login info"
|
367
|
+
def account
|
368
|
+
abort 'Harvest login info not found' unless File.exist? LOGIN_FILE_PATH
|
369
|
+
|
370
|
+
login_data = YAML.load File.read(LOGIN_FILE_PATH)
|
371
|
+
if login_data[:harvest_login]
|
372
|
+
login = login_data[:harvest_login]
|
373
|
+
token = login[:token]
|
374
|
+
account_id = login[:account_id]
|
375
|
+
user_id = login[:user_id]
|
376
|
+
|
377
|
+
if token && !token.empty? &&
|
378
|
+
account_id && account_id.is_a?(Integer) &&
|
379
|
+
user_id && user_id.is_a?(Integer)
|
380
|
+
puts """Your current Harvest login info:
|
381
|
+
- Harvest token: #{token}
|
382
|
+
- Harvest account ID: #{account_id}
|
383
|
+
- Harvest user ID: #{user_id}"""
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
desc "me", "Show your Harvest profile"
|
389
|
+
def me
|
390
|
+
Reaper.check_auth
|
391
|
+
|
392
|
+
puts "Fetching your profile, raw data will be directly shown here"
|
393
|
+
puts ''
|
394
|
+
puts JSON.pretty_generate Reaper.request 'users/me'
|
395
|
+
end
|
396
|
+
|
397
|
+
desc "config SUBCOMMAND", "Manage your Reaper configuration"
|
398
|
+
subcommand "config", Config
|
399
|
+
|
400
|
+
desc "projects", "Show your project list"
|
401
|
+
def projects
|
402
|
+
Reaper.list_projects
|
403
|
+
end
|
404
|
+
|
405
|
+
desc "show DATE/WEEK-ALIAS", "Show your recorded Harvest time entries in the given week"
|
406
|
+
def show(date_str)
|
407
|
+
mon, fri = get_week_range_from_date_str date_str
|
408
|
+
|
409
|
+
Reaper.check_auth
|
410
|
+
|
411
|
+
# both ruby and Harvest use ISO 8601 by default
|
412
|
+
from = mon.to_s
|
413
|
+
to = fri.to_s
|
414
|
+
|
415
|
+
puts "Fetching your time entries from #{from} to #{to}"
|
416
|
+
raw_entries = Reaper.request("time_entries?from=#{from}&to=#{to}")['time_entries']
|
417
|
+
|
418
|
+
if raw_entries.empty?
|
419
|
+
puts "Cannot find any time entries in the week of #{from}"
|
420
|
+
return []
|
421
|
+
end
|
422
|
+
|
423
|
+
entries = {}
|
424
|
+
raw_entries.each do |e|
|
425
|
+
date = Date.parse(e['spent_date'])
|
426
|
+
tasks = entries[date] || []
|
427
|
+
entries[date] = tasks if tasks.empty?
|
428
|
+
tasks << {
|
429
|
+
:id => e['id'],
|
430
|
+
:created_at => DateTime.parse(e['created_at']),
|
431
|
+
:project => e['project']['name'],
|
432
|
+
:project_code => e['project']['code'],
|
433
|
+
:task => e['task']['name'],
|
434
|
+
:client => e['client']['name'],
|
435
|
+
:hours => e['hours']
|
436
|
+
}
|
437
|
+
end
|
438
|
+
|
439
|
+
# sort the entries hash to make sure:
|
440
|
+
# - keys (spent date) are sorted by date
|
441
|
+
# - values (daily entries) are sorted by entry created date
|
442
|
+
|
443
|
+
entries = entries.sort.to_h
|
444
|
+
|
445
|
+
entries.each do |k, v|
|
446
|
+
v.sort_by! { |t| t[:created_at] }
|
447
|
+
end
|
448
|
+
|
449
|
+
print_time_entries mon, fri, entries
|
450
|
+
|
451
|
+
raw_entries
|
452
|
+
end
|
453
|
+
|
454
|
+
desc "delete DATE/WEEK-ALIAS", "Delete all Harvest time entries in the given week"
|
455
|
+
def delete(date_str)
|
456
|
+
mon, fri = get_week_range_from_date_str date_str
|
457
|
+
|
458
|
+
entries = show date_str
|
459
|
+
|
460
|
+
if !entries.empty?
|
461
|
+
puts "Delete #{entries.size} entrires from #{mon} to #{fri}? (Y/n)"
|
462
|
+
confirm = $stdin.gets.chomp
|
463
|
+
abort 'Deletion cancelled' unless confirm == 'Y'
|
464
|
+
end
|
465
|
+
|
466
|
+
progressbar = ProgressBar.create(
|
467
|
+
:title => 'Deleting',
|
468
|
+
:total => entries.size,
|
469
|
+
:format => '%t %c/%C %B'
|
470
|
+
)
|
471
|
+
|
472
|
+
entries.each do |e|
|
473
|
+
rsp = delete_entry e['id']
|
474
|
+
if rsp
|
475
|
+
progressbar.increment
|
476
|
+
else
|
477
|
+
abort "Deleting request failed. Your deleting actions may not be completed. Please run `reaper show #{date_str}` to check."
|
478
|
+
end
|
479
|
+
end
|
480
|
+
|
481
|
+
puts 'Deletion completed!'
|
482
|
+
end
|
483
|
+
|
484
|
+
desc "submit DATE/WEEK-ALIAS", "Submit Harvest time entries for you based on the configuration"
|
485
|
+
def submit(date_str)
|
486
|
+
mon, fri = get_week_range_from_date_str date_str
|
487
|
+
|
488
|
+
Reaper.check_auth
|
489
|
+
|
490
|
+
abort 'Cannot find Reaper configuration. Please run `reaper config update` first.' unless Reaper.load_config
|
491
|
+
|
492
|
+
existing_entries = show date_str
|
493
|
+
|
494
|
+
if existing_entries && !existing_entries.empty?
|
495
|
+
puts ''
|
496
|
+
abort "You have existing time entries within the specified date range. Reaper submit won't work in this case.\nIf you are sure they can be removed, please run `reaper delete #{date_str}` first."
|
497
|
+
end
|
498
|
+
|
499
|
+
hours_per_day = 8
|
500
|
+
days = 5
|
501
|
+
|
502
|
+
dates = []
|
503
|
+
days.times { |n| dates << mon + n }
|
504
|
+
|
505
|
+
hours = []
|
506
|
+
|
507
|
+
negative_offset_days = 0
|
508
|
+
days.times do |n|
|
509
|
+
# we don't want you to always work less than 8 hours,
|
510
|
+
# 2 is the max number of your less working days
|
511
|
+
is_positive_offset = negative_offset_days <= 4 ? [true, false].sample : true
|
512
|
+
negative_offset_days += 1 unless is_positive_offset
|
513
|
+
|
514
|
+
offset = is_positive_offset ? $config[:daily_positive_offset] : $config[:daily_negative_offset]
|
515
|
+
offset = round_hours(rand() * offset) * (is_positive_offset ? 1 : -1)
|
516
|
+
hours << offset + hours_per_day
|
517
|
+
end
|
518
|
+
|
519
|
+
total_hours = hours.inject(:+)
|
520
|
+
|
521
|
+
tasks = $config[:tasks]
|
522
|
+
|
523
|
+
tasks_hours = 0
|
524
|
+
tasks.each_with_index do |t, i|
|
525
|
+
if i < tasks.size - 1
|
526
|
+
t[:hours] = round_hours(total_hours * t[:percentage])
|
527
|
+
tasks_hours += t[:hours]
|
528
|
+
else
|
529
|
+
t[:hours] = total_hours - tasks_hours
|
530
|
+
end
|
531
|
+
end
|
532
|
+
|
533
|
+
tasks_cpy = tasks.dup
|
534
|
+
|
535
|
+
entries = {}
|
536
|
+
|
537
|
+
hours.each_with_index do |h, i|
|
538
|
+
date = dates[i]
|
539
|
+
|
540
|
+
slots = []
|
541
|
+
slots_num = (h / 0.5).to_i
|
542
|
+
slots_num.times do |n|
|
543
|
+
t = tasks_cpy.sample
|
544
|
+
t[:hours] -= 0.5
|
545
|
+
|
546
|
+
slots << {
|
547
|
+
'project_id' => t[:pid],
|
548
|
+
'task_id' => t[:tid],
|
549
|
+
'spent_date' => date.to_s,
|
550
|
+
'hours' => 0.5
|
551
|
+
}
|
552
|
+
|
553
|
+
if t[:hours] <= 0
|
554
|
+
tasks_cpy.delete t
|
555
|
+
end
|
556
|
+
end
|
557
|
+
|
558
|
+
tasks.each do |t|
|
559
|
+
slots_per_task = slots.select do |s|
|
560
|
+
t[:pid] == s['project_id'] && t[:tid] == s['task_id']
|
561
|
+
end
|
562
|
+
|
563
|
+
if !slots_per_task.empty?
|
564
|
+
entry = slots_per_task.first
|
565
|
+
entry['hours'] = slots_per_task.inject(0) { |sum, s| sum + s['hours'] }
|
566
|
+
# puts entry
|
567
|
+
|
568
|
+
daily_tasks = entries[date] || []
|
569
|
+
entries[date] = daily_tasks if daily_tasks.empty?
|
570
|
+
|
571
|
+
daily_tasks << {
|
572
|
+
:project => t[:pname],
|
573
|
+
:project_code => t[:pcode],
|
574
|
+
:task => t[:tname],
|
575
|
+
:client => t[:client],
|
576
|
+
:hours => entry['hours'],
|
577
|
+
:project_id => t[:pid],
|
578
|
+
:task_id => t[:tid],
|
579
|
+
:spent_date => entry['spent_date'],
|
580
|
+
}
|
581
|
+
|
582
|
+
daily_tasks.shuffle!
|
583
|
+
end
|
584
|
+
end
|
585
|
+
end
|
586
|
+
|
587
|
+
print_time_entries mon, fri, entries
|
588
|
+
|
589
|
+
puts ''
|
590
|
+
puts "A random set of time entries (from #{mon} to #{fri}) generated, submit now? (Y/n)"
|
591
|
+
confirm = $stdin.gets.chomp
|
592
|
+
abort 'Submit cancelled' unless confirm == 'Y'
|
593
|
+
|
594
|
+
post_data = entries.values.flatten.map do |e|
|
595
|
+
{
|
596
|
+
'user_id' => $user_id,
|
597
|
+
'project_id' => e[:project_id],
|
598
|
+
'task_id' => e[:task_id],
|
599
|
+
'spent_date' => e[:spent_date],
|
600
|
+
'hours' => e[:hours]
|
601
|
+
}
|
602
|
+
end
|
603
|
+
|
604
|
+
progressbar = ProgressBar.create(
|
605
|
+
:title => 'Submitting',
|
606
|
+
:total => post_data.size,
|
607
|
+
:format => '%t %c/%C %B'
|
608
|
+
)
|
609
|
+
|
610
|
+
post_data.each do |e|
|
611
|
+
rsp = post 'time_entries', e
|
612
|
+
if rsp
|
613
|
+
progressbar.increment
|
614
|
+
else
|
615
|
+
abort "Submit request failed. Your submit actions may not be completed. Please run `reaper show #{date_str}` to check."
|
616
|
+
end
|
617
|
+
end
|
618
|
+
|
619
|
+
puts "#{post_data.size} time entries submitted successfully. You can run `reaper show #{date_str}` to check."
|
620
|
+
end
|
621
|
+
|
622
|
+
no_commands do
|
623
|
+
def post(endpoint, data)
|
624
|
+
uri = URI("https://api.harvestapp.com/v2/#{endpoint}")
|
625
|
+
req = Net::HTTP::Post.new(uri)
|
626
|
+
req['Accept'] = "application/json"
|
627
|
+
req['Content-Type'] = "application/json"
|
628
|
+
req['Authorization'] = "Bearer #{$token}"
|
629
|
+
req['Harvest-Account-ID'] = $acc_id
|
630
|
+
res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) { |http|
|
631
|
+
http.request(req, data.to_json)
|
632
|
+
}
|
633
|
+
|
634
|
+
JSON.parse res.body
|
635
|
+
end
|
636
|
+
|
637
|
+
def start_login_server
|
638
|
+
server = TCPServer.new LOCAL_SERVER_PORT
|
639
|
+
|
640
|
+
while session = server.accept
|
641
|
+
request = session.gets
|
642
|
+
|
643
|
+
if match = request.match(/\/\?access_token=([^&]+)&/i)
|
644
|
+
$token = match.captures.first
|
645
|
+
|
646
|
+
session.print "HTTP/1.1 200\r\n"
|
647
|
+
session.print "Content-Type: text/html\r\n"
|
648
|
+
session.print "\r\n"
|
649
|
+
session.print "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"145\" height=\"28\" viewBox=\"0 0 145 28\" fill=\"currentColor\">
|
650
|
+
<path d=\"M0 27v-26h4.9v10.4h7v-10.4h4.9v26h-4.9v-11.2h-7v11.2h-4.9zM21.5 27l6.4-26h6.3l6.2 26h-4.7l-1.2-5.5h-6.8l-1.4 5.5h-4.8zm7.1-9.9h4.9l-2.4-10.5h-.1l-2.4 10.5zM56.5 27l-4.3-10.6h-2.4v10.6h-4.9v-26h7.1c5.9 0 8.7 2.9 8.7 7.8 0 3.2-1.1 5.6-3.9 6.6l4.9 11.6h-5.2zm-6.7-14.7h2.5c2.2 0 3.5-1.1 3.5-3.6s-1.3-3.6-3.5-3.6h-2.5v7.2zM64.8 1h4.9l4.5 18.6h.1l4.5-18.6h4.8l-6.6 26h-5.6l-6.6-26zM88.2 27v-26h13.5v4.4h-8.6v6h6.5v4.4h-6.5v6.8h8.9v4.4h-13.8zM118.6 8.3c-.8-2.4-1.9-3.5-3.6-3.5-1.7 0-2.7 1.1-2.7 2.8 0 3.9 11 4.2 11 12.2 0 4.4-3 7.4-8.2 7.4-4 0-7.1-2.2-8.4-7.2l4.9-1c.6 3.1 2.4 4.2 3.8 4.2 1.7 0 3-1.1 3-3.1 0-4.9-11-4.9-11-12.1 0-4.4 2.6-7.2 7.7-7.2 4.4 0 7.1 2.6 7.9 6.2l-4.4 1.3zM144.3 1v4.4h-5.7v21.6h-4.9v-21.6h-5.7v-4.4h16.3z\"></path>
|
651
|
+
</svg><div><p>Harvest authorized successfully, please check your command line.</p></div>"
|
652
|
+
|
653
|
+
session.close
|
654
|
+
break
|
655
|
+
elsif request.match(/\/\?error=access_denied/i)
|
656
|
+
puts 'Authorization failed: User denied'
|
657
|
+
session.close
|
658
|
+
break
|
659
|
+
else
|
660
|
+
puts "Unrecogonized request: #{request}"
|
661
|
+
session.close
|
662
|
+
break
|
663
|
+
end
|
664
|
+
|
665
|
+
session.close
|
666
|
+
end
|
667
|
+
|
668
|
+
if $token
|
669
|
+
puts "Authorized by Harvest successfully"
|
670
|
+
puts "Harvest token: #{$token}"
|
671
|
+
|
672
|
+
uri = URI("https://id.getharvest.com/api/v2/accounts")
|
673
|
+
req = Net::HTTP::Get.new(uri)
|
674
|
+
req['Accept'] = "application/json"
|
675
|
+
req['Authorization'] = "Bearer #{$token}"
|
676
|
+
|
677
|
+
res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) { |http|
|
678
|
+
http.request(req)
|
679
|
+
}
|
680
|
+
|
681
|
+
user = JSON.parse res.body
|
682
|
+
$acc_id = user["accounts"].first["id"]
|
683
|
+
puts "Harvest account ID: #{$acc_id}"
|
684
|
+
end
|
685
|
+
end
|
686
|
+
|
687
|
+
def delete_entry(entry_id)
|
688
|
+
Reaper.request_delete "time_entries/#{entry_id}"
|
689
|
+
end
|
690
|
+
|
691
|
+
def print_time_entries(from, to, entries)
|
692
|
+
from.upto(to) do |d|
|
693
|
+
entries[d] = [] unless entries[d]
|
694
|
+
end
|
695
|
+
|
696
|
+
entries = entries.sort.to_h
|
697
|
+
|
698
|
+
headers = []
|
699
|
+
rows = []
|
700
|
+
daily_hours = []
|
701
|
+
|
702
|
+
entries.each do |k, v|
|
703
|
+
headers << "#{k.strftime('%a')} #{k}"
|
704
|
+
daily_hours << (v.inject(0) { |sum, t| sum + t[:hours] })
|
705
|
+
end
|
706
|
+
|
707
|
+
max_row_num = entries.map { |k, v| v.size }.max
|
708
|
+
max_row_num.times do |n|
|
709
|
+
row = []
|
710
|
+
entries.each do |k, v|
|
711
|
+
if n < v.size
|
712
|
+
t = v[n]
|
713
|
+
task = "[#{t[:project_code]}] #{t[:project]} (#{t[:client]})\n \n- #{t[:task]}\n- Hours: #{t[:hours]}"
|
714
|
+
row << task.scan(/.{1,15}/).join("\n")
|
715
|
+
else
|
716
|
+
row << ''
|
717
|
+
end
|
718
|
+
end
|
719
|
+
rows << row
|
720
|
+
end
|
721
|
+
|
722
|
+
rows << daily_hours.map { |h| "Total: #{num_to_hours(h)}"}
|
723
|
+
|
724
|
+
num = entries.values.flatten.size
|
725
|
+
title = "#{num} time entries | Total hours: #{num_to_hours(daily_hours.inject(:+))}"
|
726
|
+
|
727
|
+
table = Terminal::Table.new :title => title, :headings => headers, :rows => rows
|
728
|
+
table.style = { :all_separators => true }
|
729
|
+
puts table
|
730
|
+
end
|
731
|
+
|
732
|
+
# helpers
|
733
|
+
|
734
|
+
def num_to_hours(num)
|
735
|
+
num
|
736
|
+
end
|
737
|
+
|
738
|
+
def round_hours(hours)
|
739
|
+
(hours * 2).round / 2.0
|
740
|
+
end
|
741
|
+
|
742
|
+
def get_last_monday
|
743
|
+
today = Date.today
|
744
|
+
wday = today.wday
|
745
|
+
wday = 7 if wday == 0
|
746
|
+
last_mon = today - (wday - 1)
|
747
|
+
end
|
748
|
+
|
749
|
+
def get_mon_by_date(date)
|
750
|
+
wday = date.wday
|
751
|
+
wday = 7 if wday == 0
|
752
|
+
date - (wday - 1)
|
753
|
+
end
|
754
|
+
|
755
|
+
def get_mon_of_this_week
|
756
|
+
get_mon_by_date Date.today
|
757
|
+
end
|
758
|
+
|
759
|
+
def get_mon_of_last_week
|
760
|
+
get_mon_of_this_week - 7
|
761
|
+
end
|
762
|
+
|
763
|
+
def get_week_range_from_date_str(date_str)
|
764
|
+
case date_str
|
765
|
+
when 'current'
|
766
|
+
mon = get_mon_of_this_week
|
767
|
+
when 'last'
|
768
|
+
mon = get_mon_of_last_week
|
769
|
+
else
|
770
|
+
date_str = Date.today.year.to_s << date_str if date_str.size < 5
|
771
|
+
|
772
|
+
begin
|
773
|
+
date = Date.strptime(date_str, '%Y%m%d')
|
774
|
+
rescue
|
775
|
+
abort 'Please enter a valid date string'
|
776
|
+
end
|
777
|
+
|
778
|
+
mon = get_mon_by_date date if date
|
779
|
+
end
|
780
|
+
|
781
|
+
return mon, (mon + 4)
|
782
|
+
end
|
783
|
+
end
|
784
|
+
|
785
|
+
desc "version", "Show current Reaper version"
|
786
|
+
def version
|
787
|
+
sickle = "MMMMMMMMMMMMMMMMMMMMMMMMMMMNdyo//oymMMMMMMMMMMMMMM
|
788
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMmo` `/yNMMMMMMMMMM
|
789
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMs` :hMMMMMMMM
|
790
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN- `: .yMMMMMM
|
791
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM: :d: -dMMMM
|
792
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN. sMh` oMMM
|
793
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy .MMN- /MM
|
794
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM dMMN- oM
|
795
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM` hMMMm` d
|
796
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM dMMMM+ :
|
797
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy `MMMMMd `
|
798
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN. sMMMMMm
|
799
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN- /MMMMMMy .
|
800
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMh. oMMMMMMM. s
|
801
|
+
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNy- -dMMMMMMM+ -M
|
802
|
+
MMMMMMMMMMMMd/ -/oydmNMMMMMMNmho: `/dMMMMMMMM+ .NM
|
803
|
+
MMMMMMMMMMd: syo/-. ``.` `./odMMMMMMMMMm: :NMM
|
804
|
+
MMMMMMMMd: .+` /dMMMMNmmddmmNMMMMMMMMMMMMNo``sMMMM
|
805
|
+
MMMMMMd: -yMMNy` .odMMMMMMMMMMMMMMMMMMMmo` +NMMMMM
|
806
|
+
MMMMd: -----. -o- ./ymMMMMMMMMMMMNdo- .oNMMMMMMM
|
807
|
+
MMd: -yhhhh+ -hMMMNy+- .:/+oo+/:. `:odMMMMMMMMMM
|
808
|
+
m: `:::::- -hMMMMMMMMMMmhsoo++ooshmMMMMMMMMMMMMMM
|
809
|
+
` +ssss: -hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
|
810
|
+
. +mNy- -hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
|
811
|
+
N+. `/hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM"
|
812
|
+
|
813
|
+
slogan = 'Keep your PM away (TM)'
|
814
|
+
slogan_len = slogan.length + 4
|
815
|
+
puts "#{sickle}\n\n#{'*' * slogan_len}\n* #{slogan} *\n#{'*' * slogan_len}\n\nReaper: A smart Harvest filling helper. \nVersion #{Reaper::VERSION}"
|
816
|
+
end
|
817
|
+
end
|
818
|
+
end
|