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,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
|