reckon 0.2.0
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.
- data/.document +5 -0
- data/.gitignore +22 -0
- data/LICENSE +20 -0
- data/README.rdoc +17 -0
- data/Rakefile +48 -0
- data/VERSION +1 -0
- data/bin/reckon +19 -0
- data/lib/reckon.rb +12 -0
- data/lib/reckon/app.rb +364 -0
- data/lib/reckon/ledger_parser.rb +62 -0
- data/reckon.gemspec +70 -0
- data/spec/reckon/app_spec.rb +138 -0
- data/spec/reckon/ledger_parser_spec.rb +101 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +9 -0
- metadata +148 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Andrew Cantino
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
= reckon
|
2
|
+
|
3
|
+
Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.
|
4
|
+
|
5
|
+
== Note on Patches/Pull Requests
|
6
|
+
|
7
|
+
* Fork the project.
|
8
|
+
* Make your feature addition or bug fix.
|
9
|
+
* Add tests for it. This is important so I don't break it in a
|
10
|
+
future version unintentionally.
|
11
|
+
* Commit, do not mess with rakefile, version, or history.
|
12
|
+
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
13
|
+
* Send me a pull request. Bonus points for topic branches.
|
14
|
+
|
15
|
+
== Copyright
|
16
|
+
|
17
|
+
Copyright (c) 2010 Andrew Cantino. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "reckon"
|
8
|
+
gem.summary = %Q{Utility for interactively converting and labeling CSV files for the Ledger accounting tool.}
|
9
|
+
gem.description = %Q{Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.}
|
10
|
+
gem.email = "andrew@iterationlabs.com"
|
11
|
+
gem.homepage = "http://github.com/iterationlabs/reckon"
|
12
|
+
gem.authors = ["Andrew Cantino"]
|
13
|
+
gem.add_development_dependency "rspec", ">= 1.2.9"
|
14
|
+
gem.add_dependency('fastercsv', '>= 1.5.1')
|
15
|
+
gem.add_dependency('highline', '>= 1.5.2')
|
16
|
+
gem.add_dependency('terminal-table', '>= 1.4.2')
|
17
|
+
gem.executables << 'reckon'
|
18
|
+
end
|
19
|
+
Jeweler::GemcutterTasks.new
|
20
|
+
rescue LoadError
|
21
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
22
|
+
end
|
23
|
+
|
24
|
+
require 'spec/rake/spectask'
|
25
|
+
Spec::Rake::SpecTask.new(:spec) do |spec|
|
26
|
+
spec.libs << 'lib' << 'spec'
|
27
|
+
spec.spec_files = FileList['spec/**/*_spec.rb']
|
28
|
+
end
|
29
|
+
|
30
|
+
Spec::Rake::SpecTask.new(:rcov) do |spec|
|
31
|
+
spec.libs << 'lib' << 'spec'
|
32
|
+
spec.pattern = 'spec/**/*_spec.rb'
|
33
|
+
spec.rcov = true
|
34
|
+
end
|
35
|
+
|
36
|
+
task :spec => :check_dependencies
|
37
|
+
|
38
|
+
task :default => :spec
|
39
|
+
|
40
|
+
require 'rake/rdoctask'
|
41
|
+
Rake::RDocTask.new do |rdoc|
|
42
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
43
|
+
|
44
|
+
rdoc.rdoc_dir = 'rdoc'
|
45
|
+
rdoc.title = "reckon #{version}"
|
46
|
+
rdoc.rdoc_files.include('README*')
|
47
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
48
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.2.0
|
data/bin/reckon
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'reckon'
|
5
|
+
|
6
|
+
options = Reckon::App.parse_opts
|
7
|
+
reckon = Reckon::App.new(options)
|
8
|
+
|
9
|
+
if options[:print_table]
|
10
|
+
reckon.output_table
|
11
|
+
exit
|
12
|
+
end
|
13
|
+
|
14
|
+
if !reckon.money_column_indices
|
15
|
+
puts "I was unable to determine either a single or a pair of combined columns to use as the money column."
|
16
|
+
exit
|
17
|
+
end
|
18
|
+
|
19
|
+
reckon.walk_backwards
|
data/lib/reckon.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'fastercsv'
|
5
|
+
require 'highline/import'
|
6
|
+
require 'optparse'
|
7
|
+
require 'time'
|
8
|
+
require 'terminal-table'
|
9
|
+
|
10
|
+
require File.expand_path(File.join(File.dirname(__FILE__), "reckon", "app"))
|
11
|
+
require File.expand_path(File.join(File.dirname(__FILE__), "reckon", "ledger_parser"))
|
12
|
+
|
data/lib/reckon/app.rb
ADDED
@@ -0,0 +1,364 @@
|
|
1
|
+
module Reckon
|
2
|
+
class App
|
3
|
+
VERSION = "Reckon 0.1"
|
4
|
+
|
5
|
+
attr_accessor :options, :csv_data, :accounts, :tokens, :money_column_indices, :date_column_index, :description_column_indices, :seen
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
self.options = options
|
9
|
+
self.tokens = {}
|
10
|
+
self.accounts = {}
|
11
|
+
self.seen = {}
|
12
|
+
learn!
|
13
|
+
parse
|
14
|
+
filter_csv
|
15
|
+
detect_columns
|
16
|
+
end
|
17
|
+
|
18
|
+
def filter_csv
|
19
|
+
if options[:ignore_columns]
|
20
|
+
new_columns = []
|
21
|
+
columns.each_with_index do |column, index|
|
22
|
+
new_columns << column unless options[:ignore_columns].include?(index + 1)
|
23
|
+
end
|
24
|
+
@columns = new_columns
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def learn_from(ledger)
|
29
|
+
LedgerParser.new(ledger).entries.each do |entry|
|
30
|
+
entry[:accounts].each do |account|
|
31
|
+
learn_about_account( account[:name],
|
32
|
+
[entry[:desc], account[:amount]].join(" ") ) unless account[:name] == options[:bank_account]
|
33
|
+
seen[entry[:date]] ||= {}
|
34
|
+
seen[entry[:date]][pretty_money(account[:amount])] = true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def already_seen?(row)
|
40
|
+
seen[row[:pretty_date]] && seen[row[:pretty_date]][row[:pretty_money]]
|
41
|
+
end
|
42
|
+
|
43
|
+
def learn!
|
44
|
+
if options[:existing_ledger_file]
|
45
|
+
fail "#{options[:existing_ledger_file]} doesn't exist!" unless File.exists?(options[:existing_ledger_file])
|
46
|
+
ledger_data = File.read(options[:existing_ledger_file])
|
47
|
+
learn_from(ledger_data)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def learn_about_account(account, data)
|
52
|
+
accounts[account] ||= 0
|
53
|
+
tokenize(data).each do |token|
|
54
|
+
tokens[token] ||= {}
|
55
|
+
tokens[token][account] ||= 0
|
56
|
+
tokens[token][account] += 1
|
57
|
+
accounts[account] += 1
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def tokenize(str)
|
62
|
+
str.downcase.split(/[\s\-]/)
|
63
|
+
end
|
64
|
+
|
65
|
+
def walk_backwards
|
66
|
+
seen_anything_new = false
|
67
|
+
each_row_backwards do |row|
|
68
|
+
puts Terminal::Table.new(:rows => [ [ row[:pretty_date], row[:pretty_money], row[:description] ] ])
|
69
|
+
|
70
|
+
if already_seen?(row)
|
71
|
+
puts "NOTE: This row is very similar to a previous one!"
|
72
|
+
if !seen_anything_new
|
73
|
+
puts "Skipping..."
|
74
|
+
next
|
75
|
+
end
|
76
|
+
else
|
77
|
+
seen_anything_new = true
|
78
|
+
end
|
79
|
+
|
80
|
+
ledger = if row[:money] > 0
|
81
|
+
out_of_account = ask("Which account provided this income? ([account]/[q]uit/[s]kip) ") { |q| q.default = guess_account(row) }
|
82
|
+
finish if out_of_account == "quit" || out_of_account == "q"
|
83
|
+
if out_of_account == "skip" || out_of_account == "s"
|
84
|
+
puts "Skipping"
|
85
|
+
next
|
86
|
+
end
|
87
|
+
|
88
|
+
ledger_format( row,
|
89
|
+
[options[:bank_account], row[:pretty_money]],
|
90
|
+
[out_of_account, row[:pretty_money_negated]] )
|
91
|
+
else
|
92
|
+
into_account = ask("To which account did this money go? ([account]/[q]uit/[s]kip) ") { |q| q.default = guess_account(row) }
|
93
|
+
finish if into_account == "quit" || into_account == 'q'
|
94
|
+
if into_account == "skip" || into_account == 's'
|
95
|
+
puts "Skipping"
|
96
|
+
next
|
97
|
+
end
|
98
|
+
|
99
|
+
ledger_format( row,
|
100
|
+
[into_account, row[:pretty_money_negated]],
|
101
|
+
[options[:bank_account], row[:pretty_money]] )
|
102
|
+
end
|
103
|
+
|
104
|
+
learn_from(ledger)
|
105
|
+
output(ledger)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def finish
|
110
|
+
options[:output_file].close unless options[:output_file] == STDOUT
|
111
|
+
puts "Exiting."
|
112
|
+
exit
|
113
|
+
end
|
114
|
+
|
115
|
+
def output(ledger_line)
|
116
|
+
options[:output_file].puts ledger_line
|
117
|
+
options[:output_file].flush
|
118
|
+
end
|
119
|
+
|
120
|
+
def guess_account(row)
|
121
|
+
query_tokens = tokenize(row[:description])
|
122
|
+
|
123
|
+
search_vector = []
|
124
|
+
account_vectors = {}
|
125
|
+
|
126
|
+
query_tokens.each do |token|
|
127
|
+
idf = Math.log((accounts.keys.length + 1) / ((tokens[token] || {}).keys.length.to_f + 1))
|
128
|
+
tf = 1.0 / query_tokens.length.to_f
|
129
|
+
search_vector << tf*idf
|
130
|
+
|
131
|
+
accounts.each do |account, total_terms|
|
132
|
+
tf = (tokens[token] && tokens[token][account]) ? tokens[token][account] / total_terms.to_f : 0
|
133
|
+
account_vectors[account] ||= []
|
134
|
+
account_vectors[account] << tf*idf
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Should I normalize the vectors? Probably unnecessary due to tf-idf and short documents.
|
139
|
+
|
140
|
+
account_vectors = account_vectors.to_a.map do |account, account_vector|
|
141
|
+
{ :cosine => (0...account_vector.length).to_a.inject(0) { |m, i| m + search_vector[i] * account_vector[i] },
|
142
|
+
:account => account }
|
143
|
+
end
|
144
|
+
|
145
|
+
account_vectors.sort! {|a, b| b[:cosine] <=> a[:cosine] }
|
146
|
+
account_vectors.first && account_vectors.first[:account]
|
147
|
+
end
|
148
|
+
|
149
|
+
def ledger_format(row, line1, line2)
|
150
|
+
out = "#{row[:pretty_date]}\t#{row[:description]}\n"
|
151
|
+
out += "\t#{line1.first}\t\t\t\t\t#{line1.last}\n"
|
152
|
+
out += "\t#{line2.first}\t\t\t\t\t#{line2.last}\n\n"
|
153
|
+
out
|
154
|
+
end
|
155
|
+
|
156
|
+
def money_for(index)
|
157
|
+
value = money_column_indices.inject("") { |m, i| m + columns[i][index] }
|
158
|
+
cleaned_value = value.gsub(/[^\d\.]/, '').to_f
|
159
|
+
cleaned_value *= -1 if value =~ /[\(\-]/
|
160
|
+
cleaned_value
|
161
|
+
end
|
162
|
+
|
163
|
+
def pretty_money_for(index, negate = false)
|
164
|
+
pretty_money(money_for(index), negate)
|
165
|
+
end
|
166
|
+
|
167
|
+
def pretty_money(amount, negate = false)
|
168
|
+
(amount >= 0 ? " " : "") + sprintf("%0.2f", amount * (negate ? -1 : 1)).gsub(/^((\-)|)(?=\d)/, '\1$')
|
169
|
+
end
|
170
|
+
|
171
|
+
def date_for(index)
|
172
|
+
value = columns[date_column_index][index]
|
173
|
+
value = [$1, $2, $3].join("/") if value =~ /^(\d{4})(\d{2})(\d{2})\d+\[\d+\:GMT\]$/ # chase format
|
174
|
+
Time.parse(value)
|
175
|
+
end
|
176
|
+
|
177
|
+
def pretty_date_for(index)
|
178
|
+
date_for(index).strftime("%Y/%m/%d")
|
179
|
+
end
|
180
|
+
|
181
|
+
def description_for(index)
|
182
|
+
description_column_indices.map { |i| columns[i][index] }.join("; ").squeeze(" ")
|
183
|
+
end
|
184
|
+
|
185
|
+
def output_table
|
186
|
+
output = Terminal::Table.new do |t|
|
187
|
+
t.headings = 'Date', 'Amount', 'Description'
|
188
|
+
each_row_backwards do |row|
|
189
|
+
t << [ row[:pretty_date], row[:pretty_money], row[:description] ]
|
190
|
+
end
|
191
|
+
end
|
192
|
+
puts output
|
193
|
+
end
|
194
|
+
|
195
|
+
def evaluate_columns(cols)
|
196
|
+
results = []
|
197
|
+
found_likely_money_column = false
|
198
|
+
cols.each_with_index do |column, index|
|
199
|
+
money_score = date_score = possible_neg_money_count = possible_pos_money_count = 0
|
200
|
+
column.each do |entry|
|
201
|
+
entry = entry.strip
|
202
|
+
money_score += 10 if entry[/^[\-\+\(]{0,2}\$/]
|
203
|
+
money_score += entry.gsub(/[^\d\.\-\+,\(\)]/, '').length
|
204
|
+
money_score -= 100 if entry.length > 17
|
205
|
+
money_score -= 20 if entry !~ /^[\$\+\.\-,\d\(\)]+$/
|
206
|
+
possible_neg_money_count += 1 if entry =~ /^\$?[\-\(]\$?\d+/
|
207
|
+
possible_pos_money_count += 1 if entry =~ /^\+?\$?\+?\d+/
|
208
|
+
date_score += 10 if entry =~ /^[\-\/\.\d:\[\]]+$/
|
209
|
+
date_score += entry.gsub(/[^\-\/\.\d:\[\]]/, '').length
|
210
|
+
date_score -= entry.gsub(/[\-\/\.\d:\[\]]/, '').length * 2
|
211
|
+
date_score += 30 if entry =~ /^\d+[:\/\.]\d+[:\/\.]\d+([ :]\d+[:\/\.]\d+)?$/
|
212
|
+
date_score += 10 if entry =~ /^\d+\[\d+:GMT\]$/i
|
213
|
+
end
|
214
|
+
|
215
|
+
if possible_neg_money_count > (column.length / 5.0) && possible_pos_money_count > (column.length / 5.0)
|
216
|
+
money_score += 10 * column.length
|
217
|
+
found_likely_money_column = true
|
218
|
+
end
|
219
|
+
|
220
|
+
results << { :index => index, :money_score => money_score, :date_score => date_score }
|
221
|
+
end
|
222
|
+
|
223
|
+
return [results, found_likely_money_column]
|
224
|
+
end
|
225
|
+
|
226
|
+
def merge_columns(a, b)
|
227
|
+
output_columns = []
|
228
|
+
columns.each_with_index do |column, index|
|
229
|
+
if index == a
|
230
|
+
new_column = []
|
231
|
+
column.each_with_index do |row, row_index|
|
232
|
+
new_column << row + " " + columns[b][row_index]
|
233
|
+
end
|
234
|
+
output_columns << new_column
|
235
|
+
elsif index == b
|
236
|
+
# skip
|
237
|
+
else
|
238
|
+
output_columns << column
|
239
|
+
end
|
240
|
+
end
|
241
|
+
output_columns
|
242
|
+
end
|
243
|
+
|
244
|
+
def detect_columns
|
245
|
+
results, found_likely_money_column = evaluate_columns(columns)
|
246
|
+
|
247
|
+
if found_likely_money_column
|
248
|
+
self.money_column_indices = [ results.sort { |a, b| b[:money_score] <=> a[:money_score] }.first[:index] ]
|
249
|
+
else
|
250
|
+
0.upto(columns.length - 2) do |i|
|
251
|
+
_, found_likely_money_column = evaluate_columns(merge_columns(i, i+1))
|
252
|
+
|
253
|
+
if found_likely_money_column
|
254
|
+
self.money_column_indices = [ i, i+1 ]
|
255
|
+
break
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
if money_column_indices
|
261
|
+
results.reject! {|i| money_column_indices.include?(i[:index]) }
|
262
|
+
self.date_column_index = results.sort { |a, b| b[:date_score] <=> a[:date_score] }.first[:index]
|
263
|
+
results.reject! {|i| i[:index] == date_column_index }
|
264
|
+
|
265
|
+
self.description_column_indices = results.map { |i| i[:index] }
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
def each_row_backwards
|
270
|
+
rows = []
|
271
|
+
(0...columns.first.length).to_a.each do |index|
|
272
|
+
rows << { :date => date_for(index), :pretty_date => pretty_date_for(index),
|
273
|
+
:pretty_money => pretty_money_for(index), :pretty_money_negated => pretty_money_for(index, :negate),
|
274
|
+
:money => money_for(index), :description => description_for(index) }
|
275
|
+
end
|
276
|
+
rows.sort { |a, b| a[:date] <=> b[:date] }.each do |row|
|
277
|
+
yield row
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
def columns
|
282
|
+
@columns ||= begin
|
283
|
+
last_row_length = nil
|
284
|
+
csv_data.inject([]) do |memo, row|
|
285
|
+
fail "Input CSV must have consistent row lengths." if last_row_length && row.length != last_row_length
|
286
|
+
unless row.all? { |i| i.nil? || i.length == 0 }
|
287
|
+
row.each_with_index do |entry, index|
|
288
|
+
memo[index] ||= []
|
289
|
+
memo[index] << entry.strip
|
290
|
+
end
|
291
|
+
last_row_length = row.length
|
292
|
+
end
|
293
|
+
memo
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def parse
|
299
|
+
self.csv_data = FasterCSV.parse(options[:string] || File.read(options[:file]))
|
300
|
+
end
|
301
|
+
|
302
|
+
def self.parse_opts(args = ARGV)
|
303
|
+
options = { :output_file => STDOUT }
|
304
|
+
parser = OptionParser.new do |opts|
|
305
|
+
opts.banner = "Usage: Reckon.rb [options]"
|
306
|
+
opts.separator ""
|
307
|
+
|
308
|
+
opts.on("-f", "--file FILE", "The CSV file to parse") do |file|
|
309
|
+
options[:file] = file
|
310
|
+
end
|
311
|
+
|
312
|
+
opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
|
313
|
+
options[:verbose] = v
|
314
|
+
end
|
315
|
+
|
316
|
+
opts.on("-p", "--print-table", "Print out the parsed CSV in table form") do |p|
|
317
|
+
options[:print_table] = p
|
318
|
+
end
|
319
|
+
|
320
|
+
opts.on("-o", "--output-file FILE", "The ledger file to append to") do |o|
|
321
|
+
options[:output_file] = File.open(o, 'a')
|
322
|
+
end
|
323
|
+
|
324
|
+
opts.on("-l", "--learn-from FILE", "An existing ledger file to learn accounts from") do |l|
|
325
|
+
options[:existing_ledger_file] = l
|
326
|
+
end
|
327
|
+
|
328
|
+
opts.on("", "--ignore-columns 1,2,5", "Columns to ignore in the CSV file - the first column is column 1") do |ignore|
|
329
|
+
options[:ignore_columns] = ignore.split(",").map { |i| i.to_i }
|
330
|
+
end
|
331
|
+
|
332
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
333
|
+
puts opts
|
334
|
+
exit
|
335
|
+
end
|
336
|
+
|
337
|
+
opts.on_tail("--version", "Show version") do
|
338
|
+
puts VERSION
|
339
|
+
exit
|
340
|
+
end
|
341
|
+
|
342
|
+
opts.parse!(args)
|
343
|
+
end
|
344
|
+
|
345
|
+
unless options[:file]
|
346
|
+
options[:file] = ask("What CSV file should I parse? ")
|
347
|
+
unless options[:file].length > 0
|
348
|
+
puts "\nYou must provide a CSV file to parse.\n"
|
349
|
+
puts parser
|
350
|
+
exit
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
unless options[:bank_account]
|
355
|
+
options[:bank_account] = ask("What is the account name of this bank account in Ledger? ") do |q|
|
356
|
+
q.validate = /^.{2,}$/
|
357
|
+
q.default = "Assets:Bank:Checking"
|
358
|
+
end
|
359
|
+
end
|
360
|
+
|
361
|
+
options
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
|
5
|
+
module Reckon
|
6
|
+
class LedgerParser
|
7
|
+
|
8
|
+
attr_accessor :entries
|
9
|
+
|
10
|
+
def initialize(ledger, options = {})
|
11
|
+
@entries = []
|
12
|
+
parse(ledger)
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse(ledger)
|
16
|
+
@entries = []
|
17
|
+
date = desc = nil
|
18
|
+
accounts = []
|
19
|
+
ledger.strip.split("\n").each do |entry|
|
20
|
+
next if entry =~ /^\s*$/ || entry =~ /^[^ \t\d]/
|
21
|
+
if entry =~ /^([\d\/]+)(\=[\d\/]+)?(\s+[\*!]?\s*.*?)$/
|
22
|
+
@entries << { :date => date.strip, :desc => desc.strip, :accounts => balance(accounts) } if date
|
23
|
+
date = $1
|
24
|
+
desc = $3
|
25
|
+
accounts = []
|
26
|
+
elsif date && entry =~ /^\s+([a-z\s:_\-]+)(\s*$|(\s+[\$\.,\-\d\+]+)($|\s+($|[^\$\.,\-\d\+])))/i
|
27
|
+
accounts << { :name => $1.strip, :amount => clean_money($3) }
|
28
|
+
else
|
29
|
+
@entries << { :date => date.strip, :desc => desc.strip, :accounts => balance(accounts) } if date
|
30
|
+
date = desc = nil
|
31
|
+
accounts = []
|
32
|
+
end
|
33
|
+
end
|
34
|
+
@entries << { :date => date.strip, :desc => desc.strip, :accounts => balance(accounts) } if date
|
35
|
+
end
|
36
|
+
|
37
|
+
def balance(accounts)
|
38
|
+
if accounts.any? { |i| i[:amount].nil? }
|
39
|
+
sum = accounts.inject(0) {|m, account| m + (account[:amount] || 0) }
|
40
|
+
count = 0
|
41
|
+
accounts.each do |account|
|
42
|
+
if account[:amount].nil?
|
43
|
+
count += 1
|
44
|
+
account[:amount] = 0 - sum
|
45
|
+
end
|
46
|
+
end
|
47
|
+
if count > 1
|
48
|
+
puts "Warning: unparsable entry due to more than one missing money value."
|
49
|
+
p accounts
|
50
|
+
puts
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
accounts
|
55
|
+
end
|
56
|
+
|
57
|
+
def clean_money(money)
|
58
|
+
return nil if money.nil? || money.length == 0
|
59
|
+
money.gsub(/[\$,]/, '').to_f
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
data/reckon.gemspec
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{reckon}
|
8
|
+
s.version = "0.2.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Andrew Cantino"]
|
12
|
+
s.date = %q{2010-11-06}
|
13
|
+
s.description = %q{Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.}
|
14
|
+
s.email = %q{andrew@iterationlabs.com}
|
15
|
+
s.executables = ["reckon", "reckon"]
|
16
|
+
s.extra_rdoc_files = [
|
17
|
+
"LICENSE",
|
18
|
+
"README.rdoc"
|
19
|
+
]
|
20
|
+
s.files = [
|
21
|
+
".document",
|
22
|
+
".gitignore",
|
23
|
+
"LICENSE",
|
24
|
+
"README.rdoc",
|
25
|
+
"Rakefile",
|
26
|
+
"VERSION",
|
27
|
+
"bin/reckon",
|
28
|
+
"lib/reckon.rb",
|
29
|
+
"lib/reckon/app.rb",
|
30
|
+
"lib/reckon/ledger_parser.rb",
|
31
|
+
"reckon.gemspec",
|
32
|
+
"spec/reckon/app_spec.rb",
|
33
|
+
"spec/reckon/ledger_parser_spec.rb",
|
34
|
+
"spec/spec.opts",
|
35
|
+
"spec/spec_helper.rb"
|
36
|
+
]
|
37
|
+
s.homepage = %q{http://github.com/iterationlabs/reckon}
|
38
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
39
|
+
s.require_paths = ["lib"]
|
40
|
+
s.rubygems_version = %q{1.3.7}
|
41
|
+
s.summary = %q{Utility for interactively converting and labeling CSV files for the Ledger accounting tool.}
|
42
|
+
s.test_files = [
|
43
|
+
"spec/reckon/app_spec.rb",
|
44
|
+
"spec/reckon/ledger_parser_spec.rb",
|
45
|
+
"spec/spec_helper.rb"
|
46
|
+
]
|
47
|
+
|
48
|
+
if s.respond_to? :specification_version then
|
49
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
50
|
+
s.specification_version = 3
|
51
|
+
|
52
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
53
|
+
s.add_development_dependency(%q<rspec>, [">= 1.2.9"])
|
54
|
+
s.add_runtime_dependency(%q<fastercsv>, [">= 1.5.1"])
|
55
|
+
s.add_runtime_dependency(%q<highline>, [">= 1.5.2"])
|
56
|
+
s.add_runtime_dependency(%q<terminal-table>, [">= 1.4.2"])
|
57
|
+
else
|
58
|
+
s.add_dependency(%q<rspec>, [">= 1.2.9"])
|
59
|
+
s.add_dependency(%q<fastercsv>, [">= 1.5.1"])
|
60
|
+
s.add_dependency(%q<highline>, [">= 1.5.2"])
|
61
|
+
s.add_dependency(%q<terminal-table>, [">= 1.4.2"])
|
62
|
+
end
|
63
|
+
else
|
64
|
+
s.add_dependency(%q<rspec>, [">= 1.2.9"])
|
65
|
+
s.add_dependency(%q<fastercsv>, [">= 1.5.1"])
|
66
|
+
s.add_dependency(%q<highline>, [">= 1.5.2"])
|
67
|
+
s.add_dependency(%q<terminal-table>, [">= 1.4.2"])
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
@@ -0,0 +1,138 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
4
|
+
require 'rubygems'
|
5
|
+
require 'reckon'
|
6
|
+
|
7
|
+
describe Reckon::App do
|
8
|
+
before do
|
9
|
+
@chase = Reckon::App.new(:string => CHASE_CSV)
|
10
|
+
@some_other_bank = Reckon::App.new(:string => SOME_OTHER_CSV)
|
11
|
+
@two_money_columns = Reckon::App.new(:string => TWO_MONEY_COLUMNS_BANK)
|
12
|
+
@simple_csv = Reckon::App.new(:string => SIMPLE_CSV)
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "columns" do
|
16
|
+
it "should return the csv transposed" do
|
17
|
+
@simple_csv.columns.should == [["entry1", "entry4"], ["entry2", "entry5"], ["entry3", "entry6"]]
|
18
|
+
@chase.columns.length.should == 4
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "detect_columns" do
|
23
|
+
it "should detect the money column" do
|
24
|
+
@chase.money_column_indices.should == [3]
|
25
|
+
@some_other_bank.money_column_indices.should == [3]
|
26
|
+
@two_money_columns.money_column_indices.should == [3, 4]
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should detect the date column" do
|
30
|
+
@chase.date_column_index.should == 1
|
31
|
+
@some_other_bank.date_column_index.should == 1
|
32
|
+
@two_money_columns.date_column_index.should == 0
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should consider all other columns to be description columns" do
|
36
|
+
@chase.description_column_indices.should == [0, 2]
|
37
|
+
@some_other_bank.description_column_indices.should == [0, 2]
|
38
|
+
@two_money_columns.description_column_indices.should == [1, 2, 5]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe "each_index_backwards" do
|
43
|
+
it "should hit every index" do
|
44
|
+
count = 0
|
45
|
+
@chase.each_row_backwards { count += 1}
|
46
|
+
count.should == 9
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe "money_for" do
|
51
|
+
it "should return the appropriate fields" do
|
52
|
+
@chase.money_for(1).should == -20
|
53
|
+
@chase.money_for(4).should == 1558.52
|
54
|
+
@chase.money_for(7).should == -116.22
|
55
|
+
@some_other_bank.money_for(1).should == -20
|
56
|
+
@some_other_bank.money_for(4).should == 1558.52
|
57
|
+
@some_other_bank.money_for(7).should == -116.22
|
58
|
+
@two_money_columns.money_for(0).should == -76
|
59
|
+
@two_money_columns.money_for(1).should == 327.49
|
60
|
+
@two_money_columns.money_for(2).should == -800
|
61
|
+
@two_money_columns.money_for(3).should == -88.55
|
62
|
+
@two_money_columns.money_for(4).should == 88.55
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe "date_for" do
|
67
|
+
it "should return a parsed date object" do
|
68
|
+
@chase.date_for(1).should == Time.parse("2009/12/24")
|
69
|
+
@some_other_bank.date_for(1).should == Time.parse("2010/12/24")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe "description_for" do
|
74
|
+
it "should return the combined fields that are not money for date fields" do
|
75
|
+
@chase.description_for(1).should == "CHECK; CHECK 2656"
|
76
|
+
@chase.description_for(7).should == "CREDIT; PAYPAL TRANSFER PPD ID: PAYPALSDSL"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe "pretty_money_for" do
|
81
|
+
it "work with negative and positive numbers" do
|
82
|
+
@some_other_bank.pretty_money_for(1).should == "-$20.00"
|
83
|
+
@some_other_bank.pretty_money_for(4).should == " $1558.52"
|
84
|
+
@some_other_bank.pretty_money_for(7).should == "-$116.22"
|
85
|
+
@some_other_bank.pretty_money_for(5).should == " $0.23"
|
86
|
+
@some_other_bank.pretty_money_for(6).should == "-$0.96"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
describe "merge_columns" do
|
91
|
+
it "should work on adjacent columns" do
|
92
|
+
@simple_csv.merge_columns(0,1).should == [["entry1 entry2", "entry4 entry5"], ["entry3", "entry6"]]
|
93
|
+
end
|
94
|
+
|
95
|
+
it "should work on non-adjacent columns" do
|
96
|
+
@simple_csv.merge_columns(0,2).should == [["entry1 entry3", "entry4 entry6"], ["entry2", "entry5"]]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
|
102
|
+
# Data
|
103
|
+
|
104
|
+
SIMPLE_CSV = "entry1,entry2,entry3\nentry4,entry5,entry6"
|
105
|
+
|
106
|
+
CHASE_CSV = (<<-CSV).strip
|
107
|
+
DEBIT,20091224120000[0:GMT],"HOST 037196321563 MO 12/22SLICEHOST",-85.00
|
108
|
+
CHECK,20091224120000[0:GMT],"CHECK 2656",-20.00
|
109
|
+
DEBIT,20091224120000[0:GMT],"GITHUB 041287430274 CA 12/22GITHUB 04",-7.00
|
110
|
+
CREDIT,20091223120000[0:GMT],"Some Company vendorpymt PPD ID: 59728JSL20",3520.00
|
111
|
+
CREDIT,20091223120000[0:GMT],"Blarg BLARG REVENUE PPD ID: 00jah78563",1558.52
|
112
|
+
DEBIT,20091221120000[0:GMT],"WEBSITE-BALANCE-17DEC09 12 12/17WEBSITE-BAL",-12.23
|
113
|
+
DEBIT,20091214120000[0:GMT],"WEBSITE-BALANCE-10DEC09 12 12/10WEBSITE-BAL",-20.96
|
114
|
+
CREDIT,20091211120000[0:GMT],"PAYPAL TRANSFER PPD ID: PAYPALSDSL",-116.22
|
115
|
+
CREDIT,20091210120000[0:GMT],"Some Company vendorpymt PPD ID: 5KL3832735",2105.00
|
116
|
+
CSV
|
117
|
+
|
118
|
+
SOME_OTHER_CSV = (<<-CSV).strip
|
119
|
+
DEBIT,2011/12/24,"HOST 037196321563 MO 12/22SLICEHOST",($85.00)
|
120
|
+
CHECK,2010/12/24,"CHECK 2656",($20.00)
|
121
|
+
DEBIT,2009/12/24,"GITHUB 041287430274 CA 12/22GITHUB 04",($7.00)
|
122
|
+
CREDIT,2008/12/24,"Some Company vendorpymt PPD ID: 59728JSL20",$3520.00
|
123
|
+
CREDIT,2007/12/24,"Blarg BLARG REVENUE PPD ID: 00jah78563",$1558.52
|
124
|
+
DEBIT,2006/12/24,"WEBSITE-BALANCE-17DEC09 12 12/17WEBSITE-BAL",$.23
|
125
|
+
DEBIT,2005/12/24,"WEBSITE-BALANCE-10DEC09 12 12/10WEBSITE-BAL",($0.96)
|
126
|
+
CREDIT,2004/12/24,"PAYPAL TRANSFER PPD ID: PAYPALSDSL",($116.22)
|
127
|
+
CREDIT,2003/12/24,"Some Company vendorpymt PPD ID: 5KL3832735",$2105.00
|
128
|
+
CSV
|
129
|
+
|
130
|
+
TWO_MONEY_COLUMNS_BANK = (<<-CSV).strip
|
131
|
+
4/1/2008,Check - 0000000122,122,-$76.00,"","$1,750.06"
|
132
|
+
3/28/2008,BLARG R SH 456930,"","",+$327.49,"$1,826.06"
|
133
|
+
3/27/2008,Check - 0000000112,112,-$800.00,"","$1,498.57"
|
134
|
+
3/26/2008,Check - 0000000251,251,-$88.55,"","$1,298.57"
|
135
|
+
3/26/2008,Check - 0000000251,251,"","+$88.55","$1,298.57"
|
136
|
+
CSV
|
137
|
+
|
138
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
4
|
+
require 'rubygems'
|
5
|
+
require 'reckon'
|
6
|
+
require 'pp'
|
7
|
+
|
8
|
+
describe Reckon::LedgerParser do
|
9
|
+
before do
|
10
|
+
@ledger = Reckon::LedgerParser.new(EXAMPLE_LEDGER)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "parse" do
|
14
|
+
it "should ignore non-standard entries" do
|
15
|
+
@ledger.entries.length.should == 5
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should parse entries correctly" do
|
19
|
+
@ledger.entries.first[:desc].should == "* Checking balance"
|
20
|
+
@ledger.entries.first[:date].should == "2004/05/01"
|
21
|
+
@ledger.entries.first[:accounts].first[:name].should == "Assets:Bank:Checking"
|
22
|
+
@ledger.entries.first[:accounts].first[:amount].should == 1000
|
23
|
+
@ledger.entries.first[:accounts].last[:name].should == "Equity:Opening Balances"
|
24
|
+
@ledger.entries.first[:accounts].last[:amount].should == -1000
|
25
|
+
|
26
|
+
@ledger.entries.last[:desc].should == "(100) Credit card company"
|
27
|
+
@ledger.entries.last[:date].should == "2004/05/27"
|
28
|
+
@ledger.entries.last[:accounts].first[:name].should == "Liabilities:MasterCard"
|
29
|
+
@ledger.entries.last[:accounts].first[:amount].should == 20.24
|
30
|
+
@ledger.entries.last[:accounts].last[:name].should == "Assets:Bank:Checking"
|
31
|
+
@ledger.entries.last[:accounts].last[:amount].should == -20.24
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "balance" do
|
36
|
+
it "it should balance out missing account values" do
|
37
|
+
@ledger.balance([
|
38
|
+
{ :name => "Account1", :amount => 1000 },
|
39
|
+
{ :name => "Account2", :amount => nil }
|
40
|
+
]).should == [ { :name => "Account1", :amount => 1000 }, { :name => "Account2", :amount => -1000 } ]
|
41
|
+
end
|
42
|
+
|
43
|
+
it "it should balance out missing account values" do
|
44
|
+
@ledger.balance([
|
45
|
+
{ :name => "Account1", :amount => 1000 },
|
46
|
+
{ :name => "Account2", :amount => 100 },
|
47
|
+
{ :name => "Account3", :amount => -200 },
|
48
|
+
{ :name => "Account4", :amount => nil }
|
49
|
+
]).should == [
|
50
|
+
{ :name => "Account1", :amount => 1000 },
|
51
|
+
{ :name => "Account2", :amount => 100 },
|
52
|
+
{ :name => "Account3", :amount => -200 },
|
53
|
+
{ :name => "Account4", :amount => -900 }
|
54
|
+
]
|
55
|
+
end
|
56
|
+
|
57
|
+
it "it should work on normal values too" do
|
58
|
+
@ledger.balance([
|
59
|
+
{ :name => "Account1", :amount => 1000 },
|
60
|
+
{ :name => "Account2", :amount => -1000 }
|
61
|
+
]).should == [ { :name => "Account1", :amount => 1000 }, { :name => "Account2", :amount => -1000 } ]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Data
|
66
|
+
|
67
|
+
EXAMPLE_LEDGER = (<<-LEDGER).strip
|
68
|
+
= /^Expenses:Books/
|
69
|
+
(Liabilities:Taxes) -0.10
|
70
|
+
|
71
|
+
~ Monthly
|
72
|
+
Assets:Bank:Checking $500.00
|
73
|
+
Income:Salary
|
74
|
+
|
75
|
+
2004/05/01 * Checking balance
|
76
|
+
Assets:Bank:Checking $1,000.00
|
77
|
+
Equity:Opening Balances
|
78
|
+
|
79
|
+
2004/05/01 * Investment balance
|
80
|
+
Assets:Brokerage 50 AAPL @ $30.00
|
81
|
+
Equity:Opening Balances
|
82
|
+
|
83
|
+
; blah
|
84
|
+
!account blah
|
85
|
+
|
86
|
+
!end
|
87
|
+
|
88
|
+
D $1,000
|
89
|
+
|
90
|
+
2004/05/14 * Pay day
|
91
|
+
Assets:Bank:Checking $500.00
|
92
|
+
Income:Salary
|
93
|
+
|
94
|
+
2004/05/27 Book Store
|
95
|
+
Expenses:Books $20.00
|
96
|
+
Liabilities:MasterCard
|
97
|
+
2004/05/27 (100) Credit card company
|
98
|
+
Liabilities:MasterCard $20.24
|
99
|
+
Assets:Bank:Checking
|
100
|
+
LEDGER
|
101
|
+
end
|
data/spec/spec.opts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: reckon
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 23
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 2
|
9
|
+
- 0
|
10
|
+
version: 0.2.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Andrew Cantino
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-11-06 00:00:00 -07:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: rspec
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 13
|
30
|
+
segments:
|
31
|
+
- 1
|
32
|
+
- 2
|
33
|
+
- 9
|
34
|
+
version: 1.2.9
|
35
|
+
type: :development
|
36
|
+
version_requirements: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: fastercsv
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
hash: 1
|
46
|
+
segments:
|
47
|
+
- 1
|
48
|
+
- 5
|
49
|
+
- 1
|
50
|
+
version: 1.5.1
|
51
|
+
type: :runtime
|
52
|
+
version_requirements: *id002
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
name: highline
|
55
|
+
prerelease: false
|
56
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
hash: 7
|
62
|
+
segments:
|
63
|
+
- 1
|
64
|
+
- 5
|
65
|
+
- 2
|
66
|
+
version: 1.5.2
|
67
|
+
type: :runtime
|
68
|
+
version_requirements: *id003
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: terminal-table
|
71
|
+
prerelease: false
|
72
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
hash: 3
|
78
|
+
segments:
|
79
|
+
- 1
|
80
|
+
- 4
|
81
|
+
- 2
|
82
|
+
version: 1.4.2
|
83
|
+
type: :runtime
|
84
|
+
version_requirements: *id004
|
85
|
+
description: Reckon automagically converts CSV files for use with the command-line accounting tool Ledger. It also helps you to select the correct accounts associated with the CSV data using Bayesian machine learning.
|
86
|
+
email: andrew@iterationlabs.com
|
87
|
+
executables:
|
88
|
+
- reckon
|
89
|
+
- reckon
|
90
|
+
extensions: []
|
91
|
+
|
92
|
+
extra_rdoc_files:
|
93
|
+
- LICENSE
|
94
|
+
- README.rdoc
|
95
|
+
files:
|
96
|
+
- .document
|
97
|
+
- .gitignore
|
98
|
+
- LICENSE
|
99
|
+
- README.rdoc
|
100
|
+
- Rakefile
|
101
|
+
- VERSION
|
102
|
+
- bin/reckon
|
103
|
+
- lib/reckon.rb
|
104
|
+
- lib/reckon/app.rb
|
105
|
+
- lib/reckon/ledger_parser.rb
|
106
|
+
- reckon.gemspec
|
107
|
+
- spec/reckon/app_spec.rb
|
108
|
+
- spec/reckon/ledger_parser_spec.rb
|
109
|
+
- spec/spec.opts
|
110
|
+
- spec/spec_helper.rb
|
111
|
+
has_rdoc: true
|
112
|
+
homepage: http://github.com/iterationlabs/reckon
|
113
|
+
licenses: []
|
114
|
+
|
115
|
+
post_install_message:
|
116
|
+
rdoc_options:
|
117
|
+
- --charset=UTF-8
|
118
|
+
require_paths:
|
119
|
+
- lib
|
120
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
hash: 3
|
126
|
+
segments:
|
127
|
+
- 0
|
128
|
+
version: "0"
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
130
|
+
none: false
|
131
|
+
requirements:
|
132
|
+
- - ">="
|
133
|
+
- !ruby/object:Gem::Version
|
134
|
+
hash: 3
|
135
|
+
segments:
|
136
|
+
- 0
|
137
|
+
version: "0"
|
138
|
+
requirements: []
|
139
|
+
|
140
|
+
rubyforge_project:
|
141
|
+
rubygems_version: 1.3.7
|
142
|
+
signing_key:
|
143
|
+
specification_version: 3
|
144
|
+
summary: Utility for interactively converting and labeling CSV files for the Ledger accounting tool.
|
145
|
+
test_files:
|
146
|
+
- spec/reckon/app_spec.rb
|
147
|
+
- spec/reckon/ledger_parser_spec.rb
|
148
|
+
- spec/spec_helper.rb
|