rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
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