lexdrill 0.14.0 → 0.16.0
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 +4 -4
- data/README.md +81 -30
- data/lib/lexdrill/cli.rb +100 -12
- data/lib/lexdrill/oauth_remote.rb +29 -0
- data/lib/lexdrill/remote.rb +6 -2
- data/lib/lexdrill/service_account_auth.rb +85 -0
- data/lib/lexdrill/sheets_client.rb +12 -3
- data/lib/lexdrill/version.rb +1 -1
- data/lib/lexdrill.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36e9bb140cbbb757b2c9703b8cd122a9fd62a3faa108c4cc0072755391acb65f
|
|
4
|
+
data.tar.gz: b3502832141a5b7e751f69c5b6d8e729c640f5896300053b96f99fe068968b73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f89f5aa67e5ad3170d1cd956a4c0d53ffca66993f5b94ad55a3f9ef6e32bbaa8befb8d522e03506c761826a4273b1f86f72c4e92c2230c6d2c4b2906149bb70b
|
|
7
|
+
data.tar.gz: ac7a2afdfbdff3fe694b4ec90f30eaa7c418707a9370697033cadabf0d2b389f68f5356404ab62afbff30f685d16316399777721861ffe31c2e95970047d974f
|
data/README.md
CHANGED
|
@@ -182,54 +182,103 @@ anything.
|
|
|
182
182
|
|
|
183
183
|
### Google Sheets export
|
|
184
184
|
|
|
185
|
-
`drill export
|
|
186
|
-
Google Sheet you own
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
185
|
+
`drill export <sheet-name>` mirrors your word list (text only, no stats)
|
|
186
|
+
into a tab of a Google Sheet you own; `drill import <sheet-name>` reads it
|
|
187
|
+
back. There are two ways to authenticate — pick whichever you prefer:
|
|
188
|
+
|
|
189
|
+
- **`drill remote <url>`** — a **service account** you create yourself. No
|
|
190
|
+
interactive sign-in, ever; you share the spreadsheet with the service
|
|
191
|
+
account's email address once, and `drill` uses a local private key file
|
|
192
|
+
to sign its own requests.
|
|
193
|
+
- **`drill oauth <url>`** — your **personal Google login**, via a one-time
|
|
194
|
+
interactive OAuth approval. No GCP service account needed, but you (or
|
|
195
|
+
anyone using the gem) has to click through a sign-in flow once.
|
|
196
|
+
|
|
197
|
+
If both are configured, whichever you set **more recently** wins — running
|
|
198
|
+
`drill oauth <url>` after `drill remote <url>` switches `export`/`import`
|
|
199
|
+
over to the personal-login flow, and vice versa.
|
|
200
|
+
|
|
201
|
+
Either way, `export` always **overwrites** the named tab (creating it
|
|
202
|
+
first if it doesn't exist yet) with the current word list, one phrase per
|
|
203
|
+
row, so it stays an exact mirror even if the list shrinks. `import` is the
|
|
204
|
+
reverse: it reads column A of the tab (ignoring any other columns, like an
|
|
205
|
+
old export's show counts) and **replaces** your local `.drill.txt` —
|
|
206
|
+
useful for editing the list in Sheets and pulling changes back down, or
|
|
207
|
+
seeding a fresh machine from an existing sheet.
|
|
208
|
+
|
|
209
|
+
#### Option A: service account (`drill remote`)
|
|
210
|
+
|
|
211
|
+
No embedded secret, no interactive consent screen — but it does require a
|
|
212
|
+
one-time Google Cloud Console setup, and the resulting private key file is
|
|
213
|
+
a real secret you must keep local.
|
|
214
|
+
|
|
215
|
+
1. Go to <https://console.cloud.google.com>, select a project (reuse the
|
|
216
|
+
one from Option B if you've already set that up), and make sure
|
|
217
|
+
**Google Sheets API** is enabled (**APIs & Services → Library**).
|
|
218
|
+
2. **IAM & Admin → Service Accounts → "+ CREATE SERVICE ACCOUNT"**. Name it
|
|
219
|
+
anything (e.g. `lexdrill-export`); skip granting it any project-level
|
|
220
|
+
roles — it doesn't need any, since access comes from sharing the
|
|
221
|
+
document directly.
|
|
222
|
+
3. Click into the new service account → **Keys → Add Key → Create new key
|
|
223
|
+
→ JSON → Create**. This downloads a `.json` file — **this is a real
|
|
224
|
+
secret**, equivalent to a password. Never commit it, publish it, or
|
|
225
|
+
share it with anyone.
|
|
226
|
+
4. Note the service account's email (looks like
|
|
227
|
+
`name@your-project-id.iam.gserviceaccount.com` — also the `client_email`
|
|
228
|
+
field in the downloaded JSON).
|
|
229
|
+
5. Open your target Google Sheet → **Share** → paste that email → grant
|
|
230
|
+
**Editor** access → uncheck "Notify people" → Share.
|
|
231
|
+
6. Save the downloaded key file to `~/.drill.gcp-service-account.json` on
|
|
232
|
+
your machine (`drill` reads it from that fixed path and sets it to mode
|
|
233
|
+
`0600`; it's never embedded in the gem or committed anywhere).
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
drill remote 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
|
|
237
|
+
drill export Sheet1
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
No sign-in prompt — every call authenticates silently by signing a fresh,
|
|
241
|
+
short-lived JWT with the local key file.
|
|
242
|
+
|
|
243
|
+
#### Option B: personal login (`drill oauth`)
|
|
244
|
+
|
|
245
|
+
Uses the OAuth 2.0 **Device Authorization Grant** ("visit this URL, enter
|
|
246
|
+
this code," the same style of flow `gcloud auth login` uses) — you approve
|
|
247
|
+
access to your own account once, and the resulting token is cached locally
|
|
248
|
+
at `~/.drill.gcp-token.json` (mode `0600`), never published or shared.
|
|
194
249
|
|
|
195
250
|
The OAuth client id/secret embedded in `lib/lexdrill/google_auth.rb` are
|
|
196
251
|
**not** secret for this use — Google's own docs say client credentials for
|
|
197
252
|
"TVs and Limited Input devices" / installed-app clients aren't treated as
|
|
198
|
-
confidential
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
#### One-time Google Cloud setup (you do this yourself)
|
|
253
|
+
confidential (this is fundamentally different from the service account's
|
|
254
|
+
private key above, which genuinely is a secret). The actual secret is your
|
|
255
|
+
personal refresh token, generated only after you interactively approve.
|
|
202
256
|
|
|
203
|
-
1. Go to <https://console.cloud.google.com>, create or select a project
|
|
204
|
-
|
|
205
|
-
|
|
257
|
+
1. Go to <https://console.cloud.google.com>, create or select a project,
|
|
258
|
+
enable **Google Sheets API**.
|
|
259
|
+
2. **APIs & Services → OAuth consent screen**: User type **External**;
|
|
206
260
|
publishing status **Testing** is fine — add your own Google account
|
|
207
261
|
under "Test users".
|
|
208
|
-
|
|
262
|
+
3. **APIs & Services → Credentials → Create Credentials → OAuth client
|
|
209
263
|
ID** → Application type **"TVs and Limited Input devices"** (no redirect
|
|
210
264
|
URI needed). Copy the generated Client ID and Client secret into
|
|
211
265
|
`CLIENT_ID`/`CLIENT_SECRET` in `lib/lexdrill/google_auth.rb`.
|
|
212
|
-
|
|
266
|
+
4. "Testing" publishing status has historically imposed a 7-day
|
|
213
267
|
refresh-token expiry for some scopes. If `drill export` starts asking
|
|
214
268
|
you to re-authorize every week, switch the consent screen to **"In
|
|
215
269
|
production"** — for the `spreadsheets` scope (not a "restricted" scope)
|
|
216
270
|
this just adds an "unverified app" click-through on first consent, no
|
|
217
271
|
Google review required.
|
|
218
272
|
|
|
219
|
-
#### Using it
|
|
220
|
-
|
|
221
273
|
```bash
|
|
222
|
-
drill
|
|
274
|
+
drill oauth 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
|
|
223
275
|
drill export Sheet1
|
|
224
276
|
```
|
|
225
277
|
|
|
226
|
-
The first `export` prints a URL and a short code — visit it, sign
|
|
227
|
-
the Google account that has edit access to that spreadsheet, and
|
|
228
|
-
Every
|
|
229
|
-
automatically).
|
|
230
|
-
spreadsheet, `export` creates it; either way it always **overwrites** the
|
|
231
|
-
tab's contents with the current word list text (one word/phrase per row, no
|
|
232
|
-
stats), so it stays an exact mirror even if the list shrinks.
|
|
278
|
+
The first `export`/`import` prints a URL and a short code — visit it, sign
|
|
279
|
+
in with the Google account that has edit access to that spreadsheet, and
|
|
280
|
+
approve. Every call after that is silent (the cached token refreshes
|
|
281
|
+
itself automatically).
|
|
233
282
|
|
|
234
283
|
### Commands
|
|
235
284
|
|
|
@@ -248,5 +297,7 @@ stats), so it stays an exact mirror even if the list shrinks.
|
|
|
248
297
|
| `drill stats` | Print all items as `<count>\t<phrase>` (tab-separated), sorted by show count, highest first |
|
|
249
298
|
| `drill rand <n>` | `drill next` shows a word ~1-in-`n` times (`n=1` is every time, the default) |
|
|
250
299
|
| `drill go <number>` | Jump so the next `drill next` shows item `<number>` (1-based, see `drill list`) — prints nothing itself; refuses a graduated item; has no effect while `drill beat rand` is active, since that mode ignores the counter entirely |
|
|
251
|
-
| `drill remote <url>` | Set the Google Sheet
|
|
252
|
-
| `drill
|
|
300
|
+
| `drill remote <url>` | Set the Google Sheet used by the service account flow (global, parses the spreadsheet id out of a normal share URL); whichever of `drill remote`/`drill oauth` was set more recently wins |
|
|
301
|
+
| `drill oauth <url>` | Set the Google Sheet used by the OAuth (personal-login) flow (global, parses the spreadsheet id out of a normal share URL); whichever of `drill remote`/`drill oauth` was set more recently wins |
|
|
302
|
+
| `drill export <sheet-name>` | Export the word list text to the named tab (created if it doesn't exist), overwriting it; uses whichever of `drill remote`/`drill oauth` was configured most recently (first OAuth use triggers a one-time Google device-flow sign-in) |
|
|
303
|
+
| `drill import <sheet-name>` | Replace the local word list with column A of the named tab |
|
data/lib/lexdrill/cli.rb
CHANGED
|
@@ -19,7 +19,9 @@ class Lexdrill::CLI
|
|
|
19
19
|
run_rand: %w[rand],
|
|
20
20
|
run_go: %w[go],
|
|
21
21
|
run_remote: %w[remote],
|
|
22
|
-
|
|
22
|
+
run_oauth: %w[oauth],
|
|
23
|
+
run_export: %w[export],
|
|
24
|
+
run_import: %w[import]
|
|
23
25
|
}.freeze
|
|
24
26
|
|
|
25
27
|
def self.start(argv = ARGV)
|
|
@@ -71,9 +73,13 @@ class Lexdrill::CLI
|
|
|
71
73
|
drill stats Print items as <count>\t<phrase>, tab-separated, highest count first
|
|
72
74
|
drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time)
|
|
73
75
|
drill go <number> Jump so the next `next` shows item <number> (1-based, see drill list)
|
|
74
|
-
drill remote <url> Set the Google Sheet
|
|
76
|
+
drill remote <url> Set the Google Sheet used by a local service account key
|
|
77
|
+
(~/.drill.gcp-service-account.json) — no interactive sign-in
|
|
78
|
+
drill oauth <url> Set the Google Sheet used by the OAuth (personal-login) flow
|
|
75
79
|
drill export <sheet-name> Export the word list text to the given tab (overwrites its
|
|
76
|
-
contents);
|
|
80
|
+
contents); uses whichever of drill remote/drill oauth was set
|
|
81
|
+
more recently (oauth opens a one-time sign-in on first use)
|
|
82
|
+
drill import <sheet-name> Replace the local word list with column A of the given tab
|
|
77
83
|
HELP
|
|
78
84
|
0
|
|
79
85
|
end
|
|
@@ -311,13 +317,58 @@ class Lexdrill::CLI
|
|
|
311
317
|
1
|
|
312
318
|
end
|
|
313
319
|
|
|
320
|
+
def run_oauth
|
|
321
|
+
url = argv[1]
|
|
322
|
+
return print_oauth_usage unless url
|
|
323
|
+
|
|
324
|
+
Lexdrill::OauthRemote.set(url)
|
|
325
|
+
puts "oauth spreadsheet set: #{Lexdrill::OauthRemote.spreadsheet_id}"
|
|
326
|
+
0
|
|
327
|
+
rescue ArgumentError
|
|
328
|
+
print_invalid_oauth_url(url)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def print_oauth_usage
|
|
332
|
+
warn "usage: drill oauth <google-sheets-url>"
|
|
333
|
+
1
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def print_invalid_oauth_url(url)
|
|
337
|
+
warn "drill: could not find a spreadsheet id in #{url.inspect}"
|
|
338
|
+
1
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Uses whichever of `drill remote` (service account) / `drill oauth`
|
|
342
|
+
# (personal login) was set more recently, so switching between them with
|
|
343
|
+
# either command always takes effect. Returns [spreadsheet_id,
|
|
344
|
+
# access_token], or nil if neither is configured.
|
|
345
|
+
def sheets_target
|
|
346
|
+
case latest_remote_kind
|
|
347
|
+
when :remote then [Lexdrill::Remote.spreadsheet_id, Lexdrill::ServiceAccountAuth.fetch_token!]
|
|
348
|
+
when :oauth then [Lexdrill::OauthRemote.spreadsheet_id, Lexdrill::GoogleAuth.ensure_token!]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def latest_remote_kind
|
|
353
|
+
remote_set = Lexdrill::Remote.configured?
|
|
354
|
+
oauth_set = Lexdrill::OauthRemote.configured?
|
|
355
|
+
return :remote if remote_set && (!oauth_set || newer?(Lexdrill::Remote::PATH, Lexdrill::OauthRemote::PATH))
|
|
356
|
+
return :oauth if oauth_set
|
|
357
|
+
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def newer?(path, other_path) = File.mtime(path) >= File.mtime(other_path)
|
|
362
|
+
|
|
314
363
|
def run_export
|
|
315
364
|
sheet_name = argv[1]
|
|
316
365
|
return print_export_usage unless sheet_name
|
|
317
|
-
return print_no_remote unless Lexdrill::Remote.configured?
|
|
318
366
|
|
|
319
|
-
|
|
320
|
-
|
|
367
|
+
target = sheets_target
|
|
368
|
+
return print_no_remote unless target
|
|
369
|
+
|
|
370
|
+
perform_export(sheet_name, *target)
|
|
371
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
321
372
|
warn "drill: #{error.message}"
|
|
322
373
|
1
|
|
323
374
|
rescue Lexdrill::SheetsClient::ApiError => error
|
|
@@ -327,10 +378,9 @@ class Lexdrill::CLI
|
|
|
327
378
|
1
|
|
328
379
|
end
|
|
329
380
|
|
|
330
|
-
def perform_export(sheet_name)
|
|
331
|
-
token = Lexdrill::GoogleAuth.ensure_token!
|
|
381
|
+
def perform_export(sheet_name, spreadsheet_id, token)
|
|
332
382
|
rows = export_rows
|
|
333
|
-
Lexdrill::SheetsClient.overwrite_sheet(
|
|
383
|
+
Lexdrill::SheetsClient.overwrite_sheet(spreadsheet_id, sheet_name, rows, token)
|
|
334
384
|
puts "exported #{rows.size} word(s) to #{sheet_name.inspect}"
|
|
335
385
|
0
|
|
336
386
|
end
|
|
@@ -345,7 +395,45 @@ class Lexdrill::CLI
|
|
|
345
395
|
end
|
|
346
396
|
|
|
347
397
|
def print_no_remote
|
|
348
|
-
warn "drill: no remote spreadsheet configured; run `drill remote <url>`
|
|
398
|
+
warn "drill: no remote spreadsheet configured; run `drill remote <url>` (service account) " \
|
|
399
|
+
"or `drill oauth <url>` (personal login) first"
|
|
400
|
+
1
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def run_import
|
|
404
|
+
sheet_name = argv[1]
|
|
405
|
+
return print_import_usage unless sheet_name
|
|
406
|
+
|
|
407
|
+
target = sheets_target
|
|
408
|
+
return print_no_remote unless target
|
|
409
|
+
|
|
410
|
+
perform_import(sheet_name, *target)
|
|
411
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
412
|
+
warn "drill: #{error.message}"
|
|
413
|
+
1
|
|
414
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
415
|
+
print_sheets_api_error(error, sheet_name)
|
|
416
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
417
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
418
|
+
1
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def perform_import(sheet_name, spreadsheet_id, token)
|
|
422
|
+
words = Lexdrill::SheetsClient.read_column(spreadsheet_id, sheet_name, token)
|
|
423
|
+
return print_empty_import(sheet_name) if words.empty?
|
|
424
|
+
|
|
425
|
+
File.write(Lexdrill::WordList::PATH, "#{words.join("\n")}\n", encoding: "UTF-8")
|
|
426
|
+
puts "imported #{words.size} word(s) from #{sheet_name.inspect}"
|
|
427
|
+
0
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def print_import_usage
|
|
431
|
+
warn "usage: drill import <sheet-name>"
|
|
432
|
+
1
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def print_empty_import(sheet_name)
|
|
436
|
+
warn "drill: #{sheet_name.inspect} has no data to import"
|
|
349
437
|
1
|
|
350
438
|
end
|
|
351
439
|
|
|
@@ -353,8 +441,8 @@ class Lexdrill::CLI
|
|
|
353
441
|
status = error.status
|
|
354
442
|
message = error.message
|
|
355
443
|
case status
|
|
356
|
-
when 404 then warn "drill: spreadsheet not found or not accessible
|
|
357
|
-
when 403 then warn "drill: access denied —
|
|
444
|
+
when 404 then warn "drill: spreadsheet not found or not accessible (check `drill remote`/`drill oauth`)"
|
|
445
|
+
when 403 then warn "drill: access denied — make sure the spreadsheet is shared with the right account"
|
|
358
446
|
when 400 then warn "drill: #{message} (is #{sheet_name.inspect} a real tab in the spreadsheet?)"
|
|
359
447
|
else warn "drill: Google Sheets API error (#{status}): #{message}"
|
|
360
448
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Global "which Google Sheet to export to via the OAuth device-flow login"
|
|
4
|
+
# setting. Lives at ~/.drill.oauth-remote as a plain spreadsheet id, parsed
|
|
5
|
+
# out of a normal Google Sheets share URL. Set via `drill oauth <url>`.
|
|
6
|
+
# See Lexdrill::Remote for the separate, service-account-based flow.
|
|
7
|
+
module Lexdrill::OauthRemote
|
|
8
|
+
PATH = File.join(Dir.home, ".drill.oauth-remote")
|
|
9
|
+
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
10
|
+
|
|
11
|
+
def self.set(url)
|
|
12
|
+
id = extract_id(url)
|
|
13
|
+
raise ArgumentError, "no spreadsheet id found in #{url.inspect}" unless id
|
|
14
|
+
|
|
15
|
+
File.write(PATH, id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.extract_id(url)
|
|
19
|
+
url[URL_PATTERN, 1]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.configured?
|
|
23
|
+
File.exist?(PATH)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.spreadsheet_id
|
|
27
|
+
File.read(PATH).strip if configured?
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/lexdrill/remote.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Global "which Google Sheet to export to
|
|
4
|
-
# as a plain spreadsheet id, parsed out of a normal
|
|
3
|
+
# Global "which Google Sheet to export to via a service account" setting.
|
|
4
|
+
# Lives at ~/.drill.remote as a plain spreadsheet id, parsed out of a normal
|
|
5
|
+
# Google Sheets share URL. Set via `drill remote <url>` — the user shares
|
|
6
|
+
# that spreadsheet with their service account's email directly in Google
|
|
7
|
+
# Sheets, so no interactive Google sign-in is needed at export/import time.
|
|
8
|
+
# See Lexdrill::OauthRemote for the separate, personal-login-based flow.
|
|
5
9
|
module Lexdrill::Remote
|
|
6
10
|
PATH = File.join(Dir.home, ".drill.remote")
|
|
7
11
|
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "base64"
|
|
6
|
+
|
|
7
|
+
# Authenticates to the Sheets API as a Google service account, using the
|
|
8
|
+
# JWT Bearer Token flow (RFC 7523) — no interactive consent, no refresh
|
|
9
|
+
# token to cache: a fresh short-lived JWT is signed and exchanged for an
|
|
10
|
+
# access token on every call. The service account's private key lives in a
|
|
11
|
+
# JSON file the user downloads themselves from Google Cloud Console and
|
|
12
|
+
# saves locally at PATH below — it is never embedded in gem source, never
|
|
13
|
+
# published, and lexdrill never writes or transmits it anywhere except in
|
|
14
|
+
# the signed JWT sent directly to Google's own token endpoint.
|
|
15
|
+
module Lexdrill::ServiceAccountAuth
|
|
16
|
+
PATH = File.join(Dir.home, ".drill.gcp-service-account.json")
|
|
17
|
+
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
18
|
+
SCOPE = "https://www.googleapis.com/auth/spreadsheets"
|
|
19
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
20
|
+
TOKEN_LIFETIME = 3600
|
|
21
|
+
|
|
22
|
+
class AuthError < StandardError; end
|
|
23
|
+
|
|
24
|
+
def self.configured?
|
|
25
|
+
File.exist?(PATH)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.fetch_token!
|
|
29
|
+
key = load_key
|
|
30
|
+
jwt = build_jwt(key)
|
|
31
|
+
exchange_jwt(jwt)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.load_key
|
|
35
|
+
JSON.parse(File.read(PATH))
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise AuthError, "no service account key found at #{PATH} — see README's service account setup"
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
raise AuthError, "#{PATH} is not valid JSON"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :load_key
|
|
42
|
+
|
|
43
|
+
def self.build_jwt(key)
|
|
44
|
+
claims = jwt_claims(key.fetch("client_email"))
|
|
45
|
+
signing_input = "#{encode_segment(jwt_header)}.#{encode_segment(claims)}"
|
|
46
|
+
signature = sign(signing_input, key.fetch("private_key"))
|
|
47
|
+
"#{signing_input}.#{Base64.urlsafe_encode64(signature, padding: false)}"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :build_jwt
|
|
50
|
+
|
|
51
|
+
def self.jwt_header
|
|
52
|
+
{ "alg" => "RS256", "typ" => "JWT" }
|
|
53
|
+
end
|
|
54
|
+
private_class_method :jwt_header
|
|
55
|
+
|
|
56
|
+
def self.jwt_claims(client_email)
|
|
57
|
+
now = Time.now.to_i
|
|
58
|
+
{ "iss" => client_email, "scope" => SCOPE, "aud" => TOKEN_URL, "iat" => now, "exp" => now + TOKEN_LIFETIME }
|
|
59
|
+
end
|
|
60
|
+
private_class_method :jwt_claims
|
|
61
|
+
|
|
62
|
+
def self.encode_segment(hash)
|
|
63
|
+
Base64.urlsafe_encode64(JSON.generate(hash), padding: false)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :encode_segment
|
|
66
|
+
|
|
67
|
+
def self.sign(signing_input, private_key_pem)
|
|
68
|
+
OpenSSL::PKey::RSA.new(private_key_pem).sign(OpenSSL::Digest.new("SHA256"), signing_input)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :sign
|
|
71
|
+
|
|
72
|
+
def self.exchange_jwt(jwt)
|
|
73
|
+
response = Lexdrill::HTTPClient.post_form(TOKEN_URL, { "grant_type" => GRANT_TYPE, "assertion" => jwt })
|
|
74
|
+
handle_token_response(response)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :exchange_jwt
|
|
77
|
+
|
|
78
|
+
def self.handle_token_response(response)
|
|
79
|
+
body = JSON.parse(response.body)
|
|
80
|
+
return body.fetch("access_token") if response.code == 200
|
|
81
|
+
|
|
82
|
+
raise AuthError, "Google rejected the service account credentials: #{body['error_description'] || body['error']}"
|
|
83
|
+
end
|
|
84
|
+
private_class_method :handle_token_response
|
|
85
|
+
end
|
|
@@ -4,9 +4,10 @@ require "uri"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
# Thin wrapper around the Google Sheets API v4 REST endpoints needed by
|
|
7
|
-
# `drill export`: create the target tab if it doesn't already
|
|
8
|
-
# its contents, write new rows into it, then auto-fit column
|
|
9
|
-
# the content so long phrases aren't visually truncated
|
|
7
|
+
# `drill export`/`drill import`: create the target tab if it doesn't already
|
|
8
|
+
# exist, clear its contents, write new rows into it, then auto-fit column
|
|
9
|
+
# A's width to the content so long phrases aren't visually truncated; or,
|
|
10
|
+
# for import, read column A back out of an existing tab.
|
|
10
11
|
module Lexdrill::SheetsClient
|
|
11
12
|
BASE_URL = "https://sheets.googleapis.com/v4/spreadsheets"
|
|
12
13
|
|
|
@@ -68,6 +69,14 @@ module Lexdrill::SheetsClient
|
|
|
68
69
|
handle_response(Lexdrill::HTTPClient.json_put(url, body: body, headers: auth_header(access_token)))
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# Reads column A back out of an existing tab (ignoring any other
|
|
73
|
+
# columns, e.g. show counts from an older export), skipping blank cells.
|
|
74
|
+
def self.read_column(spreadsheet_id, sheet_name, access_token)
|
|
75
|
+
url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}"
|
|
76
|
+
data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
|
|
77
|
+
data.fetch("values", []).filter_map { |row| row[0]&.strip }.reject(&:empty?)
|
|
78
|
+
end
|
|
79
|
+
|
|
71
80
|
def self.auth_header(token)
|
|
72
81
|
{ "Authorization" => "Bearer #{token}" }
|
|
73
82
|
end
|
data/lib/lexdrill/version.rb
CHANGED
data/lib/lexdrill.rb
CHANGED
|
@@ -10,8 +10,10 @@ require_relative "lexdrill/word_list"
|
|
|
10
10
|
require_relative "lexdrill/toggle"
|
|
11
11
|
require_relative "lexdrill/rand"
|
|
12
12
|
require_relative "lexdrill/remote"
|
|
13
|
+
require_relative "lexdrill/oauth_remote"
|
|
13
14
|
require_relative "lexdrill/http_client"
|
|
14
15
|
require_relative "lexdrill/google_auth"
|
|
16
|
+
require_relative "lexdrill/service_account_auth"
|
|
15
17
|
require_relative "lexdrill/sheets_client"
|
|
16
18
|
require_relative "lexdrill/shell_snippet"
|
|
17
19
|
require_relative "lexdrill/inspector"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lexdrill
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Siarhei Kisliak
|
|
@@ -36,8 +36,10 @@ files:
|
|
|
36
36
|
- lib/lexdrill/http_client.rb
|
|
37
37
|
- lib/lexdrill/inspector.rb
|
|
38
38
|
- lib/lexdrill/line_formatter.rb
|
|
39
|
+
- lib/lexdrill/oauth_remote.rb
|
|
39
40
|
- lib/lexdrill/rand.rb
|
|
40
41
|
- lib/lexdrill/remote.rb
|
|
42
|
+
- lib/lexdrill/service_account_auth.rb
|
|
41
43
|
- lib/lexdrill/sheets_client.rb
|
|
42
44
|
- lib/lexdrill/shell_snippet.rb
|
|
43
45
|
- lib/lexdrill/stats.rb
|