lexdrill 0.13.0 → 0.14.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 +53 -0
- data/lib/lexdrill/cli.rb +77 -1
- data/lib/lexdrill/google_auth.rb +158 -0
- data/lib/lexdrill/http_client.rb +71 -0
- data/lib/lexdrill/remote.rb +27 -0
- data/lib/lexdrill/sheets_client.rb +105 -0
- data/lib/lexdrill/version.rb +1 -1
- data/lib/lexdrill.rb +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12866aced5d028c5f5951d0a201a7351090cc27adb617c73e317b80df3dcaa0f
|
|
4
|
+
data.tar.gz: 2e14d88608088fdbb6bd1a0071171696182deb828b840826bd3e7a7964f1f887
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8c8947fc580a75406e2a02b164092d49dc0bf66663ed55f0b5d395374e9f169b6bb330e84cdebedbb0ea91f5af88334c8bfa5f0532699b122b9d29d01eae7d50
|
|
7
|
+
data.tar.gz: 1b7c635562627735a1f85bc1656398f8782bcfaead000c24be594ade643748e89b2e0993215c853139fce1c3587758640c5b73e43e11b6de9293aedcb85fcf59
|
data/README.md
CHANGED
|
@@ -180,6 +180,57 @@ 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` mirrors your word list (text only, no stats) into a tab of a
|
|
186
|
+
Google Sheet you own, using **your own Google account** — not a service
|
|
187
|
+
account key. That distinction matters: a service-account private key would
|
|
188
|
+
have to be embedded in this published gem, meaning anyone who `gem install`s
|
|
189
|
+
lexdrill would get it too. Instead, `drill` uses the OAuth 2.0 **Device
|
|
190
|
+
Authorization Grant** ("visit this URL, enter this code," the same style of
|
|
191
|
+
flow `gcloud auth login` uses) — you approve access to your own account
|
|
192
|
+
once, and the resulting token is cached locally at
|
|
193
|
+
`~/.drill.gcp-token.json` (mode `0600`), never published or shared.
|
|
194
|
+
|
|
195
|
+
The OAuth client id/secret embedded in `lib/lexdrill/google_auth.rb` are
|
|
196
|
+
**not** secret for this use — Google's own docs say client credentials for
|
|
197
|
+
"TVs and Limited Input devices" / installed-app clients aren't treated as
|
|
198
|
+
confidential. The actual secret is your personal refresh token, which is
|
|
199
|
+
generated only after you interactively approve, and stays on your machine.
|
|
200
|
+
|
|
201
|
+
#### One-time Google Cloud setup (you do this yourself)
|
|
202
|
+
|
|
203
|
+
1. Go to <https://console.cloud.google.com>, create or select a project.
|
|
204
|
+
2. **APIs & Services → Library** → search "Google Sheets API" → Enable.
|
|
205
|
+
3. **APIs & Services → OAuth consent screen**: User type **External**;
|
|
206
|
+
publishing status **Testing** is fine — add your own Google account
|
|
207
|
+
under "Test users".
|
|
208
|
+
4. **APIs & Services → Credentials → Create Credentials → OAuth client
|
|
209
|
+
ID** → Application type **"TVs and Limited Input devices"** (no redirect
|
|
210
|
+
URI needed). Copy the generated Client ID and Client secret into
|
|
211
|
+
`CLIENT_ID`/`CLIENT_SECRET` in `lib/lexdrill/google_auth.rb`.
|
|
212
|
+
5. "Testing" publishing status has historically imposed a 7-day
|
|
213
|
+
refresh-token expiry for some scopes. If `drill export` starts asking
|
|
214
|
+
you to re-authorize every week, switch the consent screen to **"In
|
|
215
|
+
production"** — for the `spreadsheets` scope (not a "restricted" scope)
|
|
216
|
+
this just adds an "unverified app" click-through on first consent, no
|
|
217
|
+
Google review required.
|
|
218
|
+
|
|
219
|
+
#### Using it
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
drill remote 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
|
|
223
|
+
drill export Sheet1
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The first `export` prints a URL and a short code — visit it, sign in with
|
|
227
|
+
the Google account that has edit access to that spreadsheet, and approve.
|
|
228
|
+
Every export after that is silent (the cached token refreshes itself
|
|
229
|
+
automatically). If `<sheet-name>` doesn't exist yet as a tab in the
|
|
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.
|
|
233
|
+
|
|
183
234
|
### Commands
|
|
184
235
|
|
|
185
236
|
| Command | What it does |
|
|
@@ -197,3 +248,5 @@ anything.
|
|
|
197
248
|
| `drill stats` | Print all items as `<count>\t<phrase>` (tab-separated), sorted by show count, highest first |
|
|
198
249
|
| `drill rand <n>` | `drill next` shows a word ~1-in-`n` times (`n=1` is every time, the default) |
|
|
199
250
|
| `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 to export to (global, parses the spreadsheet id out of a normal share URL) |
|
|
252
|
+
| `drill export <sheet-name>` | Export the word list text to the named tab (created if it doesn't exist), overwriting it; first run triggers a one-time Google device-flow sign-in |
|
data/lib/lexdrill/cli.rb
CHANGED
|
@@ -17,7 +17,9 @@ 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_export: %w[export]
|
|
21
23
|
}.freeze
|
|
22
24
|
|
|
23
25
|
def self.start(argv = ARGV)
|
|
@@ -69,6 +71,9 @@ class Lexdrill::CLI
|
|
|
69
71
|
drill stats Print items as <count>\t<phrase>, tab-separated, highest count first
|
|
70
72
|
drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time)
|
|
71
73
|
drill go <number> Jump so the next `next` shows item <number> (1-based, see drill list)
|
|
74
|
+
drill remote <url> Set the Google Sheet to export to (global, ~/.drill.remote)
|
|
75
|
+
drill export <sheet-name> Export the word list text to the given tab (overwrites its
|
|
76
|
+
contents); first run opens a one-time Google device-flow sign-in
|
|
72
77
|
HELP
|
|
73
78
|
0
|
|
74
79
|
end
|
|
@@ -285,6 +290,77 @@ class Lexdrill::CLI
|
|
|
285
290
|
1
|
|
286
291
|
end
|
|
287
292
|
|
|
293
|
+
def run_remote
|
|
294
|
+
url = argv[1]
|
|
295
|
+
return print_remote_usage unless url
|
|
296
|
+
|
|
297
|
+
Lexdrill::Remote.set(url)
|
|
298
|
+
puts "remote spreadsheet set: #{Lexdrill::Remote.spreadsheet_id}"
|
|
299
|
+
0
|
|
300
|
+
rescue ArgumentError
|
|
301
|
+
print_invalid_remote_url(url)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def print_remote_usage
|
|
305
|
+
warn "usage: drill remote <google-sheets-url>"
|
|
306
|
+
1
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def print_invalid_remote_url(url)
|
|
310
|
+
warn "drill: could not find a spreadsheet id in #{url.inspect}"
|
|
311
|
+
1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def run_export
|
|
315
|
+
sheet_name = argv[1]
|
|
316
|
+
return print_export_usage unless sheet_name
|
|
317
|
+
return print_no_remote unless Lexdrill::Remote.configured?
|
|
318
|
+
|
|
319
|
+
perform_export(sheet_name)
|
|
320
|
+
rescue Lexdrill::GoogleAuth::AuthError => error
|
|
321
|
+
warn "drill: #{error.message}"
|
|
322
|
+
1
|
|
323
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
324
|
+
print_sheets_api_error(error, sheet_name)
|
|
325
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
326
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
327
|
+
1
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def perform_export(sheet_name)
|
|
331
|
+
token = Lexdrill::GoogleAuth.ensure_token!
|
|
332
|
+
rows = export_rows
|
|
333
|
+
Lexdrill::SheetsClient.overwrite_sheet(Lexdrill::Remote.spreadsheet_id, sheet_name, rows, token)
|
|
334
|
+
puts "exported #{rows.size} word(s) to #{sheet_name.inspect}"
|
|
335
|
+
0
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def export_rows
|
|
339
|
+
Lexdrill::WordList.words.map { |word| [word] }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def print_export_usage
|
|
343
|
+
warn "usage: drill export <sheet-name>"
|
|
344
|
+
1
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def print_no_remote
|
|
348
|
+
warn "drill: no remote spreadsheet configured; run `drill remote <url>` first"
|
|
349
|
+
1
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def print_sheets_api_error(error, sheet_name)
|
|
353
|
+
status = error.status
|
|
354
|
+
message = error.message
|
|
355
|
+
case status
|
|
356
|
+
when 404 then warn "drill: spreadsheet not found or not accessible with this Google account (check `drill remote`)"
|
|
357
|
+
when 403 then warn "drill: access denied — the signed-in Google account needs edit access to the spreadsheet"
|
|
358
|
+
when 400 then warn "drill: #{message} (is #{sheet_name.inspect} a real tab in the spreadsheet?)"
|
|
359
|
+
else warn "drill: Google Sheets API error (#{status}): #{message}"
|
|
360
|
+
end
|
|
361
|
+
1
|
|
362
|
+
end
|
|
363
|
+
|
|
288
364
|
def print_unknown_command(command)
|
|
289
365
|
warn "drill: unknown command #{command.inspect}"
|
|
290
366
|
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,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Global "which Google Sheet to export to" setting. Lives at ~/.drill.remote
|
|
4
|
+
# as a plain spreadsheet id, parsed out of a normal Google Sheets share URL.
|
|
5
|
+
module Lexdrill::Remote
|
|
6
|
+
PATH = File.join(Dir.home, ".drill.remote")
|
|
7
|
+
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
8
|
+
|
|
9
|
+
def self.set(url)
|
|
10
|
+
id = extract_id(url)
|
|
11
|
+
raise ArgumentError, "no spreadsheet id found in #{url.inspect}" unless id
|
|
12
|
+
|
|
13
|
+
File.write(PATH, id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.extract_id(url)
|
|
17
|
+
url[URL_PATTERN, 1]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.configured?
|
|
21
|
+
File.exist?(PATH)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.spreadsheet_id
|
|
25
|
+
File.read(PATH).strip if configured?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
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`: create the target tab if it doesn't already exist, clear
|
|
8
|
+
# its contents, write new rows into it, then auto-fit column A's width to
|
|
9
|
+
# the content so long phrases aren't visually truncated.
|
|
10
|
+
module Lexdrill::SheetsClient
|
|
11
|
+
BASE_URL = "https://sheets.googleapis.com/v4/spreadsheets"
|
|
12
|
+
|
|
13
|
+
class ApiError < StandardError
|
|
14
|
+
attr_reader :status
|
|
15
|
+
|
|
16
|
+
def initialize(status, message)
|
|
17
|
+
@status = status
|
|
18
|
+
super(message)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.overwrite_sheet(spreadsheet_id, sheet_name, rows, access_token)
|
|
23
|
+
sheet_id = ensure_sheet_exists(spreadsheet_id, sheet_name, access_token)
|
|
24
|
+
clear(spreadsheet_id, sheet_name, access_token)
|
|
25
|
+
update(spreadsheet_id, sheet_name, rows, access_token)
|
|
26
|
+
autofit_first_column(spreadsheet_id, sheet_id, access_token)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.ensure_sheet_exists(spreadsheet_id, sheet_name, access_token)
|
|
30
|
+
existing = find_sheet(spreadsheet_id, sheet_name, access_token)
|
|
31
|
+
return existing["sheetId"] if existing
|
|
32
|
+
|
|
33
|
+
add_sheet(spreadsheet_id, sheet_name, access_token)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :ensure_sheet_exists
|
|
36
|
+
|
|
37
|
+
def self.find_sheet(spreadsheet_id, sheet_name, access_token)
|
|
38
|
+
url = "#{BASE_URL}/#{spreadsheet_id}?fields=sheets.properties"
|
|
39
|
+
data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
|
|
40
|
+
data.fetch("sheets", []).map { |sheet| sheet["properties"] }.find { |props| props["title"] == sheet_name }
|
|
41
|
+
end
|
|
42
|
+
private_class_method :find_sheet
|
|
43
|
+
|
|
44
|
+
def self.add_sheet(spreadsheet_id, sheet_name, access_token)
|
|
45
|
+
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
46
|
+
body = { "requests" => [{ "addSheet" => { "properties" => { "title" => sheet_name } } }] }
|
|
47
|
+
result = handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
|
|
48
|
+
result.dig("replies", 0, "addSheet", "properties", "sheetId")
|
|
49
|
+
end
|
|
50
|
+
private_class_method :add_sheet
|
|
51
|
+
|
|
52
|
+
def self.autofit_first_column(spreadsheet_id, sheet_id, access_token)
|
|
53
|
+
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
54
|
+
dimensions = { "sheetId" => sheet_id, "dimension" => "COLUMNS", "startIndex" => 0, "endIndex" => 1 }
|
|
55
|
+
body = { "requests" => [{ "autoResizeDimensions" => { "dimensions" => dimensions } }] }
|
|
56
|
+
handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
|
|
57
|
+
end
|
|
58
|
+
private_class_method :autofit_first_column
|
|
59
|
+
|
|
60
|
+
def self.clear(spreadsheet_id, sheet_name, access_token)
|
|
61
|
+
url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}:clear"
|
|
62
|
+
handle_response(Lexdrill::HTTPClient.json_post(url, body: {}, headers: auth_header(access_token)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.update(spreadsheet_id, sheet_name, rows, access_token)
|
|
66
|
+
url = "#{BASE_URL}/#{spreadsheet_id}/values/#{encoded_range(sheet_name)}?valueInputOption=RAW"
|
|
67
|
+
body = { "range" => quoted_range(sheet_name), "majorDimension" => "ROWS", "values" => rows }
|
|
68
|
+
handle_response(Lexdrill::HTTPClient.json_put(url, body: body, headers: auth_header(access_token)))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.auth_header(token)
|
|
72
|
+
{ "Authorization" => "Bearer #{token}" }
|
|
73
|
+
end
|
|
74
|
+
private_class_method :auth_header
|
|
75
|
+
|
|
76
|
+
# Quoting the sheet name defends against tab names containing spaces;
|
|
77
|
+
# harmless for plain names too. The URL path and the request body's
|
|
78
|
+
# "range" field must match exactly (Google rejects the request otherwise),
|
|
79
|
+
# so both go through this same quoting.
|
|
80
|
+
def self.quoted_range(sheet_name)
|
|
81
|
+
"'#{sheet_name}'"
|
|
82
|
+
end
|
|
83
|
+
private_class_method :quoted_range
|
|
84
|
+
|
|
85
|
+
def self.encoded_range(sheet_name)
|
|
86
|
+
URI.encode_www_form_component(quoted_range(sheet_name))
|
|
87
|
+
end
|
|
88
|
+
private_class_method :encoded_range
|
|
89
|
+
|
|
90
|
+
def self.handle_response(response)
|
|
91
|
+
status = response.code
|
|
92
|
+
return JSON.parse(response.body) if status == 200
|
|
93
|
+
|
|
94
|
+
raise ApiError.new(status, error_message(response))
|
|
95
|
+
end
|
|
96
|
+
private_class_method :handle_response
|
|
97
|
+
|
|
98
|
+
def self.error_message(response)
|
|
99
|
+
status = response.code
|
|
100
|
+
JSON.parse(response.body).dig("error", "message") || "HTTP #{status}"
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
"HTTP #{status}"
|
|
103
|
+
end
|
|
104
|
+
private_class_method :error_message
|
|
105
|
+
end
|
data/lib/lexdrill/version.rb
CHANGED
data/lib/lexdrill.rb
CHANGED
|
@@ -9,6 +9,10 @@ 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/http_client"
|
|
14
|
+
require_relative "lexdrill/google_auth"
|
|
15
|
+
require_relative "lexdrill/sheets_client"
|
|
12
16
|
require_relative "lexdrill/shell_snippet"
|
|
13
17
|
require_relative "lexdrill/inspector"
|
|
14
18
|
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.14.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,13 @@ 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
|
|
37
39
|
- lib/lexdrill/rand.rb
|
|
40
|
+
- lib/lexdrill/remote.rb
|
|
41
|
+
- lib/lexdrill/sheets_client.rb
|
|
38
42
|
- lib/lexdrill/shell_snippet.rb
|
|
39
43
|
- lib/lexdrill/stats.rb
|
|
40
44
|
- lib/lexdrill/toggle.rb
|