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.
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
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ require_relative '../lib/reaper'
5
+
6
+ # capture ctrl+c and show a simple message before quitting
7
+ trap 'SIGINT' do
8
+ puts ''
9
+ puts 'Reaper terminated by user'
10
+ exit 130
11
+ end
12
+
13
+ Reaper::Main.start(ARGV)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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