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