lib/reckon/app.rb in reckon-0.3.8 vs lib/reckon/app.rb in reckon-0.3.9
- old
+ new
@@ -2,41 +2,30 @@
require 'pp'
module Reckon
class App
VERSION = "Reckon 0.1"
- attr_accessor :options, :csv_data, :accounts, :tokens, :money_column_indices, :date_column_index, :description_column_indices, :seen
+ attr_accessor :options, :accounts, :tokens, :seen, :csv_parser
def initialize(options = {})
self.options = options
self.tokens = {}
self.accounts = {}
self.seen = {}
self.options[:currency] ||= '$'
+ options[:string] = File.read(options[:file]) unless options[:string]
+ @csv_parser = CSVParser.new( options )
learn!
- parse
- filter_csv
- detect_columns
end
- def filter_csv
- if options[:ignore_columns]
- new_columns = []
- columns.each_with_index do |column, index|
- new_columns << column unless options[:ignore_columns].include?(index + 1)
- end
- @columns = new_columns
- end
- 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]
+ [entry[:desc], account[:amount]].join(" ") ) unless account[:name] == options[:bank_account]
seen[entry[:date]] ||= {}
- seen[entry[:date]][pretty_money(account[:amount])] = true
+ seen[entry[:date]][@csv_parser.pretty_money(account[:amount])] = true
end
end
end
def already_seen?(row)
@@ -154,219 +143,35 @@
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
end
- def money_for(index)
- value = money_column_indices.inject("") { |m, i| m + columns[i][index] }
- value = value.gsub(/\./, '').gsub(/,/, '.') if options[:comma_separates_cents]
- cleaned_value = value.gsub(/[^\d\.]/, '').to_f
- cleaned_value *= -1 if value =~ /[\(\-]/
- cleaned_value = -(cleaned_value) if options[:inverse]
- cleaned_value
- end
-
- def pretty_money_for(index, negate = false)
- pretty_money(money_for(index), negate)
- end
-
- def pretty_money(amount, negate = false)
- currency = options[:currency]
- if options[:suffixed]
- (amount >= 0 ? " " : "") + sprintf("%0.2f #{currency}", amount * (negate ? -1 : 1))
- else
- (amount >= 0 ? " " : "") + sprintf("%0.2f", amount * (negate ? -1 : 1)).gsub(/^((\-)|)(?=\d)/, "\\1#{currency}")
- end
- end
-
- def date_for(index)
- value = columns[date_column_index][index]
- if options[:date_format].nil?
- value = [$1, $2, $3].join("/") if value =~ /^(\d{4})(\d{2})(\d{2})\d+\[\d+\:GMT\]$/ # chase format
- value = [$3, $2, $1].join("/") if value =~ /^(\d{2})\.(\d{2})\.(\d{4})$/ # german format
- value = [$3, $2, $1].join("/") if value =~ /^(\d{2})\-(\d{2})\-(\d{4})$/ # nordea format
- value = [$1, $2, $3].join("/") if value =~ /^(\d{4})(\d{2})(\d{2})/ # yyyymmdd format
- else
- begin
- value = Date.strptime(value, options[:date_format])
- rescue
- puts "I'm having trouble parsing #{value} with the desired format: #{options[:date_format]}"
- exit 1
- end
- end
- begin
- guess = Chronic.parse(value, :context => :past)
- if guess.to_i < 953236800 && value =~ /\//
- guess = Chronic.parse((value.split("/")[0...-1] + [(2000 + value.split("/").last.to_i).to_s]).join("/"), :context => :past)
- end
- guess
- rescue
- puts "I'm having trouble parsing #{value}, which I thought was a date. Please report this so that we"
- puts "can make this parser better!"
- end
- end
-
- def pretty_date_for(index)
- date_for(index).strftime("%Y/%m/%d")
- end
-
- def description_for(index)
- description_column_indices.map { |i| columns[i][index] }.join("; ").squeeze(" ").gsub(/(;\s+){2,}/, '').strip
- 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
end
puts output
end
- def evaluate_columns(cols)
- results = []
- found_likely_money_column = false
- cols.each_with_index do |column, index|
- money_score = date_score = possible_neg_money_count = possible_pos_money_count = 0
- last = nil
- column.reverse.each_with_index do |entry, row_from_bottom|
- row = csv_data[csv_data.length - 1 - row_from_bottom]
- entry = entry.strip
- money_score += 20 if entry[/^[\-\+\(]{0,2}\$/]
- money_score += 20 if entry[/^\$?\-?\$?\d+[\.,\d]*?[\.,]\d\d$/]
- money_score += entry.gsub(/[^\d\.\-\+,\(\)]/, '').length if entry.length < 7
- money_score -= entry.length if entry.length > 8
- money_score -= 20 if entry !~ /^[\$\+\.\-,\d\(\)]+$/
- possible_neg_money_count += 1 if entry =~ /^\$?[\-\(]\$?\d+/
- possible_pos_money_count += 1 if entry =~ /^\+?\$?\+?\d+/
- date_score += 10 if entry =~ /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i
- date_score += 5 if entry =~ /^[\-\/\.\d:\[\]]+$/
- date_score += entry.gsub(/[^\-\/\.\d:\[\]]/, '').length if entry.gsub(/[^\-\/\.\d:\[\]]/, '').length > 3
- date_score -= entry.gsub(/[\-\/\.\d:\[\]]/, '').length
- date_score += 30 if entry =~ /^\d+[:\/\.]\d+[:\/\.]\d+([ :]\d+[:\/\.]\d+)?$/
- date_score += 10 if entry =~ /^\d+\[\d+:GMT\]$/i
-
- # Try to determine if this is a balance column
- entry_as_num = entry.gsub(/[^\-\d\.]/, '').to_f
- if last && entry_as_num != 0 && last != 0
- row.each do |row_entry|
- row_entry = row_entry.to_s.gsub(/[^\-\d\.]/, '').to_f
- if row_entry != 0 && last + row_entry == entry_as_num
- money_score -= 10
- break
- end
- end
- end
- last = entry_as_num
- end
-
- if possible_neg_money_count > (column.length / 5.0) && possible_pos_money_count > (column.length / 5.0)
- money_score += 10 * column.length
- found_likely_money_column = true
- end
-
- results << { :index => index, :money_score => money_score, :date_score => date_score }
- end
-
- return [results, found_likely_money_column]
- end
-
- def merge_columns(a, b)
- output_columns = []
- columns.each_with_index do |column, index|
- if index == a
- new_column = []
- column.each_with_index do |row, row_index|
- new_column << row + " " + (columns[b][row_index] || '')
- end
- output_columns << new_column
- elsif index == b
- # skip
- else
- output_columns << column
- end
- end
- output_columns
- end
-
- def detect_columns
- results, found_likely_money_column = evaluate_columns(columns)
- self.money_column_indices = [ results.sort { |a, b| b[:money_score] <=> a[:money_score] }.first[:index] ]
-
- if !found_likely_money_column
- found_likely_double_money_columns = false
- 0.upto(columns.length - 2) do |i|
- _, found_likely_double_money_columns = evaluate_columns(merge_columns(i, i+1))
-
- if found_likely_double_money_columns
- self.money_column_indices = [ i, i+1 ]
- unless settings[:testing]
- puts "It looks like this CSV has two seperate columns for money, one of which shows positive"
- puts "changes and one of which shows negative changes. If this is true, great. Otherwise,"
- puts "please report this issue to us so we can take a look!\n"
- end
- break
- end
- end
-
- if !found_likely_double_money_columns && !settings[:testing]
- puts "I didn't find a high-likelyhood money column, but I'm taking my best guess with column #{money_column_indices.first + 1}."
- end
- end
-
- results.reject! {|i| money_column_indices.include?(i[:index]) }
- self.date_column_index = results.sort { |a, b| b[:date_score] <=> a[:date_score] }.first[:index]
- results.reject! {|i| i[:index] == date_column_index }
-
- self.description_column_indices = results.map { |i| i[:index] }
- end
-
def each_row_backwards
rows = []
- (0...columns.first.length).to_a.each do |index|
- rows << { :date => date_for(index), :pretty_date => pretty_date_for(index),
- :pretty_money => pretty_money_for(index), :pretty_money_negated => pretty_money_for(index, :negate),
- :money => money_for(index), :description => description_for(index) }
+ (0...@csv_parser.columns.first.length).to_a.each do |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_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|
yield row
end
end
- def columns
- @columns ||= begin
- last_row_length = nil
- csv_data.inject([]) do |memo, row|
- # fail "Input CSV must have consistent row lengths." if last_row_length && row.length != last_row_length
- unless row.all? { |i| i.nil? || i.length == 0 }
- row.each_with_index do |entry, index|
- memo[index] ||= []
- memo[index] << (entry || '').strip
- end
- last_row_length = row.length
- end
- memo
- end
- end
- end
-
- def parse
- data = options[:string] || File.read(options[:file])
-
- if RUBY_VERSION =~ /^1\.9/ || RUBY_VERSION =~ /^2/
- data = data.force_encoding(options[:encoding] || 'BINARY').encode('UTF-8', :invalid => :replace, :undef => :replace, :replace => '?')
- csv_engine = CSV
- else
- csv_engine = FasterCSV
- end
-
- @csv_data = csv_engine.parse data.strip, :col_sep => options[:csv_separator] || ','
- csv_data.shift if options[:contains_header]
- csv_data
- end
-
def self.parse_opts(args = ARGV)
options = { :output_file => STDOUT }
parser = OptionParser.new do |opts|
opts.banner = "Usage: Reckon.rb [options]"
opts.separator ""
@@ -401,12 +206,13 @@
opts.on("", "--ignore-columns 1,2,5", "Columns to ignore in the CSV file - the first column is column 1") do |ignore|
options[:ignore_columns] = ignore.split(",").map { |i| i.to_i }
end
- opts.on("", "--contains-header", "The first row of the CSV is a header and should be skipped") do |contains_header|
- options[:contains_header] = contains_header
+ opts.on("", "--contains-header [N]", "The first row of the CSV is a header and should be skipped. Optionally add the number of rows to skip.") do |contains_header|
+ options[:contains_header] = 1
+ options[:contains_header] = contains_header.to_i if contains_header
end
opts.on("", "--csv-separator ','", "Separator for parsing the CSV - default is comma.") do |csv_separator|
options[:csv_separator] = csv_separator
end
@@ -459,18 +265,8 @@
q.default = "Assets:Bank:Checking"
end
end
options
- end
-
- @settings = { :testing => false }
-
- def self.settings
- @settings
- end
-
- def settings
- self.class.settings
end
end
end