total_recall 0.1.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.
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.own
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'http://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in total_recall.gemspec
4
+ gemspec
@@ -0,0 +1,55 @@
1
+ TotalRecall
2
+ ============
3
+
4
+ Turn your bank record csv's into a [Ledger](http://ledger-cli.org/3.0/doc/ledger3.html) journal.
5
+
6
+ Usage
7
+ ------------
8
+
9
+ $ total_recall
10
+ Tasks:
11
+ total_recall help [TASK] # Describe available tasks or one specific task
12
+ total_recall ledger -i, --input=INPUT -p, --parser=PARSER # Convert input to ledger-transactions
13
+ total_recall parse -i, --input=INPUT -p, --parser=PARSER # Parses input-file and prints it
14
+
15
+ # typically you would do:
16
+ $ total_recall ledger -i abn.csv -p abn > abn.dat
17
+
18
+ Develop
19
+ ------------
20
+
21
+ $ git clone git://github.com/eval/total_recall.git
22
+ $ cd total_recall
23
+ $ bundle
24
+ $ rake spec
25
+
26
+ Author
27
+ ------
28
+
29
+ Gert Goet (eval) :: gert@thinkcreate.nl :: @gertgoet
30
+
31
+ License
32
+ ------
33
+
34
+ (The MIT license)
35
+
36
+ Copyright (c) 2011 Gert Goet, ThinkCreate
37
+
38
+ Permission is hereby granted, free of charge, to any person obtaining
39
+ a copy of this software and associated documentation files (the
40
+ "Software"), to deal in the Software without restriction, including
41
+ without limitation the rights to use, copy, modify, merge, publish,
42
+ distribute, sublicense, and/or sell copies of the Software, and to
43
+ permit persons to whom the Software is furnished to do so, subject to
44
+ the following conditions:
45
+
46
+ The above copyright notice and this permission notice shall be
47
+ included in all copies or substantial portions of the Software.
48
+
49
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
50
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
51
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
52
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
53
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
54
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
55
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new do |t|
6
+ t.rspec_opts = ['--options', "\"#{File.dirname(__FILE__)}/spec/spec.opts\""]
7
+ end
8
+
9
+ desc "Run the specs"
10
+ task :default => :spec
11
+
12
+ desc 'Removes trailing whitespace'
13
+ task :whitespace do
14
+ sh %{find . -name '*.rb' -exec sed -i '' 's/ *$//g' {} \\;}
15
+ end
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'total_recall'
3
+
4
+ TotalRecall::Cli.start
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'total_recall'
3
+
4
+ TotalRecall::Cli.start
@@ -0,0 +1,295 @@
1
+ require "total_recall/version"
2
+ require "thor"
3
+ require "mustache"
4
+
5
+ module TotalRecall
6
+ module ParseStrategy
7
+ class Ing
8
+ require 'time'
9
+
10
+ # Expected: Hash with:
11
+ #:amount => Float,
12
+ #:currency => String,
13
+ #:description => String,
14
+ #:date => Date
15
+ def parse_row(row)
16
+ amount = row[6].sub(/,/,'.').to_f
17
+ {
18
+ :amount => (row[5] == 'Bij' ? amount : -amount),
19
+ :date => Date.parse(row[0]),
20
+ :description => [row[1], row[8]].map{|i| i.strip.gsub(/\s+/, ' ')}.join(' '),
21
+ :currency => 'EUR'
22
+ }
23
+ end
24
+
25
+
26
+ def self.options
27
+ {:col_sep => ",", :headers => true}
28
+ end
29
+
30
+ def options
31
+ self.class.options
32
+ end
33
+ end # /Ing
34
+
35
+ class Abn
36
+ require 'time'
37
+
38
+ # Expected: Hash with:
39
+ #:amount => Float,
40
+ #:currency => String,
41
+ #:description => String,
42
+ #:date => Date
43
+ def parse_row(row)
44
+ {
45
+ :amount => row[6].sub(/,/,'.').to_f,
46
+ :date => Date.parse(row[2]),
47
+ :description => row[7].strip.gsub(/\s+/,' '),
48
+ :currency => row[1]
49
+ }
50
+ end
51
+
52
+ def self.options
53
+ {:col_sep => "\t"}
54
+ end
55
+
56
+ def options
57
+ self.class.options
58
+ end
59
+ end # /Abn
60
+ end # /ParseStrategy
61
+
62
+ class Ledger < Mustache
63
+ attr_reader :default_account, :options
64
+
65
+ def initialize(default_account, transactions, options={})
66
+ @options = {:max_account_width => 80, :decimal_mark => ','}.merge(options)
67
+ @default_account = default_account
68
+ @_transactions = transactions
69
+ end
70
+
71
+ def transactions
72
+ @transactions ||= begin
73
+ @_transactions.map do |i|
74
+ res = {}
75
+ res[:to], res[:from] = (i[:amount] > 0 ? [default_account, i[:account]] : [i[:account], default_account])
76
+ if i[:tags].any?
77
+ tagsline = "\n ; :%s:" % (i[:tags] * ':')
78
+ tagskey = i[:amount] > 0 ? :from_tags : :to_tags
79
+ res[tagskey] = tagsline
80
+ end
81
+ res[:amount] = fmt_amount(i[:amount].abs)
82
+ res[:description], res[:date], res[:currency] = i[:description], i[:date], i[:currency]
83
+ res[:spacer] = " " * (options[:max_account_width] - res[:to].size)
84
+ res
85
+ end
86
+ end
87
+ end
88
+
89
+
90
+ def self.template;<<-TEMPLATE
91
+ {{# transactions}}
92
+ {{date}} {{description}}
93
+ {{to}} {{spacer}}{{currency}} {{amount}}{{to_tags}}
94
+ {{from}}{{from_tags}}
95
+
96
+ {{/ transactions}}
97
+ TEMPLATE
98
+ end
99
+
100
+ protected
101
+ def fmt_amount(amount)
102
+ ("%10.2f" % amount).split('.') * options[:decimal_mark]
103
+ end
104
+ end
105
+
106
+ class AccountGuesser
107
+ attr_reader :accounts, :tokens
108
+
109
+ def initialize
110
+ @accounts = {}
111
+ @tokens = {}
112
+ end
113
+
114
+ # copied from reckon(https://github.com/iterationlabs/reckon)
115
+ def guess(data)
116
+ query_tokens = tokenize(data)
117
+
118
+ search_vector = []
119
+ account_vectors = {}
120
+
121
+ query_tokens.each do |token|
122
+ idf = Math.log((accounts.keys.length + 1) / ((tokens[token] || {}).keys.length.to_f + 1))
123
+ tf = 1.0 / query_tokens.length.to_f
124
+ search_vector << tf*idf
125
+
126
+ accounts.each do |account, total_terms|
127
+ tf = (tokens[token] && tokens[token][account]) ? tokens[token][account] / total_terms.to_f : 0
128
+ account_vectors[account] ||= []
129
+ account_vectors[account] << tf*idf
130
+ end
131
+ end
132
+
133
+ # Should I normalize the vectors? Probably unnecessary due to tf-idf and short documents.
134
+ account_vectors = account_vectors.to_a.map do |account, account_vector|
135
+ { :cosine => (0...account_vector.length).to_a.inject(0) { |m, i| m + search_vector[i] * account_vector[i] },
136
+ :account => account }
137
+ end
138
+
139
+ account_vectors.sort! {|a, b| b[:cosine] <=> a[:cosine] }
140
+ account_vectors.first && account_vectors.first[:account]
141
+ end
142
+
143
+ # copied from reckon(https://github.com/iterationlabs/reckon)
144
+ def learn(account, data)
145
+ accounts[account] ||= 0
146
+ tokenize(data).each do |token|
147
+ tokens[token] ||= {}
148
+ tokens[token][account] ||= 0
149
+ tokens[token][account] += 1
150
+ accounts[account] += 1
151
+ end
152
+ end
153
+
154
+ protected
155
+ def tokenize(str)
156
+ str.downcase.split(/[\s\-]/)
157
+ end
158
+ end
159
+
160
+ class BankParser
161
+ require 'csv'
162
+ attr_reader :strategy
163
+
164
+ def initialize(options={})
165
+ @strategy = options[:strategy]
166
+ end
167
+
168
+ # Parses csv content and returns array of hashes.
169
+ #
170
+ # @example
171
+ # parser = TotalRecall::BankParser.new(:strategy => TotalRecall::ParseStrategy::Some.new)
172
+ # parser.parse("12.1\n1.99") #=> [{:amount => 12.1}, {:amount => 1.99}]
173
+ #
174
+ # @param [String] str content of csv to parse.
175
+ # @return [Array<Hash>]
176
+ def parse(str, options={})
177
+ options = strategy.options.merge(options)
178
+
179
+ result = []
180
+ CSV.parse(str, options){|row| result << strategy.parse_row(row)}
181
+ result
182
+ end
183
+ end #/BankParser
184
+
185
+
186
+ class Cli < Thor
187
+ require "terminal-table"
188
+ require "highline/import"
189
+ require "bayes_motel"
190
+
191
+ no_tasks do
192
+ def self.strategies
193
+ {
194
+ 'abn' => TotalRecall::ParseStrategy::Abn,
195
+ 'ing' => TotalRecall::ParseStrategy::Ing
196
+ }
197
+ end
198
+
199
+ def highline
200
+ @highline ||= HighLine.new($stdin, $stderr)
201
+ end
202
+
203
+ def strategies
204
+ self.class.strategies
205
+ end
206
+
207
+ def parser(strategy)
208
+ TotalRecall::BankParser.new(:strategy => strategy)
209
+ end
210
+ end
211
+
212
+ desc "ledger", "Convert input to ledger-transactions"
213
+ method_option :input, :aliases => "-i", :desc => "CSV file to use for input", :required => true
214
+ method_option :parser, :aliases => "-p", :desc => "Parser to use (one of #{strategies.keys.inspect})", :required => true
215
+ def ledger
216
+ strategy = strategies[options[:parser]]
217
+ file_contents = File.read(options[:input])
218
+ rows = parser(strategy.new).parse(file_contents)
219
+
220
+ guesser = AccountGuesser.new
221
+
222
+ default_account = highline.ask("What is the account name of this bank account in Ledger?\n> ")
223
+ transactions = [] # [{<row>, :account => 'Expenses:Car'}, ...]
224
+ tags = []
225
+ start_from = 0
226
+ begin
227
+ rows.each_with_index do |row, ix|
228
+ if start_from && start_from != ix
229
+ next
230
+ elsif start_from
231
+ start_from = nil
232
+ end
233
+
234
+ guessed = guesser.guess(row[:description])
235
+ question = row[:amount] > 0 ? "What account provided this income?" : "To which account did this money go?"
236
+
237
+ $stderr.puts Terminal::Table.new(:rows => [ [ row[:date], row[:amount], row[:description] ] ])
238
+ account = highline.ask("#{question} (#{guessed}) or [d]one, [s]kip, [p]revious\n> ")
239
+ account = account.empty? ? guessed : account # defaults not working
240
+ case account
241
+ when 'd'
242
+ break
243
+ when 's'
244
+ next
245
+ when 'p'
246
+ transactions.pop
247
+ start_from = [0, (ix - 1)].max
248
+ raise
249
+ end
250
+
251
+ transaction_tags = []
252
+ loop do
253
+ tag = highline.choose do |menu|
254
+ menu.shell = true
255
+ menu.prompt = "Add tags? "
256
+ menu.prompt << "(#{transaction_tags.join(',')})" if transaction_tags.any?
257
+
258
+ menu.choices('Done'){ nil }
259
+ menu.choices(*tags - transaction_tags)
260
+ menu.choice('New...'){ newtag = highline.ask('Tag: ');tags << newtag; newtag }
261
+ end
262
+ break unless tag
263
+ transaction_tags << tag
264
+ end
265
+
266
+ guesser.learn(account, row[:description])
267
+ transactions << row.merge(:account => account, :tags => transaction_tags)
268
+ end
269
+ rescue
270
+ $stderr.puts "Let's do that again"
271
+ retry
272
+ end
273
+
274
+ puts Ledger.new(default_account, transactions).render
275
+ end
276
+
277
+ desc "parse", "Parses input-file and prints it"
278
+ method_option :input, :aliases => "-i", :desc => "CSV file to use for input", :required => true
279
+ method_option :parser, :aliases => "-p", :desc => "Parser to use (one of #{strategies.keys.join(',')})", :required => true
280
+ def parse
281
+ strategy = strategies[options[:parser]]
282
+ file_contents = File.read(options[:input])
283
+ data = parser(strategy.new).parse(file_contents)
284
+
285
+ table = Terminal::Table.new do |t|
286
+ t.headings = 'Date', 'Amount', 'Description'
287
+ data.each do |row|
288
+ t << [ row[:date], row[:amount], row[:description] ]
289
+ end
290
+ end
291
+ puts table
292
+ end
293
+
294
+ end
295
+ end
@@ -0,0 +1,3 @@
1
+ module TotalRecall
2
+ VERSION = "0.1.0"
3
+ end
File without changes
@@ -0,0 +1,2 @@
1
+ 123456789 EUR 20101230 100,00 70,00 20101230 -30,00 BEA NR:12AA34 30.12.10/22.24 iCentre CS AMSTERDAM,PAS123
2
+ 123456789 EUR 20101231 70,00 50,00 20101231 -20,00 BEA NR:12AA34 31.12.10/22.24 iCentre CS AMSTERDAM,PAS123
@@ -0,0 +1,3 @@
1
+ "Datum","Naam / Omschrijving","Rekening","Tegenrekening","Code","Af Bij","Bedrag (EUR)","MutatieSoort","Mededelingen"
2
+ "20101231","Omschrijving ","123456789","012345678","GT","Af","100,00","Internetbankieren","mededeling eèéêë"
3
+ "20101230","Omschrijving ","123456789","012345678","GT","Af","200,00","Internetbankieren","mededeling "
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,15 @@
1
+ require "rubygems"
2
+ require "bundler"
3
+ Bundler.setup
4
+
5
+ $:.unshift File.expand_path("../../lib", __FILE__)
6
+ require "total_recall"
7
+
8
+ # fullpath to csv-file
9
+ def fixture_pathname(str)
10
+ Pathname(%W(spec fixtures #{str}.csv).join(File::SEPARATOR)).realpath
11
+ end
12
+
13
+ def fixture_contents(str)
14
+ File.read(fixture_pathname(str))
15
+ end
@@ -0,0 +1,52 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe TotalRecall::BankParser do
4
+ context '#parse' do
5
+ subject{ described_class.new(:strategy => TotalRecall::ParseStrategy::Abn.new) }
6
+
7
+ it "should return an Array" do
8
+ subject.parse(fixture_contents('abn')).class.should == Array
9
+ end
10
+ end
11
+ end
12
+
13
+
14
+ shared_examples "a parser" do
15
+ context '#parse_row' do
16
+ it "should return a hash" do
17
+ CSV.parse(fixture_contents(@fixture), subject.options).each do |i|
18
+ parsed_row = subject.parse_row(i)
19
+
20
+ parsed_row.class.should == Hash
21
+ end
22
+ end
23
+
24
+ it "should return the correct keys and classes" do
25
+ expected_keys_and_classes = {
26
+ :amount => Float,
27
+ :currency => String,
28
+ :description => String,
29
+ :date => Date
30
+ }
31
+
32
+ CSV.parse(fixture_contents(@fixture), subject.options).each do |i|
33
+ parsed_row = subject.parse_row(i)
34
+
35
+ parsed_row.keys.should =~ expected_keys_and_classes.keys
36
+ expected_keys_and_classes.each do |key, klass|
37
+ parsed_row.send(:[], key).class.should == klass
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ describe TotalRecall::ParseStrategy::Abn do
45
+ before{ @fixture = 'abn' }
46
+ it_behaves_like "a parser"
47
+ end
48
+
49
+ describe TotalRecall::ParseStrategy::Ing do
50
+ before{ @fixture = 'ing' }
51
+ it_behaves_like "a parser"
52
+ end
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/total_recall/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Gert Goet"]
6
+ gem.email = ["gert@thinkcreate.nl"]
7
+ gem.description = %q{Turn your bank records csv's into Ledger journals}
8
+ gem.summary = %q{Turn your bank records csv's into Ledger journals}
9
+ gem.homepage = ""
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "total_recall"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = TotalRecall::VERSION
17
+
18
+ gem.add_dependency('thor', '~> 0.14.6')
19
+ gem.add_dependency('terminal-table', '~> 1.4.4')
20
+ gem.add_dependency('highline', '~> 1.6.1')
21
+ gem.add_dependency('bayes_motel', '~> 0.1.0')
22
+ gem.add_dependency('mustache', '~> 0.99.4')
23
+ gem.add_development_dependency('rspec', '~> 2.7.0')
24
+ end
metadata ADDED
@@ -0,0 +1,135 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: total_recall
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Gert Goet
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-01-09 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: thor
16
+ requirement: &70281655447440 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.14.6
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *70281655447440
25
+ - !ruby/object:Gem::Dependency
26
+ name: terminal-table
27
+ requirement: &70281655446380 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 1.4.4
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *70281655446380
36
+ - !ruby/object:Gem::Dependency
37
+ name: highline
38
+ requirement: &70281655445700 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 1.6.1
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *70281655445700
47
+ - !ruby/object:Gem::Dependency
48
+ name: bayes_motel
49
+ requirement: &70281655444800 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: 0.1.0
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: *70281655444800
58
+ - !ruby/object:Gem::Dependency
59
+ name: mustache
60
+ requirement: &70281655444120 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ~>
64
+ - !ruby/object:Gem::Version
65
+ version: 0.99.4
66
+ type: :runtime
67
+ prerelease: false
68
+ version_requirements: *70281655444120
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: &70281655443400 !ruby/object:Gem::Requirement
72
+ none: false
73
+ requirements:
74
+ - - ~>
75
+ - !ruby/object:Gem::Version
76
+ version: 2.7.0
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: *70281655443400
80
+ description: Turn your bank records csv's into Ledger journals
81
+ email:
82
+ - gert@thinkcreate.nl
83
+ executables:
84
+ - total_recall
85
+ - total_recall.rb
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - .gitignore
90
+ - Gemfile
91
+ - README.md
92
+ - Rakefile
93
+ - bin/total_recall
94
+ - bin/total_recall.rb
95
+ - lib/total_recall.rb
96
+ - lib/total_recall/version.rb
97
+ - spec/fixtures/.keep
98
+ - spec/fixtures/abn.csv
99
+ - spec/fixtures/ing.csv
100
+ - spec/spec.opts
101
+ - spec/spec_helper.rb
102
+ - spec/total_recall/bank_parser_spec.rb
103
+ - total_recall.gemspec
104
+ homepage: ''
105
+ licenses: []
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ none: false
112
+ requirements:
113
+ - - ! '>='
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ none: false
118
+ requirements:
119
+ - - ! '>='
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubyforge_project:
124
+ rubygems_version: 1.8.10
125
+ signing_key:
126
+ specification_version: 3
127
+ summary: Turn your bank records csv's into Ledger journals
128
+ test_files:
129
+ - spec/fixtures/.keep
130
+ - spec/fixtures/abn.csv
131
+ - spec/fixtures/ing.csv
132
+ - spec/spec.opts
133
+ - spec/spec_helper.rb
134
+ - spec/total_recall/bank_parser_spec.rb
135
+ has_rdoc: