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,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RVGP
|
|
4
|
+
module Commands
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# This class contains dispatch logic for the 'validate_journal' command and task.
|
|
7
|
+
class ValidateJournal < RVGP::Base::Command
|
|
8
|
+
accepts_options OPTION_ALL, OPTION_LIST
|
|
9
|
+
|
|
10
|
+
include RakeTask
|
|
11
|
+
rake_tasks :validate_journal
|
|
12
|
+
|
|
13
|
+
# @!visibility private
|
|
14
|
+
# This class principally represents the journals, by way of the reconciler
|
|
15
|
+
# in which the journal is defined. See RVGP::Base::Command::ReconcilerTarget, for
|
|
16
|
+
# most of the logic that this class inherits. Typically, these targets take
|
|
17
|
+
# the form of "#\\{year}-#\\{reconciler_name}"
|
|
18
|
+
class Target < RVGP::Base::Command::ReconcilerTarget
|
|
19
|
+
for_command :validate_journal
|
|
20
|
+
|
|
21
|
+
# @!visibility private
|
|
22
|
+
def uptodate?
|
|
23
|
+
@reconciler.validated?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @!visibility private
|
|
27
|
+
def mark_validated!
|
|
28
|
+
@reconciler.mark_validated!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @!visibility private
|
|
32
|
+
def execute(_options)
|
|
33
|
+
disable_checks = @reconciler.disable_checks.map(&:to_sym)
|
|
34
|
+
|
|
35
|
+
# Make sure the file exists, before proceeding with anything:
|
|
36
|
+
return [I18n.t('commands.reconcile.errors.journal_missing')], [] unless File.exist? @reconciler.output_file
|
|
37
|
+
|
|
38
|
+
warnings = []
|
|
39
|
+
errors = []
|
|
40
|
+
|
|
41
|
+
RVGP.journal_validations.classes.each do |klass|
|
|
42
|
+
next if disable_checks.include? klass.name.to_sym
|
|
43
|
+
|
|
44
|
+
validation = klass.new @reconciler
|
|
45
|
+
|
|
46
|
+
next if validation.valid?
|
|
47
|
+
|
|
48
|
+
warnings += validation.warnings
|
|
49
|
+
errors += validation.errors
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@reconciler.mark_validated! if (errors.length + warnings.length).zero?
|
|
53
|
+
|
|
54
|
+
[warnings, errors]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RVGP
|
|
4
|
+
module Commands
|
|
5
|
+
# @!visibility private
|
|
6
|
+
# This class contains dispatch logic for the 'validate_system' command and task.
|
|
7
|
+
class ValidateSystem < RVGP::Base::Command
|
|
8
|
+
accepts_options OPTION_ALL, OPTION_LIST
|
|
9
|
+
|
|
10
|
+
include RakeTask
|
|
11
|
+
rake_tasks :validate_system
|
|
12
|
+
|
|
13
|
+
# @!visibility private
|
|
14
|
+
# This class principally represents the system validations, that are defined
|
|
15
|
+
# in the application directory. Unlike the journal validations, these
|
|
16
|
+
# targets are not specific to years, or reconcilers.
|
|
17
|
+
class Target < RVGP::Base::Command::Target
|
|
18
|
+
# @!visibility private
|
|
19
|
+
def initialize(validation_klass)
|
|
20
|
+
@validation_klass = validation_klass
|
|
21
|
+
@description = validation_klass.description
|
|
22
|
+
super validation_klass.name, validation_klass.status_label
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @!visibility private
|
|
26
|
+
def uptodate?
|
|
27
|
+
@validation_klass.validated?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @!visibility private
|
|
31
|
+
def execute(_options)
|
|
32
|
+
validation = @validation_klass.new
|
|
33
|
+
validation.mark_validated! if validation.valid?
|
|
34
|
+
[validation.warnings, validation.errors]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @!visibility private
|
|
38
|
+
def self.all
|
|
39
|
+
RVGP.system_validations.collect { |klass| new klass }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base/command'
|
|
4
|
+
|
|
5
|
+
module RVGP
|
|
6
|
+
# This module contains the implementation of each task in the rake process.
|
|
7
|
+
# However, these commands aren't documented yet. And, may never be. They're
|
|
8
|
+
# mostly not useful for any api purpose. Documentation for each of these
|
|
9
|
+
# commands is available via `rvgp --help`.
|
|
10
|
+
#
|
|
11
|
+
# However, these files are really useful examples to review, if you want to
|
|
12
|
+
# create rake tasks and/or custom rvgp commands in your projects. Take a look
|
|
13
|
+
# at the source code, for easy implementations that you can follow:
|
|
14
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/cashflow.rb cashflow.rb}
|
|
15
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/grid.rb grid.rb}
|
|
16
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/new_project.rb new_project.rb}
|
|
17
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/plot.rb plot.rb}
|
|
18
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/publish_gsheets.rb publish_gsheets.rb}
|
|
19
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/ireconcile.rb ireconcile.rb}
|
|
20
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/reconcile.rb reconcile.rb}
|
|
21
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/validate_journal.rb validate_journal.rb}
|
|
22
|
+
# - {https://github.com/brighton36/rvgp/blob/main/lib/rvgp/commands/validate_system.rb validate_system.rb}
|
|
23
|
+
#
|
|
24
|
+
# The 'new_project.rb is a bit of a special case, due to it being the only command that's available in the
|
|
25
|
+
# absensce of a 'current project'. And, as such, might not be the best example to emulate...
|
|
26
|
+
module Commands
|
|
27
|
+
class << self
|
|
28
|
+
# @!visibility private
|
|
29
|
+
def require_files!
|
|
30
|
+
Dir.glob([File.dirname(__FILE__), 'commands', '*.rb'].join('/')).sort.each { |file| require file }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @!visibility private
|
|
34
|
+
def dispatch!(*args)
|
|
35
|
+
# Let's start parsing args:
|
|
36
|
+
|
|
37
|
+
# NOTE: There's a kind of outstanding 'bug' here, where, any commands
|
|
38
|
+
# that have -d or --help options would be picked up by the global
|
|
39
|
+
# handling here. The solution is not to have -d or --help in your
|
|
40
|
+
# local commands. We don't detect that atm, but we may want to at some
|
|
41
|
+
# point. For now, just, don't use these options
|
|
42
|
+
options, command_args = RVGP::Base::Command::Option.remove_options_from_args(
|
|
43
|
+
[%i[help h], [:dir, :d, { has_value: true }]].map { |a| RVGP::Base::Command::Option.new(*a) },
|
|
44
|
+
args
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
command_name = command_args.shift
|
|
48
|
+
|
|
49
|
+
# Process global options:
|
|
50
|
+
app_dir = if options[:dir]
|
|
51
|
+
options[:dir]
|
|
52
|
+
elsif ENV.key? 'LEDGER_FILE'
|
|
53
|
+
File.dirname ENV['LEDGER_FILE']
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# I'm not crazy about this implementation, but, it's a special case. So,
|
|
57
|
+
# we dispatch the new_project command in this way:
|
|
58
|
+
if command_name == 'new_project'
|
|
59
|
+
require_files!
|
|
60
|
+
dispatch_klass RVGP::Commands::NewProject, app_dir
|
|
61
|
+
exit
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless app_dir && File.directory?(app_dir)
|
|
65
|
+
# To solve the chicken and the egg problem, that's caused by
|
|
66
|
+
# user-defined commands adding to our help. We have two ways of
|
|
67
|
+
# handling the help. Here, we display the help, even if there's no app_dir:
|
|
68
|
+
if options[:help]
|
|
69
|
+
# This will only show the help for built-in commands, as we were
|
|
70
|
+
# not able to load the project_dir's commands
|
|
71
|
+
require_files!
|
|
72
|
+
RVGP::Commands.help!
|
|
73
|
+
else
|
|
74
|
+
error! 'error.no_application_dir', dir: app_dir
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Initialize the provided app:
|
|
79
|
+
begin
|
|
80
|
+
RVGP.initialize_app app_dir unless command_name == 'new_project'
|
|
81
|
+
rescue RVGP::Application::InvalidProjectDir
|
|
82
|
+
error! 'error.invalid_application_dir', directory: app_dir
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# If we were able to load the project directory, and help was requested,
|
|
86
|
+
# we offer help here, as we can show them help for their user defined
|
|
87
|
+
# commands, at this time:
|
|
88
|
+
RVGP::Commands.help! if options[:help]
|
|
89
|
+
|
|
90
|
+
# Dispatch the command:
|
|
91
|
+
command_klass = RVGP.commands.find { |klass| klass.name == command_name }
|
|
92
|
+
|
|
93
|
+
if command_name.nil?
|
|
94
|
+
error! 'error.missing_command'
|
|
95
|
+
elsif command_klass
|
|
96
|
+
dispatch_klass command_klass, command_args
|
|
97
|
+
else
|
|
98
|
+
error! 'error.command_unrecognized', command: command_name
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @!visibility private
|
|
103
|
+
def help!
|
|
104
|
+
# Find the widest option's width, and use that for alignment.
|
|
105
|
+
widest_option = RVGP.commands.map { |cmd| cmd.options.map(&:long) }.flatten.max.length
|
|
106
|
+
|
|
107
|
+
indent = I18n.t('help.indent')
|
|
108
|
+
puts [
|
|
109
|
+
I18n.t('help.usage', program: File.basename($PROGRAM_NAME)),
|
|
110
|
+
[indent, I18n.t('help.description')],
|
|
111
|
+
I18n.t('help.command_introduction'),
|
|
112
|
+
I18n.t('help.target_introduction'),
|
|
113
|
+
I18n.t('help.global_option_introduction'),
|
|
114
|
+
I18n.t('help.command_list_introduction'),
|
|
115
|
+
RVGP.commands.map do |command_klass|
|
|
116
|
+
[
|
|
117
|
+
[indent, RVGP.pastel.bold(command_klass.name)].join,
|
|
118
|
+
[indent, I18n.t(format('help.commands.%s.description', command_klass.name))].join,
|
|
119
|
+
if command_klass.options.empty?
|
|
120
|
+
nil
|
|
121
|
+
else
|
|
122
|
+
[nil,
|
|
123
|
+
command_klass.options.map do |option|
|
|
124
|
+
[indent * 2,
|
|
125
|
+
'-', option.short, ', ',
|
|
126
|
+
format("--%-#{widest_option}s", option.long), ' ',
|
|
127
|
+
I18n.t(format('help.commands.%<command>s.options.%<option>s',
|
|
128
|
+
command: command_klass.name,
|
|
129
|
+
option: option.long.to_s))].join
|
|
130
|
+
end,
|
|
131
|
+
nil]
|
|
132
|
+
end
|
|
133
|
+
]
|
|
134
|
+
end
|
|
135
|
+
].flatten.join "\n"
|
|
136
|
+
exit
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def error!(i18n_key, **options)
|
|
142
|
+
puts [RVGP.pastel.red(I18n.t('error.error')), I18n.t(i18n_key, **options)].join(': ')
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def dispatch_klass(command_klass, command_args)
|
|
147
|
+
command = command_klass.new(*command_args)
|
|
148
|
+
if command.valid?
|
|
149
|
+
command.execute!
|
|
150
|
+
else
|
|
151
|
+
puts RVGP.pastel.bold(I18n.t('error.command_errors', command: command_klass.name))
|
|
152
|
+
command.errors.each do |error|
|
|
153
|
+
puts RVGP.pastel.red(I18n.t('error.command_error', error: error))
|
|
154
|
+
end
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
gem 'tty-table'
|
|
4
|
+
require 'tty-table'
|
|
5
|
+
|
|
6
|
+
module RVGP
|
|
7
|
+
# This class implements a basic graphical dashboard, for use on ansi terminals.
|
|
8
|
+
# These dashboards resemble tables, with stylized headers and footers.
|
|
9
|
+
# Here's a rough example, of what these dashboards look like:
|
|
10
|
+
# ```
|
|
11
|
+
# ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
|
|
12
|
+
# │▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ Personal Dashboard ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒│
|
|
13
|
+
# ├────────────────────────────────────────────────────┬─────────────┬─────────────┬─────────────┬─────────────┤
|
|
14
|
+
# │ Account │ 01-23 │ 02-23 │ 03-23 │ 04-23 │
|
|
15
|
+
# ├────────────────────────────────────────────────────┼─────────────┼─────────────┼─────────────┼─────────────┤
|
|
16
|
+
# │ Personal:Expenses:Food:Groceries │ $ 500.00 │ $ 510.00 │ $ 520.00 │ $ 530.00 │
|
|
17
|
+
# │ Personal:Expenses:Food:Restaurants │ $ 250.00 │ $ 260.00 │ $ 270.00 │ $ 280.00 │
|
|
18
|
+
# │ Personal:Expenses:Phone:Service │ $ 75.00 │ $ 75.00 │ $ 75.00 │ $ 75.00 │
|
|
19
|
+
# │ Personal:Expenses:Transportation:Gas │ $ 150.00 │ $ 180.00 │ $ 280.00 │ $ 175.00 │
|
|
20
|
+
# ├────────────────────────────────────────────────────┼─────────────┼─────────────┼─────────────┼─────────────┤
|
|
21
|
+
# │ Expenses │ $ 975.00 │ $ 1,025.00 │ $ 1,145.00 │ $ 1,060.00 │
|
|
22
|
+
# │ Income │ $ -2,500.00 │ $ -2,500.00 │ $ -2,500.00 │ $ -2,500.00 │
|
|
23
|
+
# ├────────────────────────────────────────────────────┼─────────────┼─────────────┼─────────────┼─────────────┤
|
|
24
|
+
# │ Cash Flow │ $ -1,525.00 │ $ -1,475.00 │ $ -1,355.00 │ $ -1,440.00 │
|
|
25
|
+
# └────────────────────────────────────────────────────┴─────────────┴─────────────┴─────────────┴─────────────┘
|
|
26
|
+
# ```
|
|
27
|
+
#
|
|
28
|
+
# There's a lot of functionality here, but, it's mostly unused at the moment, outside the cashflow command.
|
|
29
|
+
# Ultimately, this is probably going to end up becoming a {RVGP::Grid} viewing tool on the cli.
|
|
30
|
+
# @attr_reader [String] label The label for this dashboard. This is used in the first row, of the output
|
|
31
|
+
# @attr_reader [String] series_column_label The label, describing what our series represents. This is also
|
|
32
|
+
# known as the 'keystone'.
|
|
33
|
+
# @attr_reader [RVGP::Utilities::GridQuery] csv The grid and data that this Dashboard will output
|
|
34
|
+
class Dashboard
|
|
35
|
+
# @!visibility private
|
|
36
|
+
CELL_PADDING = [0, 1, 0, 1].freeze
|
|
37
|
+
# @!visibility private
|
|
38
|
+
NULL_CELL_TO_TABLE = { value: '⋯', alignment: :center }.freeze
|
|
39
|
+
|
|
40
|
+
attr_reader :label, :series_column_label, :csv
|
|
41
|
+
|
|
42
|
+
# Create a Dashboard, which can thereafter be output to the console via {#to_s}
|
|
43
|
+
# @param [String] label See {Dashboard#label}
|
|
44
|
+
# @param [String] csv See {Dashboard#csv}
|
|
45
|
+
# @param [Hash] options Additional, optional, parameters
|
|
46
|
+
# @option options [String] series_column_label see {Dashboard#series_column_label}
|
|
47
|
+
# @option options [Pastel] pastel (Pastel.new) A Pastel object to use, for coloring and boldfacing
|
|
48
|
+
# @option options [Proc<Array<Object>, Array<Object>>] columns_ordered_by This proc is sent to Enumerable#sort with
|
|
49
|
+
# two parameters, of type Array. Each of these array's is a
|
|
50
|
+
# column. Your columns are ordered based on whether -1, 0, or 1
|
|
51
|
+
# is returned. See Enumerable#sort for details on how this
|
|
52
|
+
# works.
|
|
53
|
+
# @option options [Proc<Object>] format_data_cell This proc is called, with the contents of each data cell in the
|
|
54
|
+
# dashboard. Whatever it returns is converted to a string, and
|
|
55
|
+
# rendered.
|
|
56
|
+
# @option options [Proc<Object>] format_series_label This proc is called, with the contents of each series label in
|
|
57
|
+
# the dashboard. Whatever it returns is converted to a string,
|
|
58
|
+
# and rendered.
|
|
59
|
+
# @option options [Array<Hash<Symbol,String>>] summaries An array of Hashes, each of which is expected to contain
|
|
60
|
+
# :label and :contents parameters. In addition, a :prettify
|
|
61
|
+
# parameter is also supported. Each of these Hashes are
|
|
62
|
+
# rendered at the bottom of the table, using the :label and
|
|
63
|
+
# :contents provided. If :prettify is provided, this
|
|
64
|
+
# parameter is provided the row, before rendering, so that
|
|
65
|
+
# ansi formatting is applied to the :contents.
|
|
66
|
+
def initialize(label, csv, options = {})
|
|
67
|
+
@label = label
|
|
68
|
+
@csv = csv
|
|
69
|
+
@pastel = options[:pastel] || Pastel.new
|
|
70
|
+
@series_column_label = options[:series_column_label] || 'Series'
|
|
71
|
+
|
|
72
|
+
@columns_ordered_by = options[:columns_ordered_by]
|
|
73
|
+
|
|
74
|
+
@format_data_cell = options[:format_data_cell]
|
|
75
|
+
@format_series_label = options[:format_series_label]
|
|
76
|
+
@summaries = options[:summaries]
|
|
77
|
+
|
|
78
|
+
unless @summaries.all? { |s| %w[label contents].all? { |k| s.key? k.to_sym } }
|
|
79
|
+
raise StandardError, 'One or more summaries are incomplete'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Calculates the width requirements of each column, given the data that is present in that column
|
|
84
|
+
# Note that we're not including the padding in this calculation.
|
|
85
|
+
# @return [Array<Integer>] The widths for each column
|
|
86
|
+
def column_data_widths
|
|
87
|
+
# Now compute the width of each cell's contents:
|
|
88
|
+
to_a.inject([]) do |ret, row|
|
|
89
|
+
row.map.with_index do |cell, col_i|
|
|
90
|
+
cell_width = cell.respond_to?(:length) ? cell.length : 0
|
|
91
|
+
ret[col_i].nil? || ret[col_i] < cell_width ? cell_width : ret[col_i]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The goal here is to return the full table, without ansi decorators, and
|
|
97
|
+
# without any to_s output options that will mutate state. The returned object's
|
|
98
|
+
# may or may not be String, depending on whether the :format_series_row was provided to #initialize
|
|
99
|
+
# @return [Array<Array<Object>>] The grid, that this dashboard will render
|
|
100
|
+
def to_a
|
|
101
|
+
# This is the table in full, without ansi, ordering, or width modifiers.
|
|
102
|
+
# More or less, this is a plain text representation, in full, of the data
|
|
103
|
+
|
|
104
|
+
@to_a ||= [[series_column_label] + sorted_headers] +
|
|
105
|
+
series_rows.dup.map!(&method(:format_series_row)) + summary_rows
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Render this Dashboard to a string. Presumably for printing to the console.
|
|
109
|
+
# @param [Hash] options Optional formatting specifiers
|
|
110
|
+
# @option options [Array<Integer>] column_widths Use these widths for our columns, instead of the automatically
|
|
111
|
+
# deduced widths. This parameter eventually makes it's way down to
|
|
112
|
+
# TTY::Table's column_widths parameter.
|
|
113
|
+
# @option options [Proc<Array<Object>>] show_row This proc is called with a row, as its parameter. And, if the Proc
|
|
114
|
+
# returns true, the row is displayed. (And if not, the row is hidden)
|
|
115
|
+
# @option options [Proc<Array<Object>>] rows_ordered_by This proc is sent to Enumerable#sort_by! with a row, as its
|
|
116
|
+
# parameter. The returned value, will be used as the sort
|
|
117
|
+
# element thereafter
|
|
118
|
+
# @return [String] Your dashboard. The finished product. Print this to STDOUT
|
|
119
|
+
def to_s(options = {})
|
|
120
|
+
column_widths = options.key?(:column_widths) ? options[:column_widths] : nil
|
|
121
|
+
header_row = [series_column_label] + sorted_headers
|
|
122
|
+
footer_rows = summary_rows
|
|
123
|
+
content_rows = series_rows
|
|
124
|
+
|
|
125
|
+
if column_widths
|
|
126
|
+
([header_row] + content_rows + footer_rows).each { |row| row.pop row.length - column_widths.length }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Now let's strip the rows we no longer need to show:
|
|
130
|
+
content_rows.select!(&options[:show_row]) if options.key? :show_row
|
|
131
|
+
|
|
132
|
+
# Sort the content:
|
|
133
|
+
content_rows.sort_by!(&options[:rows_ordered_by]) if options.key? :rows_ordered_by
|
|
134
|
+
|
|
135
|
+
# Then format the series and data cells:
|
|
136
|
+
content_rows.map!(&method(:format_series_row))
|
|
137
|
+
|
|
138
|
+
prettify format('%s Dashboard', label.to_s), [header_row] + content_rows + footer_rows, column_widths
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# This helper is provided with the intention of being used with {RVGP::Dashboard#column_data_widths}.
|
|
142
|
+
# Given the return value of #column_data_widths, this method will return the width of a rendered
|
|
143
|
+
# dashboard onto the console. That means we account for padding and cell separation character(s) in
|
|
144
|
+
# this calculation.
|
|
145
|
+
# @param [Array<Integer>] column_widths The widths of each column in the table whose width you wish to
|
|
146
|
+
# calculate
|
|
147
|
+
# @return [Integer] The width of the table, once rendered
|
|
148
|
+
def self.table_width_given_column_widths(column_widths)
|
|
149
|
+
accumulated_width = 1 # One is the width of the left-most border '|'
|
|
150
|
+
accumulated_width + column_widths.map do |w|
|
|
151
|
+
[Dashboard::CELL_PADDING[1], w, Dashboard::CELL_PADDING[3], 1] # This one is the cell's right-most border '|'
|
|
152
|
+
end.flatten.sum
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def column_count
|
|
158
|
+
series_rows[0].length
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def summary_rows
|
|
162
|
+
series_column = series_rows.map { |row| row[0] }
|
|
163
|
+
@summary_rows ||= @summaries.map do |summary|
|
|
164
|
+
format_series_row([summary[:label]] + 0.upto(column_count).map do |i|
|
|
165
|
+
summary[:contents].call(series_column, series_rows.map { |row| row[i + 1] })
|
|
166
|
+
end.to_a)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def format_series_row(row)
|
|
171
|
+
series_label = row[0]
|
|
172
|
+
series_data = row[1..]
|
|
173
|
+
series_label = @format_series_label.call(series_label) if @format_series_label
|
|
174
|
+
series_data.map! { |cell| @format_data_cell.call(cell) } if @format_data_cell
|
|
175
|
+
[series_label] + series_data
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def sorted_headers
|
|
179
|
+
@sorted_headers ||= @columns_ordered_by ? csv.headers.sort(&@columns_ordered_by) : csv.headers
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def series_rows
|
|
183
|
+
@series_rows ||= csv.data.keys.map do |series|
|
|
184
|
+
[series] + sorted_headers.map { |header| csv.data[series][header] }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# This handles pretty much all of the presentation code. Given a set of cells,
|
|
189
|
+
# it outputs the 'pretty' tables in ansi.
|
|
190
|
+
def prettify(title, rows, out_col_widths)
|
|
191
|
+
separators = [0] # NOTE: 1 is the header row
|
|
192
|
+
|
|
193
|
+
rows = rows.each_with_index.map do |row, i|
|
|
194
|
+
prettifier = nil
|
|
195
|
+
|
|
196
|
+
# Is this a subtotal row?
|
|
197
|
+
if rows.length - i <= @summaries.length
|
|
198
|
+
# This is kind of sloppy, but, it works for now. We assume that there's
|
|
199
|
+
# a final subtotal row, and any other summaries are subtotals. At least
|
|
200
|
+
# for presentation logic. (Where to put the lines and such)
|
|
201
|
+
prettifier = @summaries[@summaries.length - (rows.length - i)][:prettify]
|
|
202
|
+
|
|
203
|
+
# This is the first of the summaries, or the total
|
|
204
|
+
separators << (i - 1) if [@summaries.length, 1].include? rows.length - i
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if i.zero?
|
|
208
|
+
row.each_with_index.map { |cell, j| { value: @pastel.bold(cell), alignment: j.zero? ? :left : :center } }
|
|
209
|
+
elsif prettifier
|
|
210
|
+
prettifier.call(row)
|
|
211
|
+
else
|
|
212
|
+
row.each_with_index.map do |cell, j|
|
|
213
|
+
if i.zero?
|
|
214
|
+
{ value: @pastel.bold(cell), alignment: j.zero? ? :left : :center }
|
|
215
|
+
elsif j.zero?
|
|
216
|
+
@pastel.blue cell.to_s
|
|
217
|
+
else
|
|
218
|
+
cell || NULL_CELL_TO_TABLE
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Insert separators, bottom to top:
|
|
225
|
+
table_out = TTY::Table.new(rows).render(:unicode) do |renderer|
|
|
226
|
+
renderer.alignments = [:left] + 2.upto(rows.length).to_a.map { :right }
|
|
227
|
+
renderer.padding = CELL_PADDING
|
|
228
|
+
renderer.border do
|
|
229
|
+
top_left '├'
|
|
230
|
+
top_right '┤'
|
|
231
|
+
end
|
|
232
|
+
renderer.border.separator = separators
|
|
233
|
+
renderer.column_widths = out_col_widths if out_col_widths
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
table_width = table_out.lines[0].length
|
|
237
|
+
title_space = (table_width - 3 - 2 - title.length).to_f / 2
|
|
238
|
+
|
|
239
|
+
[
|
|
240
|
+
# Headcap:
|
|
241
|
+
['┌', '─' * (table_width - 3), '┐'].join,
|
|
242
|
+
['│',
|
|
243
|
+
@pastel.blue('▒') * title_space.ceil, ' ',
|
|
244
|
+
@pastel.blue(title),
|
|
245
|
+
' ', @pastel.blue('▒') * title_space.floor,
|
|
246
|
+
'│'].join,
|
|
247
|
+
# Content:
|
|
248
|
+
table_out
|
|
249
|
+
].join("\n")
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|