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,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+ require 'googleauth'
5
+ require 'googleauth/stores/file_token_store'
6
+ require 'google/apis/sheets_v4'
7
+
8
+ module RVGP
9
+ class Plot
10
+ # This module contains the classes which support our 'Publish to Google' features.
11
+ module GoogleDrive
12
+ # This class works as a driver, to the plot subsystem. And, exports plots too a
13
+ # google sheets document.
14
+ #
15
+ # Here's the guide I used, to generate the credentials:
16
+ # https://medium.com/building-rigup/wrangling-google-services-in-ruby-5493e906c84f
17
+ #
18
+ # NOTE: The guide doesn't exactly say it, but, make sure that your oauth client ID
19
+ # is of type 'Web Application'. That will ensure that you're able to provide
20
+ # a redirect URI, and use the playground...
21
+ #
22
+ # You'll want to write these credentials, in the following files:
23
+ #
24
+ # **config/google-secrets.yml**
25
+ # ```
26
+ # client_id: ""
27
+ # project_id: "ruby-rake-accounting"
28
+ # client_secret: ""
29
+ # token_path: "google-token.yml"
30
+ # application_name: "rvgp"
31
+ # ```
32
+ #
33
+ # **config/google-token.yml**
34
+ # ```
35
+ # ---
36
+ # default: '{"client_id":"","access_token":"","refresh_token":"","scope":["https://www.googleapis.com/auth/spreadsheets"],"expiration_time_millis":1682524731000}'
37
+ # ```
38
+ #
39
+ # The empty values in these files, should be populated with the values you secured
40
+ # following the medium link above. The only exception here, might be that refresh_token.
41
+ # Which, I think gets written by the googleauth library, automatically.
42
+ # @attr_reader [String] current_sheet_id The id of the most recently created sheet, as provided by Google
43
+ # @attr_reader [String] spreadsheet_url The url to this Sheet, which can be used to access the sheet, by the user.
44
+ class ExportSheets
45
+ # The required parameter, :secrets_file, wasn't supplied
46
+ class MissingSecretsFile < StandardError
47
+ # The error message we're outputing
48
+ MSG_FORMAT = 'Missing required parameter :secrets_file'
49
+
50
+ def initialize
51
+ super MSG_FORMAT
52
+ end
53
+ end
54
+
55
+ # The the contents of the :secrets_file, was missing one or more required parameters
56
+ class MissingSecretsParams < StandardError
57
+ # The error message we're outputing
58
+ MSG_FORMAT = 'Config file is missing one or more of the required parameters: ' \
59
+ ':client_id, :project_id, :client_secret, :token_path, :application_name'
60
+
61
+ def initialize
62
+ super MSG_FORMAT
63
+ end
64
+ end
65
+
66
+ # This is just a shorthand we're using, to simply the sheets_v4 implementation
67
+ SV4 = Google::Apis::SheetsV4
68
+
69
+ # Dates are expected to be provided wrt to their relative offset from this date
70
+ LOTUS_EPOCH = Date.new 1899, 12, 30
71
+
72
+ # The coloring schemes, used by our sheets. This constant... needs some work
73
+ COLOR_SCHEMES = [
74
+ # Pink: TODO : maybe remove this, or put into a palettes file/option
75
+ [233, 29, 99, 255, 255, 255, 253, 220, 232]
76
+ ].freeze
77
+
78
+ # Our OAuth base_url
79
+ OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'
80
+
81
+ attr_reader :current_sheet_id, :spreadsheet_url
82
+
83
+ # Create a Spreadsheet, in a Google Drive.
84
+ # @param [Hash] options The parameters governing this Spreadsheet
85
+ # @option options [String] :secrets_file The path to a yaml file, containing the secrets used to login to this
86
+ # drive.
87
+ # @option options [String] :title The title of this spreadsheet
88
+ # @option options [TrueClass,FalseClass] :log_http_requests a flag to indicate whether we want to debug the http
89
+ # session involved in the creation of this spreadsheet
90
+ def initialize(options)
91
+ raise MissingSecretsFile unless options.key? :secrets_file
92
+
93
+ config = Psych.load File.read(options[:secrets_file]), symbolize_names: true
94
+
95
+ raise MissingSecretsParams unless %w[
96
+ client_id project_id client_secret token_path application_name
97
+ ].all? { |p| config.key? p.to_sym }
98
+
99
+ @spreadsheet_title = options[:title]
100
+ @service = SV4::SheetsService.new
101
+ @service.client_options.log_http_requests = true if options[:log_http_requests]
102
+ @client_id = Google::Auth::ClientId.from_hash(
103
+ 'installed' => {
104
+ 'client_id' => config[:client_id],
105
+ 'project_id' => config[:project_id],
106
+ 'client_secret' => config[:client_secret],
107
+ 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth',
108
+ 'token_uri' => 'https://oauth2.googleapis.com/token',
109
+ 'auth_provider_x509_cert_url' => 'https://www.googleapis.com/oauth2/v1/certs',
110
+ 'redirect_uris' => ['urn:ietf:wg:oauth:2.0:oob', 'http://localhost']
111
+ }
112
+ )
113
+
114
+ @token_store = Google::Auth::Stores::FileTokenStore.new(
115
+ file: [File.dirname(options[:secrets_file]), config[:token_path]].join('/')
116
+ )
117
+ @application_name = config[:application_name]
118
+
119
+ @service.client_options.application_name = @application_name
120
+ @service.authorization = authorize
121
+
122
+ @current_sheet_id = nil
123
+ @now = Time.now
124
+ end
125
+
126
+ # Add the provided sheet, to the Spreadsheet document
127
+ # @param [RVGP::Plot::GoogleDrive::Sheet] sheet The options, and data, for this sheet
128
+ # @return [void]
129
+ def sheet(sheet)
130
+ raise StandardError, 'Too many columns...' if sheet.columns.length > 26
131
+ raise StandardError, 'No header...' if sheet.columns.empty?
132
+
133
+ # Create a sheet, or update the sheet 0 title:
134
+ if current_sheet_id.nil?
135
+ update_sheet_title! 0, sheet.title
136
+ else
137
+ add_sheet! sheet.title
138
+ end
139
+
140
+ # Now that we have a title and sheet id, we can insert the data:
141
+ update_spreadsheet_value! sheet.title, [sheet.columns] + sheet.rows
142
+
143
+ # Format the sheet:
144
+ batch_update_spreadsheet! [
145
+ # Set the Date column:
146
+ repeat_cell(
147
+ create_range(1, 0, sheet.rows.count + 1),
148
+ 'userEnteredFormat.numberFormat',
149
+ { userEnteredFormat: { numberFormat: { type: 'DATE', pattern: 'mm/dd/yy' } } }
150
+ ),
151
+ # Set the Money columns:
152
+ repeat_cell(
153
+ create_range(1, 1, sheet.rows.count + 1, sheet.columns.count),
154
+ 'userEnteredFormat.numberFormat',
155
+ { userEnteredFormat: { numberFormat: { type: 'CURRENCY', pattern: '"$"#,##0.00' } } }
156
+ ),
157
+ # Format the header row text:
158
+ repeat_cell(
159
+ create_range(0, 0, 1, sheet.columns.count),
160
+ 'userEnteredFormat(textFormat,horizontalAlignment)',
161
+ { userEnteredFormat: { textFormat: { bold: true }, horizontalAlignment: 'CENTER' } }
162
+ ),
163
+ # Color-band the rows:
164
+ band_rows(sheet.rows.count + 1, sheet.columns.count),
165
+ # Resize the series columns:
166
+ update_column_width(1, sheet.columns.count, 70)
167
+ ], skip_serialization: true
168
+
169
+ # Add a chart!
170
+ add_chart! sheet
171
+ end
172
+
173
+ private
174
+
175
+ def authorize
176
+ authorizer = Google::Auth::UserAuthorizer.new(@client_id,
177
+ Google::Apis::SheetsV4::AUTH_SPREADSHEETS,
178
+ @token_store)
179
+ user_id = 'default'
180
+ credentials = authorizer.get_credentials user_id
181
+ if credentials.nil?
182
+ # I'm a little fuzzy on some of this, since it's been a while since I've
183
+ # used this path
184
+ url = authorizer.get_authorization_url base_url: OOB_URI
185
+ puts 'Open the following URL in the browser and enter the ' \
186
+ "resulting code after authorization:\n" + url
187
+ code = gets
188
+ credentials = authorizer.get_and_store_credentials_from_code(
189
+ user_id: user_id, code: code, base_url: OOB_URI
190
+ )
191
+ end
192
+ credentials
193
+ end
194
+
195
+ def spreadsheet_title
196
+ @now.strftime @spreadsheet_title
197
+ end
198
+
199
+ # Create new spreadsheet object or return the existing one:
200
+ def spreadsheet_id
201
+ return @spreadsheet_id if @spreadsheet_id
202
+
203
+ response = @service.create_spreadsheet(
204
+ SV4::Spreadsheet.new(properties: { title: spreadsheet_title })
205
+ )
206
+
207
+ @spreadsheet_url = response.spreadsheet_url
208
+ @spreadsheet_id = response.spreadsheet_id
209
+ @current_sheet_id = response.sheets[0].properties.sheet_id
210
+
211
+ unless [@spreadsheet_id, @spreadsheet_url, @current_sheet_id].all?
212
+ raise StandardError, 'Unable to create spreadsheet'
213
+ end
214
+
215
+ @spreadsheet_id
216
+ end
217
+
218
+ def update_sheet_title!(sheet_id, title)
219
+ update_sheet_properties_request = SV4::UpdateSheetPropertiesRequest.new(
220
+ properties: { sheet_id: sheet_id, title: title }, fields: 'title'
221
+ )
222
+ request_body = SV4::BatchUpdateSpreadsheetRequest.new(
223
+ requests: [SV4::Request.new(
224
+ update_sheet_properties: update_sheet_properties_request
225
+ )]
226
+ )
227
+
228
+ response = @service.batch_update_spreadsheet spreadsheet_id, request_body
229
+
230
+ raise StandardError, 'Malformed response' unless spreadsheet_id == response.spreadsheet_id
231
+ end
232
+
233
+ def band_rows(row_count, col_count)
234
+ SV4::Request.new(
235
+ add_banding: SV4::AddBandingRequest.new(
236
+ banded_range: SV4::BandedRange.new(
237
+ range: create_range(0, 0, row_count, col_count),
238
+ row_properties: band_scheme(0)
239
+ )
240
+ )
241
+ )
242
+ end
243
+
244
+ def palettes_file_path
245
+ # This has about 151 color palettes at the time of writing...
246
+ File.expand_path format('%s/../color-palettes.yml', File.dirname(__FILE__))
247
+ end
248
+
249
+ def hex_to_rgb(hex)
250
+ colors = hex.scan(/[0-9a-f]{#{hex.length == 6 ? 2 : 1}}/i).map { |h| h.hex.to_f / 255 }
251
+ SV4::Color.new red: colors[0], green: colors[1], blue: colors[2]
252
+ end
253
+
254
+ def band_scheme(number)
255
+ hr, hg, hb, fr, fg, fb, sr, sg, sb = COLOR_SCHEMES[number]
256
+
257
+ SV4::BandingProperties.new(
258
+ header_color: SV4::Color.new(red: hr.to_f / 255, green: hg.to_f / 255, blue: hb.to_f / 255),
259
+ first_band_color: SV4::Color.new(red: fr.to_f / 255, green: fg.to_f / 255, blue: fb.to_f / 255),
260
+ second_band_color: SV4::Color.new(red: sr.to_f / 255, green: sg.to_f / 255, blue: sb.to_f / 255)
261
+ )
262
+ end
263
+
264
+ def add_sheet!(title)
265
+ request_body = SV4::BatchUpdateSpreadsheetRequest.new(
266
+ requests: [
267
+ SV4::Request.new(add_sheet: SV4::AddSheetRequest.new(properties: { title: title }))
268
+ ]
269
+ )
270
+
271
+ response = @service.batch_update_spreadsheet spreadsheet_id, request_body
272
+
273
+ raise StandardError, 'Invalid sheet index' unless [
274
+ response.replies[0].add_sheet.properties.title,
275
+ response.replies[0].add_sheet.properties.sheet_id,
276
+ response.replies[0].add_sheet.properties.title == title
277
+ ].all?
278
+
279
+ @current_sheet_id = response.replies[0].add_sheet.properties.sheet_id
280
+ end
281
+
282
+ def update_spreadsheet_value!(sheet_title, values)
283
+ range_name = [format('%<title>s!A1:%<col>s%<row>d',
284
+ title: sheet_title,
285
+ col: (values[0].length + 64).chr,
286
+ row: values.count)]
287
+
288
+ # We do this because dates are a pita, and are easier to send as floats
289
+ values_transformed = values.map do |row|
290
+ row.map { |c| c.is_a?(Date) ? (c - LOTUS_EPOCH).to_f : c }
291
+ end
292
+
293
+ response = @service.update_spreadsheet_value(
294
+ spreadsheet_id,
295
+ range_name,
296
+ SV4::ValueRange.new(values: values_transformed),
297
+ value_input_option: 'RAW'
298
+ )
299
+
300
+ unless [(response.updated_cells == values_transformed.flatten.compact.count),
301
+ (response.updated_rows == values_transformed.count),
302
+ (response.updated_columns == values_transformed.max_by(&:length).length)].all?
303
+ raise StandardError, 'Not all cells were updated'
304
+ end
305
+ end
306
+
307
+ def create_range(start_row = 0, start_col = 0, end_row = nil, end_col = nil)
308
+ SV4::GridRange.new(
309
+ sheet_id: current_sheet_id,
310
+ start_row_index: start_row,
311
+ start_column_index: start_col,
312
+ end_row_index: end_row || (start_row + 1),
313
+ end_column_index: end_col || (start_col + 1)
314
+ )
315
+ end
316
+
317
+ def update_column_width(col_start, col_end, width)
318
+ SV4::Request.new(
319
+ update_dimension_properties: SV4::UpdateDimensionPropertiesRequest.new(
320
+ range: {
321
+ sheet_id: current_sheet_id,
322
+ dimension: 'COLUMNS',
323
+ start_index: col_start,
324
+ end_index: col_end
325
+ },
326
+ fields: 'pixelSize',
327
+ properties: SV4::DimensionProperties.new(pixel_size: width)
328
+ )
329
+ )
330
+ end
331
+
332
+ def repeat_cell(range, fields, cell)
333
+ { repeat_cell: { range: range, fields: fields, cell: cell } }
334
+ end
335
+
336
+ def batch_update_spreadsheet!(requests, skip_serialization: false)
337
+ # Seems like there a bug in the BatchUpdateSpreadsheetRequest::Reflection
338
+ # that is preventing the api from serializing the 'cell' key. I think that
339
+ # the word is a reserved word in the reflection api...
340
+ # Nonetheless, the manual approach works with skip_serialization enabled.
341
+ #
342
+ request_body = { requests: requests }
343
+
344
+ @service.batch_update_spreadsheet(
345
+ spreadsheet_id,
346
+ skip_serialization ? request_body.to_h.to_json : request_body,
347
+ options: { skip_serialization: skip_serialization }
348
+ )
349
+ end
350
+
351
+ def add_chart!(sheet)
352
+ stacked_type = nil
353
+
354
+ if sheet.options.key?(:google)
355
+ gparams = sheet.options[:google]
356
+
357
+ default_series_type = gparams[:default_series_type]
358
+ chart_type = gparams[:chart_type] ? gparams[:chart_type].to_s.upcase : nil
359
+ stacked_type = gparams[:stacked_type] if gparams[:stacked_type]
360
+
361
+ series_colors = gparams[:series_colors] ? gparams[:series_colors].transform_keys(&:to_s) : nil
362
+ series_types = gparams[:series_types] ? gparams[:series_types].transform_keys(&:to_s) : nil
363
+ series_line_styles = gparams[:series_line_styles].transform_keys(&:to_s) if gparams[:series_line_styles]
364
+ series_target_axis = gparams[:series_target_axis]
365
+ end
366
+
367
+ series = (1...sheet.columns.count).map do |i|
368
+ series_params = {
369
+ targetAxis: format('%s_AXIS', (series_target_axis || :left).to_s.upcase),
370
+ series: { sourceRange: { sources: [create_range(0, i, sheet.rows.count + 1, i + 1)] } }
371
+ }
372
+
373
+ if series_colors && sheet.columns[i]
374
+ color = series_colors[sheet.columns[i].to_sym]
375
+ series_params[:color] = hex_to_rgb(color) if color
376
+ end
377
+
378
+ if series_line_styles
379
+ style = series_line_styles[sheet.columns[i].to_sym]
380
+ series_params[:lineStyle] = SV4::LineStyle.new type: 'MEDIUM_DASHED' if style == 'dashed'
381
+ end
382
+
383
+ series_params[:type] = series_types[sheet.columns[i]] || default_series_type if series_types
384
+
385
+ series_params
386
+ end
387
+
388
+ request_body = [
389
+ { addChart: {
390
+ chart: {
391
+ position: { newSheet: true },
392
+ spec: {
393
+ title: sheet.title,
394
+ basicChart: {
395
+ headerCount: 1,
396
+ chartType: chart_type || 'LINE',
397
+ legendPosition: 'RIGHT_LEGEND',
398
+ stackedType: stacked_type,
399
+ domains: [{
400
+ domain: { sourceRange: { sources: [create_range(0, 0, sheet.rows.count + 1)] } }
401
+ }],
402
+ series: series
403
+ }
404
+ }
405
+ }
406
+ } }
407
+ ]
408
+
409
+ response = batch_update_spreadsheet! request_body, skip_serialization: true
410
+
411
+ chart_sheet_id = response.replies[0].add_chart.chart.position.sheet_id
412
+
413
+ raise StandardError, 'Error Creating Chart' unless chart_sheet_id
414
+
415
+ # Rename the new chart tab name to match the title:
416
+ request_body = SV4::BatchUpdateSpreadsheetRequest.new(
417
+ requests: [SV4::Request.new(
418
+ update_sheet_properties: SV4::UpdateSheetPropertiesRequest.new(
419
+ properties: SV4::SheetProperties.new(
420
+ sheet_id: chart_sheet_id,
421
+ title: format('%s (Chart)', sheet.title)
422
+ ),
423
+ fields: 'title'
424
+ )
425
+ )]
426
+ )
427
+ response = @service.batch_update_spreadsheet spreadsheet_id, request_body
428
+
429
+ raise StandardError, 'Malformed response' unless spreadsheet_id == response.spreadsheet_id
430
+ end
431
+ end
432
+ end
433
+ end
434
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RVGP
4
+ class Plot
5
+ module GoogleDrive
6
+ # This class represents a csv-like matrix, that is to be sent to google, as a sheet
7
+ # in an exported workbook. There's not much logic here.
8
+ # @attr_reader [string] title The title of this sheet
9
+ # @attr_reader [Hash] options The options configured on this sheet
10
+ class Sheet
11
+ attr_accessor :title, :options
12
+
13
+ # This is the maximum number of columns that we support, in our sheets. This number is
14
+ # mostly here because Google stipulates this restriction
15
+ MAX_COLUMNS = 26
16
+
17
+ # A sheet, and its options.
18
+ # @param [String] title The title of this sheet
19
+ # @param [Array<Array<Object>>] grid The data contents of this sheet
20
+ # @param [Hash] options The parameters governing this Sheet
21
+ # @option options [String] :default_series_type This parameter is sent to google's addChart method. Expected
22
+ # to be either "COLUMN" or "LINE".
23
+ # @option options [String] :chart_type ('LINE') This parameter determines the kind plot that is built. Expected
24
+ # to be one of: "area", or "column_and_lines"
25
+ # @option options [String] :stacked_type This parameter is sent to google's addChart method. Expected
26
+ # to be either "STACKED" or nil.
27
+ # @option options [Hash<String,String>] :series_colors A Hash, indexed under the name of a series, whose value
28
+ # is set to the intended html rgb color of that series.
29
+ # @option options [Hash<String,String>] :series_types A Hash, indexed under the name of a series, whose value is
30
+ # set to either "COLUMN" or "LINE". This setting allows you
31
+ # to override the :default_series_type, for a specific
32
+ # series.
33
+ # @option options [Hash<String,String>] :series_line_styles A Hash, indexed under the name of a series, whose
34
+ # value is set to either "dashed" or nil. This
35
+ # setting allows you to dash a series on the
36
+ # resulting plot.
37
+ # @option options [Symbol] :series_target_axis (:left) Either :bottom, :left, or :right. This parameter is sent
38
+ # to google's addChart method, to determine the 'targetAxis' of the
39
+ # plot.
40
+ def initialize(title, grid, options = {})
41
+ @title = title
42
+ @options = options
43
+ @grid = grid
44
+
45
+ # This is a Google constraint:
46
+ if columns.length > MAX_COLUMNS
47
+ raise StandardError, format('Too many columns. Max is %<max>d, provided %<provided>d.',
48
+ max: MAX_COLUMNS,
49
+ provided: columns.length)
50
+ end
51
+ end
52
+
53
+ # The column titles for this spreadsheet, as calculated from the provided data.
54
+ # @return [Array<Object>]
55
+ def columns
56
+ @grid[0]
57
+ end
58
+
59
+ # The rows values for this spreadsheet, as calculated from the provided data.
60
+ # @return [Array<Object>]
61
+ def rows
62
+ @grid[1...]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end