├── .gitignore ├── .gitlab-ci.yml ├── CHANGELOG.md ├── Gemfile ├── README.md ├── Rakefile ├── exe ├── total_recall └── total_recall.rb ├── lib ├── total_recall.rb └── total_recall │ ├── templates │ ├── sample.yml.tt │ └── simple.yml.tt │ └── version.rb ├── spec ├── spec_helper.rb └── total_recall │ └── total_recall_spec.rb └── total_recall.gemspec /.gitignore: -------------------------------------------------------------------------------- 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 19 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | .spec: &spec 2 | stage: test 3 | tags: 4 | - docker 5 | script: 6 | - bundle install --binstubs --path vendor --without production --jobs $(nproc) > /dev/null 7 | - bin/rspec 8 | - gem build total_recall.gemspec 9 | 10 | spec2.1: 11 | image: ruby:2.1 12 | <<: *spec 13 | 14 | spec2.3: 15 | image: ruby:2.3 16 | <<: *spec 17 | 18 | spec2.4: 19 | image: ruby:2.4 20 | <<: *spec 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.7.0 / unreleased 2 | 3 | * '--version' and '-v' handled by version-subcommand 4 | 5 | * pass csv-file to ledger-subcommand: 6 | 7 | ```bash 8 | $ total_recall ledger -c bank.yml --csv ~/Downloads/bank.csv 9 | ``` 10 | 11 | * init-subcommand skips yml-extension if provided 12 | 13 | 14 | 15 | # 0.6.0 / 2017-03-08 16 | 17 | * move repository to GitLab 18 | 19 | * upgrade dependencies 20 | 21 | * added option to use only transaction-section from template 22 | 23 | This makes adding transactions to an existing ledger-file easier. 24 | 25 | ```bash 26 | $ total_recall ledger -c bank.yml --transactions-only >> bank.dat 27 | ``` 28 | 29 | # 0.5.0 / 2014-06-11 30 | 31 | * extend the ledger subcommand by passing it a file with customizations 32 | 33 | ```ruby 34 | $ cat my_extension.rb 35 | module MyExtension 36 | def ask_account(*args) 37 | # some custom stuff 38 | super 39 | end 40 | end 41 | TotalRecall::SessionHelper.include MyExtension 42 | 43 | $ total_recall ledger -c sample.yml -r ./my_extension.rb 44 | ``` 45 | 46 | * add version subcommand 47 | 48 | * add default-helper 49 | 50 | Let's you point to the default-value of an attribute: 51 | 52 | ```yaml 53 | :context: 54 | :transactions: 55 | :__defaults__: 56 | :a: 1 57 | :a: !!proc | 58 | ask("What value has a?", default: default) 59 | ``` 60 | 61 | * add transaction-helper 62 | 63 | This allows you to use already set attributes of the transaction: 64 | 65 | ```yaml 66 | :context: 67 | :transactions: 68 | :a: 1 69 | :b: !!proc | 70 | transaction.a.succ 71 | ``` 72 | 73 | # 0.4.0 / 2014-06-04 74 | 75 | * Add yaml-config. 76 | 77 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in total_recall.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TotalRecall [![build status](https://gitlab.com/eval/total_recall/badges/master/build.svg)](https://gitlab.com/eval/total_recall/commits/master) 2 | 3 | Turn **any** csv-file into a [Ledger](http://ledger-cli.org/) journal. 4 | 5 | ## Introduction 6 | 7 | `total_recall` assumes nothing about the structure of your csv, nor of the ledger-file you want to create. 8 | Instead, one creates a yaml-config consisting of: 9 | * a [mustache-template](https://github.com/defunkt/mustache) of the ledger-file 10 | * the source (and parse-options) of the csv 11 | * the value of every template-variable via Ruby lambdas 12 | 13 | ## Example 14 | 15 | After installation you run `total_recall init bank` to generate the following file: 16 | ```yaml 17 | # file: bank.yml 18 | :total_recall: 19 | :version: 0.6.0 20 | :template: 21 | :raw: |- 22 | ; -*- ledger -*-¬ 23 | 24 | {{# transactions}} 25 | {{date}} {{{description}}} 26 | {{to}} EUR {{amount}} 27 | {{from}} 28 | 29 | {{/ transactions}} 30 | 31 | :csv: 32 | #:file: total_recall.csv 33 | :raw: |- 34 | "2013-11-01","Foo","2013-11-02","1.638,00" 35 | "2013-11-02","Bar","2013-11-03","-492,93" 36 | :options: 37 | #:col_sep: ";" 38 | #:headers: false 39 | 40 | :context: 41 | :transactions: 42 | :__defaults__: 43 | :from: !!proc | 44 | ask_account("What account provides these transactions?", default: 'Assets:Checking') 45 | :date: !!proc row[0] 46 | :description: !!proc row[1] 47 | :amount: !!proc row[3] 48 | :to: !!proc | 49 | render_row(columns: [0, 1, 3]) 50 | ask_account("To what account did the money go?") 51 | ``` 52 | 53 | The `template`-section is pretty straightforward: you can add any variable you need using the [mustache-syntax](http://mustache.github.io/mustache.5.html). 54 | The `csv`-section defines where csv comes from and what parse-options should be used. It's best to start with a csv-snippet in `raw` (and leave `file` commented) in order to test-run the config. 55 | 56 | In the `context`-section the actual mapping is done: in this section your should define a key for every variable in the template. 57 | A key's value is derived from the csv via Ruby. This can be done via a simple mapping: `:date: !!proc row[0]`, via some specific operation: `:data: !!proc Date.parse(row[0]).iso8601` or via one of the [interactive helpers](https://gitlab.com/eval/total_recall/blob/v0.6.0/lib/total_recall.rb#L27-50) as you can see for the `to`-field above. 58 | Fields can also have default-values: the `from`-field for example is the same for all rows. 59 | 60 | As it's all Ruby, you can make the mapping as smart as you like: 61 | ``` 62 | :context: 63 | :transactions: 64 | :description: !!proc row[3] 65 | :to: !!proc | 66 | guess = begin 67 | case self.description # the description-field is defined above 68 | when /CREDITCARD/ then "Liabilities:MasterCard" 69 | when /INTERNET/i then "Expenses:Communication" 70 | end 71 | end 72 | ask_account("To what account did the money go?", default: guess) 73 | ... 74 | ``` 75 | 76 | See [Extensibility](#extensibility) below for providing your own Ruby module with helpers (i.e. your own self-learning account-guesser!). 77 | 78 | Once your config is done, you can give it a spin: 79 | ```bash 80 | # the result will be echoed: 81 | $ total_recall ledger -c bank.yml 82 | 83 | # to quickly see if the output is actually valid ledger: 84 | $ total_recall ledger -c bank.yml | ledger -f - reg 85 | ``` 86 | 87 | When the output looks good and doesn't make Ledger choke, you can uncomment the file-key in the csv-section and run it against the real csv-data: 88 | ```bash 89 | $ total_recall ledger -c bank.yml > bank.dat 90 | ``` 91 | 92 | That's it! 93 | 94 | To see an extensive annotated config-file do: 95 | ```bash 96 | $ total_recall sample 97 | ``` 98 | 99 | ## Install 100 | 101 | ```bash 102 | gem install total_recall 103 | ``` 104 | 105 | ## Usage 106 | 107 | ```bash 108 | total_recall 109 | 110 | # Commands: 111 | # total_recall help [COMMAND] # Describe available commands or one specific command 112 | # total_recall init NAME # Generate a minimal config NAME.yml 113 | # total_recall ledger -c, --config=CONFIG # Convert CONFIG to a ledger 114 | # total_recall sample # Generate an annotated config 115 | # total_recall version # Show total_recall version 116 | 117 | # typically you would do: 118 | total_recall init my-bank 119 | 120 | # fiddle with the settings in 'my-bank.yml' and test-run it: 121 | total_recal ledger -c my-bank.yml 122 | # to skip prompts just provide dummy-data: 123 | yes 'Dummy' | total_recal ledger -c my-bank.yml 124 | 125 | # export it to a journal: 126 | total_recall ledger -c my-bank.yml > my-bank.dat 127 | 128 | # verify correctness with ledger: 129 | ledger -f my-bank.dat bal 130 | ``` 131 | 132 | ## Extensibility 133 | 134 | You can extend the ledger subcommand by passing a file with additions to it: 135 | 136 | ``` 137 | total_recall ledger -c my-bank.yml -r ./my_extension.rb 138 | ``` 139 | 140 | This makes it possible to add helpers or redefine existing ones: 141 | 142 | ```ruby 143 | cat my_extension.rb 144 | module MyExtension 145 | # adding some options to an existing helper: 146 | def ask_account(question, options = {}) 147 | question.upcase! if options.delete(:scream) 148 | super 149 | end 150 | 151 | # a new helper: 152 | def guess_account(question, options = {}) 153 | guess = Guesser.new.guess 154 | ask_account(question, default: guess) 155 | end 156 | end 157 | 158 | TotalRecall::SessionHelper.include MyExtension 159 | ``` 160 | 161 | ## Develop 162 | 163 | ```bash 164 | git clone https://gitlab.com/eval/total_recall.git 165 | cd total_recall 166 | bundle 167 | bundle exec rake spec 168 | ``` 169 | ## Author 170 | 171 | Gert Goet (eval) :: gert@thinkcreate.nl :: @gertgoet 172 | 173 | ## License 174 | 175 | (The MIT license) 176 | 177 | Copyright (c) 2017 Gert Goet, ThinkCreate 178 | 179 | Permission is hereby granted, free of charge, to any person obtaining 180 | a copy of this software and associated documentation files (the 181 | "Software"), to deal in the Software without restriction, including 182 | without limitation the rights to use, copy, modify, merge, publish, 183 | distribute, sublicense, and/or sell copies of the Software, and to 184 | permit persons to whom the Software is furnished to do so, subject to 185 | the following conditions: 186 | 187 | The above copyright notice and this permission notice shall be 188 | included in all copies or substantial portions of the Software. 189 | 190 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 191 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 192 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 193 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 194 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 195 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 196 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 197 | 198 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /exe/total_recall: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'total_recall' 3 | 4 | TotalRecall::Cli.start 5 | -------------------------------------------------------------------------------- /exe/total_recall.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'total_recall' 3 | 4 | TotalRecall::Cli.start 5 | -------------------------------------------------------------------------------- /lib/total_recall.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'thor' 3 | require 'mustache' 4 | require 'csv' 5 | 6 | module TotalRecall 7 | module DefaultHelper 8 | require 'highline/import' 9 | require "terminal-table" 10 | 11 | def transaction 12 | self 13 | end 14 | 15 | def config 16 | @config 17 | end 18 | 19 | def row 20 | @row 21 | end 22 | 23 | def default 24 | @default 25 | end 26 | 27 | def ask(question, &block) 28 | highline.ask(question, &block) 29 | end 30 | 31 | # Prompts the user for an account-name. 32 | # 33 | # @param question [String] the question 34 | # @param options [Hash] 35 | # @option options [String] :default (nil) account-name that will be used 36 | # if no input is provided. 37 | # 38 | # @example 39 | # ask_account("What account did this money come from?", 40 | # default: 'Expenses:Various') 41 | # What account did this money come from? |Expenses:Various| 42 | # _ 43 | # 44 | # @return [String] the account-name 45 | def ask_account(question, options = {}) 46 | options = { default: nil }.merge(options) 47 | highline.ask(question) do |q| 48 | q.default = options[:default] if options[:default] 49 | end 50 | end 51 | 52 | def render_row(options = {}) 53 | options = { columns: [] }.merge(options) 54 | _row = options[:columns].map {|i| row[i] } 55 | $stderr.puts Terminal::Table.new(rows: [ _row ]) 56 | end 57 | 58 | def extract_transaction(row) 59 | @row = row 60 | transactions_config.each do |k,v| 61 | next if k[/^__/] 62 | self[k] = value_for(k, v) 63 | end 64 | self 65 | end 66 | 67 | protected 68 | def value_for(key, v) 69 | if v.respond_to?(:call) 70 | @default = self[key.to_sym] 71 | instance_eval(&v) 72 | else 73 | v 74 | end 75 | ensure 76 | @default = nil 77 | end 78 | 79 | def transactions_config 80 | config[:context][:transactions] 81 | end 82 | 83 | def highline 84 | @highline ||= HighLine.new($stdin, $stderr) 85 | end 86 | end 87 | 88 | # Include your module into {SessionHelper} if you want to 89 | # add helpers or redefine existing ones. 90 | # 91 | # @example 92 | # module MyExtension 93 | # def guess_account(question, options = {}) 94 | # guess = AccountGuesser.new.guess 95 | # ask_account("What acount provided this?", default: guess) 96 | # end 97 | # end 98 | # 99 | # TotalRecall::SessionHelper.include MyExtension 100 | module SessionHelper 101 | include DefaultHelper 102 | end 103 | 104 | class Config 105 | YAML::add_builtin_type('proc') {|_, val| eval("proc { #{val} }") } 106 | 107 | def initialize(options = {}) 108 | options = { file: 'total_recall.yml', csv_file: nil }.merge(options) 109 | @csv_file = File.expand_path(options[:csv_file]) if options[:csv_file] 110 | @config_file = File.expand_path(options[:file]) 111 | @transactions_only = !!options[:transactions_only] 112 | end 113 | 114 | def config 115 | @config ||= YAML.load_file(@config_file) 116 | end 117 | 118 | def csv_file 119 | @csv_file ||= begin 120 | config[:csv][:file] && 121 | File.expand_path(config[:csv][:file], File.dirname(@config_file)) 122 | end 123 | end 124 | 125 | def csv 126 | @csv ||= begin 127 | csv_raw = csv_file ? File.read(csv_file) : config[:csv][:raw] 128 | CSV.parse(csv_raw, config[:csv][:options] || {}) 129 | end 130 | end 131 | 132 | def template_file 133 | config[:template][:file] && 134 | File.expand_path(config[:template][:file], File.dirname(@config_file)) 135 | end 136 | 137 | def template 138 | @template ||= begin 139 | template_file ? File.read(template_file) : config[:template][:raw] 140 | end 141 | end 142 | 143 | def transactions_only_template 144 | @transactions_only_template ||= begin 145 | Mustache::Template.new("").tap do |t| 146 | _transactions_tokens = proc { transactions_tokens } 147 | t.define_singleton_method(:tokens) do |*| 148 | _transactions_tokens.call 149 | end 150 | end 151 | end 152 | end 153 | 154 | def transactions_tokens 155 | @transactions_tokens ||= begin 156 | Mustache::Template.new(template).tokens.detect do |type, tag, *rest| 157 | type == :mustache && tag == :section && 158 | [:mustache, :fetch, ["transactions"]] 159 | end 160 | end 161 | end 162 | 163 | def context 164 | @context ||= config[:context].merge(transactions: transactions) 165 | end 166 | 167 | def session 168 | @session ||= session_class.new(transactions_config_defaults, :config => config) 169 | end 170 | 171 | def transaction_attributes 172 | @transaction_attributes ||= begin 173 | transactions_config.dup.delete_if{|k,_| k[/__/]}.keys | 174 | transactions_config_defaults.keys 175 | end 176 | end 177 | 178 | def session_class 179 | @session_class ||= begin 180 | Class.new(Struct.new(*transaction_attributes)) do 181 | include SessionHelper 182 | 183 | def initialize(values = {}, options = {}) 184 | @config = options[:config] 185 | values.each do |k,v| 186 | self[k] = value_for(k, v) 187 | end 188 | end 189 | end 190 | end 191 | end 192 | 193 | def transactions 194 | @transactions ||= begin 195 | csv.each_with_object([]) do |row, transactions| 196 | transactions << Hash[session.extract_transaction(row).each_pair.to_a] 197 | end 198 | end 199 | end 200 | 201 | def transactions_config 202 | config[:context][:transactions] 203 | end 204 | 205 | def transactions_config_defaults 206 | transactions_config[:__defaults__] || {} 207 | end 208 | 209 | def ledger 210 | tmp = @transactions_only ? 211 | transactions_only_template : 212 | template 213 | 214 | Mustache.render(tmp, context) 215 | end 216 | end 217 | 218 | class Cli < Thor 219 | require 'total_recall/version' 220 | 221 | include Thor::Actions 222 | source_root File.expand_path('../total_recall/templates', __FILE__) 223 | 224 | desc "ledger", "Convert CONFIG to a ledger" 225 | method_option :config, :aliases => "-c", :desc => "Config file", :required => true 226 | method_option :require, :aliases => "-r", :desc => "File to load" 227 | method_option :csv, :aliases => "-f", :desc => "csv file" 228 | method_option :transactions_only, :type => :boolean 229 | def ledger 230 | load(options[:require]) if options[:require] 231 | 232 | config_path = File.expand_path(options[:config]) 233 | puts TotalRecall::Config.new(file: config_path, 234 | csv_file: options[:csv], 235 | :transactions_only => options[:transactions_only]).ledger 236 | end 237 | 238 | desc "sample", "Generate an annotated config" 239 | def sample 240 | @version = TotalRecall::VERSION 241 | template("sample.yml.tt") 242 | 243 | say "Now run '#{$0} ledger -c sample.yml' to generate the ledger" 244 | end 245 | 246 | desc "init NAME", "Generate a minimal config NAME.yml" 247 | def init(name = "total_recall") 248 | destination = name[/\.yml$/] ? name : (name + ".yml") 249 | 250 | @version = TotalRecall::VERSION 251 | @name = destination.split(/\.yml$/).first 252 | template("simple.yml.tt", destination) 253 | end 254 | 255 | desc "version, --version, -v", "Show total_recall version" 256 | def version 257 | puts TotalRecall::VERSION 258 | end 259 | map %w(-v --version) => :version 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /lib/total_recall/templates/sample.yml.tt: -------------------------------------------------------------------------------- 1 | --- 2 | :total_recall: 3 | # The version of total_recall that generated this file 4 | :version: <%= @version %> 5 | :template: 6 | # 1). Provide the [mustache-template](https://github.com/defunkt/mustache) 7 | # for your ledger. 8 | # 9 | # Either assign a template to :raw: or point to a file via :file:. 10 | # The location of :file: is relative to this file. 11 | # :file: takes precedence over :raw: 12 | # 13 | # The template MUST contain a section `transactions`. 14 | # 15 | #:file: ledger.mustache 16 | :raw: |- 17 | ; -*- ledger -*-¬ 18 | ; Generated by total_recall at {{generated_at}} 19 | --decimal-comma 20 | 21 | {{# transactions}} 22 | {{date}} {{{description}}} 23 | {{to}} {{currency}} {{amount}} 24 | {{from}} 25 | 26 | {{/ transactions}} 27 | 28 | :csv: 29 | # 2). Provide the csv 30 | # 31 | # Either assign csv to :raw: or point to a csv-file via :file:. 32 | # :raw: is ideal for experimenting with the csv-format. 33 | # 34 | # The location of :file: is relative to this file. 35 | # :file: takes precedence over :raw: 36 | # 37 | #:file: sample.csv # also overwritable via ledger-subcommand. See `total_recall help ledger`. 38 | :raw: |- 39 | "date";"description";"effective_data";"amount" 40 | "2013-11-01";"Foo";"2013-11-02";"1.638,00" 41 | "2013-11-02";"Bar";"2013-11-03";"-492,93" 42 | :options: 43 | # Any option accepted by CSV#new (http://www.ruby-doc.org/stdlib-2.1.1/libdoc/csv/rdoc/CSV.html#method-c-new). 44 | :col_sep: ";" 45 | :headers: true 46 | :header_converters: :symbol # row[:date] rather than row[0] 47 | 48 | :context: 49 | # 3). Define the context 50 | # 51 | # The context is in essence the dictionary that will be applied to the template. 52 | # The value for transactions will be expanded to an array, one for every line in the csv. 53 | :transactions: 54 | # transactions have defaults... 55 | :__defaults__: 56 | :from: !!proc | 57 | ask_account("What account provides these transactions?", 58 | default: 'Assets:Checking') 59 | :currency: $ 60 | # ...and fields that vary per transaction. 61 | # Helper method exist such as `row`, `ask_account` (see https://gitlab.com/eval/total_recall/blob/v0.6.0/lib/total_recall.rb#L7 for more). 62 | :date: !!proc row[:date] 63 | :description: !!proc row[:description] 64 | :amount: !!proc row[:amount] 65 | :currency: !!proc | 66 | case row[:description] # `this.description` can also be used: this would be the 'processed' description. 67 | when /some regex to detect non-dollar/ then '€' 68 | else 69 | default # the value for this field in __defaults__ 70 | end 71 | :to: !!proc | 72 | # show the row and let the user decide 73 | render_row(columns: [:date, :description, :amount]) 74 | ask_account("To what account did the money go?") 75 | :generated_at: !!proc Time.now 76 | 77 | # You can add any other data needed: 78 | # :ticker_sybols: 79 | # "Apple": AAPL 80 | # "Google Inc.": GOOG 81 | # 82 | # Example usage: 83 | # :context: 84 | # :transactions: 85 | # :symbol: !!proc config[:ticker_symbols][row[1]] 86 | -------------------------------------------------------------------------------- /lib/total_recall/templates/simple.yml.tt: -------------------------------------------------------------------------------- 1 | --- 2 | :total_recall: 3 | :version: <%= @version %> 4 | :template: 5 | :raw: |- 6 | ; -*- ledger -*-¬ 7 | 8 | {{# transactions}} 9 | {{date}} {{{description}}} 10 | {{to}} EUR {{amount}} 11 | {{from}} 12 | 13 | {{/ transactions}} 14 | 15 | :csv: 16 | #:file: <%= @name %>.csv # also overwritable via ledger-subcommand. See `total_recall help ledger`. 17 | :raw: |- 18 | "2013-11-01","Foo","2013-11-02","1.638,00" 19 | "2013-11-02","Bar","2013-11-03","-492,93" 20 | :options: 21 | #:col_sep: ";" 22 | #:headers: false 23 | #:header_converters: :symbol # row[:date] rather than row[0]. Requires headers. 24 | 25 | :context: 26 | :transactions: 27 | :__defaults__: 28 | :from: !!proc | 29 | ask_account("What account provides these transactions?", default: 'Assets:Checking') 30 | :date: !!proc row[0] 31 | :description: !!proc row[1] 32 | :amount: !!proc row[3] 33 | :to: !!proc | 34 | render_row(columns: [0, 1, 3]) 35 | ask_account("To what account did the money go?") 36 | -------------------------------------------------------------------------------- /lib/total_recall/version.rb: -------------------------------------------------------------------------------- 1 | module TotalRecall 2 | VERSION = "0.6.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'total_recall' 3 | require 'fakefs/spec_helpers' 4 | 5 | RSpec.configure do |config| 6 | config.color = true 7 | config.tty = true 8 | end 9 | -------------------------------------------------------------------------------- /spec/total_recall/total_recall_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TotalRecall::Config do 4 | include FakeFS::SpecHelpers 5 | 6 | def stubbed_file(path, content) 7 | # SOURCE http://edgeapi.rubyonrails.org/classes/String.html#method-i-strip_heredoc 8 | indent = content.scan(/^[ \t]*(?=\S)/).min.size rescue 0 9 | content = content.gsub(/^[ \t]{#{indent}}/, '') 10 | 11 | FakeFS do 12 | File.open(path, 'w'){|f| f.print content } 13 | end 14 | end 15 | 16 | def instance_with_config(config, options = {}) 17 | options = {file: 'config.yml'}.merge(options) 18 | stubbed_file(options[:file], config) 19 | described_class.new(options) 20 | end 21 | 22 | describe '#config' do 23 | it 'yields the config as hash' do 24 | instance = instance_with_config(<<-CONFIG) 25 | :csv: 26 | :raw: Some csv 27 | :a: 1 28 | CONFIG 29 | 30 | expect(instance.config).to eql({csv: { raw: 'Some csv'}, a: 1}) 31 | end 32 | end 33 | 34 | describe '#csv' do 35 | it 'yields csv assigned to :raw' do 36 | instance = instance_with_config(<<-CONFIG) 37 | :csv: 38 | :raw: Some csv 39 | CONFIG 40 | 41 | expect(instance.csv).to eql(CSV.parse('Some csv')) 42 | end 43 | 44 | it 'yields csv from file :file' do 45 | csv_file = stubbed_file('some.csv', 'Some csv') 46 | instance = instance_with_config(<<-CONFIG) 47 | :csv: 48 | :file: some.csv 49 | CONFIG 50 | 51 | expect(instance.csv).to eql(CSV.parse('Some csv')) 52 | end 53 | 54 | it 'yields csv from :file when both :raw and :file are configured' do 55 | csv_file = stubbed_file('some.csv', 'Some csv') 56 | instance = instance_with_config(<<-CONFIG) 57 | :csv: 58 | :file: some.csv 59 | :raw: Some raw csv 60 | CONFIG 61 | 62 | expect(instance.csv).to eql(CSV.parse('Some csv')) 63 | end 64 | 65 | specify 'csv-options are passed on to CSV#read' do 66 | instance = instance_with_config(<<-CONFIG) 67 | :csv: 68 | :options: 69 | :option1: true 70 | CONFIG 71 | 72 | expect(CSV).to receive(:parse).with(anything(), { option1: true }) 73 | instance.csv 74 | end 75 | end 76 | 77 | describe '#template' do 78 | it 'yields template assigned to :raw' do 79 | instance = instance_with_config(<<-CONFIG) 80 | :template: 81 | :raw: |- 82 | Raw template 83 | here 84 | CONFIG 85 | 86 | expect(instance.template).to eql("Raw template\nhere") 87 | end 88 | 89 | it 'yields template from file :file' do 90 | template_file = stubbed_file('template.mustache', 'File template') 91 | instance = instance_with_config(<<-CONFIG) 92 | :template: 93 | :file: template.mustache 94 | CONFIG 95 | 96 | expect(instance.template).to eql('File template') 97 | end 98 | 99 | it 'yields template from :file when both :raw and :file are configured' do 100 | template_file = stubbed_file('template.mustache', 'File template') 101 | instance = instance_with_config(<<-CONFIG) 102 | :template: 103 | :file: template.mustache 104 | :raw: Raw template 105 | CONFIG 106 | 107 | expect(instance.template).to eql('File template') 108 | end 109 | 110 | context 'transactions only' do 111 | it 'takes only the transactions-section of the template into account' do 112 | instance = instance_with_config(<<-CONFIG, :transactions_only => true) 113 | :csv: 114 | :raw: |- 115 | row1 116 | :context: 117 | :a: 1 118 | :transactions: 119 | :b: !!proc row[0] 120 | :template: 121 | :raw: |- 122 | {{a}} 123 | {{# transactions}} 124 | {{b}} 125 | {{/ transactions}} 126 | CONFIG 127 | 128 | expect(instance.ledger).to eql("row1\n") 129 | end 130 | end 131 | end 132 | 133 | describe 'YAML types' do 134 | it 'allows proc-types' do 135 | instance = instance_with_config(<<-CONFIG) 136 | :a: !!proc 1 + 1 137 | :b: !!proc | 138 | 1 + 1 139 | CONFIG 140 | 141 | expect(instance.config[:a].call).to eq 2 142 | expect(instance.config[:b].call).to eq 2 143 | end 144 | end 145 | 146 | describe '#context' do 147 | it 'has a transaction per line of csv' do 148 | instance = instance_with_config(<<-CONFIG) 149 | :csv: 150 | :raw: |- 151 | 1 152 | 1 153 | :context: 154 | :transactions: 155 | :from: From 156 | :to: !!proc 1 + 1 157 | :amount: !!proc row[0] 158 | CONFIG 159 | 160 | transactions = instance.context[:transactions] 161 | expect(transactions.size).to eq 2 162 | 163 | transaction = transactions.first 164 | expect(transaction).to match({from: 'From', to: 2, amount: "1"}) 165 | end 166 | 167 | it 'adds defaults to every transaction' do 168 | instance = instance_with_config(<<-CONFIG) 169 | :csv: 170 | :raw: |- 171 | line 1 172 | line 2 173 | :context: 174 | :transactions: 175 | :__defaults__: 176 | :default: !!proc 1 177 | :from: From 178 | CONFIG 179 | 180 | transaction = instance.context[:transactions].first 181 | 182 | expect(transaction).to match({from: 'From', default: 1}) 183 | end 184 | 185 | it 'may contain any other settings' do 186 | instance = instance_with_config(<<-CONFIG) 187 | :csv: 188 | :raw: some csv 189 | :context: 190 | :transactions: 191 | :from: From 192 | :a: 1 193 | CONFIG 194 | 195 | expect(instance.context).to match(transactions: [{from: 'From'}], a: 1) 196 | end 197 | end 198 | 199 | describe 'helper methods' do 200 | describe '#transaction' do 201 | it 'gives access to the existing attributes' do 202 | instance = instance_with_config(<<-CONFIG) 203 | :csv: 204 | :raw: some csv 205 | :context: 206 | :transactions: 207 | :a: attribute a 208 | :b: !!proc | 209 | %|not %s| % transaction.a 210 | CONFIG 211 | 212 | expect(instance.transactions.first).to match({a: 'attribute a', b: 'not attribute a'}) 213 | end 214 | end 215 | 216 | describe '#config' do 217 | it 'gives access to the full config' do 218 | instance = instance_with_config(<<-CONFIG) 219 | :csv: 220 | :raw: some csv 221 | :context: 222 | :transactions: 223 | :a: !!proc | 224 | config[:a] 225 | :a: 1 226 | CONFIG 227 | 228 | expect(instance.transactions.first).to match({a: 1}) 229 | end 230 | end 231 | 232 | describe '#default' do 233 | it 'gives access to the default' do 234 | instance = instance_with_config(<<-CONFIG) 235 | :csv: 236 | :raw: some csv 237 | :context: 238 | :transactions: 239 | :__defaults__: 240 | :a: !!proc 2 241 | :b?: true 242 | :a: !!proc | 243 | default.succ 244 | :b?: true 245 | CONFIG 246 | 247 | expect(instance.transactions.first).to match({a: 3, b?: true}) 248 | end 249 | end 250 | end 251 | end 252 | -------------------------------------------------------------------------------- /total_recall.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'total_recall/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "total_recall" 8 | gem.version = TotalRecall::VERSION 9 | gem.authors = ["Gert Goet"] 10 | gem.email = ["gert@thinkcreate.nl"] 11 | gem.description = %q{Turn any csv into a Ledger journal} 12 | gem.summary = %q{Turn any csv into a Ledger journal} 13 | gem.homepage = "https://gitlab.com/eval/total_recall/tree/master#totalrecall-" 14 | gem.license = "MIT" 15 | 16 | gem.files = `git ls-files -z`.split("\x0") 17 | gem.bindir = "exe" 18 | gem.executables = gem.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 20 | gem.require_paths = ["lib"] 21 | 22 | gem.add_dependency 'thor', '~> 0.19' 23 | gem.add_dependency 'terminal-table', '~> 1.7' 24 | gem.add_dependency 'highline', '~> 1.7' 25 | gem.add_dependency 'mustache', '~> 1.0' 26 | gem.add_development_dependency 'bundler', '~> 1.11' 27 | gem.add_development_dependency 'rake', '~> 12.0' 28 | gem.add_development_dependency 'rspec', '~> 3.4' 29 | gem.add_development_dependency 'fakefs', '~> 0.10' 30 | end 31 | --------------------------------------------------------------------------------