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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a20cfbe4b7db3dbc41bec9f5e6bac7954660f81c5396bd39337b620f78038cfd
4
- data.tar.gz: 6a26fc1605e888f21569d4109f774d85d753e6e17f6e0a40b220d071cda5549c
3
+ metadata.gz: 36e9bb140cbbb757b2c9703b8cd122a9fd62a3faa108c4cc0072755391acb65f
4
+ data.tar.gz: b3502832141a5b7e751f69c5b6d8e729c640f5896300053b96f99fe068968b73
5
5
  SHA512:
6
- metadata.gz: 664c053ae57b8f89b78364699dc865067e461086145c8be708e84473df83642966751bdec82c42e977f185dba6ca3799ae8aa65faa2ddd4e34f27245054a79ed
7
- data.tar.gz: 6d3c8d58b6b1b198857287546c215e519b023d051020bd4980ffe1958a7103bdc1d2167811f28aa1db42d8b93233b85923137b5f39e107c4224042c60fdce7c4
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lexdrill
4
- VERSION = "0.13.0"
4
+ VERSION = "0.16.0"
5
5
  end
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.13.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-10 00:00:00.000000000 Z
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