lib/reckon/app.rb in reckon-0.3.10 vs lib/reckon/app.rb in reckon-0.4.0
- old
+ new
@@ -1,7 +1,8 @@
#coding: utf-8
require 'pp'
+require 'yaml'
module Reckon
class App
VERSION = "Reckon 0.3.10"
attr_accessor :options, :accounts, :tokens, :seen, :csv_parser
@@ -15,10 +16,15 @@
options[:string] = File.read(options[:file]) unless options[:string]
@csv_parser = CSVParser.new( options )
learn!
end
+ def interactive_output(str)
+ return if options[:unattended]
+ puts str
+ end
+
def learn_from(ledger)
LedgerParser.new(ledger).entries.each do |entry|
entry[:accounts].each do |account|
learn_about_account( account[:name],
[entry[:desc], account[:amount]].join(" ") ) unless account[:name] == options[:bank_account]
@@ -30,16 +36,30 @@
def already_seen?(row)
seen[row[:pretty_date]] && seen[row[:pretty_date]][row[:pretty_money]]
end
+ def extract_account_tokens(subtree, account = nil)
+ if subtree.is_a?(Array)
+ { account => subtree }
+ else
+ at = subtree.map { |k, v| extract_account_tokens(v, [account, k].compact.join(':')) }
+ at.inject({}) { |k, v| k = k.merge(v)}
+ end
+ end
+
def learn!
- if options[:existing_ledger_file]
- fail "#{options[:existing_ledger_file]} doesn't exist!" unless File.exists?(options[:existing_ledger_file])
- ledger_data = File.read(options[:existing_ledger_file])
- learn_from(ledger_data)
+ if options[:account_tokens_file]
+ fail "#{options[:account_tokens_file]} doesn't exist!" unless File.exists?(options[:account_tokens_file])
+ extract_account_tokens(YAML.load_file(options[:account_tokens_file])).each do |account, tokens|
+ tokens.each { |t| learn_about_account(account, t) }
+ end
end
+ return unless options[:existing_ledger_file]
+ fail "#{options[:existing_ledger_file]} doesn't exist!" unless File.exists?(options[:existing_ledger_file])
+ ledger_data = File.read(options[:existing_ledger_file])
+ learn_from(ledger_data)
end
def learn_about_account(account, data)
accounts[account] ||= 0
tokenize(data).each do |token|
@@ -55,63 +75,83 @@
end
def walk_backwards
seen_anything_new = false
each_row_backwards do |row|
- puts Terminal::Table.new(:rows => [ [ row[:pretty_date], row[:pretty_money], row[:description] ] ])
+ interactive_output Terminal::Table.new(:rows => [ [ row[:pretty_date], row[:pretty_money], row[:description] ] ])
if already_seen?(row)
- puts "NOTE: This row is very similar to a previous one!"
+ interactive_output "NOTE: This row is very similar to a previous one!"
if !seen_anything_new
- puts "Skipping..."
+ interactive_output "Skipping..."
next
end
else
seen_anything_new = true
end
+ possible_answers = weighted_account_match( row ).map! { |a| a[:account] }
+
ledger = if row[:money] > 0
- out_of_account = ask("Which account provided this income? ([account]/[q]uit/[s]kip) ") { |q| q.default = guess_account(row) }
+ 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"
- puts "Skipping"
+ interactive_output "Skipping"
next
end
ledger_format( row,
[options[:bank_account], row[:pretty_money]],
[out_of_account, row[:pretty_money_negated]] )
else
- into_account = ask("To which account did this money go? ([account]/[q]uit/[s]kip) ") { |q| q.default = guess_account(row) }
+ 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'
- puts "Skipping"
+ interactive_output "Skipping"
next
end
ledger_format( row,
[into_account, row[:pretty_money_negated]],
[options[:bank_account], row[:pretty_money]] )
end
- learn_from(ledger)
+ learn_from(ledger) unless options[:account_tokens_file]
output(ledger)
end
end
def finish
options[:output_file].close unless options[:output_file] == STDOUT
- puts "Exiting."
+ interactive_output "Exiting."
exit
end
def output(ledger_line)
options[:output_file].puts ledger_line
options[:output_file].flush
end
- def guess_account(row)
+ # Weigh accounts by how well they match the row
+ def weighted_account_match( row )
query_tokens = tokenize(row[:description])
search_vector = []
account_vectors = {}
@@ -131,13 +171,20 @@
account_vectors = account_vectors.to_a.map do |account, account_vector|
{ :cosine => (0...account_vector.length).to_a.inject(0) { |m, i| m + search_vector[i] * account_vector[i] },
:account => account }
end
-
account_vectors.sort! {|a, b| b[:cosine] <=> a[:cosine] }
- account_vectors.first && account_vectors.first[:account]
+
+ # Return empty set if no accounts matched so that we can fallback to the defaults in the unattended mode
+ if options[:unattended]
+ if account_vectors.first && account_vectors.first[:account]
+ account_vectors = [] if account_vectors.first[:cosine] == 0
+ end
+ end
+
+ return account_vectors
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"
@@ -150,19 +197,19 @@
t.headings = 'Date', 'Amount', 'Description'
each_row_backwards do |row|
t << [ row[:pretty_date], row[:pretty_money], row[:description] ]
end
end
- puts output
+ interactive_output output
end
def each_row_backwards
rows = []
(0...@csv_parser.columns.first.length).to_a.each do |index|
- rows << { :date => @csv_parser.date_for(index),
+ rows << { :date => @csv_parser.date_for(index),
:pretty_date => @csv_parser.pretty_date_for(index),
- :pretty_money => @csv_parser.pretty_money_for(index),
+ :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 { |a, b| a[:date] <=> b[:date] }.each do |row|
@@ -219,11 +266,11 @@
opts.on("", "--comma-separates-cents", "Use comma instead of period to deliminate dollars from cents when parsing ($100,50 instead of $100.50)") do |c|
options[:comma_separates_cents] = c
end
- opts.on("", "--encoding e", "Specify an encoding for the CSV file") do |e|
+ opts.on("", "--encoding 'UTF-8'", "Specify an encoding for the CSV file; not usually needed") do |e|
options[:encoding] = e
end
opts.on("-c", "--currency '$'", "Currency symbol to use, defaults to $ (£, EUR)") do |e|
options[:currency] = e
@@ -231,10 +278,26 @@
opts.on("", "--date-format '%d/%m/%Y'", "Force the date format (see Ruby DateTime strftime)") do |d|
options[:date_format] = d
end
+ opts.on("-u", "--unattended", "Don't ask questions and guess all the accounts automatically. Used with --learn-from or --account-tokens options.") do |n|
+ options[:unattended] = n
+ end
+
+ opts.on("-t", "--account-tokens FILE", "YAML file with manually-assigned tokens for each account (see README)") do |a|
+ options[:account_tokens_file] = a
+ end
+
+ opts.on("", "--default-into-account name", "Default into account") do |a|
+ options[:default_into_account] = a
+ end
+
+ opts.on("", "--default-outof-account name", "Default 'out of' account") do |a|
+ options[:default_outof_account] = a
+ end
+
opts.on("", "--suffixed", "If --currency should be used as a suffix. Defaults to false.") do |e|
options[:suffixed] = e
end
opts.on_tail("-h", "--help", "Show this message") do
@@ -258,10 +321,14 @@
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|
+ q.readline = true
q.validate = /^.{2,}$/
q.default = "Assets:Bank:Checking"
end
end