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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. 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