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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'journal/journal'
4
+ require_relative 'journal/currency'
5
+ require_relative 'journal/commodity'
6
+ require_relative 'journal/complex_commodity'
7
+ require_relative 'journal/posting'
8
+
9
+ # Extensions to the ruby stdlib implementation of String. Offered as a convenience.
10
+ class String
11
+ # Given a string, such as "$ 20.57", or "1 MERCEDESBENZ", Construct and return a {RVGP::Journal::Commodity}
12
+ # representation.
13
+ # see {RVGP::Journal::Commodity.from_s}
14
+ # @return [RVGP::Journal::Commodity] the parsed string
15
+ def to_commodity
16
+ RVGP::Journal::Commodity.from_s self
17
+ end
18
+
19
+ # Parse a string, into a {RVGP::Journal::Posting::Tag} object
20
+ # see {RVGP::Journal::Posting::Tag.from_s}
21
+ def to_tag
22
+ RVGP::Journal::Posting::Tag.from_s self
23
+ end
24
+ end
@@ -0,0 +1,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RVGP
6
+ class Plot
7
+ # This module contains the code needed to produce Gnuplot .gpi files, from a grid, and
8
+ # styling options.
9
+ module Gnuplot
10
+ # Palette(s) are loaded from a template, and contain logic related to coloring
11
+ # base elements (fonts/background/line-colors/etc), as well as relating to
12
+ # series/element colors in the plot.
13
+ class Palette
14
+ # @param [Hash] opts The options to configure this palette with
15
+ # @option opts [Hash<Symbol, String>] :base The base colors for this plot. Currently, the following base colors
16
+ # are supported: :title_rgb, :background_rgb, :font_rgb, :grid_rgb,
17
+ # :axis_rgb, :key_text_rgb . This option expects keys to be one of the
18
+ # supported colors, and values to be in the 'standard' html color
19
+ # format, resembling "#rrggbb" (with rr, gg, and bb being a
20
+ # hexadecimal color code)
21
+ #
22
+ # @option opts [Array<String>] :series An array of colors, in the 'standard' html color format. There is no
23
+ # limit to the size of this array.
24
+ def initialize(opts = {})
25
+ @series_colors = opts[:series]
26
+ @base_colors = opts[:base]
27
+ @last_series_color = -1
28
+ @last_series_direction = 1
29
+ end
30
+
31
+ # Return the current series color. And, increments the 'current' color pointer, so that a subsequent call to
32
+ # this method returns 'the next color', and continues the cycle. Should there be no
33
+ # further series colors available, at the time of advance, the 'current' color
34
+ # moves back to the first element in the provided :series colors.
35
+ # @return [String] The html color code of the color, before we advanced the series
36
+ def series_next!
37
+ @last_series_color += @last_series_direction
38
+ @series_colors[@last_series_color % @series_colors.length]
39
+ end
40
+
41
+ # @!visibility private
42
+ def respond_to_missing?(name, _include_private = false)
43
+ @base_colors.key? name
44
+ end
45
+
46
+ # Unhandled methods, are assumed to be base_color requests. And, commensurately, the :base option
47
+ # in the constructor is used to satisfy any such methods
48
+ # @return [String] An html color code, for the requested base color
49
+ def method_missing(name)
50
+ @base_colors.key?(name) ? base_color(name) : super(name)
51
+ end
52
+
53
+ # Returns the base colors, currently supported, that were supplied in the {#initialize} base: option
54
+ # @return [Hash<Symbol, String>] An html color code, for the requested base color
55
+ def base_to_h
56
+ # We'll probably want to expand this more at some point...
57
+ { title_rgb: title, background_rgb: background, font_rgb: font,
58
+ grid_rgb: grid, axis_rgb: axis, key_text_rgb: key_text }
59
+ end
60
+
61
+ # This is used by some charts, due to gnuplot requiring us to inverse the
62
+ # order of series. The from_origin parameter, is the new origin, by which
63
+ # we'll begin to assign colors, in reverse order
64
+ def reverse_series_colors!(from_origin)
65
+ @last_series_color = from_origin
66
+ @last_series_direction = -1
67
+ end
68
+
69
+ private
70
+
71
+ def base_color(name)
72
+ raise StandardError, format('No such base_color "%s"', name) unless @base_colors.key? name
73
+
74
+ @base_colors[name].is_a?(String) ? @base_colors[name] : base_color(@base_colors[name])
75
+ end
76
+ end
77
+
78
+ # This base class offers some helpers, used by our Element classes to handle
79
+ # common options, and keep things DRY.
80
+ class ChartBuilder
81
+ ONE_MONTH_IN_SECONDS = 2_592_000 # 30 days
82
+
83
+ # Create a chart
84
+ # @param [Hash] opts options to configure this chart
85
+ # @option opts [Symbol] :domain This option specifies the 'type' of the domain. Currently, the only supported
86
+ # type is :monthly
87
+ # @option opts [Integer,Date] :xrange_start The plot domain origin, either the number 1, or a date
88
+ # @option opts [Date] :xrange_end The end of the plot domain
89
+ # @option opts [Hash<Symbol, String>] :axis Axis labels. At the moment, :bottom and :left are supported keys.
90
+ # @param [RVGP::Plot::Gnuplot::Plot] gnuplot A Plot to attach this Chart to
91
+ def initialize(opts, gnuplot)
92
+ # TODO: At some point, we probably want to support inverting the key order.
93
+ # which, as best I can tell, will involve writing a 'fake' chart,
94
+ # that's not displayed. But which will create a key, which is
95
+ # displayed, in the order we want
96
+ @gnuplot = gnuplot
97
+
98
+ if opts[:domain]
99
+ @domain = opts[:domain].to_sym
100
+ case @domain
101
+ when :monthly
102
+ # This is mostly needed, because gnuplot doesn't always do the best
103
+ # job of automatically detecting the domain bounds...
104
+ gnuplot.set 'xdata', 'time'
105
+ gnuplot.set 'xtics', ONE_MONTH_IN_SECONDS
106
+
107
+ dates = gnuplot.column(0).map { |xtic| Date.strptime xtic, '%m-%y' }.sort
108
+ is_multiyear = dates.first.year != dates.last.year
109
+
110
+ unless dates.empty?
111
+ opts[:xrange_start] ||= is_multiyear ? dates.first : Date.new(dates.first.year, 1, 1)
112
+ opts[:xrange_end] ||= is_multiyear ? dates.last : Date.new(dates.last.year, 12, 31)
113
+ end
114
+ else
115
+ raise StandardError, format('Unsupported domain %s', @domain.inspect)
116
+ end
117
+ end
118
+
119
+ gnuplot.set 'xrange', format_xrange(opts) if xrange? opts
120
+ gnuplot.set 'xlabel', opts[:axis][:bottom] if opts[:axis] && opts[:axis][:bottom]
121
+ gnuplot.set 'ylabel', opts[:axis][:left] if opts[:axis] && opts[:axis][:left]
122
+ end
123
+
124
+ # Returns a enumerator, for use by {Plot#plot_command}, when building charts. Mostly,
125
+ # this method is what determines if the series are started from one, going up to numcols. Or, are started
126
+ # from num_cols, and go down to one.
127
+ # @return [Enumerator] An enumerator to progress through the chart's series
128
+ def series_range(num_cols)
129
+ reverse_series_range? ? (num_cols - 1).downto(1) : 1.upto(num_cols - 1)
130
+ end
131
+
132
+ # Returns the column number specifier, a string, for use by {Plot#plot_command}, when building charts.
133
+ # In some charts, this is as simple as num + 1. In others, this line can contain more complex gnuplot code.
134
+ # @return [String] Returns the gnuplot formatted series_num, for the series at position num.
135
+ def format_num(num)
136
+ (num + 1).to_s
137
+ end
138
+
139
+ private
140
+
141
+ def reverse_series_range?
142
+ @reverse_series_range || false
143
+ end
144
+
145
+ def format_xrange(opts)
146
+ fmt_parts = %i[xrange_start xrange_end].each_with_object({}) do |attr, ret|
147
+ value = opts[attr]
148
+ value = value.strftime('%m-%y') if value.respond_to?(:strftime)
149
+ ret.merge!(attr => value.is_a?(String) ? format('"%s"', value) : value.to_s)
150
+ end
151
+ format '[%<xrange_start>s:%<xrange_end>s]', fmt_parts
152
+ end
153
+
154
+ def xrange?(opts)
155
+ %i[xrange_start xrange_end].any? { |attr| opts[attr] }
156
+ end
157
+
158
+ def reverse_series_colors!
159
+ @gnuplot.palette.reverse_series_colors! @gnuplot.num_cols - 1
160
+ end
161
+ end
162
+
163
+ # This Chart element contains the logic necessary to render Integrals
164
+ # (shaded areas, under a line), onto the plot canvas.
165
+ class AreaChart < ChartBuilder
166
+ # (see ChartBuilder#initialize)
167
+ # @option opts [TrueClass,FalseClass] :is_stacked Whether the series on this chart are offset from the origin,
168
+ # or are offset from each other (aka 'stacked on top of each
169
+ # other')
170
+ def initialize(opts, gnuplot)
171
+ super opts, gnuplot
172
+ @reverse_series_range = opts[:is_stacked]
173
+ reverse_series_colors! if reverse_series_range?
174
+ end
175
+
176
+ # The gnuplot data specifier components, for series n
177
+ # @param _ [Integer] Series number
178
+ # @return [Hash<Symbol, Object>] :using and :with strings, for use by gnuplot
179
+ def series(_)
180
+ { using: [1, using_data],
181
+ with: "filledcurves x1 fillcolor '%<rgb>s'" }
182
+ end
183
+
184
+ # The chart types we support, intended for use in the chart_type parameter of your plot yaml.
185
+ # This class supports: 'area'
186
+ # @return [Array<String>] Supported chart types.
187
+ def self.types
188
+ %w[area]
189
+ end
190
+
191
+ private
192
+
193
+ def using_data
194
+ if reverse_series_range?
195
+ '(sum [col=2:%<num>s] (valid(col) ? column(col) : 0.0))'
196
+ else
197
+ '(valid(%<num>s) ? column(%<num>s) : 0.0)'
198
+ end
199
+ end
200
+ end
201
+
202
+ # This Chart element contains the logic used to render histograms (bars) along with lines,
203
+ # onto the plot canvas
204
+ class ColumnAndLineChart < ChartBuilder
205
+ # (see ChartBuilder#initialize)
206
+ # @option opts [TrueClass, FalseClass] :is_clustered (false) A flag indicating whether to cluster (true) or
207
+ # row-stack (false) the bar series.
208
+ # @option opts [Symbol] :columns_rendered_as There are two methods that can be used to render columns
209
+ # (:histograms & :boxes). The :boxes method supports time-format
210
+ # domains. While :histograms supports non-reversed series ranges.
211
+ # @option opts [Hash<String, Symbol>] :series_types A hash, indexed by series name, whose value is either
212
+ # :column, or :line.
213
+ def initialize(opts, gnuplot)
214
+ super opts, gnuplot
215
+ @is_clustered = opts[:is_clustered]
216
+ @columns_rendered_as = opts[:columns_rendered_as]
217
+ @columns_rendered_as ||= @domain == :monthly ? :boxes : :histograms
218
+
219
+ @series_types = {}
220
+ @series_types = opts[:series_types].transform_keys(&:to_s) if opts[:series_types]
221
+
222
+ case @columns_rendered_as
223
+ when :histograms
224
+ gnuplot.set 'style', format('histogram %s', clustered? ? 'clustered' : 'rowstacked')
225
+ gnuplot.set 'style', 'fill solid'
226
+ when :boxes
227
+ @reverse_series_range = true
228
+ # This puts a black line around the columns:
229
+ gnuplot.set 'style', 'fill solid border -1'
230
+ reverse_series_colors!
231
+
232
+ # TODO: The box width straddles the tic, which, causes the box widths to
233
+ # be half-width on the left and right sides of the plot. Roughly here,
234
+ # we want to expand that xrange start/end by maybe two weeks.
235
+ # This will require a bit more work than we want atm, because:
236
+ # 1. We'd have to change the timefmt, and the grids, to report days
237
+ # 2. We need to move the gnuplot.set in the initializer() into something
238
+ # farther down the code path.
239
+ else
240
+ raise StandardError, format('Unsupported columns_rendered_as %s', @columns_rendered_as.inspect)
241
+ end
242
+ end
243
+
244
+ # Returns the value of the :is_clusted initialization parameter.
245
+ # @return [TrueClass, FalseClass] Whether or not this chart is clustered
246
+ def clustered?
247
+ @is_clustered
248
+ end
249
+
250
+ # The gnuplot data specifier components, for series n
251
+ # @param num [Integer] Series number
252
+ # @return [Hash<Symbol, Object>] :using and :with strings, for use by gnuplot
253
+ def series(num)
254
+ type = series_type num
255
+ using = [using(type)]
256
+
257
+ using.send(*@columns_rendered_as == :histograms ? [:push, 'xtic(1)'] : [:unshift, '1'])
258
+
259
+ { using: using, with: with(type) }
260
+ end
261
+
262
+ # Given the provided column number, return either :column, or :line, depending on whether this column
263
+ # has a value, as specified in the initialization parameter :series_types
264
+ # @return [Symbol] Either :column, or :line
265
+ def series_type(num)
266
+ title = @gnuplot.series_name(num)
267
+ @series_types.key?(title) ? @series_types[title].downcase.to_sym : :column
268
+ end
269
+
270
+ # (see ChartBuilder#series_range)
271
+ def series_range(num_cols)
272
+ ret = super num_cols
273
+ return ret unless @columns_rendered_as == :boxes
274
+
275
+ # We want the lines to draw over the columns. This achieves that.
276
+ # It's possible that you want lines behind the columns. If so, add
277
+ # an option to the class and submit a pr..
278
+ ret.sort_by { |n| series_type(n) == :column ? 0 : 1 }
279
+ end
280
+
281
+ # (see ChartBuilder#format_num)
282
+ def format_num(num)
283
+ if reverse_series_range? && series_type(num) == :column
284
+ columns_for_sum = num.downto(1).map do |n|
285
+ # We need to handle empty numbers, in order to fix that weird double-wide column bug
286
+ format '(valid(%<num>s) ? column(%<num>s) : 0.0)', num: n + 1 if series_type(n) == :column
287
+ end
288
+ format '(%s)', columns_for_sum.compact.join('+')
289
+ else
290
+ super(num)
291
+ end
292
+ end
293
+
294
+ # The chart types we support, intended for use in the chart_type parameter of your plot yaml.
295
+ # This class supports: COMBO column_and_lines column lines.
296
+ # @return [Array<String>] Supported chart types.
297
+ def self.types
298
+ %w[COMBO column_and_lines column lines]
299
+ end
300
+
301
+ private
302
+
303
+ def using(type)
304
+ type == :column && clustered? ? '(valid(%<num>s) ? column(%<num>s) : 0.0)' : '%<num>s'
305
+ end
306
+
307
+ def with(type)
308
+ case type
309
+ when :column
310
+ "#{@columns_rendered_as} linetype rgb '%<rgb>s'"
311
+ when :line
312
+ "lines smooth unique lc rgb '%<rgb>s' lt 1 lw 2"
313
+ else
314
+ raise StandardError, format('Unsupported series_type %s', series_type.inspect)
315
+ end
316
+ end
317
+ end
318
+
319
+ # This class represents, and generates, a gnuplot gpi file. Either to string, or, to the filesystem.
320
+ # This class will typically work with classes derived from {RVGP::Plot::Gnuplot::ChartBuilder}, and
321
+ # an instance of this class is provided as a parameter to the #initialize method of a ChartBuilder.
322
+ # @attr_reader [Array<String>] additional_lines Arbitrary lines, presumably of gnuplot code, that are appended to
323
+ # the generated gpi, after the settings, and before the plot
324
+ # commands(s).
325
+ # @attr_reader [Hash[String, String]] settings A hash of setting to value pairs, whech are transcribed (via the
326
+ # 'set' directive) to the plot
327
+ # @attr_reader [Hash[Symbol, Object]] template A hash containing a :header string, and a :colors Hash. These
328
+ # objects are used to construct the aesthetics of the generated gpi.
329
+ # For more details on what options are supported in the :colors key,
330
+ # see the colors section of:
331
+ # {https://github.com/brighton36/rra/blob/main/resources/gnuplot/default.yml default.yml}
332
+ # @attr_reader [RVGP::Plot::Gnuplot::ChartBuilder] element An instance of {Plot::ELEMENTS}, to which plot
333
+ # directive generation is delegated.
334
+ # @attr_reader [Array<Array<String>>] dataset A grid, whose first row contains the headers, and in which each
335
+ # additional row's first element, is a keystone.
336
+ class Plot
337
+ # These are the gnuplot elements, that we currently support:
338
+ ELEMENTS = [AreaChart, ColumnAndLineChart].freeze
339
+ # These attributes were pulled from the gnuplot gem, and indicate which #set key's require string quoting.
340
+ # This implementation isn't very good, but, was copied out of the Gnuplot gem
341
+ SET_QUOTED = %w[title output xlabel x2label ylabel y2label clabel cblabel zlabel].freeze
342
+ # This is a string formatting specifier, used to composed a plot directive, to gnuplot
343
+ PLOT_COMMAND_LINE = ['%<using>s', 'title %<title>s', 'with %<with>s'].compact.join(" \\\n ").freeze
344
+
345
+ attr_accessor :additional_lines
346
+ attr_reader :settings, :template, :element, :dataset
347
+
348
+ # Create a plot
349
+ # @param [String] title The title of this plot
350
+ # @param [Array<Array<String>>] dataset A grid, whose first row contains the headers, and in which each
351
+ # additional row's first element, is a keystone.
352
+ # @param [Hash] opts options to configure this plot, and its {element}. Unrecognized options, in this
353
+ # parameter, are delegated to the specified :chart_type for further handling.
354
+ # @option opts [Symbol] :additional_lines ([]) see {additional_lines}
355
+ # @option opts [Symbol] :template see {template}
356
+ # @option opts [String] :chart_type A string, that is matched against .types of available ELEMENTS, and
357
+ # used to initialize an instance of a matched class, to create our {element}
358
+ def initialize(title, dataset, opts = {})
359
+ @title = title
360
+ @dataset = dataset
361
+ @settings = []
362
+ @additional_lines = Array(opts[:additional_lines])
363
+ @template = opts[:template]
364
+
365
+ element_klass = ELEMENTS.find { |element| element.types.any? opts[:chart_type] }
366
+ raise StandardError, format('Unsupported chart_type %s', opts[:chart_type]) unless element_klass
367
+
368
+ @element = element_klass.new opts, self
369
+ end
370
+
371
+ # Assembles a gpi file's contents, and returns them as a string
372
+ # @return [String] the generated gpi file
373
+ def script
374
+ vars = { title: @title }.merge palette.base_to_h
375
+
376
+ [format("$DATA << EOD\n%sEOD\n", to_csv),
377
+ format(template[:header], vars),
378
+ @settings.map { |setting| setting.map(&:to_s).join(' ') << "\n" },
379
+ format(@additional_lines.join("\n"), vars),
380
+ plot_command, "\n"].flatten.join
381
+ end
382
+
383
+ # Runs the gnuplot command, feeding the contents of {script} to it's stdin and returns the output.
384
+ # raises StandardError, citing errors, if gnuplot returns errors.
385
+ # @return [String] the output of gnuplot, with some selective squelching of output
386
+ def execute!(persist: true)
387
+ output, errors, status = Open3.capture3 Gnuplot.gnuplot(persist), stdin_data: script
388
+
389
+ # For reasons unknown, this is sent to stderr, in response to the
390
+ # 'set decimal locale' instruction. Which we need to set.
391
+ errors = errors.lines.reject { |line| /^decimal_sign in locale is/.match(line) }
392
+
393
+ unless status.success? || !errors.empty?
394
+ raise StandardError,
395
+ format('gnuplot exited non-zero (%<status>s): %<errors>s',
396
+ status: status.exitstatus,
397
+ errors: errors.join("\n"))
398
+ end
399
+
400
+ output
401
+ end
402
+
403
+ # Transcribes a 'set' directive, into the generated plot, with the given key as the set variable name,
404
+ # and the provided value, as that set's value. Some values (See {SET_QUOTED}) are interpolated. Others
405
+ # are merely transcribed directly as provided, without escaping.
406
+ # @param [String] key The gnuplot variable you wish to set
407
+ # @param [String] value The value to set to, if any
408
+ # @return [void]
409
+ def set(key, value = nil)
410
+ quoted_value = value && SET_QUOTED.include?(key) ? quote_value(value) : value
411
+ @settings << [:set, key, quoted_value].compact
412
+ nil
413
+ end
414
+
415
+ # Transcribes an 'unset' directive, into the generated plot, with the given key as the unset variable name.
416
+ # @param [String] key The gnuplot variable you wish to unset
417
+ # @return [void]
418
+ def unset(key)
419
+ @settings << [:unset, key]
420
+ nil
421
+ end
422
+
423
+ # Returns column n of dataset, not including the header row
424
+ # @param [Integer] num The column number to return
425
+ # @return [Array<String>] The column that was found, from top to bottom
426
+ def column(num)
427
+ dataset[1...].map { |row| row[num] }
428
+ end
429
+
430
+ # Returns the header row, at position num
431
+ # @param [Integer] num The series number to query
432
+ # @return [String] The name of the series, at row num
433
+ def series_name(num)
434
+ dataset[0][num]
435
+ end
436
+
437
+ # Returns the number of columns in the dataset, including the keystone
438
+ # @return [Integer] the length of dataset[0]
439
+ def num_cols
440
+ dataset[0].length
441
+ end
442
+
443
+ # The current {Palette} instance that we're using for our color queries
444
+ def palette
445
+ @palette ||= Palette.new @template[:colors]
446
+ end
447
+
448
+ private
449
+
450
+ def to_csv
451
+ CSV.generate { |csv| dataset.each { |row| csv << row } }
452
+ end
453
+
454
+ def plot_command
455
+ # NOTE: n == 0 is the keystone.
456
+ plot_command_lines = element.series_range(num_cols).map.with_index do |n, i|
457
+ title = series_name n
458
+
459
+ # Note that the gnuplot calls these series 'elements', but, we're keeping
460
+ # with series
461
+ series = { title: "'#{title}'" }.merge(element.series(n))
462
+ series[:using] = format(' %<prefix>s using %<usings>s',
463
+ prefix: i.zero? ? nil : ' \'\'',
464
+ usings: Array(series[:using]).map(&:to_s).join(':'))
465
+
466
+ format(format(PLOT_COMMAND_LINE, series), { rgb: palette.series_next!, num: element.format_num(n) })
467
+ end
468
+
469
+ format("plot $DATA \\\n%<lines>s", lines: plot_command_lines.join(", \\\n"))
470
+ end
471
+
472
+ def quote_value(value)
473
+ value =~ /^["'].*['"]$/ ? value : "\"#{value}\""
474
+ end
475
+ end
476
+ end
477
+ end
478
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RVGP
4
+ class Plot
5
+ module GoogleDrive
6
+ # This class is roughly, an kind of diagnostic alternative to {RVGP::Plot::GoogleDrive::ExportSheets},
7
+ # which implements the :csvdir option of the {RVGP::Commands::PublishGsheets} command.
8
+ # Mostly, this object offers the methods that {RVGP::Plot::GoogleDrive::ExportSheets} provides, and
9
+ # writes the sheets that would have otherwise been published to google - into a
10
+ # local directory, with csv files representing the Google sheet. This is mostly
11
+ # a debugging and diagnostic function.
12
+ # @attr_reader [String] destination The destination path, provided in the constructor
13
+ class ExportLocalCsvs
14
+ attr_accessor :destination
15
+
16
+ # Output google-intentioned spreadsheet sheets, to csvs in a directory
17
+ # @param [Hash] options The parameters governing this export
18
+ # @option options [String] :format What format, to output the csvs in. Currently, The only supported value is
19
+ # 'csv'.
20
+ # @option options [String] :destination The path to a folder, to export sheets into
21
+ def initialize(options)
22
+ unless [(options[:format] == 'csv'), File.directory?(options[:destination])].all?
23
+ raise StandardError, 'Invalid Options, missing :destination'
24
+ end
25
+
26
+ @destination = options[:destination]
27
+ end
28
+
29
+ # Ouput the provided sheet, into the destination path, as a csv
30
+ # @param [RVGP::Plot::GoogleDrive::Sheet] sheet The options, and data, for this sheet
31
+ # @return [void]
32
+ def sheet(sheet)
33
+ shortname = sheet.title.tr('^a-zA-Z0-9', '_').gsub(/_+/, '_').downcase.chomp('_')
34
+
35
+ CSV.open([destination.chomp('/'), '/', shortname, '.csv'].join, 'wb') do |csv|
36
+ ([sheet.columns] + sheet.rows).each do |row|
37
+ csv << row.map { |c| c.is_a?(Date) ? c.strftime('%m/%d/%Y') : c }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end