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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- 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
|