rvgp 0.3.2
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.
- 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
|