rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
@@ -0,0 +1,139 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n'
|
4
|
+
require 'pastel'
|
5
|
+
require 'tty-screen'
|
6
|
+
|
7
|
+
module RVGP
|
8
|
+
class Application
|
9
|
+
# These methods output 'pretty' indications of the build process, and is used
|
10
|
+
# by both the rvgp standalone bin, as well as the rake processes themselves.
|
11
|
+
# This class manages colors, icons, indentation and thread syncronization
|
12
|
+
# concerns, relating to status display.
|
13
|
+
# NOTE: This class doesn't know if it wants to be a logger or something else... Let's
|
14
|
+
# see where it ends up
|
15
|
+
#
|
16
|
+
# @attr_reader [Integer] tty_cols The width of the terminal, in characters - This number is calculated at the time
|
17
|
+
# of Object instantiation.
|
18
|
+
class StatusOutputRake
|
19
|
+
attr_reader :tty_cols
|
20
|
+
|
21
|
+
# Create a new STDOUT status output logger
|
22
|
+
# @param [Hash] opts what options to configure this registry with
|
23
|
+
# @option opts [Pastel] :pastel A pastel object to use, for formatting output
|
24
|
+
def initialize(opts = {})
|
25
|
+
@semaphore = Mutex.new
|
26
|
+
@pastel = opts[:pastel] || Pastel.new
|
27
|
+
|
28
|
+
@last_header_outputted = nil
|
29
|
+
|
30
|
+
begin
|
31
|
+
@tty_cols = TTY::Screen.width
|
32
|
+
rescue Errno::ENOENT
|
33
|
+
@tty_cols = 0
|
34
|
+
end
|
35
|
+
|
36
|
+
# NOTE: This is the smallest width I bothered testing on. I don't think
|
37
|
+
# we really care to support this code path in general. If you're this
|
38
|
+
# narrow, umm, fix that.
|
39
|
+
@tty_cols = 40 if @tty_cols < 40
|
40
|
+
|
41
|
+
@complete = I18n.t 'status.indicators.complete'
|
42
|
+
@indent = I18n.t 'status.indicators.indent'
|
43
|
+
@fill = I18n.t 'status.indicators.fill'
|
44
|
+
@attention1 = I18n.t 'status.indicators.attention1'
|
45
|
+
@attention2 = I18n.t 'status.indicators.attention2'
|
46
|
+
@truncated = I18n.t 'status.indicators.truncated'
|
47
|
+
end
|
48
|
+
|
49
|
+
# This is the only public interface, at this time, for use in outputting status.
|
50
|
+
# This is the only method that RVGP needs implemented, on a status output object, should
|
51
|
+
# you choose to write your own.
|
52
|
+
# @param [String] cmd The command that's emmitting this status message
|
53
|
+
# @param [String] desc The I18n key, appended to 'status.commands.', that contains the message you wish to output
|
54
|
+
# @yield [void] A block that contains the execution of this message, and which will return an execution status.
|
55
|
+
# @yieldreturn [Hash<Symbol, Array>] A hash, with the keys :errors, and :warnings, each of which contains a list
|
56
|
+
# of errors and warnings that occurred, during the execution of this block.
|
57
|
+
# These will be stylized and output to the user.
|
58
|
+
# @return [void]
|
59
|
+
def info(cmd, desc, &block)
|
60
|
+
icon, header, prefix = *%w[icon header prefix].map do |attr|
|
61
|
+
I18n.t format('status.commands.%<cmd>s.%<attr>s', cmd: cmd.to_s, attr: attr)
|
62
|
+
end
|
63
|
+
|
64
|
+
@semaphore.synchronize do
|
65
|
+
unless @last_header_outputted == cmd
|
66
|
+
puts ["\n", icon, ' ', @pastel.bold(header)].join
|
67
|
+
@last_header_outputted = cmd
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
ret = block.call || {}
|
72
|
+
|
73
|
+
has_warn = ret.key?(:warnings) && !ret[:warnings].empty?
|
74
|
+
has_fail = ret.key?(:errors) && !ret[:errors].empty?
|
75
|
+
|
76
|
+
status_length = (if has_warn && has_fail
|
77
|
+
I18n.t 'status.indicators.complete_and', left: @complete, right: @complete
|
78
|
+
else
|
79
|
+
@complete
|
80
|
+
end).length
|
81
|
+
|
82
|
+
status = if has_warn && has_fail
|
83
|
+
I18n.t 'status.indicators.complete_and',
|
84
|
+
left: @pastel.yellow(@complete),
|
85
|
+
right: @pastel.red(@complete)
|
86
|
+
elsif has_warn
|
87
|
+
@pastel.yellow(@complete)
|
88
|
+
elsif has_fail
|
89
|
+
@pastel.red(@complete)
|
90
|
+
else
|
91
|
+
@pastel.green(@complete)
|
92
|
+
end
|
93
|
+
|
94
|
+
fixed_element_width = ([@indent, prefix, ' ', @indent, ' ', ' '].join.length + status_length)
|
95
|
+
|
96
|
+
available_width = tty_cols - fixed_element_width
|
97
|
+
|
98
|
+
# If the description is gigantic, we need to constrain it. That's our
|
99
|
+
# variable-length column, so to speak:
|
100
|
+
fill = if available_width <= desc.length
|
101
|
+
# The plus one is due to the compact'd nil below, which, removes a space
|
102
|
+
# character, that would have otherwise been placed next to the @fill
|
103
|
+
desc = desc[0...available_width - @truncated.length - 1] + @truncated
|
104
|
+
' '
|
105
|
+
elsif (available_width - desc.length) > 1
|
106
|
+
[' ', @fill * (available_width - desc.length - 2), ' '].join
|
107
|
+
elsif (available_width - desc.length) == 1
|
108
|
+
' '
|
109
|
+
else # This should only be zero
|
110
|
+
''
|
111
|
+
end
|
112
|
+
|
113
|
+
@semaphore.synchronize do
|
114
|
+
puts [
|
115
|
+
[@indent, @pastel.bold(prefix), ' ', @pastel.blue(desc), fill, status].join,
|
116
|
+
has_warn ? status_tree(:yellow, ret[:warnings]) : nil,
|
117
|
+
has_fail ? status_tree(:red, ret[:errors]) : nil
|
118
|
+
].compact.flatten.join("\n")
|
119
|
+
end
|
120
|
+
|
121
|
+
ret
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def status_tree(color, results)
|
127
|
+
results.map do |result|
|
128
|
+
result.each_with_index.map do |messages, depth|
|
129
|
+
(messages.is_a?(String) ? [messages] : messages).map do |msg|
|
130
|
+
[@indent * 2, @indent * depth,
|
131
|
+
depth.positive? ? @pastel.send(color, @attention2) : @pastel.send(color, @attention1),
|
132
|
+
depth.positive? ? msg : @pastel.send(color, msg)].join
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'application/status_output'
|
4
|
+
require_relative 'application/config'
|
5
|
+
|
6
|
+
module RVGP
|
7
|
+
# The main application class, by which all projects are defined. This class
|
8
|
+
# contains the methods and properties that are intrinsic to the early stages of
|
9
|
+
# project initialization, and which provides the functionality used by
|
10
|
+
# submodules initialized after initialization. In addition, this class implements
|
11
|
+
# the main() entry point used by Rakefiles, and in turn, instigates the
|
12
|
+
# equivalent entry points in various modules thereafter.
|
13
|
+
#
|
14
|
+
# @attr_reader [String] project_path The directory path, from which this application was initialized.
|
15
|
+
# @attr_reader [RVGP::Application::StatusOutputRake] logger The application logger. This is provided so that callers
|
16
|
+
# can output to the console. (Or wherever the output device is logging)
|
17
|
+
# @attr_reader [RVGP::Journal::Pricer] pricer This attribute contains the pricer that's used by the application. Price
|
18
|
+
# data is automatically loaded from config.prices_path (typically
|
19
|
+
# 'journals/prices.db')
|
20
|
+
# @attr_reader [RVGP::Application::Config] config The application configuration, most of which is parsed from the
|
21
|
+
# config.yaml
|
22
|
+
class Application
|
23
|
+
# This error is thrown when the project_path provided to {Application#initialize} doesn't exist, and/or is
|
24
|
+
# otherwise invalid.
|
25
|
+
class InvalidProjectDir < StandardError; end
|
26
|
+
|
27
|
+
attr_reader :project_path, :logger, :pricer, :config
|
28
|
+
|
29
|
+
# Creates an instance of Application, given the files and structure of the provided project path.
|
30
|
+
# @param project_path [String] The path, to an RVGP project directory.
|
31
|
+
def initialize(project_path)
|
32
|
+
raise InvalidProjectDir unless [project_path, format('%s/app', project_path)].all? { |f| Dir.exist? f }
|
33
|
+
|
34
|
+
@project_path = project_path
|
35
|
+
@config = RVGP::Application::Config.new project_path
|
36
|
+
@logger = RVGP::Application::StatusOutputRake.new pastel: RVGP.pastel
|
37
|
+
|
38
|
+
if File.exist? config.prices_path
|
39
|
+
@pricer = RVGP::Journal::Pricer.new(
|
40
|
+
File.read(config.prices_path),
|
41
|
+
# See the documentation in RVGP::Journal::Pricer#initialize, to better understand what's happening here.
|
42
|
+
# And, Note that this functionality is only supported when ledger is the pta adapter.
|
43
|
+
before_price_add: lambda { |time, from_alpha, to|
|
44
|
+
puts [
|
45
|
+
RVGP.pastel.yellow(I18n.t('error.warning')),
|
46
|
+
I18n.t('error.missing_entry_in_prices_db', time: time, from: from_alpha, to: to)
|
47
|
+
].join ' '
|
48
|
+
}
|
49
|
+
)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Include all the project files:
|
53
|
+
require_commands!
|
54
|
+
require_validations!
|
55
|
+
require_grids!
|
56
|
+
end
|
57
|
+
|
58
|
+
# @return [Array] An array, containing all the reconciler objects, defined in the project
|
59
|
+
def reconcilers
|
60
|
+
@reconcilers ||= RVGP::Base::Reconciler.all project_path
|
61
|
+
end
|
62
|
+
|
63
|
+
# This method will insert all the project tasks, into a Rake object.
|
64
|
+
# Typically, 'self' is that object, when calling from a Rakefile. (aka 'main')
|
65
|
+
# @param rake_main [Object] The Rake object to attach RVGP to.
|
66
|
+
# @return [void]
|
67
|
+
def initialize_rake!(rake_main)
|
68
|
+
require 'rake/clean'
|
69
|
+
|
70
|
+
CLEAN.include FileList[RVGP.app.config.build_path('*')]
|
71
|
+
|
72
|
+
# This removes clobber from the task list:
|
73
|
+
Rake::Task['clobber'].clear_comments
|
74
|
+
|
75
|
+
project_tasks_dir = format('%s/tasks', project_path)
|
76
|
+
Rake.add_rakelib project_tasks_dir if File.directory? project_tasks_dir
|
77
|
+
|
78
|
+
RVGP.commands.each do |command_klass|
|
79
|
+
command_klass.initialize_rake rake_main if command_klass.respond_to? :initialize_rake
|
80
|
+
end
|
81
|
+
|
82
|
+
rake_main.instance_eval do
|
83
|
+
default_tasks = %i[reconcile validate_journal validate_system]
|
84
|
+
multitask reconcile: RVGP.app.reconcilers.map { |tf| "reconcile:#{tf.as_taskname}" }
|
85
|
+
multitask validate_journal: RVGP.app.reconcilers.map { |tf| "validate_journal:#{tf.as_taskname}" }
|
86
|
+
multitask validate_system: RVGP.system_validations.task_names
|
87
|
+
|
88
|
+
# There's a chicken-and-an-egg problem that's due:
|
89
|
+
# - users (potentially) wanting to see/trigger specific plot and grid targets, in a clean project
|
90
|
+
# - A pre-requisite that journals (and grids) exist, in order to determine what grid/plot targets
|
91
|
+
# are available.
|
92
|
+
# So, what we do here, is do our best to determine what's available in a clean build. And, at the
|
93
|
+
# time at which we're ready to start buildings grids/plots - we re-initialize the available tasks
|
94
|
+
# based on what was built prior in the running build.
|
95
|
+
#
|
96
|
+
# Most grids can be determined by examining the reconciler years that exist in the app/ directory.
|
97
|
+
# But, in the case that new year starts, and the prior year hasn't been rotated, we'll be adding
|
98
|
+
# additional grids here.
|
99
|
+
#
|
100
|
+
# As for plots... probably we can do a better job of pre-determining those. But, they're pretty
|
101
|
+
# inconsequential in the build time, so, unless someone needs this feature for some reason, there
|
102
|
+
# are 'no' plots at the time of a full rake build, and the rescan adds them here after the grids
|
103
|
+
# are built.
|
104
|
+
|
105
|
+
isnt_reconciled = RVGP::Commands::Reconcile::Target.all.any? { |t| !t.uptodate? }
|
106
|
+
if isnt_reconciled
|
107
|
+
desc I18n.t('commands.rescan_grids.target_description')
|
108
|
+
task :rescan_grids do |_task, _task_args|
|
109
|
+
RVGP::Commands::Grid.initialize_rake rake_main
|
110
|
+
multitask grid: RVGP.grids.task_names
|
111
|
+
end
|
112
|
+
default_tasks << :rescan_grids
|
113
|
+
end
|
114
|
+
|
115
|
+
default_tasks << :grid
|
116
|
+
multitask grid: RVGP.grids.task_names
|
117
|
+
|
118
|
+
if isnt_reconciled || RVGP::Commands::Grid::Target.all.any? { |t| !t.uptodate? }
|
119
|
+
# This re-registers the grid tasks, into the rake
|
120
|
+
desc I18n.t('commands.rescan_plots.target_description')
|
121
|
+
task :rescan_plots do |_task, _task_args|
|
122
|
+
RVGP::Commands::Plot.initialize_rake rake_main
|
123
|
+
multitask plot: RVGP::Commands::Plot::Target.all.map { |t| "plot:#{t.name}" }
|
124
|
+
end
|
125
|
+
default_tasks << :rescan_plots
|
126
|
+
end
|
127
|
+
|
128
|
+
default_tasks << :plot
|
129
|
+
multitask plot: RVGP::Commands::Plot::Target.all.map { |t| "plot:#{t.name}" }
|
130
|
+
|
131
|
+
task default: default_tasks
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# This helper method will create the provided subdir inside the project's build/ directory, if that
|
136
|
+
# subdir doesn't already exist. In the case that this subdir already exists, the method terminates
|
137
|
+
# gracefully, without action.
|
138
|
+
# @return [void]
|
139
|
+
def ensure_build_dir!(subdir)
|
140
|
+
path = RVGP.app.config.build_path subdir
|
141
|
+
FileUtils.mkdir_p path unless File.directory? path
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
def require_commands!
|
147
|
+
# Built-in commands:
|
148
|
+
RVGP::Commands.require_files!
|
149
|
+
|
150
|
+
# App commands:
|
151
|
+
require_app_files! 'commands'
|
152
|
+
end
|
153
|
+
|
154
|
+
def require_grids!
|
155
|
+
require_app_files! 'grids'
|
156
|
+
end
|
157
|
+
|
158
|
+
def require_validations!
|
159
|
+
# Built-in validations:
|
160
|
+
Dir.glob(RVGP::Gem.root('lib/rvgp/validations/*.rb')).sort.each { |file| require file }
|
161
|
+
|
162
|
+
# App validations:
|
163
|
+
require_app_files! 'validations'
|
164
|
+
end
|
165
|
+
|
166
|
+
def require_app_files!(subdir)
|
167
|
+
Dir.glob([project_path, 'app', subdir, '*.rb'].join('/')).sort.each { |file| require file }
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|