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