reckon 0.8.1 → 0.9.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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +1 -1
- data/.gitignore +5 -0
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +21 -21
- data/README.md +2 -0
- data/Rakefile +2 -2
- data/bin/build-new-version.sh +3 -2
- data/bin/reckon +1 -1
- data/lib/reckon/app.rb +27 -24
- data/lib/reckon/beancount_parser.rb +150 -0
- data/lib/reckon/cosine_similarity.rb +0 -1
- data/lib/reckon/csv_parser.rb +89 -44
- data/lib/reckon/date_column.rb +18 -7
- data/lib/reckon/ledger_parser.rb +23 -15
- data/lib/reckon/money.rb +18 -16
- data/lib/reckon/options.rb +47 -18
- data/lib/reckon/version.rb +1 -1
- data/lib/reckon.rb +1 -0
- data/spec/cosine_training_and_test.rb +1 -1
- data/spec/data_fixtures/multi-line-field.csv +5 -0
- data/spec/integration/ask_for_account/cli_input.txt +1 -0
- data/spec/integration/invalid_header_example/output.ledger +6 -7
- data/spec/integration/invalid_header_example/test_args +1 -1
- data/spec/integration/tab_delimited_file/input.csv +2 -0
- data/spec/integration/tab_delimited_file/output.ledger +8 -0
- data/spec/integration/tab_delimited_file/test_args +1 -0
- data/spec/integration/test.sh +3 -5
- data/spec/integration/two_money_columns_manual/input.csv +5 -0
- data/spec/integration/two_money_columns_manual/output.ledger +16 -0
- data/spec/integration/two_money_columns_manual/test_args +1 -0
- data/spec/reckon/csv_parser_spec.rb +85 -26
- data/spec/reckon/date_column_spec.rb +6 -0
- data/spec/reckon/ledger_parser_spec.rb +25 -23
- data/spec/reckon/options_spec.rb +2 -2
- data/spec/spec_helper.rb +2 -0
- metadata +17 -141
- data/spec/integration/ask_for_account/cli_input.exp +0 -33
data/lib/reckon/csv_parser.rb
CHANGED
@@ -1,32 +1,28 @@
|
|
1
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stringio'
|
2
4
|
|
3
5
|
module Reckon
|
6
|
+
# Parses CSV files
|
4
7
|
class CSVParser
|
5
|
-
attr_accessor :options, :csv_data, :money_column_indices, :date_column_index,
|
8
|
+
attr_accessor :options, :csv_data, :money_column_indices, :date_column_index,
|
9
|
+
:description_column_indices, :money_column, :date_column
|
6
10
|
|
7
11
|
def initialize(options = {})
|
8
12
|
self.options = options
|
13
|
+
|
14
|
+
self.options[:csv_separator] = "\t" if options[:csv_separator] == '\t'
|
9
15
|
self.options[:currency] ||= '$'
|
16
|
+
|
17
|
+
# we convert to a string so we can do character encoding cleanup
|
10
18
|
@csv_data = parse(options[:string] || File.read(options[:file]), options[:file])
|
11
19
|
filter_csv
|
12
20
|
detect_columns
|
13
21
|
end
|
14
22
|
|
23
|
+
# transpose csv_data (array of rows) to an array of columns
|
15
24
|
def columns
|
16
|
-
@columns ||=
|
17
|
-
begin
|
18
|
-
last_row_length = nil
|
19
|
-
csv_data.inject([]) do |memo, row|
|
20
|
-
unless row.all? { |i| i.nil? || i.length == 0 }
|
21
|
-
row.each_with_index do |entry, index|
|
22
|
-
memo[index] ||= []
|
23
|
-
memo[index] << (entry || '').strip
|
24
|
-
end
|
25
|
-
last_row_length = row.length
|
26
|
-
end
|
27
|
-
memo
|
28
|
-
end
|
29
|
-
end
|
25
|
+
@columns ||= @csv_data[0].zip(*@csv_data[1..])
|
30
26
|
end
|
31
27
|
|
32
28
|
def date_for(index)
|
@@ -34,7 +30,7 @@ module Reckon
|
|
34
30
|
end
|
35
31
|
|
36
32
|
def pretty_date_for(index)
|
37
|
-
@date_column.pretty_for(
|
33
|
+
@date_column.pretty_for(index)
|
38
34
|
end
|
39
35
|
|
40
36
|
def money_for(index)
|
@@ -42,7 +38,7 @@ module Reckon
|
|
42
38
|
end
|
43
39
|
|
44
40
|
def pretty_money(amount, negate = false)
|
45
|
-
Money.new(
|
41
|
+
Money.new(amount, @options).pretty(negate)
|
46
42
|
end
|
47
43
|
|
48
44
|
def pretty_money_for(index, negate = false)
|
@@ -54,11 +50,11 @@ module Reckon
|
|
54
50
|
|
55
51
|
def description_for(index)
|
56
52
|
description_column_indices.map { |i| columns[i][index].to_s.strip }
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
53
|
+
.reject(&:empty?)
|
54
|
+
.join("; ")
|
55
|
+
.squeeze(" ")
|
56
|
+
.gsub(/(;\s+){2,}/, '')
|
57
|
+
.strip
|
62
58
|
end
|
63
59
|
|
64
60
|
def row(index)
|
@@ -84,9 +80,10 @@ module Reckon
|
|
84
80
|
money_score = date_score = possible_neg_money_count = possible_pos_money_count = 0
|
85
81
|
last = nil
|
86
82
|
column.reverse.each_with_index do |entry, row_from_bottom|
|
83
|
+
entry ||= "" # entries can be nil
|
87
84
|
row = csv_data[csv_data.length - 1 - row_from_bottom]
|
88
85
|
entry = entry.strip
|
89
|
-
money_score += Money::likelihood(
|
86
|
+
money_score += Money::likelihood(entry)
|
90
87
|
possible_neg_money_count += 1 if entry =~ /^\$?[\-\(]\$?\d+/
|
91
88
|
possible_pos_money_count += 1 if entry =~ /^\+?\$?\+?\d+/
|
92
89
|
date_score += DateColumn.likelihood(entry)
|
@@ -97,8 +94,8 @@ module Reckon
|
|
97
94
|
row.each do |row_entry|
|
98
95
|
row_entry = row_entry.to_s.gsub(/[^\-\d\.]/, '').to_f
|
99
96
|
if row_entry != 0 && last + row_entry == entry_as_num
|
100
|
-
|
101
|
-
|
97
|
+
money_score -= 10
|
98
|
+
break
|
102
99
|
end
|
103
100
|
end
|
104
101
|
end
|
@@ -110,7 +107,8 @@ module Reckon
|
|
110
107
|
found_likely_money_column = true
|
111
108
|
end
|
112
109
|
|
113
|
-
results << { :index => index, :money_score => money_score,
|
110
|
+
results << { :index => index, :money_score => money_score,
|
111
|
+
:date_score => date_score }
|
114
112
|
end
|
115
113
|
|
116
114
|
results.sort_by! { |n| -n[:money_score] }
|
@@ -126,24 +124,18 @@ module Reckon
|
|
126
124
|
return results.sort_by { |n| n[:index] }
|
127
125
|
end
|
128
126
|
|
129
|
-
def found_double_money_column(id1, id2)
|
130
|
-
self.money_column_indices = [id1, id2]
|
131
|
-
puts "It looks like this CSV has two seperate columns for money, one of which shows positive"
|
132
|
-
puts "changes and one of which shows negative changes. If this is true, great. Otherwise,"
|
133
|
-
puts "please report this issue to us so we can take a look!\n"
|
134
|
-
end
|
135
|
-
|
136
127
|
# Some csv files negative/positive amounts are indicated in separate account
|
137
128
|
def detect_sign_column
|
138
129
|
return if columns[0].length <= 2 # This test needs requires more than two rows otherwise will lead to false positives
|
130
|
+
|
139
131
|
signs = []
|
140
132
|
if @money_column_indices[0] > 0
|
141
|
-
column = columns[
|
133
|
+
column = columns[@money_column_indices[0] - 1]
|
142
134
|
signs = column.uniq
|
143
135
|
end
|
144
136
|
if (signs.length != 2 &&
|
145
137
|
(@money_column_indices[0] + 1 < columns.length))
|
146
|
-
column = columns[
|
138
|
+
column = columns[@money_column_indices[0] + 1]
|
147
139
|
signs = column.uniq
|
148
140
|
end
|
149
141
|
if signs.length == 2
|
@@ -162,14 +154,34 @@ module Reckon
|
|
162
154
|
def detect_columns
|
163
155
|
results = evaluate_columns(columns)
|
164
156
|
|
157
|
+
# We keep money_column options for backwards compatibility reasons, while
|
158
|
+
# adding option to specify multiple money_columns
|
165
159
|
if options[:money_column]
|
166
160
|
self.money_column_indices = [options[:money_column] - 1]
|
161
|
+
|
162
|
+
# One or two columns can be specified as money_columns
|
163
|
+
elsif options[:money_columns]
|
164
|
+
if options[:money_columns].length == 1
|
165
|
+
self.money_column_indices = [options[:money_column] - 1]
|
166
|
+
elsif options[:money_columns].length == 2
|
167
|
+
in_col, out_col = options[:money_columns]
|
168
|
+
self.money_column_indices = [in_col - 1, out_col - 1]
|
169
|
+
else
|
170
|
+
puts "Unable to determine money columns, use --money-columns to specify the 1 or 2 column(s) reckon should use."
|
171
|
+
end
|
172
|
+
|
173
|
+
# If no money_column(s) argument is supplied, try to automatically infer money_column(s)
|
167
174
|
else
|
168
|
-
self.money_column_indices = results.select { |n|
|
175
|
+
self.money_column_indices = results.select { |n|
|
176
|
+
n[:is_money_column]
|
177
|
+
}.map { |n| n[:index] }
|
169
178
|
if self.money_column_indices.length == 1
|
179
|
+
# TODO: print the unfiltered column number, not the filtered
|
180
|
+
# ie if money column is 7, but we ignore columns 4 and 5, this prints "Using column 5 as the money column"
|
170
181
|
puts "Using column #{money_column_indices.first + 1} as the money column. Use --money-colum to specify a different one."
|
171
182
|
elsif self.money_column_indices.length == 2
|
172
|
-
|
183
|
+
puts "Using columns #{money_column_indices[0] + 1} and #{money_column_indices[1] + 1} as money column. Use --money-columns to specify different ones."
|
184
|
+
self.money_column_indices = self.money_column_indices[0..1]
|
173
185
|
else
|
174
186
|
puts "Unable to determine a money column, use --money-column to specify the column reckon should use."
|
175
187
|
end
|
@@ -195,20 +207,53 @@ module Reckon
|
|
195
207
|
self.description_column_indices = results.map { |i| i[:index] }
|
196
208
|
end
|
197
209
|
|
198
|
-
def parse(data, filename=nil)
|
210
|
+
def parse(data, filename = nil)
|
199
211
|
# Use force_encoding to convert the string to utf-8 with as few invalid characters
|
200
212
|
# as possible.
|
201
213
|
data.force_encoding(try_encoding(data, filename))
|
202
214
|
data = data.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
203
215
|
data.sub!("\xEF\xBB\xBF", '') # strip byte order marker, if it exists
|
204
216
|
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
217
|
+
separator = options[:csv_separator] || guess_column_separator(data)
|
218
|
+
header_lines_to_skip = options[:contains_header] || 0
|
219
|
+
# -1 is skip 0 footer rows
|
220
|
+
footer_lines_to_skip = (options[:contains_footer] || 0) + 1
|
221
|
+
|
222
|
+
# convert to a stringio object to handle multi-line fields
|
223
|
+
parser_opts = {
|
224
|
+
col_sep: separator,
|
225
|
+
skip_blanks: true
|
226
|
+
}
|
227
|
+
begin
|
228
|
+
rows = CSV.parse(StringIO.new(data), **parser_opts)
|
229
|
+
rows[header_lines_to_skip..-footer_lines_to_skip]
|
230
|
+
rescue CSV::MalformedCSVError
|
231
|
+
# try removing N header lines before parsing
|
232
|
+
index = 0
|
233
|
+
count = 0
|
234
|
+
while count < header_lines_to_skip
|
235
|
+
index = data.index("\n", index) + 1 # skip over newline character
|
236
|
+
count += 1
|
237
|
+
end
|
238
|
+
rows = CSV.parse(StringIO.new(data[index..-1]), **parser_opts)
|
239
|
+
rows[0..-footer_lines_to_skip]
|
209
240
|
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def guess_column_separator(data)
|
244
|
+
delimiters = [',', "\t", ';', ':', '|']
|
245
|
+
|
246
|
+
counts = [0] * delimiters.length
|
247
|
+
|
248
|
+
data.each_line do |line|
|
249
|
+
delimiters.each_with_index do |delim, i|
|
250
|
+
counts[i] += line.count(delim)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
LOGGER.info("guessing #{delimiters[counts.index(counts.max)]} as csv separator")
|
210
255
|
|
211
|
-
|
256
|
+
delimiters[counts.index(counts.max)]
|
212
257
|
end
|
213
258
|
|
214
259
|
def try_encoding(data, filename = nil)
|
data/lib/reckon/date_column.rb
CHANGED
@@ -2,13 +2,17 @@ module Reckon
|
|
2
2
|
class DateColumn < Array
|
3
3
|
attr_accessor :endian_precedence
|
4
4
|
def initialize( arr = [], options = {} )
|
5
|
-
|
5
|
+
# output date format
|
6
|
+
@ledger_date_format = options[:ledger_date_format]
|
7
|
+
|
8
|
+
# input date format
|
9
|
+
date_format = options[:date_format]
|
6
10
|
arr.each do |value|
|
7
|
-
if
|
11
|
+
if date_format
|
8
12
|
begin
|
9
|
-
value = Date.strptime(value,
|
13
|
+
value = Date.strptime(value, date_format)
|
10
14
|
rescue
|
11
|
-
puts "I'm having trouble parsing '#{value}' with the desired format: #{
|
15
|
+
puts "I'm having trouble parsing '#{value}' with the desired format: #{date_format}"
|
12
16
|
exit 1
|
13
17
|
end
|
14
18
|
else
|
@@ -34,7 +38,7 @@ module Reckon
|
|
34
38
|
self.push( value )
|
35
39
|
end
|
36
40
|
# if endian_precedence still nil, raise error
|
37
|
-
unless @endian_precedence ||
|
41
|
+
unless @endian_precedence || date_format
|
38
42
|
raise( "Unable to determine date format. Please specify using --date-format" )
|
39
43
|
end
|
40
44
|
end
|
@@ -54,7 +58,7 @@ module Reckon
|
|
54
58
|
date = self.for(index)
|
55
59
|
return "" if date.nil?
|
56
60
|
|
57
|
-
date.strftime(@
|
61
|
+
date.strftime(@ledger_date_format || '%Y-%m-%d')
|
58
62
|
end
|
59
63
|
|
60
64
|
def self.likelihood(entry)
|
@@ -65,7 +69,14 @@ module Reckon
|
|
65
69
|
date_score -= entry.gsub(/[\-\/\.\d:\[\]]/, '').length
|
66
70
|
date_score += 30 if entry =~ /^\d+[:\/\.-]\d+[:\/\.-]\d+([ :]\d+[:\/\.]\d+)?$/
|
67
71
|
date_score += 10 if entry =~ /^\d+\[\d+:GMT\]$/i
|
68
|
-
|
72
|
+
|
73
|
+
begin
|
74
|
+
DateTime.parse(entry)
|
75
|
+
date_score += 20
|
76
|
+
rescue Date::Error, ArgumentError
|
77
|
+
end
|
78
|
+
|
79
|
+
date_score
|
69
80
|
end
|
70
81
|
end
|
71
82
|
end
|
data/lib/reckon/ledger_parser.rb
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
# From: https://www.ledger-cli.org/3.0/doc/ledger3.html#Transactions-and-Comments
|
@@ -110,20 +109,20 @@ require 'rubygems'
|
|
110
109
|
module Reckon
|
111
110
|
class LedgerParser
|
112
111
|
|
113
|
-
|
114
|
-
|
115
|
-
def initialize(
|
112
|
+
# ledger is an object that response to #each_line,
|
113
|
+
# (i.e. a StringIO or an IO object)
|
114
|
+
def initialize(options = {})
|
116
115
|
@options = options
|
117
116
|
@date_format = options[:ledger_date_format] || options[:date_format] || '%Y-%m-%d'
|
118
|
-
parse(ledger)
|
119
117
|
end
|
120
118
|
|
121
119
|
def parse(ledger)
|
122
|
-
|
120
|
+
entries = []
|
123
121
|
new_entry = {}
|
124
122
|
in_comment = false
|
125
123
|
comment_chars = ';#%*|'
|
126
|
-
ledger.
|
124
|
+
ledger.each_line do |entry|
|
125
|
+
entry.rstrip!
|
127
126
|
# strip comment lines
|
128
127
|
in_comment = true if entry == 'comment'
|
129
128
|
in_comment = false if entry == 'end comment'
|
@@ -132,7 +131,7 @@ module Reckon
|
|
132
131
|
|
133
132
|
# (date, type, code, description), type and code are optional
|
134
133
|
if (m = entry.match(%r{^(\d+[\d/-]+)\s+([*!])?\s*(\([^)]+\))?\s*(.*)$}))
|
135
|
-
add_entry(new_entry)
|
134
|
+
add_entry(entries, new_entry)
|
136
135
|
new_entry = {
|
137
136
|
date: try_parse_date(m[1]),
|
138
137
|
type: m[2] || "",
|
@@ -141,23 +140,24 @@ module Reckon
|
|
141
140
|
accounts: []
|
142
141
|
}
|
143
142
|
elsif entry =~ /^\s*$/ && new_entry[:date]
|
144
|
-
add_entry(new_entry)
|
143
|
+
add_entry(entries,new_entry)
|
145
144
|
new_entry = {}
|
146
145
|
elsif new_entry[:date] && entry =~ /^\s+/
|
147
146
|
LOGGER.info("Adding new account #{entry}")
|
148
147
|
new_entry[:accounts] << parse_account_line(entry)
|
149
148
|
else
|
150
149
|
LOGGER.info("Unknown entry type: #{entry}")
|
151
|
-
add_entry(new_entry)
|
150
|
+
add_entry(entries, new_entry)
|
152
151
|
new_entry = {}
|
153
152
|
end
|
154
153
|
end
|
155
|
-
add_entry(new_entry)
|
154
|
+
add_entry(entries, new_entry)
|
155
|
+
entries
|
156
156
|
end
|
157
157
|
|
158
158
|
# roughly matches ledger csv format
|
159
|
-
def to_csv
|
160
|
-
return
|
159
|
+
def to_csv(ledger)
|
160
|
+
return parse(ledger).flat_map do |n|
|
161
161
|
n[:accounts].map do |a|
|
162
162
|
row = [
|
163
163
|
n[:date].strftime(@date_format),
|
@@ -174,13 +174,21 @@ module Reckon
|
|
174
174
|
end
|
175
175
|
end
|
176
176
|
|
177
|
+
def format_row(row, line1, line2)
|
178
|
+
out = "#{row[:pretty_date]}\t#{row[:description]}#{row[:note] ? "\t; " + row[:note]: ""}\n"
|
179
|
+
out += "\t#{line1.first}\t\t\t#{line1.last}\n"
|
180
|
+
out += "\t#{line2.first}\t\t\t#{line2.last}\n\n"
|
181
|
+
out
|
182
|
+
end
|
183
|
+
|
184
|
+
|
177
185
|
private
|
178
186
|
|
179
|
-
def add_entry(entry)
|
187
|
+
def add_entry(entries, entry)
|
180
188
|
return unless entry[:date] && entry[:accounts].length > 1
|
181
189
|
|
182
190
|
entry[:accounts] = balance(entry[:accounts])
|
183
|
-
|
191
|
+
entries << entry
|
184
192
|
end
|
185
193
|
|
186
194
|
def try_parse_date(date_str)
|
data/lib/reckon/money.rb
CHANGED
@@ -6,11 +6,10 @@ module Reckon
|
|
6
6
|
include Comparable
|
7
7
|
attr_accessor :amount, :currency, :suffixed
|
8
8
|
def initialize(amount, options = {})
|
9
|
-
@options = options
|
10
9
|
@amount_raw = amount
|
11
10
|
@raw = options[:raw]
|
12
11
|
|
13
|
-
@amount = parse(amount, options)
|
12
|
+
@amount = parse(amount, options[:comma_separates_cents])
|
14
13
|
@amount = -@amount if options[:inverse]
|
15
14
|
@currency = options[:currency] || "$"
|
16
15
|
@suffixed = options[:suffixed]
|
@@ -21,7 +20,7 @@ module Reckon
|
|
21
20
|
end
|
22
21
|
|
23
22
|
def to_s
|
24
|
-
return @
|
23
|
+
return @raw ? "#{@amount_raw} | #{@amount}" : @amount
|
25
24
|
end
|
26
25
|
|
27
26
|
# unary minus
|
@@ -60,34 +59,37 @@ module Reckon
|
|
60
59
|
return (@amount >= 0 ? " " : "") + amt
|
61
60
|
end
|
62
61
|
|
62
|
+
def self.likelihood(entry)
|
63
|
+
money_score = 0
|
64
|
+
# digits separated by , or . with no more than 2 trailing digits
|
65
|
+
money_score += 40 if entry.match(/\d+[,.]\d{2}[^\d]*$/)
|
66
|
+
money_score += 10 if entry[/^\$?\-?\$?\d+[\.,\d]*?[\.,]\d\d$/]
|
67
|
+
money_score += 10 if entry[/\d+[\.,\d]*?[\.,]\d\d$/]
|
68
|
+
money_score += entry.gsub(/[^\d\.\-\+,\(\)]/, '').length if entry.length < 7
|
69
|
+
money_score -= entry.length if entry.length > 12
|
70
|
+
money_score -= 20 if (entry !~ /^[\$\+\.\-,\d\(\)]+$/) && entry.length > 0
|
71
|
+
money_score
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
63
76
|
def pretty_amount(amount)
|
64
77
|
sprintf("%0.2f", amount).reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
65
78
|
end
|
66
79
|
|
67
|
-
def parse(value,
|
80
|
+
def parse(value, comma_separates_cents)
|
68
81
|
value = value.to_s
|
69
82
|
# Empty string is treated as money with value 0
|
70
83
|
return value.to_f if value.to_s.empty?
|
71
84
|
|
72
85
|
invert = value.match(/^\(.*\)$/)
|
73
86
|
value = value.gsub(/[^0-9,.-]/, '')
|
74
|
-
value = value.tr('.', '').tr(',', '.') if
|
87
|
+
value = value.tr('.', '').tr(',', '.') if comma_separates_cents
|
75
88
|
value = value.tr(',', '')
|
76
89
|
value = value.to_f
|
77
90
|
return invert ? -value : value
|
78
91
|
end
|
79
92
|
|
80
|
-
def Money::likelihood(entry)
|
81
|
-
money_score = 0
|
82
|
-
# digits separated by , or . with no more than 2 trailing digits
|
83
|
-
money_score += 40 if entry.match(/\d+[,.]\d{2}[^\d]*$/)
|
84
|
-
money_score += 10 if entry[/^\$?\-?\$?\d+[\.,\d]*?[\.,]\d\d$/]
|
85
|
-
money_score += 10 if entry[/\d+[\.,\d]*?[\.,]\d\d$/]
|
86
|
-
money_score += entry.gsub(/[^\d\.\-\+,\(\)]/, '').length if entry.length < 7
|
87
|
-
money_score -= entry.length if entry.length > 12
|
88
|
-
money_score -= 20 if (entry !~ /^[\$\+\.\-,\d\(\)]+$/) && entry.length > 0
|
89
|
-
money_score
|
90
|
-
end
|
91
93
|
end
|
92
94
|
|
93
95
|
class MoneyColumn < Array
|
data/lib/reckon/options.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Reckon
|
4
|
+
# Singleton class for parsing command line flags
|
2
5
|
class Options
|
3
|
-
|
4
|
-
|
5
|
-
def self.parse(args = ARGV, stdin = $stdin)
|
6
|
+
def self.parse_command_line_options(args = ARGV, stdin = $stdin)
|
7
|
+
cli = HighLine.new
|
6
8
|
options = { output_file: $stdout }
|
7
9
|
OptionParser.new do |opts|
|
8
10
|
opts.banner = "Usage: Reckon.rb [options]"
|
@@ -32,36 +34,52 @@ module Reckon
|
|
32
34
|
options[:output_file] = File.open(o, 'a')
|
33
35
|
end
|
34
36
|
|
35
|
-
opts.on("-l", "--learn-from FILE",
|
37
|
+
opts.on("-l", "--learn-from FILE",
|
38
|
+
"An existing ledger file to learn accounts from") do |l|
|
36
39
|
options[:existing_ledger_file] = l
|
37
40
|
end
|
38
41
|
|
39
|
-
opts.on("", "--ignore-columns 1,2,5",
|
42
|
+
opts.on("", "--ignore-columns 1,2,5",
|
43
|
+
"Columns to ignore, starts from 1") do |ignore|
|
40
44
|
options[:ignore_columns] = ignore.split(",").map(&:to_i)
|
41
45
|
end
|
42
46
|
|
43
|
-
opts.on("", "--money-column 2", Integer,
|
47
|
+
opts.on("", "--money-column 2", Integer,
|
48
|
+
"Column number of the money column, starts from 1") do |col|
|
44
49
|
options[:money_column] = col
|
45
50
|
end
|
46
51
|
|
52
|
+
opts.on("", "--money-columns 2,3",
|
53
|
+
"Column number of the money columns, starts from 1 (1 or 2 columns)") do |ignore|
|
54
|
+
options[:money_columns] = ignore.split(",").map(&:to_i)
|
55
|
+
end
|
56
|
+
|
47
57
|
opts.on("", "--raw-money", "Don't format money column (for stocks)") do |n|
|
48
58
|
options[:raw] = n
|
49
59
|
end
|
50
60
|
|
51
|
-
opts.on("", "--date-column 3", Integer,
|
61
|
+
opts.on("", "--date-column 3", Integer,
|
62
|
+
"Column number of the date column, starts from 1") do |col|
|
52
63
|
options[:date_column] = col
|
53
64
|
end
|
54
65
|
|
55
|
-
opts.on("", "--contains-header [N]", Integer,
|
66
|
+
opts.on("", "--contains-header [N]", Integer,
|
67
|
+
"Skip N header rows - default 1") do |hdr|
|
56
68
|
options[:contains_header] = 1
|
57
69
|
options[:contains_header] = hdr.to_i
|
58
70
|
end
|
59
71
|
|
72
|
+
opts.on("", "--contains-footer [N]", Integer,
|
73
|
+
"Skip N footer rows - default 0") do |hdr|
|
74
|
+
options[:contains_footer] = hdr.to_i || 0
|
75
|
+
end
|
76
|
+
|
60
77
|
opts.on("", "--csv-separator ','", "CSV separator (default ',')") do |sep|
|
61
78
|
options[:csv_separator] = sep
|
62
79
|
end
|
63
80
|
|
64
|
-
opts.on("", "--comma-separates-cents",
|
81
|
+
opts.on("", "--comma-separates-cents",
|
82
|
+
"Use comma to separate cents ($100,50 vs. $100.50)") do |c|
|
65
83
|
options[:comma_separates_cents] = c
|
66
84
|
end
|
67
85
|
|
@@ -69,23 +87,28 @@ module Reckon
|
|
69
87
|
options[:encoding] = e
|
70
88
|
end
|
71
89
|
|
72
|
-
opts.on("-c", "--currency '$'",
|
73
|
-
|
90
|
+
opts.on("-c", "--currency '$'",
|
91
|
+
"Currency symbol to use - default $ (ex £, EUR)") do |e|
|
92
|
+
options[:currency] = e || '$'
|
74
93
|
end
|
75
94
|
|
76
|
-
opts.on("", "--date-format FORMAT",
|
95
|
+
opts.on("", "--date-format FORMAT",
|
96
|
+
"CSV file date format (see `date` for format)") do |d|
|
77
97
|
options[:date_format] = d
|
78
98
|
end
|
79
99
|
|
80
|
-
opts.on("", "--ledger-date-format FORMAT",
|
100
|
+
opts.on("", "--ledger-date-format FORMAT",
|
101
|
+
"Ledger date format (see `date` for format)") do |d|
|
81
102
|
options[:ledger_date_format] = d
|
82
103
|
end
|
83
104
|
|
84
|
-
opts.on("-u", "--unattended",
|
105
|
+
opts.on("-u", "--unattended",
|
106
|
+
"Don't ask questions and guess all the accounts automatically. Use with --learn-from or --account-tokens options.") do |n|
|
85
107
|
options[:unattended] = n
|
86
108
|
end
|
87
109
|
|
88
|
-
opts.on("-t", "--account-tokens FILE",
|
110
|
+
opts.on("-t", "--account-tokens FILE",
|
111
|
+
"YAML file with manually-assigned tokens for each account (see README)") do |a|
|
89
112
|
options[:account_tokens_file] = a
|
90
113
|
end
|
91
114
|
|
@@ -103,7 +126,8 @@ module Reckon
|
|
103
126
|
options[:default_outof_account] = a
|
104
127
|
end
|
105
128
|
|
106
|
-
opts.on("", "--fail-on-unknown-account",
|
129
|
+
opts.on("", "--fail-on-unknown-account",
|
130
|
+
"Fail on unmatched transactions.") do |n|
|
107
131
|
options[:fail_on_unknown_account] = n
|
108
132
|
end
|
109
133
|
|
@@ -111,6 +135,11 @@ module Reckon
|
|
111
135
|
options[:suffixed] = e
|
112
136
|
end
|
113
137
|
|
138
|
+
opts.on("", "--ledger-format FORMAT",
|
139
|
+
"Output/Learn format: BEANCOUNT or LEDGER. Default: LEDGER") do |n|
|
140
|
+
options[:format] = n
|
141
|
+
end
|
142
|
+
|
114
143
|
opts.on_tail("-h", "--help", "Show this message") do
|
115
144
|
puts opts
|
116
145
|
exit
|
@@ -133,7 +162,7 @@ module Reckon
|
|
133
162
|
end
|
134
163
|
|
135
164
|
unless options[:file]
|
136
|
-
options[:file] =
|
165
|
+
options[:file] = cli.ask("What CSV file should I parse? ")
|
137
166
|
unless options[:file].empty?
|
138
167
|
puts "\nYou must provide a CSV file to parse.\n"
|
139
168
|
puts parser
|
@@ -144,7 +173,7 @@ module Reckon
|
|
144
173
|
unless options[:bank_account]
|
145
174
|
raise "Must specify --account in unattended mode" if options[:unattended]
|
146
175
|
|
147
|
-
options[:bank_account] =
|
176
|
+
options[:bank_account] = cli.ask("What is this account named in Ledger?\n") do |q|
|
148
177
|
q.readline = true
|
149
178
|
q.validate = /^.{2,}$/
|
150
179
|
q.default = "Assets:Bank:Checking"
|
data/lib/reckon/version.rb
CHANGED
data/lib/reckon.rb
CHANGED
@@ -15,6 +15,7 @@ require_relative 'reckon/cosine_similarity'
|
|
15
15
|
require_relative 'reckon/date_column'
|
16
16
|
require_relative 'reckon/money'
|
17
17
|
require_relative 'reckon/ledger_parser'
|
18
|
+
require_relative 'reckon/beancount_parser'
|
18
19
|
require_relative 'reckon/csv_parser'
|
19
20
|
require_relative 'reckon/options'
|
20
21
|
require_relative 'reckon/app'
|
@@ -8,7 +8,7 @@ ledger_file = ARGV[0]
|
|
8
8
|
account = ARGV[1]
|
9
9
|
seed = ARGV[2] ? ARGV[2].to_i : Random.new_seed
|
10
10
|
|
11
|
-
ledger = Reckon::LedgerParser.new(File.
|
11
|
+
ledger = Reckon::LedgerParser.new(File.new(ledger_file))
|
12
12
|
matcher = Reckon::CosineSimilarity.new({})
|
13
13
|
|
14
14
|
train = []
|
@@ -0,0 +1 @@
|
|
1
|
+
Test::Bank
|
@@ -1,8 +1,7 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
4016-02-19
|
6
|
-
Income:Unknown
|
7
|
-
Assets:Bank:Checking $0.00
|
1
|
+
2016-02-18 COTISATION JAZZ; COTISATION JAZZ; EUR
|
2
|
+
Expenses:Unknown
|
3
|
+
Assets:Bank:Checking -$8.10
|
8
4
|
|
5
|
+
2016-02-19 VIR RECU 508160; VIR RECU 1234567834S DE: Francois REF: 123457891234567894561231 PROVENANCE: DE Allemagne; EUR
|
6
|
+
Assets:Bank:Checking $50.00
|
7
|
+
Expenses:Unknown
|
@@ -1 +1 @@
|
|
1
|
-
-f input.csv --unattended --account Assets:Bank:Checking --contains-header 4
|
1
|
+
-f input.csv --unattended --account Assets:Bank:Checking --contains-header 4 --comma-separates-cents --verbose
|
@@ -0,0 +1 @@
|
|
1
|
+
-f input.csv --unattended -c € --ignore-columns 4,5 --comma-separates-cents -v --date-format '%Y%m%d' --csv-separator '\t' -a Test::Account
|