lib/reckon/app.rb in reckon-0.5.4 vs lib/reckon/app.rb in reckon-0.6.0
- old
+ new
@@ -1,43 +1,41 @@
# coding: utf-8
+
require 'pp'
require 'yaml'
module Reckon
class App
attr_accessor :options, :seen, :csv_parser, :regexps, :matcher
+ @@cli = HighLine.new
def initialize(options = {})
LOGGER.level = Logger::INFO if options[:verbose]
self.options = options
self.regexps = {}
- self.seen = {}
+ self.seen = Set.new
self.options[:currency] ||= '$'
- options[:string] = File.read(options[:file]) unless options[:string]
@csv_parser = CSVParser.new( options )
@matcher = CosineSimilarity.new(options)
learn!
end
def interactive_output(str)
return if options[:unattended]
+
puts str
end
def learn!
learn_from_account_tokens(options[:account_tokens_file])
-
- ledger_file = options[:existing_ledger_file]
- return unless ledger_file
- fail "#{ledger_file} doesn't exist!" unless File.exists?(ledger_file)
- learn_from(File.read(ledger_file))
+ learn_from_ledger_file(options[:existing_ledger_file])
end
def learn_from_account_tokens(filename)
return unless filename
- fail "#{filename} doesn't exist!" unless File.exists?(filename)
+ raise "#{filename} doesn't exist!" unless File.exist?(filename)
extract_account_tokens(YAML.load_file(filename)).each do |account, tokens|
tokens.each do |t|
if t.start_with?('/')
add_regexp(account, t)
@@ -46,18 +44,31 @@
end
end
end
end
- def learn_from(ledger)
+ def learn_from_ledger_file(ledger_file)
+ return unless ledger_file
+
+ raise "#{ledger_file} doesn't exist!" unless File.exist?(ledger_file)
+
+ learn_from_ledger(File.read(ledger_file))
+ end
+
+ def learn_from_ledger(ledger)
+ LOGGER.info "learning from #{ledger}"
LedgerParser.new(ledger).entries.each do |entry|
entry[:accounts].each do |account|
str = [entry[:desc], account[:amount]].join(" ")
- @matcher.add_document(account[:name], str) unless account[:name] == options[:bank_account]
+ if account[:name] != options[:bank_account]
+ LOGGER.info "adding document #{account[:name]} #{str}"
+ @matcher.add_document(account[:name], str)
+ end
pretty_date = entry[:date].iso8601
- seen[pretty_date] ||= {}
- seen[pretty_date][@csv_parser.pretty_money(account[:amount])] = true
+ if account[:name] == options[:bank_account]
+ seen << seen_key(pretty_date, @csv_parser.pretty_money(account[:amount]))
+ end
end
end
end
# Add tokens from account_tokens_file to accounts
@@ -89,13 +100,14 @@
end
regexps[Regexp.new(match[1], options)] = account
end
def walk_backwards
+ cmd_options = "[account]/[q]uit/[s]kip/[n]ote/[d]escription"
seen_anything_new = false
each_row_backwards do |row|
- interactive_output Terminal::Table.new(:rows => [ [ row[:pretty_date], row[:pretty_money], row[:description] ] ])
+ print_transaction([row])
if already_seen?(row)
interactive_output "NOTE: This row is very similar to a previous one!"
if !seen_anything_new
interactive_output "Skipping..."
@@ -103,54 +115,32 @@
end
else
seen_anything_new = true
end
- possible_answers = suggest(row)
-
- ledger = if row[:money] > 0
- if options[:unattended]
- out_of_account = possible_answers.first || options[:default_outof_account] || 'Income:Unknown'
- else
- out_of_account = ask("Which account provided this income? ([account]/[q]uit/[s]kip) ") { |q|
- q.completion = possible_answers
- q.readline = true
- q.default = possible_answers.first
- }
- end
-
- finish if out_of_account == "quit" || out_of_account == "q"
- if out_of_account == "skip" || out_of_account == "s"
- interactive_output "Skipping"
- next
- end
-
- ledger_format( row,
- [options[:bank_account], row[:pretty_money]],
- [out_of_account, row[:pretty_money_negated]] )
+ if row[:money] > 0
+ # out_of_account
+ answer = ask_account_question("Which account provided this income? (#{cmd_options})", row)
+ line1 = [options[:bank_account], row[:pretty_money]]
+ line2 = [answer, ""]
else
- if options[:unattended]
- into_account = possible_answers.first || options[:default_into_account] || 'Expenses:Unknown'
- else
- into_account = ask("To which account did this money go? ([account]/[q]uit/[s]kip) ") { |q|
- q.completion = possible_answers
- q.readline = true
- q.default = possible_answers.first
- }
- end
- finish if into_account == "quit" || into_account == 'q'
- if into_account == "skip" || into_account == 's'
- interactive_output "Skipping"
- next
- end
+ # into_account
+ answer = ask_account_question("To which account did this money go? (#{cmd_options})", row)
+# line1 = [answer, row[:pretty_money_negated]]
+ line1 = [answer, ""]
+ line2 = [options[:bank_account], row[:pretty_money]]
+ end
- ledger_format( row,
- [into_account, row[:pretty_money_negated]],
- [options[:bank_account], row[:pretty_money]] )
+ finish if %w[quit q].include?(answer)
+ if %w[skip s].include?(answer)
+ interactive_output "Skipping"
+ next
end
- learn_from(ledger) unless options[:account_tokens_file]
+ ledger = ledger_format(row, line1, line2)
+ LOGGER.info "ledger line: #{ledger}"
+ learn_from_ledger(ledger) unless options[:account_tokens_file]
output(ledger)
end
end
def each_row_backwards
@@ -165,57 +155,136 @@
:pretty_money => @csv_parser.pretty_money_for(index),
:pretty_money_negated => @csv_parser.pretty_money_for(index, :negate),
:money => @csv_parser.money_for(index),
:description => @csv_parser.description_for(index) }
end
- rows.sort_by { |n| n[:date] }.each {|row| yield row }
+ rows.sort_by { |n| n[:date] }.each { |row| yield row }
end
- def most_specific_regexp_match( row )
+ def print_transaction(rows)
+ str = "\n"
+ header = %w[Date Amount Description Note]
+ maxes = header.map(&:length)
+
+ rows = rows.map { |r| [r[:pretty_date], r[:pretty_money], r[:description], r[:note]] }
+
+ rows.each do |r|
+ r.length.times { |i| l = r[i] ? r[i].length : 0; maxes[i] = l if maxes[i] < l }
+ end
+
+ header.each_with_index do |n, i|
+ str += " #{n.center(maxes[i])} |"
+ end
+ str += "\n"
+
+ rows.each do |row|
+ row.each_with_index do |_, i|
+ just = maxes[i]
+ str += sprintf(" %#{just}s |", row[i])
+ end
+ str += "\n"
+ end
+
+ interactive_output str
+ end
+
+ def ask_account_question(msg, row)
+ possible_answers = suggest(row)
+ LOGGER.info "possible_answers===> #{possible_answers.inspect}"
+
+ if options[:unattended]
+ default = if row[:pretty_money][0] == '-'
+ options[:default_into_account] || 'Expenses:Unknown'
+ else
+ options[:default_outof_account] || 'Income:Unknown'
+ end
+ return possible_answers[0] || default
+ end
+
+ answer = @@cli.ask(msg) do |q|
+ q.completion = possible_answers
+ q.readline = true
+ q.default = possible_answers.first
+ end
+
+ # if answer isn't n/note/d/description, must be an account name, or skip, or quit
+ return answer unless %w[n note d description].include?(answer)
+
+ add_description(row) if %w[d description].include?(answer)
+ add_note(row) if %w[n note].include?(answer)
+
+ print_transaction([row])
+ # give user a chance to set account name or retry description
+ return ask_account_question(msg, row)
+ end
+
+ def add_description(row)
+ desc_answer = @@cli.ask("Enter a new description for this transaction (empty line aborts)\n") do |q|
+ q.overwrite = true
+ q.readline = true
+ q.default = row[:description]
+ end
+
+ row[:description] = desc_answer unless desc_answer.empty?
+ end
+
+ def add_note(row)
+ desc_answer = @@cli.ask("Enter a new note for this transaction (empty line aborts)\n") do |q|
+ q.overwrite = true
+ q.readline = true
+ q.default = row[:note]
+ end
+
+ row[:note] = desc_answer unless desc_answer.empty?
+ end
+
+ def most_specific_regexp_match(row)
matches = regexps.map { |regexp, account|
if match = regexp.match(row[:description])
[account, match[0]]
end
}.compact
- matches.sort_by! { |account, matched_text| matched_text.length }.map(&:first)
+ matches.sort_by! { |_account, matched_text| matched_text.length }.map(&:first)
end
def suggest(row)
most_specific_regexp_match(row) +
@matcher.find_similar(row[:description]).map { |n| n[:account] }
end
def ledger_format(row, line1, line2)
- out = "#{row[:pretty_date]}\t#{row[:description]}\n"
- out += "\t#{line1.first}\t\t\t\t\t#{line1.last}\n"
- out += "\t#{line2.first}\t\t\t\t\t#{line2.last}\n\n"
+ out = "#{row[:pretty_date]}\t#{row[:description]}\t; #{row[:note]}\n"
+ out += "\t#{line1.first}\t\t\t#{line1.last}\n"
+ out += "\t#{line2.first}\t\t\t#{line2.last}\n\n"
out
end
def output(ledger_line)
options[:output_file].puts ledger_line
options[:output_file].flush
end
+ def seen_key(date, amount)
+ return [date, amount].join("|")
+ end
+
def already_seen?(row)
- seen[row[:pretty_date]] && seen[row[:pretty_date]][row[:pretty_money]]
+ seen.include?(seen_key(row[:pretty_date], row[:pretty_money]))
end
def finish
options[:output_file].close unless options[:output_file] == STDOUT
interactive_output "Exiting."
exit
end
def output_table
- output = Terminal::Table.new do |t|
- t.headings = 'Date', 'Amount', 'Description'
- each_row_backwards do |row|
- t << [ row[:pretty_date], row[:pretty_money], row[:description] ]
- end
+ rows = []
+ each_row_backwards do |row|
+ rows << row
end
- interactive_output output
+ print_transaction(rows)
end
def self.parse_opts(args = ARGV)
options = { :output_file => STDOUT }
parser = OptionParser.new do |opts|
@@ -319,21 +388,21 @@
opts.parse!(args)
end
unless options[:file]
- options[:file] = ask("What CSV file should I parse? ")
+ options[:file] = @@cli.ask("What CSV file should I parse? ")
unless options[:file].length > 0
puts "\nYou must provide a CSV file to parse.\n"
puts parser
exit
end
end
unless options[:bank_account]
fail "Please specify --account for the unattended mode" if options[:unattended]
- options[:bank_account] = ask("What is the account name of this bank account in Ledger? ") do |q|
+ options[:bank_account] = @@cli.ask("What is the account name of this bank account in Ledger? ") do |q|
q.readline = true
q.validate = /^.{2,}$/
q.default = "Assets:Bank:Checking"
end
end