lexdrill 0.13.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 +104 -0
- data/lib/lexdrill/cli.rb +165 -1
- data/lib/lexdrill/google_auth.rb +158 -0
- data/lib/lexdrill/http_client.rb +71 -0
- data/lib/lexdrill/oauth_remote.rb +29 -0
- data/lib/lexdrill/remote.rb +31 -0
- data/lib/lexdrill/service_account_auth.rb +85 -0
- data/lib/lexdrill/sheets_client.rb +114 -0
- data/lib/lexdrill/version.rb +1 -1
- data/lib/lexdrill.rb +6 -0
- metadata +8 -2
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
|
@@ -180,6 +180,106 @@ longer comes up automatically). If every word in the list has been
|
|
|
180
180
|
mastered, `next` reports that on stderr and exits 1 instead of showing
|
|
181
181
|
anything.
|
|
182
182
|
|
|
183
|
+
### Google Sheets export
|
|
184
|
+
|
|
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.
|
|
249
|
+
|
|
250
|
+
The OAuth client id/secret embedded in `lib/lexdrill/google_auth.rb` are
|
|
251
|
+
**not** secret for this use — Google's own docs say client credentials for
|
|
252
|
+
"TVs and Limited Input devices" / installed-app clients aren't treated as
|
|
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.
|
|
256
|
+
|
|
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**;
|
|
260
|
+
publishing status **Testing** is fine — add your own Google account
|
|
261
|
+
under "Test users".
|
|
262
|
+
3. **APIs & Services → Credentials → Create Credentials → OAuth client
|
|
263
|
+
ID** → Application type **"TVs and Limited Input devices"** (no redirect
|
|
264
|
+
URI needed). Copy the generated Client ID and Client secret into
|
|
265
|
+
`CLIENT_ID`/`CLIENT_SECRET` in `lib/lexdrill/google_auth.rb`.
|
|
266
|
+
4. "Testing" publishing status has historically imposed a 7-day
|
|
267
|
+
refresh-token expiry for some scopes. If `drill export` starts asking
|
|
268
|
+
you to re-authorize every week, switch the consent screen to **"In
|
|
269
|
+
production"** — for the `spreadsheets` scope (not a "restricted" scope)
|
|
270
|
+
this just adds an "unverified app" click-through on first consent, no
|
|
271
|
+
Google review required.
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
drill oauth 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
|
|
275
|
+
drill export Sheet1
|
|
276
|
+
```
|
|
277
|
+
|
|
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).
|
|
282
|
+
|
|
183
283
|
### Commands
|
|
184
284
|
|
|
185
285
|
| Command | What it does |
|
|
@@ -197,3 +297,7 @@ anything.
|
|
|
197
297
|
| `drill stats` | Print all items as `<count>\t<phrase>` (tab-separated), sorted by show count, highest first |
|
|
198
298
|
| `drill rand <n>` | `drill next` shows a word ~1-in-`n` times (`n=1` is every time, the default) |
|
|
199
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 |
|
|
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
|
@@ -17,7 +17,11 @@ class Lexdrill::CLI
|
|
|
17
17
|
run_open: %w[open],
|
|
18
18
|
run_stats: %w[stats],
|
|
19
19
|
run_rand: %w[rand],
|
|
20
|
-
run_go: %w[go]
|
|
20
|
+
run_go: %w[go],
|
|
21
|
+
run_remote: %w[remote],
|
|
22
|
+
run_oauth: %w[oauth],
|
|
23
|
+
run_export: %w[export],
|
|
24
|
+
run_import: %w[import]
|
|
21
25
|
}.freeze
|
|
22
26
|
|
|
23
27
|
def self.start(argv = ARGV)
|
|
@@ -69,6 +73,13 @@ class Lexdrill::CLI
|
|
|
69
73
|
drill stats Print items as <count>\t<phrase>, tab-separated, highest count first
|
|
70
74
|
drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time)
|
|
71
75
|
drill go <number> Jump so the next `next` shows item <number> (1-based, see drill list)
|
|
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
|
|
79
|
+
drill export <sheet-name> Export the word list text to the given tab (overwrites its
|
|
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
|
|
72
83
|
HELP
|
|
73
84
|
0
|
|
74
85
|
end
|
|
@@ -285,6 +296,159 @@ class Lexdrill::CLI
|
|
|
285
296
|
1
|
|
286
297
|
end
|
|
287
298
|
|
|
299
|
+
def run_remote
|
|
300
|
+
url = argv[1]
|
|
301
|
+
return print_remote_usage unless url
|
|
302
|
+
|
|
303
|
+
Lexdrill::Remote.set(url)
|
|
304
|
+
puts "remote spreadsheet set: #{Lexdrill::Remote.spreadsheet_id}"
|
|
305
|
+
0
|
|
306
|
+
rescue ArgumentError
|
|
307
|
+
print_invalid_remote_url(url)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def print_remote_usage
|
|
311
|
+
warn "usage: drill remote <google-sheets-url>"
|
|
312
|
+
1
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def print_invalid_remote_url(url)
|
|
316
|
+
warn "drill: could not find a spreadsheet id in #{url.inspect}"
|
|
317
|
+
1
|
|
318
|
+
end
|
|
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
|
+
|
|
363
|
+
def run_export
|
|
364
|
+
sheet_name = argv[1]
|
|
365
|
+
return print_export_usage unless sheet_name
|
|
366
|
+
|
|
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
|
|
372
|
+
warn "drill: #{error.message}"
|
|
373
|
+
1
|
|
374
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
375
|
+
print_sheets_api_error(error, sheet_name)
|
|
376
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
377
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
378
|
+
1
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def perform_export(sheet_name, spreadsheet_id, token)
|
|
382
|
+
rows = export_rows
|
|
383
|
+
Lexdrill::SheetsClient.overwrite_sheet(spreadsheet_id, sheet_name, rows, token)
|
|
384
|
+
puts "exported #{rows.size} word(s) to #{sheet_name.inspect}"
|
|
385
|
+
0
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def export_rows
|
|
389
|
+
Lexdrill::WordList.words.map { |word| [word] }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def print_export_usage
|
|
393
|
+
warn "usage: drill export <sheet-name>"
|
|
394
|
+
1
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def print_no_remote
|
|
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"
|
|
437
|
+
1
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def print_sheets_api_error(error, sheet_name)
|
|
441
|
+
status = error.status
|
|
442
|
+
message = error.message
|
|
443
|
+
case status
|
|
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"
|
|
446
|
+
when 400 then warn "drill: #{message} (is #{sheet_name.inspect} a real tab in the spreadsheet?)"
|
|
447
|
+
else warn "drill: Google Sheets API error (#{status}): #{message}"
|
|
448
|
+
end
|
|
449
|
+
1
|
|
450
|
+
end
|
|
451
|
+
|
|
288
452
|
def print_unknown_command(command)
|
|
289
453
|
warn "drill: unknown command #{command.inspect}"
|
|
290
454
|
print_help
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
# Handles the Google OAuth 2.0 Device Authorization Grant ("visit this URL,
|
|
7
|
+
# enter this code") so `drill export` can write to a Sheet as the user's own
|
|
8
|
+
# Google account, without ever needing a service account key.
|
|
9
|
+
#
|
|
10
|
+
# CLIENT_ID/CLIENT_SECRET below are NOT confidential for this OAuth client
|
|
11
|
+
# type ("TVs and Limited Input devices" / installed-app device flow) — see
|
|
12
|
+
# README's "Google Sheets export" section for why it's safe to ship these in
|
|
13
|
+
# published gem source. The actual secret is the refresh_token persisted at
|
|
14
|
+
# PATH below, obtained only after this specific user's own interactive
|
|
15
|
+
# consent, which never leaves this machine and is never published.
|
|
16
|
+
module Lexdrill::GoogleAuth
|
|
17
|
+
PATH = File.join(Dir.home, ".drill.gcp-token.json")
|
|
18
|
+
|
|
19
|
+
CLIENT_ID = "608056429593-ncdgh4bnfpuf9486l2rlt7g763h1gl6g.apps.googleusercontent.com"
|
|
20
|
+
CLIENT_SECRET = "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"
|
|
21
|
+
|
|
22
|
+
SCOPE = "https://www.googleapis.com/auth/spreadsheets"
|
|
23
|
+
DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code"
|
|
24
|
+
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
25
|
+
GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
26
|
+
DEFAULT_POLL_INTERVAL = 5
|
|
27
|
+
DEFAULT_EXPIRES_IN = 1800
|
|
28
|
+
EXPIRY_SKEW = 60 # seconds of safety margin before treating a token as expired
|
|
29
|
+
|
|
30
|
+
class AuthError < StandardError; end
|
|
31
|
+
|
|
32
|
+
def self.ensure_token!
|
|
33
|
+
return login! unless configured?
|
|
34
|
+
|
|
35
|
+
data = load_token
|
|
36
|
+
return data["access_token"] if Time.now.to_i < data["expires_at"].to_i
|
|
37
|
+
|
|
38
|
+
refresh!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.configured?
|
|
42
|
+
File.exist?(PATH)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.clear!
|
|
46
|
+
FileUtils.rm_f(PATH)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.login!
|
|
50
|
+
device = request_device_code
|
|
51
|
+
print_instructions(device)
|
|
52
|
+
poll_for_token(device)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.request_device_code
|
|
56
|
+
response = Lexdrill::HTTPClient.post_form(DEVICE_CODE_URL, { "client_id" => CLIENT_ID, "scope" => SCOPE })
|
|
57
|
+
JSON.parse(response.body)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :request_device_code
|
|
60
|
+
|
|
61
|
+
def self.print_instructions(device)
|
|
62
|
+
puts "To let lexdrill export to Google Sheets, visit:"
|
|
63
|
+
puts " #{device['verification_url']}"
|
|
64
|
+
puts "and enter this code: #{device['user_code']}"
|
|
65
|
+
puts "Waiting for you to approve..."
|
|
66
|
+
end
|
|
67
|
+
private_class_method :print_instructions
|
|
68
|
+
|
|
69
|
+
def self.poll_for_token(device)
|
|
70
|
+
interval = (device["interval"] || DEFAULT_POLL_INTERVAL).to_i
|
|
71
|
+
deadline = Time.now.to_i + (device["expires_in"] || DEFAULT_EXPIRES_IN).to_i
|
|
72
|
+
loop do
|
|
73
|
+
check_not_expired(deadline)
|
|
74
|
+
sleep(interval)
|
|
75
|
+
outcome = classify_poll_result(attempt_token_exchange(device["device_code"]))
|
|
76
|
+
return outcome[:token] if outcome[:done]
|
|
77
|
+
|
|
78
|
+
interval += 5 if outcome[:slow_down]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
private_class_method :poll_for_token
|
|
82
|
+
|
|
83
|
+
def self.check_not_expired(deadline)
|
|
84
|
+
return if Time.now.to_i <= deadline
|
|
85
|
+
|
|
86
|
+
raise AuthError, "device code expired before authorization completed; run the command again"
|
|
87
|
+
end
|
|
88
|
+
private_class_method :check_not_expired
|
|
89
|
+
|
|
90
|
+
def self.classify_poll_result(result)
|
|
91
|
+
return { done: true, token: persist_token(result) } if result["access_token"]
|
|
92
|
+
|
|
93
|
+
error = result["error"]
|
|
94
|
+
return { done: false } if error == "authorization_pending"
|
|
95
|
+
return { done: false, slow_down: true } if error == "slow_down"
|
|
96
|
+
|
|
97
|
+
raise AuthError, "Google authorization failed: #{error}"
|
|
98
|
+
end
|
|
99
|
+
private_class_method :classify_poll_result
|
|
100
|
+
|
|
101
|
+
def self.attempt_token_exchange(device_code)
|
|
102
|
+
response = Lexdrill::HTTPClient.post_form(TOKEN_URL, {
|
|
103
|
+
"client_id" => CLIENT_ID, "client_secret" => CLIENT_SECRET,
|
|
104
|
+
"device_code" => device_code, "grant_type" => GRANT_TYPE_DEVICE
|
|
105
|
+
})
|
|
106
|
+
JSON.parse(response.body)
|
|
107
|
+
end
|
|
108
|
+
private_class_method :attempt_token_exchange
|
|
109
|
+
|
|
110
|
+
def self.refresh!
|
|
111
|
+
data = load_token
|
|
112
|
+
params = {
|
|
113
|
+
"client_id" => CLIENT_ID, "client_secret" => CLIENT_SECRET,
|
|
114
|
+
"refresh_token" => data["refresh_token"], "grant_type" => "refresh_token"
|
|
115
|
+
}
|
|
116
|
+
handle_refresh_response(Lexdrill::HTTPClient.post_form(TOKEN_URL, params), data)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.handle_refresh_response(response, data)
|
|
120
|
+
body = JSON.parse(response.body)
|
|
121
|
+
raise_invalid_grant(body) if response.code == 400 && body["error"] == "invalid_grant"
|
|
122
|
+
|
|
123
|
+
data["access_token"] = body["access_token"]
|
|
124
|
+
data["expires_at"] = Time.now.to_i + body["expires_in"].to_i - EXPIRY_SKEW
|
|
125
|
+
save(data)
|
|
126
|
+
data["access_token"]
|
|
127
|
+
end
|
|
128
|
+
private_class_method :handle_refresh_response
|
|
129
|
+
|
|
130
|
+
def self.raise_invalid_grant(body)
|
|
131
|
+
clear!
|
|
132
|
+
raise AuthError, "Google access was revoked or expired (#{body['error_description']}); " \
|
|
133
|
+
"run `drill export` again to re-authorize"
|
|
134
|
+
end
|
|
135
|
+
private_class_method :raise_invalid_grant
|
|
136
|
+
|
|
137
|
+
def self.persist_token(data)
|
|
138
|
+
token = {
|
|
139
|
+
"access_token" => data["access_token"],
|
|
140
|
+
"refresh_token" => data["refresh_token"],
|
|
141
|
+
"expires_at" => Time.now.to_i + data["expires_in"].to_i - EXPIRY_SKEW
|
|
142
|
+
}
|
|
143
|
+
save(token)
|
|
144
|
+
token["access_token"]
|
|
145
|
+
end
|
|
146
|
+
private_class_method :persist_token
|
|
147
|
+
|
|
148
|
+
def self.load_token
|
|
149
|
+
JSON.parse(File.read(PATH))
|
|
150
|
+
end
|
|
151
|
+
private_class_method :load_token
|
|
152
|
+
|
|
153
|
+
def self.save(token)
|
|
154
|
+
File.write(PATH, JSON.generate(token))
|
|
155
|
+
File.chmod(0o600, PATH) # the ONE lexdrill config file holding a real secret
|
|
156
|
+
end
|
|
157
|
+
private_class_method :save
|
|
158
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
# The only place lexdrill talks to Net::HTTP directly. Lexdrill::GoogleAuth
|
|
8
|
+
# (form-encoded OAuth endpoints) and Lexdrill::SheetsClient (JSON REST) both
|
|
9
|
+
# go through here, so every low-level connectivity failure (DNS, timeout,
|
|
10
|
+
# TLS, connection refused) is normalized to a single NetworkError for
|
|
11
|
+
# callers to handle, instead of each caller needing to know every possible
|
|
12
|
+
# Net::HTTP/OpenSSL/socket exception class.
|
|
13
|
+
module Lexdrill::HTTPClient
|
|
14
|
+
OPEN_TIMEOUT = 10
|
|
15
|
+
READ_TIMEOUT = 15
|
|
16
|
+
|
|
17
|
+
Response = Struct.new(:code, :body)
|
|
18
|
+
|
|
19
|
+
class NetworkError < StandardError; end
|
|
20
|
+
|
|
21
|
+
def self.post_form(url, params)
|
|
22
|
+
uri = URI(url)
|
|
23
|
+
perform(uri) { |http| http.post(uri.path, URI.encode_www_form(params)) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.json_post(url, body:, headers: {})
|
|
27
|
+
json_request(Net::HTTP::Post, url, body, headers)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.json_put(url, body:, headers: {})
|
|
31
|
+
json_request(Net::HTTP::Put, url, body, headers)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.json_get(url, headers: {})
|
|
35
|
+
uri = URI(url)
|
|
36
|
+
request = build_get_request(uri, headers)
|
|
37
|
+
perform(uri) { |http| http.request(request) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.build_get_request(uri, headers)
|
|
41
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
42
|
+
headers.each { |key, value| request[key] = value }
|
|
43
|
+
request
|
|
44
|
+
end
|
|
45
|
+
private_class_method :build_get_request
|
|
46
|
+
|
|
47
|
+
def self.json_request(method_class, url, body, headers)
|
|
48
|
+
uri = URI(url)
|
|
49
|
+
request = build_json_request(method_class, uri, body, headers)
|
|
50
|
+
perform(uri) { |http| http.request(request) }
|
|
51
|
+
end
|
|
52
|
+
private_class_method :json_request
|
|
53
|
+
|
|
54
|
+
def self.build_json_request(method_class, uri, body, headers)
|
|
55
|
+
request = method_class.new(uri.request_uri)
|
|
56
|
+
headers.each { |key, value| request[key] = value }
|
|
57
|
+
request["Content-Type"] = "application/json"
|
|
58
|
+
request.body = JSON.generate(body)
|
|
59
|
+
request
|
|
60
|
+
end
|
|
61
|
+
private_class_method :build_json_request
|
|
62
|
+
|
|
63
|
+
def self.perform(uri, &block)
|
|
64
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
65
|
+
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT, &block)
|
|
66
|
+
Response.new(response.code.to_i, response.body)
|
|
67
|
+
rescue StandardError => error
|
|
68
|
+
raise NetworkError, error.message
|
|
69
|
+
end
|
|
70
|
+
private_class_method :perform
|
|
71
|
+
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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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.
|
|
9
|
+
module Lexdrill::Remote
|
|
10
|
+
PATH = File.join(Dir.home, ".drill.remote")
|
|
11
|
+
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
12
|
+
|
|
13
|
+
def self.set(url)
|
|
14
|
+
id = extract_id(url)
|
|
15
|
+
raise ArgumentError, "no spreadsheet id found in #{url.inspect}" unless id
|
|
16
|
+
|
|
17
|
+
File.write(PATH, id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.extract_id(url)
|
|
21
|
+
url[URL_PATTERN, 1]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.configured?
|
|
25
|
+
File.exist?(PATH)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.spreadsheet_id
|
|
29
|
+
File.read(PATH).strip if configured?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# Thin wrapper around the Google Sheets API v4 REST endpoints needed by
|
|
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.
|
|
11
|
+
module Lexdrill::SheetsClient
|
|
12
|
+
BASE_URL = "https://sheets.googleapis.com/v4/spreadsheets"
|
|
13
|
+
|
|
14
|
+
class ApiError < StandardError
|
|
15
|
+
attr_reader :status
|
|
16
|
+
|
|
17
|
+
def initialize(status, message)
|
|
18
|
+
@status = status
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.overwrite_sheet(spreadsheet_id, sheet_name, rows, access_token)
|
|
24
|
+
sheet_id = ensure_sheet_exists(spreadsheet_id, sheet_name, access_token)
|
|
25
|
+
clear(spreadsheet_id, sheet_name, access_token)
|
|
26
|
+
update(spreadsheet_id, sheet_name, rows, access_token)
|
|
27
|
+
autofit_first_column(spreadsheet_id, sheet_id, access_token)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.ensure_sheet_exists(spreadsheet_id, sheet_name, access_token)
|
|
31
|
+
existing = find_sheet(spreadsheet_id, sheet_name, access_token)
|
|
32
|
+
return existing["sheetId"] if existing
|
|
33
|
+
|
|
34
|
+
add_sheet(spreadsheet_id, sheet_name, access_token)
|
|
35
|
+
end
|
|
36
|
+
private_class_method :ensure_sheet_exists
|
|
37
|
+
|
|
38
|
+
def self.find_sheet(spreadsheet_id, sheet_name, access_token)
|
|
39
|
+
url = "#{BASE_URL}/#{spreadsheet_id}?fields=sheets.properties"
|
|
40
|
+
data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
|
|
41
|
+
data.fetch("sheets", []).map { |sheet| sheet["properties"] }.find { |props| props["title"] == sheet_name }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :find_sheet
|
|
44
|
+
|
|
45
|
+
def self.add_sheet(spreadsheet_id, sheet_name, access_token)
|
|
46
|
+
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
47
|
+
body = { "requests" => [{ "addSheet" => { "properties" => { "title" => sheet_name } } }] }
|
|
48
|
+
result = handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
|
|
49
|
+
result.dig("replies", 0, "addSheet", "properties", "sheetId")
|
|
50
|
+
end
|
|
51
|
+
private_class_method :add_sheet
|
|
52
|
+
|
|
53
|
+
def self.autofit_first_column(spreadsheet_id, sheet_id, access_token)
|
|
54
|
+
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
55
|
+
dimensions = { "sheetId" => sheet_id, "dimension" => "COLUMNS", "startIndex" => 0, "endIndex" => 1 }
|
|
56
|
+
body = { "requests" => [{ "autoResizeDimensions" => { "dimensions" => dimensions } }] }
|
|
57
|
+
handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
|
|
58
|
+
end
|
|
59
|
+
private_class_method :autofit_first_column
|
|
60
|
+
|
|
61
|
+
def self.clear(spreadsheet_id, sheet_name, access_token)
|
|
62
|
+
url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}:clear"
|
|
63
|
+
handle_response(Lexdrill::HTTPClient.json_post(url, body: {}, headers: auth_header(access_token)))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.update(spreadsheet_id, sheet_name, rows, access_token)
|
|
67
|
+
url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}?valueInputOption=RAW"
|
|
68
|
+
body = { "range" => quoted_range(sheet_name), "majorDimension" => "ROWS", "values" => rows }
|
|
69
|
+
handle_response(Lexdrill::HTTPClient.json_put(url, body: body, headers: auth_header(access_token)))
|
|
70
|
+
end
|
|
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
|
+
|
|
80
|
+
def self.auth_header(token)
|
|
81
|
+
{ "Authorization" => "Bearer #{token}" }
|
|
82
|
+
end
|
|
83
|
+
private_class_method :auth_header
|
|
84
|
+
|
|
85
|
+
# Quoting the sheet name defends against tab names containing spaces;
|
|
86
|
+
# harmless for plain names too. The URL path and the request body's
|
|
87
|
+
# "range" field must match exactly (Google rejects the request otherwise),
|
|
88
|
+
# so both go through this same quoting.
|
|
89
|
+
def self.quoted_range(sheet_name)
|
|
90
|
+
"'#{sheet_name}'"
|
|
91
|
+
end
|
|
92
|
+
private_class_method :quoted_range
|
|
93
|
+
|
|
94
|
+
def self.encoded_range(sheet_name)
|
|
95
|
+
URI.encode_www_form_component(quoted_range(sheet_name))
|
|
96
|
+
end
|
|
97
|
+
private_class_method :encoded_range
|
|
98
|
+
|
|
99
|
+
def self.handle_response(response)
|
|
100
|
+
status = response.code
|
|
101
|
+
return JSON.parse(response.body) if status == 200
|
|
102
|
+
|
|
103
|
+
raise ApiError.new(status, error_message(response))
|
|
104
|
+
end
|
|
105
|
+
private_class_method :handle_response
|
|
106
|
+
|
|
107
|
+
def self.error_message(response)
|
|
108
|
+
status = response.code
|
|
109
|
+
JSON.parse(response.body).dig("error", "message") || "HTTP #{status}"
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
"HTTP #{status}"
|
|
112
|
+
end
|
|
113
|
+
private_class_method :error_message
|
|
114
|
+
end
|
data/lib/lexdrill/version.rb
CHANGED
data/lib/lexdrill.rb
CHANGED
|
@@ -9,6 +9,12 @@ require_relative "lexdrill/stats"
|
|
|
9
9
|
require_relative "lexdrill/word_list"
|
|
10
10
|
require_relative "lexdrill/toggle"
|
|
11
11
|
require_relative "lexdrill/rand"
|
|
12
|
+
require_relative "lexdrill/remote"
|
|
13
|
+
require_relative "lexdrill/oauth_remote"
|
|
14
|
+
require_relative "lexdrill/http_client"
|
|
15
|
+
require_relative "lexdrill/google_auth"
|
|
16
|
+
require_relative "lexdrill/service_account_auth"
|
|
17
|
+
require_relative "lexdrill/sheets_client"
|
|
12
18
|
require_relative "lexdrill/shell_snippet"
|
|
13
19
|
require_relative "lexdrill/inspector"
|
|
14
20
|
require_relative "lexdrill/colorizer"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: lexdrill prints a vocabulary word or phrase on demand, tracking how often
|
|
14
14
|
each one has been shown.
|
|
@@ -32,9 +32,15 @@ files:
|
|
|
32
32
|
- lib/lexdrill/counter.rb
|
|
33
33
|
- lib/lexdrill/default_words.rb
|
|
34
34
|
- lib/lexdrill/format.rb
|
|
35
|
+
- lib/lexdrill/google_auth.rb
|
|
36
|
+
- lib/lexdrill/http_client.rb
|
|
35
37
|
- lib/lexdrill/inspector.rb
|
|
36
38
|
- lib/lexdrill/line_formatter.rb
|
|
39
|
+
- lib/lexdrill/oauth_remote.rb
|
|
37
40
|
- lib/lexdrill/rand.rb
|
|
41
|
+
- lib/lexdrill/remote.rb
|
|
42
|
+
- lib/lexdrill/service_account_auth.rb
|
|
43
|
+
- lib/lexdrill/sheets_client.rb
|
|
38
44
|
- lib/lexdrill/shell_snippet.rb
|
|
39
45
|
- lib/lexdrill/stats.rb
|
|
40
46
|
- lib/lexdrill/toggle.rb
|