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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +1 -1
  3. data/.gitignore +5 -0
  4. data/.rubocop.yml +20 -0
  5. data/CHANGELOG.md +22 -0
  6. data/Gemfile.lock +21 -21
  7. data/README.md +2 -0
  8. data/Rakefile +2 -2
  9. data/bin/build-new-version.sh +3 -2
  10. data/bin/reckon +1 -1
  11. data/lib/reckon/app.rb +27 -24
  12. data/lib/reckon/beancount_parser.rb +150 -0
  13. data/lib/reckon/cosine_similarity.rb +0 -1
  14. data/lib/reckon/csv_parser.rb +89 -44
  15. data/lib/reckon/date_column.rb +18 -7
  16. data/lib/reckon/ledger_parser.rb +23 -15
  17. data/lib/reckon/money.rb +18 -16
  18. data/lib/reckon/options.rb +47 -18
  19. data/lib/reckon/version.rb +1 -1
  20. data/lib/reckon.rb +1 -0
  21. data/spec/cosine_training_and_test.rb +1 -1
  22. data/spec/data_fixtures/multi-line-field.csv +5 -0
  23. data/spec/integration/ask_for_account/cli_input.txt +1 -0
  24. data/spec/integration/invalid_header_example/output.ledger +6 -7
  25. data/spec/integration/invalid_header_example/test_args +1 -1
  26. data/spec/integration/tab_delimited_file/input.csv +2 -0
  27. data/spec/integration/tab_delimited_file/output.ledger +8 -0
  28. data/spec/integration/tab_delimited_file/test_args +1 -0
  29. data/spec/integration/test.sh +3 -5
  30. data/spec/integration/two_money_columns_manual/input.csv +5 -0
  31. data/spec/integration/two_money_columns_manual/output.ledger +16 -0
  32. data/spec/integration/two_money_columns_manual/test_args +1 -0
  33. data/spec/reckon/csv_parser_spec.rb +85 -26
  34. data/spec/reckon/date_column_spec.rb +6 -0
  35. data/spec/reckon/ledger_parser_spec.rb +25 -23
  36. data/spec/reckon/options_spec.rb +2 -2
  37. data/spec/spec_helper.rb +2 -0
  38. metadata +17 -141
  39. data/spec/integration/ask_for_account/cli_input.exp +0 -33
@@ -1,32 +1,28 @@
1
- #coding: utf-8
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, :description_column_indices, :money_column, :date_column
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( index )
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( amount, @options ).pretty( negate )
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
- .reject(&:empty?)
58
- .join("; ")
59
- .squeeze(" ")
60
- .gsub(/(;\s+){2,}/, '')
61
- .strip
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( entry )
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
- money_score -= 10
101
- break
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, :date_score => date_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[ @money_column_indices[0] - 1 ]
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[ @money_column_indices[0] + 1 ]
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| n[:is_money_column] }.map { |n| n[:index] }
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
- found_double_money_column(*self.money_column_indices)
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
- rows = []
206
- data.each_line.with_index do |line, i|
207
- next if i < (options[:contains_header] || 0)
208
- rows << CSV.parse_line(line, col_sep: options[:csv_separator] || ',')
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
- rows
256
+ delimiters[counts.index(counts.max)]
212
257
  end
213
258
 
214
259
  def try_encoding(data, filename = nil)
@@ -2,13 +2,17 @@ module Reckon
2
2
  class DateColumn < Array
3
3
  attr_accessor :endian_precedence
4
4
  def initialize( arr = [], options = {} )
5
- @options = options
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 options[:date_format]
11
+ if date_format
8
12
  begin
9
- value = Date.strptime(value, options[:date_format])
13
+ value = Date.strptime(value, date_format)
10
14
  rescue
11
- puts "I'm having trouble parsing '#{value}' with the desired format: #{options[:date_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 || options[:date_format]
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(@options[:ledger_date_format] || '%Y-%m-%d')
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
- return date_score
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
@@ -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
- attr_accessor :entries
114
-
115
- def initialize(ledger, options = {})
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
- @entries = []
120
+ entries = []
123
121
  new_entry = {}
124
122
  in_comment = false
125
123
  comment_chars = ';#%*|'
126
- ledger.strip.split("\n").each do |entry|
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 @entries.flat_map do |n|
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
- @entries << entry
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 @options[:raw] ? "#{@amount_raw} | #{@amount}" : @amount
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, options = {})
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 options[:comma_separates_cents]
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
@@ -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
- @@cli = HighLine.new
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", "An existing ledger file to learn accounts from") do |l|
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", "Columns to ignore, starts from 1") do |ignore|
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, "Column number of the money column, starts from 1") do |col|
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, "Column number of the date column, starts from 1") do |col|
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, "Skip N header rows - default 1") do |hdr|
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", "Use comma to separate cents ($100,50 vs. $100.50)") do |c|
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 '$'", "Currency symbol to use - default $ (ex £, EUR)") do |e|
73
- options[:currency] = e
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", "CSV file date format (see `date` for format)") do |d|
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", "Ledger date format (see `date` for format)") do |d|
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", "Don't ask questions and guess all the accounts automatically. Use with --learn-from or --account-tokens options.") do |n|
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", "YAML file with manually-assigned tokens for each account (see README)") do |a|
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", "Fail on unmatched transactions.") do |n|
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] = @@cli.ask("What CSV file should I parse? ")
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] = @@cli.ask("What is this account named in Ledger?\n") do |q|
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"
@@ -1,3 +1,3 @@
1
1
  module Reckon
2
- VERSION="0.8.1"
2
+ VERSION = "0.9.1"
3
3
  end
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.read(ledger_file))
11
+ ledger = Reckon::LedgerParser.new(File.new(ledger_file))
12
12
  matcher = Reckon::CosineSimilarity.new({})
13
13
 
14
14
  train = []
@@ -0,0 +1,5 @@
1
+ ,311053760,2002-09-10T23:00:04,Merchant Transaction,Complete,,,"Lyft, Inc",- $21.59,,,,,,Venmo balance,,,,,Venmo,,
2
+ ,,,,,,,,,,,,,,,,,$23.40,$0.00,,$0.00,"In case of errors or questions about your
3
+ electronic transfers:
4
+ This is a multi-line string
5
+ "
@@ -0,0 +1 @@
1
+ Test::Bank
@@ -1,8 +1,7 @@
1
- 4016-02-18
2
- Assets:Bank:Checking $10.00
3
- Income:Unknown
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,2 @@
1
+ 123456789 EUR 20160102 15,00 10,00 20160102 -5,00 DESCRIPTION
2
+ 123456789 EUR 20160102 10,00 0,00 20160102 -10,00 DESCRIPTION
@@ -0,0 +1,8 @@
1
+ 2016-01-02 123456789; EUR; 20160102; DESCRIPTION
2
+ Expenses:Unknown
3
+ Test::Account -€5.00
4
+
5
+ 2016-01-02 123456789; EUR; 20160102; DESCRIPTION
6
+ Expenses:Unknown
7
+ Test::Account -€10.00
8
+
@@ -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