reckon 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|