class Reaper::Main

Public Instance Methods

account() click to toggle source
# File lib/reaper.rb, line 366
def account
  abort 'Harvest login info not found' unless File.exist? LOGIN_FILE_PATH
  
  login_data = YAML.load File.read(LOGIN_FILE_PATH)
  if login_data[:harvest_login]
    login = login_data[:harvest_login]
    token = login[:token]
    account_id = login[:account_id]
    user_id = login[:user_id]
  
    if token && !token.empty? && 
      account_id && account_id.is_a?(Integer) && 
      user_id && user_id.is_a?(Integer)
      puts """Your current Harvest login info:
- Harvest token: #{token}
- Harvest account ID: #{account_id}
- Harvest user ID: #{user_id}"""
    end
  end
end
delete(date_str) click to toggle source
# File lib/reaper.rb, line 462
def delete(date_str)
  mon, fri = get_week_range_from_date_str date_str

  entries, _ = show date_str
  
  if !entries.empty?
    puts "Delete #{entries.size} entrires from #{mon} to #{fri}? (Y/n)"
    confirm = $stdin.gets.chomp
    abort 'Deletion cancelled' unless confirm == 'Y'
  end
  
  progressbar = ProgressBar.create(
    :title => 'Deleting', 
    :total => entries.size,
    :format => '%t %c/%C %B'
  )
  
  entries.each do |e|
    rsp = delete_entry e['id']
    if rsp 
      progressbar.increment
    else
      abort "Deleting request failed. Your deleting actions may not be completed. Please run `reaper show #{date_str}` to check."
    end
  end

  puts 'Deletion completed!'
end
delete_entry(entry_id) click to toggle source
# File lib/reaper.rb, line 562
def delete_entry(entry_id)
  Reaper.request_delete "time_entries/#{entry_id}"
end
donate() click to toggle source
get_last_monday() click to toggle source
# File lib/reaper.rb, line 821
def get_last_monday
  today = Date.today
  wday = today.wday
  wday = 7 if wday == 0
  last_mon = today - (wday - 1)
end
get_mon_by_date(date) click to toggle source
# File lib/reaper.rb, line 828
def get_mon_by_date(date)
  wday = date.wday
  wday = 7 if wday == 0
  date - (wday - 1)
end
get_mon_of_last_week() click to toggle source
# File lib/reaper.rb, line 838
def get_mon_of_last_week
  get_mon_of_this_week - 7
end
get_mon_of_this_week() click to toggle source
# File lib/reaper.rb, line 834
def get_mon_of_this_week
  get_mon_by_date Date.today
end
get_week_range_from_date_str(date_str) click to toggle source
# File lib/reaper.rb, line 842
def get_week_range_from_date_str(date_str)
  case date_str
  when 'current'
    mon = get_mon_of_this_week
  when 'last'
    mon = get_mon_of_last_week
  else
    date_str = Date.today.year.to_s << date_str if date_str.size < 5
  
    begin
      date = Date.strptime(date_str, '%Y%m%d')
    rescue
      abort 'Please enter a valid date string'
    end
  
    mon = get_mon_by_date date if date
  end
  
  return mon, (mon + 4)
end
login() click to toggle source
# File lib/reaper.rb, line 347
def login
  Reaper.openWebpage "https://id.getharvest.com/oauth2/authorize?client_id=#{HARVEST_CLIENT_ID}&response_type=token"
  
  start_login_server
  
  if $token
    me = Reaper.request 'users/me'
    $user_id = me['id']
    puts "Harvest user ID: #{$user_id}"
  
    login_data = { :harvest_login => 
      { :token => $token, :account_id => $acc_id, :user_id => $user_id } 
    }
    
    File.write(LOGIN_FILE_PATH, login_data.to_yaml)
  end
end
me() click to toggle source
# File lib/reaper.rb, line 388
def me
  Reaper.check_auth

  puts "Fetching your Harvest profile..."
  rsp = Reaper.request 'users/me'

  if rsp
    rsp.each { |k, v| rsp[k] = v.scan(/.{1,30}/).join("\n") if (v && v.to_s.size > 30) }

    title = 'Harvest Profile'
    rows = rsp.to_a
    table = Terminal::Table.new :title => title, :rows => rows
    puts table
  end
end
num_to_hours(num) click to toggle source

helpers

# File lib/reaper.rb, line 813
def num_to_hours(num)
  num
end
post(endpoint, data) click to toggle source
# File lib/reaper.rb, line 498
def post(endpoint, data)
  uri = URI("https://api.harvestapp.com/v2/#{endpoint}")
  req = Net::HTTP::Post.new(uri)
  req['Accept'] = "application/json"
  req['Content-Type'] = "application/json"
  req['Authorization'] = "Bearer #{$token}"
  req['Harvest-Account-ID'] = $acc_id
  res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) { |http|
    http.request(req, data.to_json)
  }
    
  JSON.parse res.body
end
print_time_entries(from, to, entries) click to toggle source
projects() click to toggle source
# File lib/reaper.rb, line 408
def projects
  Reaper.list_projects
end
round_hours(hours) click to toggle source
# File lib/reaper.rb, line 817
def round_hours(hours)
  (hours * 2).round / 2.0
end
show(date_str) click to toggle source
# File lib/reaper.rb, line 413
def show(date_str)
  mon, fri = get_week_range_from_date_str date_str
  
  Reaper.check_auth

  # both ruby and Harvest use ISO 8601 by default
  from = mon.to_s
  to = fri.to_s
  
  puts "Fetching your time entries from #{from} to #{to}"
  raw_entries = Reaper.request("time_entries?user_id=#{$user_id}&from=#{from}&to=#{to}")['time_entries']
  
  if raw_entries.empty?
    puts "Cannot find any time entries in the week of #{from}" 
    return []
  end
  
  entries = {}
  raw_entries.each do |e|
    date = Date.parse(e['spent_date'])
    tasks = entries[date] || []
    entries[date] = tasks if tasks.empty?
    tasks << {
      :id => e['id'],
      :created_at => DateTime.parse(e['created_at']),
      :project => e['project']['name'],
      :project_code => e['project']['code'],
      :task => e['task']['name'],
      :client => e['client']['name'],
      :hours => e['hours']
    }
  end
  
  # sort the entries hash to make sure:
  # - keys (spent date) are sorted by date
  # - values (daily entries) are sorted by entry created date
  
  entries = entries.sort.to_h
  
  entries.each do |k, v|
    v.sort_by! { |t| t[:created_at] }
  end
  
  print_time_entries mon, fri, entries

  return raw_entries, entries
end
start_login_server() click to toggle source
# File lib/reaper.rb, line 512
def start_login_server
  server = TCPServer.new LOCAL_SERVER_PORT
  
  while session = server.accept
    request = session.gets
    
    if match = request.match(/\/\?access_token=([^&]+)&/i)
      $token = match.captures.first

      session.print "HTTP/1.1 200\r\n"
      session.print "Content-Type: text/html\r\n"
      session.print "\r\n"
      session.print "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"145\" height=\"28\" viewBox=\"0 0 145 28\" fill=\"currentColor\">
      <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>
    </svg><div><p>Harvest authorized successfully, please check your command line.</p></div>"

      session.close
      break
    elsif request.match(/\/\?error=access_denied/i)
      puts 'Authorization failed: User denied'
      session.close
      break
    else
      puts "Unrecogonized request: #{request}"
      session.close
      break
    end

    session.close
  end
  
  if $token
    puts "Authorized by Harvest successfully"
    puts "Harvest token: #{$token}"
  
    uri = URI("https://id.getharvest.com/api/v2/accounts")
    req = Net::HTTP::Get.new(uri)
    req['Accept'] = "application/json"
    req['Authorization'] = "Bearer #{$token}"
  
    res = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => true) { |http|
      http.request(req)
    }
  
    user = JSON.parse res.body
    $acc_id = user["accounts"].first["id"]
    puts "Harvest account ID: #{$acc_id}"
  end
end
submit(date_str) click to toggle source
# File lib/reaper.rb, line 493
def submit(date_str)
  submit_time_entries date_str, options[:excluded]
end
submit_time_entries(date_str, excluded_weekdays_str) click to toggle source
# File lib/reaper.rb, line 607
def submit_time_entries(date_str, excluded_weekdays_str)
  mon, fri = get_week_range_from_date_str date_str
  
  if excluded_weekdays_str && !excluded_weekdays_str.empty?
    excluded_weekdays = excluded_weekdays_str
      .split(',')
      .map { |s| s.strip.downcase }
      .uniq

    valid_weekdays = %w(mon tue wed thu fri)
    unless (excluded_weekdays - valid_weekdays).empty? && excluded_weekdays.size < valid_weekdays.size
      abort "Argument 'excluded' contains invalid options. Only single or multiple values (comma separated) in #{valid_weekdays} is allowed."
    end

    excluded_weekdays_offset = excluded_weekdays.map { |d| valid_weekdays.index d }.sort
  end
  
  has_excluded = excluded_weekdays_offset != nil && !excluded_weekdays_offset.empty?

  Reaper.check_auth

  abort 'Cannot find Reaper configuration. Please run `reaper config update` first.' unless Reaper.load_config

  _, existing_entries = show date_str

  if existing_entries
    num = existing_entries.values.map { |v| v.size }.inject(:+)
    
    if num > 0
      puts ''

      is_clean_after_excluded = false

      if has_excluded
        entries = existing_entries.select { |k, v| !(excluded_weekdays_offset.include? (k - mon)) }
        if entries.values.map { |v| v.size }.inject(:+) > 0
          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."
        else
          is_clean_after_excluded = true
        end
      end

      if !is_clean_after_excluded
        non_vocation_entries = existing_entries.select do |k, v|
          case v.size
          when 0
            false
          when 1
            entry = v.first
            entry[:client] != 'Time Off' || entry[:hours] != 8
          else
            true
          end
        end

        # all the entries are 8 hours vacation/public holiday/sick leave
        if non_vocation_entries.empty?
          case num
          when 5
            abort "All 5 days within the specified week have been marked as 'Time Off'. Reaper submit won't work in this case.\nIf you are sure they can be removed, please run `reaper delete #{date_str}` first."
          else
            puts "#{num} days within the specified week have been marked as 'Time Off'. Do you want to exclude them and submit time entries for the rest of the days? (Y/n)"
            excluded_dates = existing_entries.select { |k, v| !v.empty? }.keys
            excluded_arg = excluded_dates.map { |d| d.strftime('%a') }.join(',')

            confirm = $stdin.gets.chomp
            abort unless confirm == 'Y'
            submit_time_entries date_str, excluded_arg
            return
          end
        end

        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."
      end
    end
  end

  hours_per_day = 8
  days = has_excluded ? 5 - excluded_weekdays_offset.size : 5
  
  dates = []
  5.times { |n| dates << mon + n if !has_excluded || !(excluded_weekdays_offset.include? n) }
  
  hours = []

  negative_offset_days = 0
  days.times do |_|
    # we don't want you to always work less than 8 hours,
    # 2 is the max number of your less working days
    is_positive_offset = negative_offset_days <= 2 ? [true, false].sample : true
    negative_offset_days += 1 unless is_positive_offset
  
    offset = is_positive_offset ? $config[:daily_positive_offset] : $config[:daily_negative_offset]
    offset = round_hours(rand() * offset) * (is_positive_offset ? 1 : -1)
    hours << offset + hours_per_day
  end

  total_hours = hours.inject(:+)

  tasks = $config[:tasks]

  tasks_hours = 0
  tasks.each_with_index do |t, i|
    if i < tasks.size - 1
      t[:hours] = round_hours(total_hours * t[:percentage])
      tasks_hours += t[:hours]
    else
      t[:hours] = total_hours - tasks_hours
    end
  end

  tasks_cpy = tasks.dup

  entries = {}

  hours.each_with_index do |h, i|
    date = dates[i]

    slots = []
    slots_num = (h / 0.5).to_i
    slots_num.times do |n|
      t = tasks_cpy.sample
      t[:hours] -= 0.5

      slots << {
        'project_id' => t[:pid],
        'task_id' => t[:tid],
        'spent_date' => date.to_s,
        'hours' => 0.5
      }

      if t[:hours] <= 0
        tasks_cpy.delete t
      end
    end

    tasks.each do |t|
      slots_per_task = slots.select do |s|
        t[:pid] == s['project_id'] && t[:tid] == s['task_id']
      end

      if !slots_per_task.empty?
        entry = slots_per_task.first
        entry['hours'] = slots_per_task.inject(0) { |sum, s| sum + s['hours'] }
        # puts entry

        daily_tasks = entries[date] || []
        entries[date] = daily_tasks if daily_tasks.empty?

        daily_tasks << {
          :project => t[:pname],
          :project_code => t[:pcode],
          :task => t[:tname],
          :client => t[:client],
          :hours => entry['hours'],
          :project_id => t[:pid],
          :task_id => t[:tid],
          :spent_date => entry['spent_date'],
        }

        daily_tasks.shuffle!
      end
    end
  end

  print_time_entries mon, fri, entries

  puts ''

  range = "from #{mon} to #{fri}"
  range << ", #{excluded_weekdays.join(', ')} excluded" if has_excluded

  puts "A random set of time entries (#{range}) generated, submit now? (Y/n)"
  confirm = $stdin.gets.chomp
  abort 'Submit cancelled' unless confirm == 'Y'

  post_data = entries.values.flatten.map do |e|
    {
      'user_id' => $user_id,
      'project_id' => e[:project_id],
      'task_id' => e[:task_id],
      'spent_date' => e[:spent_date],
      'hours' => e[:hours]
    }
  end

  progressbar = ProgressBar.create(
    :title => 'Submitting', 
    :total => post_data.size,
    :format => '%t %c/%C %B'
  )

  post_data.each do |e|
    rsp = post 'time_entries', e
    if rsp 
      progressbar.increment
    else
      abort "Submit request failed. Your submit actions may not be completed. Please run `reaper show #{date_str}` to check."
    end
  end

  puts "#{post_data.size} time entries submitted successfully. You can run `reaper show #{date_str}` to check."
end
test() click to toggle source
# File lib/reaper.rb, line 335
def test
  a = {"no"=>"0", "po"=>"0", "p0"=>"20769370", "t0"=>"11683694", "pct0"=>"56", "p1"=>"20500923", "t1"=>"11728114", "pct1"=>"22", "p2"=>"20500918", "t2"=>"11728114", "pct2"=>"22"}
  puts a
  
  max_index = a.keys.select { |e| e =~ /p\d+/ }
    .map { |e| e[1..-1] }
    .max
  
  puts max_index
end
version() click to toggle source
# File lib/reaper.rb, line 872
    def version
    sickle = "MMMMMMMMMMMMMMMMMMMMMMMMMMMNdyo//oymMMMMMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMmo` `/yNMMMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMs`   :hMMMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN- `: .yMMMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM: :d: -dMMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN. sMh` oMMM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy .MMN- /MM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM  dMMN- oM
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM` hMMMm` d
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM  dMMMM+ :
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMy `MMMMMd `
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN. sMMMMMm  
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN- /MMMMMMy .
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMh. oMMMMMMM. s
MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNy- -dMMMMMMM+ -M
MMMMMMMMMMMMd/ -/oydmNMMMMMMNmho: `/dMMMMMMMM+ .NM
MMMMMMMMMMd:  syo/-.   ``.`  `./odMMMMMMMMMm: :NMM
MMMMMMMMd: .+` /dMMMMNmmddmmNMMMMMMMMMMMMNo``sMMMM
MMMMMMd: -yMMNy` .odMMMMMMMMMMMMMMMMMMMmo` +NMMMMM
MMMMd:   -----. -o- ./ymMMMMMMMMMMMNdo- .oNMMMMMMM
MMd: -yhhhh+  -hMMMNy+-  .:/+oo+/:. `:odMMMMMMMMMM
m:  `:::::- -hMMMMMMMMMMmhsoo++ooshmMMMMMMMMMMMMMM
` +ssss:  -hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
. +mNy- -hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM
N+.  `/hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM"

      slogan = 'Keep your PM away (TM)'
      slogan_len = slogan.length + 4
      puts "#{sickle}\n\n#{'*' * slogan_len}\n* #{slogan} *\n#{'*' * slogan_len}\n\nReaper: A smart Harvest filling helper. \nVersion #{Reaper::VERSION}"
    end