total_recall 0.1.0

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