lexdrill 0.21.0 → 0.26.1
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 +100 -47
- data/lib/lexdrill/auth_mode.rb +23 -0
- data/lib/lexdrill/browser.rb +20 -0
- data/lib/lexdrill/cli.rb +345 -91
- data/lib/lexdrill/http_client.rb +9 -1
- data/lib/lexdrill/inspector.rb +8 -3
- data/lib/lexdrill/line_formatter.rb +5 -5
- data/lib/lexdrill/sheets_client.rb +27 -4
- data/lib/lexdrill/version.rb +1 -1
- data/lib/lexdrill/workbooks.rb +106 -0
- data/lib/lexdrill.rb +3 -3
- metadata +5 -5
- data/lib/lexdrill/oauth_remote.rb +0 -29
- data/lib/lexdrill/remote.rb +0 -31
- data/lib/lexdrill/remote_target.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d3dc4600b6ad7303df0b607b7b9ded50b00cf4b3a7ca98e4fa05ca0e1663121
|
|
4
|
+
data.tar.gz: 90c8bedb7c4145f633b88506cd190f840ec9f87b5bbc1286803aa264da56eb8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3d24e21d27f2b7bfbba4fdd738adce07c6a7006e53334fde2a9e061351c15da86b3e1f7b24e5dc90828e4aa0577af0669eec4d331fa3d36ae1abaa9ba31e998
|
|
7
|
+
data.tar.gz: 4558b0d82b02be2c20a7921c741ca066d857f3aa43cdfa006318647dc709757ff360fbba0a0cdafb160254311fb8a43486e491e033a85b8a4ab68260b165039e
|
data/README.md
CHANGED
|
@@ -48,11 +48,11 @@ starter list (a set of NLP presuppositions) the first time it runs, so
|
|
|
48
48
|
there's always something to drill.
|
|
49
49
|
|
|
50
50
|
Run `drill next` to print the current word and advance to the next one.
|
|
51
|
-
Output is `counter/total` (the word's own 1-based
|
|
52
|
-
|
|
53
|
-
its show count — see "Mastery" below), all on one line:
|
|
51
|
+
Output is the drill sign and `counter/total` (the word's own 1-based
|
|
52
|
+
position in the list), together in yellow, a space, then the word (colored
|
|
53
|
+
by its show count — see "Mastery" below), all on one line:
|
|
54
54
|
```
|
|
55
|
-
1/6
|
|
55
|
+
⟳1/6 apple
|
|
56
56
|
```
|
|
57
57
|
(word 1 of 6 total)
|
|
58
58
|
|
|
@@ -176,31 +176,36 @@ the default, so it's also the way to undo `random`).
|
|
|
176
176
|
|
|
177
177
|
### Google Sheets export
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
back. There are two ways to authenticate — pick whichever you prefer:
|
|
179
|
+
lexdrill can mirror your word list to a tab in a Google Sheet, and pull it
|
|
180
|
+
back. Three independent layers, each a one-time setup:
|
|
182
181
|
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
1. **Auth mode** (`drill remote` / `drill oauth`, no arguments) — *how*
|
|
183
|
+
you authenticate to Google. A single global choice.
|
|
184
|
+
2. **Workbooks** (`drill wb ...`) — *which* spreadsheets lexdrill knows
|
|
185
|
+
about, and which one is currently active. You can add as many as you
|
|
186
|
+
like.
|
|
187
|
+
3. **Sheets** (`drill sh ...`) — *which tab*, within the active workbook,
|
|
188
|
+
is currently active.
|
|
189
|
+
|
|
190
|
+
Once all three are set, `drill push`/`drill pull` (no arguments) act on
|
|
191
|
+
whichever workbook + tab are currently active — and `drill add <text>`
|
|
192
|
+
automatically pushes the updated list too, so your local list and the
|
|
193
|
+
sheet stay in sync as you go.
|
|
194
|
+
|
|
195
|
+
#### Step 1: choose an auth mode
|
|
196
|
+
|
|
197
|
+
- **`drill remote`** — a **service account** you create yourself. No
|
|
198
|
+
interactive sign-in, ever; you share each spreadsheet with the service
|
|
185
199
|
account's email address once, and `drill` uses a local private key file
|
|
186
200
|
to sign its own requests.
|
|
187
|
-
- **`drill oauth
|
|
201
|
+
- **`drill oauth`** — your **personal Google login**, via a one-time
|
|
188
202
|
interactive OAuth approval. No GCP service account needed, but you (or
|
|
189
203
|
anyone using the gem) has to click through a sign-in flow once.
|
|
190
204
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
over to the personal-login flow, and vice versa.
|
|
205
|
+
Whichever you pick applies to every workbook — it's one global setting,
|
|
206
|
+
independent of which spreadsheet is active.
|
|
194
207
|
|
|
195
|
-
|
|
196
|
-
first if it doesn't exist yet) with the current word list, one phrase per
|
|
197
|
-
row, so it stays an exact mirror even if the list shrinks. `import` is the
|
|
198
|
-
reverse: it reads column A of the tab (ignoring any other columns, like an
|
|
199
|
-
old export's show counts) and **replaces** your local `~/.drill/words` —
|
|
200
|
-
useful for editing the list in Sheets and pulling changes back down, or
|
|
201
|
-
seeding a fresh machine from an existing sheet.
|
|
202
|
-
|
|
203
|
-
#### Option A: service account (`drill remote`)
|
|
208
|
+
##### Option A: service account (`drill remote`)
|
|
204
209
|
|
|
205
210
|
No embedded secret, no interactive consent screen — but it does require a
|
|
206
211
|
one-time Google Cloud Console setup, and the resulting private key file is
|
|
@@ -211,7 +216,7 @@ a real secret you must keep local.
|
|
|
211
216
|
**Google Sheets API** is enabled (**APIs & Services → Library**).
|
|
212
217
|
2. **IAM & Admin → Service Accounts → "+ CREATE SERVICE ACCOUNT"**. Name it
|
|
213
218
|
anything (e.g. `lexdrill-export`); skip granting it any project-level
|
|
214
|
-
roles — it doesn't need any, since access comes from sharing
|
|
219
|
+
roles — it doesn't need any, since access comes from sharing each
|
|
215
220
|
document directly.
|
|
216
221
|
3. Click into the new service account → **Keys → Add Key → Create new key
|
|
217
222
|
→ JSON → Create**. This downloads a `.json` file — **this is a real
|
|
@@ -219,22 +224,21 @@ a real secret you must keep local.
|
|
|
219
224
|
share it with anyone.
|
|
220
225
|
4. Note the service account's email (looks like
|
|
221
226
|
`name@your-project-id.iam.gserviceaccount.com` — also the `client_email`
|
|
222
|
-
field in the downloaded JSON).
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
field in the downloaded JSON). Share each spreadsheet you want lexdrill
|
|
228
|
+
to use with this email (**Share** → paste it → grant **Editor** access
|
|
229
|
+
→ uncheck "Notify people" → Share).
|
|
230
|
+
5. Save the downloaded key file to `~/.drill/gcp-service-account.json` on
|
|
226
231
|
your machine (`drill` reads it from that fixed path; it's never embedded
|
|
227
232
|
in the gem or committed anywhere).
|
|
228
233
|
|
|
229
234
|
```bash
|
|
230
|
-
drill remote
|
|
231
|
-
drill export Sheet1
|
|
235
|
+
drill remote
|
|
232
236
|
```
|
|
233
237
|
|
|
234
238
|
No sign-in prompt — every call authenticates silently by signing a fresh,
|
|
235
239
|
short-lived JWT with the local key file.
|
|
236
240
|
|
|
237
|
-
|
|
241
|
+
##### Option B: personal login (`drill oauth`)
|
|
238
242
|
|
|
239
243
|
Uses the OAuth 2.0 **Device Authorization Grant** ("visit this URL, enter
|
|
240
244
|
this code," the same style of flow `gcloud auth login` uses) — you approve
|
|
@@ -258,21 +262,69 @@ personal refresh token, generated only after you interactively approve.
|
|
|
258
262
|
URI needed). Copy the generated Client ID and Client secret into
|
|
259
263
|
`CLIENT_ID`/`CLIENT_SECRET` in `lib/lexdrill/google_auth.rb`.
|
|
260
264
|
4. "Testing" publishing status has historically imposed a 7-day
|
|
261
|
-
refresh-token expiry for some scopes. If `drill
|
|
265
|
+
refresh-token expiry for some scopes. If `drill push` starts asking
|
|
262
266
|
you to re-authorize every week, switch the consent screen to **"In
|
|
263
267
|
production"** — for the `spreadsheets` scope (not a "restricted" scope)
|
|
264
268
|
this just adds an "unverified app" click-through on first consent, no
|
|
265
269
|
Google review required.
|
|
266
270
|
|
|
267
271
|
```bash
|
|
268
|
-
drill oauth
|
|
269
|
-
|
|
272
|
+
drill oauth
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The first call that needs a token prints a URL and a short code — visit
|
|
276
|
+
it, sign in with the Google account that has edit access to your
|
|
277
|
+
spreadsheets, and approve. Every call after that is silent (the cached
|
|
278
|
+
token refreshes itself automatically).
|
|
279
|
+
|
|
280
|
+
#### Step 2: add and switch between workbooks
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
drill wb add 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit'
|
|
284
|
+
drill wb
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`drill wb add <url>` fetches the spreadsheet's own title via the API and
|
|
288
|
+
uses that as its name — no need to type one. The very first workbook you
|
|
289
|
+
add becomes active automatically; adding more doesn't switch you away
|
|
290
|
+
from whichever is currently active. `drill wb` (or `drill wb index`, same
|
|
291
|
+
thing) lists every known workbook, marking the active one with the drill
|
|
292
|
+
sign (⟳); `drill wb use <name>` switches; `drill wb remove <name>` forgets
|
|
293
|
+
one.
|
|
294
|
+
|
|
295
|
+
#### Step 3: add and switch between tabs
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
drill sh
|
|
299
|
+
drill sh use Sheet1
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`drill sh` (or `drill sh index`, or `drill ls`) lists the tabs in the active workbook,
|
|
303
|
+
marking the active one with the drill sign (⟳). `drill sh use <name>`
|
|
304
|
+
switches to a tab **and immediately pulls it** — replacing your local
|
|
305
|
+
word list with that tab's column A. `drill sh add <name>` creates a new,
|
|
306
|
+
empty tab (and makes it active if no tab was selected yet); `drill sh
|
|
307
|
+
remove <name>` deletes one — this is
|
|
308
|
+
real and permanent, so double-check the name.
|
|
309
|
+
|
|
310
|
+
#### Staying in sync
|
|
311
|
+
|
|
312
|
+
Once a workbook and tab are both active:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
drill push # overwrite the active tab with your current word list
|
|
316
|
+
drill pull # replace your word list with the active tab's column A
|
|
317
|
+
drill add some new phrase # appends locally, then pushes automatically
|
|
318
|
+
drill open # opens the active tab in your browser
|
|
270
319
|
```
|
|
271
320
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
321
|
+
`push` always **overwrites** the active tab with the current word list,
|
|
322
|
+
one phrase per row, so it stays an exact mirror even if the list shrinks.
|
|
323
|
+
`pull` is the reverse: it reads column A (ignoring any other columns,
|
|
324
|
+
like an old push's show counts) and **replaces** your local
|
|
325
|
+
`~/.drill/words`. `drill add` never fails because of a sync problem —
|
|
326
|
+
if the push fails (no network, tab renamed, etc.) it just warns on
|
|
327
|
+
stderr; the word is still added locally either way.
|
|
276
328
|
|
|
277
329
|
### Commands
|
|
278
330
|
|
|
@@ -280,20 +332,21 @@ itself automatically).
|
|
|
280
332
|
|---|---|
|
|
281
333
|
| `drill next` | Print the current word and advance |
|
|
282
334
|
| `drill start` / `drill stop` | Pause/resume the automatic per-prompt hook (doesn't affect manual `next`) |
|
|
283
|
-
| `drill inspect` | Show the active config directory, word count, counter value, toggle, beat, rand, color, and
|
|
335
|
+
| `drill inspect` | Show the active config directory, word count, counter value, toggle, beat, rand, color, auth mode, workbook, and sheet state |
|
|
284
336
|
| `drill hook zsh\|bash` | Print the shell integration snippet (used above) |
|
|
285
337
|
| `drill beat <2-8> <repetitions>` / `drill beat none` | Set or disable the rhythm |
|
|
286
338
|
| `drill polka\|waltz\|rock\|jazz\|jiga\|balkan\|samba <repetitions>` | Shorthand for a fixed loop size (see table above) |
|
|
287
339
|
| `drill color random\|default` | Color each shown word randomly, or by its show count (`default`) |
|
|
288
|
-
| `drill add <text>` | Append a new item to the end of the list |
|
|
289
|
-
| `drill list` | Show how many times each item has been shown, numbered |
|
|
290
|
-
| `drill
|
|
340
|
+
| `drill add <text>` | Append a new item to the end of the list; also pushes to the active tab, if one is selected |
|
|
341
|
+
| `drill list` | Show how many times each item has been shown, numbered (`drill all` also works) |
|
|
342
|
+
| `drill edit` | Open the list file in `$EDITOR`/`$VISUAL` (falls back to `vi`) |
|
|
291
343
|
| `drill stats` | Print all items as `<count>\t<phrase>` (tab-separated), sorted by show count, highest first |
|
|
292
344
|
| `drill rand <n>` | `drill next` shows a word ~1-in-`n` times (`n=1` is every time, the default) |
|
|
293
345
|
| `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 |
|
|
294
|
-
| `drill remote
|
|
295
|
-
| `drill
|
|
296
|
-
| `drill
|
|
297
|
-
| `drill
|
|
298
|
-
| `drill
|
|
299
|
-
| `drill
|
|
346
|
+
| `drill remote` / `drill oauth` | Choose the Google Sheets auth mode: local service account key, or personal OAuth login (global, applies to every workbook) |
|
|
347
|
+
| `drill wb` / `wb index` / `add <url>` / `remove <name>` / `use <name>` | List (bare `drill wb` is an alias for `wb index`)/add/forget/switch known workbooks; `add` names the workbook after the spreadsheet's own title; the active one is marked with the drill sign |
|
|
348
|
+
| `drill sh` / `drill ls` / `sh index` / `add <name>` / `remove <name>` / `use <name>` | List (`drill sh`/`drill ls` are aliases for `sh index`)/create/delete/switch tabs in the active workbook; `use` also pulls the tab's contents; the active one is marked with the drill sign |
|
|
349
|
+
| `drill use <name>` | Alias for `drill sh use <name>` |
|
|
350
|
+
| `drill open` | Open the active tab in your default browser |
|
|
351
|
+
| `drill push` | Overwrite the active tab with the current word list text |
|
|
352
|
+
| `drill pull` | Replace the local word list with column A of the active tab |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Global "how to authenticate to Google Sheets" choice: "remote" (service
|
|
4
|
+
# account, no interactive sign-in) or "oauth" (personal Google login).
|
|
5
|
+
# Independent of which workbook is active — see Lexdrill::Workbooks for
|
|
6
|
+
# that. Set via `drill remote` / `drill oauth` (no arguments).
|
|
7
|
+
module Lexdrill::AuthMode
|
|
8
|
+
PATH = Lexdrill::Config.path("auth-mode")
|
|
9
|
+
REMOTE = "remote"
|
|
10
|
+
OAUTH = "oauth"
|
|
11
|
+
VALID = [REMOTE, OAUTH].freeze
|
|
12
|
+
|
|
13
|
+
def self.set(mode)
|
|
14
|
+
File.write(PATH, mode)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.current
|
|
18
|
+
return nil unless File.exist?(PATH)
|
|
19
|
+
|
|
20
|
+
value = File.read(PATH).strip
|
|
21
|
+
VALID.include?(value) ? value : nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
# Opens a URL in the system's default browser, cross-platform, for
|
|
6
|
+
# `drill open`.
|
|
7
|
+
module Lexdrill::Browser
|
|
8
|
+
def self.open(url)
|
|
9
|
+
system(*open_command(url), out: File::NULL, err: File::NULL)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.open_command(url)
|
|
13
|
+
case RbConfig::CONFIG["host_os"]
|
|
14
|
+
when /darwin/ then ["open", url]
|
|
15
|
+
when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", "", url]
|
|
16
|
+
else ["xdg-open", url]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
private_class_method :open_command
|
|
20
|
+
end
|
data/lib/lexdrill/cli.rb
CHANGED
|
@@ -13,17 +13,19 @@ class Lexdrill::CLI
|
|
|
13
13
|
run_beat_alias: %w[polka waltz rock jazz jiga balkan samba],
|
|
14
14
|
run_color: %w[color],
|
|
15
15
|
run_add: %w[add],
|
|
16
|
-
run_list: %w[list],
|
|
17
|
-
|
|
16
|
+
run_list: %w[list all],
|
|
17
|
+
run_edit: %w[edit],
|
|
18
18
|
run_stats: %w[stats],
|
|
19
19
|
run_rand: %w[rand],
|
|
20
20
|
run_go: %w[go],
|
|
21
21
|
run_remote: %w[remote],
|
|
22
22
|
run_oauth: %w[oauth],
|
|
23
23
|
run_wb: %w[wb],
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
run_sh: %w[sh ls],
|
|
25
|
+
run_use: %w[use],
|
|
26
|
+
run_open: %w[open],
|
|
27
|
+
run_push: %w[push],
|
|
28
|
+
run_pull: %w[pull]
|
|
27
29
|
}.freeze
|
|
28
30
|
|
|
29
31
|
def self.start(argv = ARGV)
|
|
@@ -69,21 +71,29 @@ class Lexdrill::CLI
|
|
|
69
71
|
drill polka|waltz|rock|jazz|jiga|balkan|samba [repetitions]
|
|
70
72
|
Shorthand for a fixed loop size (2 through 8, in order)
|
|
71
73
|
drill color random|default Color each word randomly, or by its show count (default)
|
|
72
|
-
drill add <text> Append a new item to the
|
|
73
|
-
drill list Show how many times each item has been shown
|
|
74
|
-
drill
|
|
74
|
+
drill add <text> Append a new item to the list (and push, if a sheet is selected)
|
|
75
|
+
drill list Show how many times each item has been shown (drill all also works)
|
|
76
|
+
drill edit Open the list file in $EDITOR/$VISUAL (falls back to vi)
|
|
75
77
|
drill stats Print items as <count>\t<phrase>, tab-separated, highest count first
|
|
76
78
|
drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time)
|
|
77
79
|
drill go <number> Jump so the next `next` shows item <number> (1-based, see drill list)
|
|
78
|
-
drill remote
|
|
80
|
+
drill remote Use a local service account key for Google Sheets auth
|
|
79
81
|
(~/.drill/gcp-service-account.json) — no interactive sign-in
|
|
80
|
-
drill oauth
|
|
81
|
-
drill wb
|
|
82
|
-
drill
|
|
83
|
-
drill
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
drill
|
|
82
|
+
drill oauth Use your personal Google login for Google Sheets auth
|
|
83
|
+
drill wb Alias for drill wb index
|
|
84
|
+
drill wb index List known workbooks (name and URL)
|
|
85
|
+
drill wb add <url> Add a workbook by URL (named after its own title)
|
|
86
|
+
drill wb remove <name> Forget a workbook
|
|
87
|
+
drill wb use <name> Switch the active workbook
|
|
88
|
+
drill sh Alias for drill sh index (drill ls also works)
|
|
89
|
+
drill sh index List the tabs in the active workbook
|
|
90
|
+
drill sh add <name> Create a new tab
|
|
91
|
+
drill sh remove <name> Delete a tab
|
|
92
|
+
drill sh use <name> Switch the active tab (pulls its contents)
|
|
93
|
+
drill use <name> Alias for drill sh use <name>
|
|
94
|
+
drill open Open the active tab in your browser
|
|
95
|
+
drill push Push the word list text to the active tab (overwrites it)
|
|
96
|
+
drill pull Replace the local word list with the active tab's column A
|
|
87
97
|
HELP
|
|
88
98
|
0
|
|
89
99
|
end
|
|
@@ -216,15 +226,34 @@ class Lexdrill::CLI
|
|
|
216
226
|
1
|
|
217
227
|
end
|
|
218
228
|
|
|
229
|
+
# Appends locally, then opportunistically pushes the updated list to the
|
|
230
|
+
# active tab if one is selected — a failed push is reported but doesn't
|
|
231
|
+
# change the exit code, since the local add always succeeds regardless.
|
|
219
232
|
def run_add
|
|
220
233
|
text = argv[1..].join(" ")
|
|
221
234
|
return print_add_usage if text.empty?
|
|
222
235
|
|
|
223
236
|
File.open(Lexdrill::WordList::PATH, "a", encoding: "UTF-8") { |file| file.puts(text) }
|
|
224
237
|
puts "added: #{text}"
|
|
238
|
+
push_after_add
|
|
225
239
|
0
|
|
226
240
|
end
|
|
227
241
|
|
|
242
|
+
def push_after_add
|
|
243
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
244
|
+
sheet_name = Lexdrill::Workbooks.current_sheet
|
|
245
|
+
return unless spreadsheet_id && sheet_name
|
|
246
|
+
|
|
247
|
+
token = current_token
|
|
248
|
+
return unless token
|
|
249
|
+
|
|
250
|
+
Lexdrill::WordList.instance_variable_set(:@words, nil)
|
|
251
|
+
perform_push(spreadsheet_id, sheet_name, token)
|
|
252
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError,
|
|
253
|
+
Lexdrill::SheetsClient::ApiError, Lexdrill::HTTPClient::NetworkError => error
|
|
254
|
+
warn "drill: added locally, but couldn't push to #{sheet_name.inspect}: #{error.message}"
|
|
255
|
+
end
|
|
256
|
+
|
|
228
257
|
def print_add_usage
|
|
229
258
|
warn "usage: drill add <text>"
|
|
230
259
|
1
|
|
@@ -239,7 +268,7 @@ class Lexdrill::CLI
|
|
|
239
268
|
0
|
|
240
269
|
end
|
|
241
270
|
|
|
242
|
-
def
|
|
271
|
+
def run_edit
|
|
243
272
|
editor_cmd = (ENV["VISUAL"] || ENV["EDITOR"] || "vi").split
|
|
244
273
|
system(*editor_cmd, Lexdrill::WordList::PATH) ? 0 : 1
|
|
245
274
|
end
|
|
@@ -299,74 +328,170 @@ class Lexdrill::CLI
|
|
|
299
328
|
end
|
|
300
329
|
|
|
301
330
|
def run_remote
|
|
302
|
-
|
|
303
|
-
|
|
331
|
+
Lexdrill::AuthMode.set(Lexdrill::AuthMode::REMOTE)
|
|
332
|
+
puts "auth mode set: remote (service account)"
|
|
333
|
+
0
|
|
334
|
+
end
|
|
304
335
|
|
|
305
|
-
|
|
306
|
-
|
|
336
|
+
def run_oauth
|
|
337
|
+
Lexdrill::AuthMode.set(Lexdrill::AuthMode::OAUTH)
|
|
338
|
+
puts "auth mode set: oauth (personal login)"
|
|
307
339
|
0
|
|
308
|
-
rescue ArgumentError
|
|
309
|
-
print_invalid_remote_url(url)
|
|
310
340
|
end
|
|
311
341
|
|
|
312
|
-
def
|
|
313
|
-
|
|
342
|
+
def current_token
|
|
343
|
+
case Lexdrill::AuthMode.current
|
|
344
|
+
when Lexdrill::AuthMode::REMOTE then Lexdrill::ServiceAccountAuth.fetch_token!
|
|
345
|
+
when Lexdrill::AuthMode::OAUTH then Lexdrill::GoogleAuth.ensure_token!
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def print_no_auth_mode
|
|
350
|
+
warn "drill: no auth mode configured; run `drill remote` (service account) or `drill oauth` (personal login) first"
|
|
314
351
|
1
|
|
315
352
|
end
|
|
316
353
|
|
|
317
|
-
def
|
|
318
|
-
|
|
354
|
+
def run_wb
|
|
355
|
+
case argv[1]
|
|
356
|
+
when nil, "index" then run_wb_index
|
|
357
|
+
when "add" then run_wb_add
|
|
358
|
+
when "remove" then run_wb_remove
|
|
359
|
+
when "use" then run_wb_use
|
|
360
|
+
else print_wb_usage
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def print_wb_usage
|
|
365
|
+
warn "usage: drill wb index|add <url>|remove <name>|use <name>"
|
|
319
366
|
1
|
|
320
367
|
end
|
|
321
368
|
|
|
322
|
-
def
|
|
323
|
-
|
|
324
|
-
return
|
|
369
|
+
def run_wb_index
|
|
370
|
+
names = Lexdrill::Workbooks.names
|
|
371
|
+
return print_no_workbooks if names.empty?
|
|
325
372
|
|
|
326
|
-
Lexdrill::
|
|
327
|
-
puts "
|
|
373
|
+
current = Lexdrill::Workbooks.current_name
|
|
374
|
+
names.each { |name| puts "#{current_marker(name == current)} #{name} #{Lexdrill::Workbooks.url_for(name)}" }
|
|
328
375
|
0
|
|
329
|
-
rescue ArgumentError
|
|
330
|
-
print_invalid_oauth_url(url)
|
|
331
376
|
end
|
|
332
377
|
|
|
333
|
-
|
|
334
|
-
|
|
378
|
+
# A painted drill sign for the current item, or a blank space otherwise —
|
|
379
|
+
# used by `drill wb`/`drill sh` to mark the active workbook/sheet.
|
|
380
|
+
def current_marker(is_current)
|
|
381
|
+
is_current ? Lexdrill::Colorizer.paint_yellow(Lexdrill::LineFormatter::SEPARATOR) : " "
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def print_no_workbooks
|
|
385
|
+
warn "drill: no workbooks configured; run `drill wb add <url>`"
|
|
335
386
|
1
|
|
336
387
|
end
|
|
337
388
|
|
|
338
|
-
def
|
|
339
|
-
|
|
389
|
+
def run_wb_add
|
|
390
|
+
url = argv[2]
|
|
391
|
+
return print_wb_add_usage unless url
|
|
392
|
+
|
|
393
|
+
spreadsheet_id = Lexdrill::Workbooks.extract_id(url)
|
|
394
|
+
return print_invalid_wb_url(url) unless spreadsheet_id
|
|
395
|
+
|
|
396
|
+
add_workbook(spreadsheet_id)
|
|
397
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
398
|
+
warn "drill: #{error.message}"
|
|
399
|
+
1
|
|
400
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
401
|
+
print_sheets_api_error(error)
|
|
402
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
403
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
340
404
|
1
|
|
341
405
|
end
|
|
342
406
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
# access_token], or nil if neither is configured.
|
|
347
|
-
def sheets_target
|
|
348
|
-
kind = Lexdrill::RemoteTarget.kind
|
|
349
|
-
spreadsheet_id = Lexdrill::RemoteTarget.spreadsheet_id
|
|
350
|
-
return unless spreadsheet_id
|
|
407
|
+
def add_workbook(spreadsheet_id)
|
|
408
|
+
token = current_token
|
|
409
|
+
return print_no_auth_mode unless token
|
|
351
410
|
|
|
352
|
-
|
|
411
|
+
title = Lexdrill::SheetsClient.spreadsheet_title(spreadsheet_id, token)
|
|
412
|
+
return print_wb_name_taken(title) if Lexdrill::Workbooks.names.include?(title)
|
|
413
|
+
|
|
414
|
+
Lexdrill::Workbooks.add(title, spreadsheet_id)
|
|
415
|
+
puts "workbook added: #{title.inspect}"
|
|
416
|
+
0
|
|
353
417
|
end
|
|
354
418
|
|
|
355
|
-
def
|
|
356
|
-
|
|
357
|
-
|
|
419
|
+
def print_wb_add_usage
|
|
420
|
+
warn "usage: drill wb add <url>"
|
|
421
|
+
1
|
|
422
|
+
end
|
|
358
423
|
|
|
359
|
-
|
|
424
|
+
def print_invalid_wb_url(url)
|
|
425
|
+
warn "drill: could not find a spreadsheet id in #{url.inspect}"
|
|
426
|
+
1
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def print_wb_name_taken(title)
|
|
430
|
+
warn "drill: a workbook named #{title.inspect} is already configured"
|
|
431
|
+
1
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def run_wb_remove
|
|
435
|
+
name = argv[2]
|
|
436
|
+
return print_wb_remove_usage unless name
|
|
437
|
+
return print_no_such_workbook(name) unless Lexdrill::Workbooks.remove(name)
|
|
438
|
+
|
|
439
|
+
puts "workbook removed: #{name.inspect}"
|
|
360
440
|
0
|
|
361
441
|
end
|
|
362
442
|
|
|
363
|
-
def
|
|
364
|
-
|
|
365
|
-
|
|
443
|
+
def print_wb_remove_usage
|
|
444
|
+
warn "usage: drill wb remove <name>"
|
|
445
|
+
1
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def print_no_such_workbook(name)
|
|
449
|
+
warn "drill: no workbook named #{name.inspect} (see `drill wb index`)"
|
|
450
|
+
1
|
|
451
|
+
end
|
|
366
452
|
|
|
367
|
-
|
|
368
|
-
|
|
453
|
+
def run_wb_use
|
|
454
|
+
name = argv[2]
|
|
455
|
+
return print_wb_use_usage unless name
|
|
456
|
+
return print_no_such_workbook(name) unless Lexdrill::Workbooks.use(name)
|
|
457
|
+
|
|
458
|
+
puts "workbook set: #{name.inspect}"
|
|
369
459
|
0
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def print_wb_use_usage
|
|
463
|
+
warn "usage: drill wb use <name>"
|
|
464
|
+
1
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def run_sh
|
|
468
|
+
case argv[1]
|
|
469
|
+
when nil, "index" then run_sh_index
|
|
470
|
+
when "add" then run_sh_add
|
|
471
|
+
when "remove" then run_sh_remove
|
|
472
|
+
when "use" then run_sh_use
|
|
473
|
+
else print_sh_usage
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def print_sh_usage
|
|
478
|
+
warn "usage: drill sh index|add <name>|remove <name>|use <name>"
|
|
479
|
+
1
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def print_no_workbook_selected
|
|
483
|
+
warn "drill: no workbook selected; run `drill wb add <url>` or `drill wb use <name>` first"
|
|
484
|
+
1
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def run_sh_index
|
|
488
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
489
|
+
return print_no_workbook_selected unless spreadsheet_id
|
|
490
|
+
|
|
491
|
+
token = current_token
|
|
492
|
+
return print_no_auth_mode unless token
|
|
493
|
+
|
|
494
|
+
list_sheets(spreadsheet_id, token)
|
|
370
495
|
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
371
496
|
warn "drill: #{error.message}"
|
|
372
497
|
1
|
|
@@ -377,80 +502,209 @@ class Lexdrill::CLI
|
|
|
377
502
|
1
|
|
378
503
|
end
|
|
379
504
|
|
|
380
|
-
def
|
|
381
|
-
|
|
382
|
-
|
|
505
|
+
def list_sheets(spreadsheet_id, token)
|
|
506
|
+
current = Lexdrill::Workbooks.current_sheet
|
|
507
|
+
Lexdrill::SheetsClient.sheet_titles(spreadsheet_id, token).each do |title|
|
|
508
|
+
puts "#{current_marker(title == current)} #{title}"
|
|
509
|
+
end
|
|
510
|
+
0
|
|
511
|
+
end
|
|
383
512
|
|
|
384
|
-
|
|
385
|
-
|
|
513
|
+
def run_sh_add
|
|
514
|
+
name = argv[2]
|
|
515
|
+
return print_sh_add_usage unless name
|
|
386
516
|
|
|
387
|
-
|
|
517
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
518
|
+
return print_no_workbook_selected unless spreadsheet_id
|
|
519
|
+
|
|
520
|
+
token = current_token
|
|
521
|
+
return print_no_auth_mode unless token
|
|
522
|
+
|
|
523
|
+
add_sheet(spreadsheet_id, name, token)
|
|
388
524
|
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
389
525
|
warn "drill: #{error.message}"
|
|
390
526
|
1
|
|
391
527
|
rescue Lexdrill::SheetsClient::ApiError => error
|
|
392
|
-
print_sheets_api_error(error,
|
|
528
|
+
print_sheets_api_error(error, name)
|
|
393
529
|
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
394
530
|
warn "drill: network error talking to Google (#{error.message})"
|
|
395
531
|
1
|
|
396
532
|
end
|
|
397
533
|
|
|
398
|
-
def
|
|
399
|
-
|
|
400
|
-
Lexdrill::
|
|
401
|
-
puts "
|
|
534
|
+
def add_sheet(spreadsheet_id, name, token)
|
|
535
|
+
sheet_id = Lexdrill::SheetsClient.add_sheet(spreadsheet_id, name, token)
|
|
536
|
+
Lexdrill::Workbooks.set_current_sheet(name, sheet_id) unless Lexdrill::Workbooks.current_sheet
|
|
537
|
+
puts "sheet added: #{name.inspect}"
|
|
402
538
|
0
|
|
403
539
|
end
|
|
404
540
|
|
|
405
|
-
def
|
|
406
|
-
|
|
541
|
+
def print_sh_add_usage
|
|
542
|
+
warn "usage: drill sh add <name>"
|
|
543
|
+
1
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def run_sh_remove
|
|
547
|
+
name = argv[2]
|
|
548
|
+
return print_sh_remove_usage unless name
|
|
549
|
+
|
|
550
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
551
|
+
return print_no_workbook_selected unless spreadsheet_id
|
|
552
|
+
|
|
553
|
+
token = current_token
|
|
554
|
+
return print_no_auth_mode unless token
|
|
555
|
+
|
|
556
|
+
remove_sheet(spreadsheet_id, name, token)
|
|
557
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
558
|
+
warn "drill: #{error.message}"
|
|
559
|
+
1
|
|
560
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
561
|
+
print_sheets_api_error(error, name)
|
|
562
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
563
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
564
|
+
1
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def remove_sheet(spreadsheet_id, name, token)
|
|
568
|
+
sheet_id = Lexdrill::SheetsClient.find_sheet_id(spreadsheet_id, name, token)
|
|
569
|
+
return print_no_such_sheet(name) unless sheet_id
|
|
570
|
+
|
|
571
|
+
Lexdrill::SheetsClient.delete_sheet(spreadsheet_id, sheet_id, token)
|
|
572
|
+
puts "sheet removed: #{name.inspect}"
|
|
573
|
+
0
|
|
407
574
|
end
|
|
408
575
|
|
|
409
|
-
def
|
|
410
|
-
warn "usage: drill
|
|
576
|
+
def print_sh_remove_usage
|
|
577
|
+
warn "usage: drill sh remove <name>"
|
|
411
578
|
1
|
|
412
579
|
end
|
|
413
580
|
|
|
414
|
-
def
|
|
415
|
-
warn "drill: no
|
|
416
|
-
"or `drill oauth <url>` (personal login) first"
|
|
581
|
+
def print_no_such_sheet(name)
|
|
582
|
+
warn "drill: no tab named #{name.inspect} in the current workbook"
|
|
417
583
|
1
|
|
418
584
|
end
|
|
419
585
|
|
|
420
|
-
def
|
|
421
|
-
|
|
422
|
-
|
|
586
|
+
def run_sh_use
|
|
587
|
+
use_sheet(argv[2])
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# `drill use <name>` is a shortcut for `drill sh use <name>` — the name
|
|
591
|
+
# sits at a different argv position, so this and run_sh_use share the
|
|
592
|
+
# same underlying logic rather than duplicating it.
|
|
593
|
+
def run_use
|
|
594
|
+
use_sheet(argv[1])
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def use_sheet(name)
|
|
598
|
+
return print_sh_use_usage unless name
|
|
599
|
+
|
|
600
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
601
|
+
return print_no_workbook_selected unless spreadsheet_id
|
|
423
602
|
|
|
424
|
-
|
|
425
|
-
return
|
|
603
|
+
token = current_token
|
|
604
|
+
return print_no_auth_mode unless token
|
|
426
605
|
|
|
427
|
-
|
|
606
|
+
select_sheet(spreadsheet_id, name, token)
|
|
428
607
|
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
429
608
|
warn "drill: #{error.message}"
|
|
430
609
|
1
|
|
431
610
|
rescue Lexdrill::SheetsClient::ApiError => error
|
|
432
|
-
print_sheets_api_error(error,
|
|
611
|
+
print_sheets_api_error(error, name)
|
|
433
612
|
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
434
613
|
warn "drill: network error talking to Google (#{error.message})"
|
|
435
614
|
1
|
|
436
615
|
end
|
|
437
616
|
|
|
438
|
-
def
|
|
439
|
-
|
|
440
|
-
return
|
|
617
|
+
def select_sheet(spreadsheet_id, name, token)
|
|
618
|
+
sheet_id = Lexdrill::SheetsClient.find_sheet_id(spreadsheet_id, name, token)
|
|
619
|
+
return print_no_such_sheet(name) unless sheet_id
|
|
441
620
|
|
|
442
|
-
|
|
443
|
-
|
|
621
|
+
Lexdrill::Workbooks.set_current_sheet(name, sheet_id)
|
|
622
|
+
perform_pull(spreadsheet_id, name, token)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def print_sh_use_usage
|
|
626
|
+
warn "usage: drill sh use <name> (or drill use <name>)"
|
|
627
|
+
1
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def run_open
|
|
631
|
+
url = Lexdrill::Workbooks.current_url
|
|
632
|
+
return print_no_workbook_selected unless url
|
|
633
|
+
|
|
634
|
+
sheet_id = Lexdrill::Workbooks.current_sheet_id
|
|
635
|
+
Lexdrill::Browser.open(sheet_id ? "#{url}#gid=#{sheet_id}" : url)
|
|
636
|
+
0
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def print_no_sheet_selected
|
|
640
|
+
warn "drill: no sheet selected; run `drill sh use <name>` first"
|
|
641
|
+
1
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def run_push
|
|
645
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
646
|
+
return print_no_workbook_selected unless spreadsheet_id
|
|
647
|
+
|
|
648
|
+
sheet_name = Lexdrill::Workbooks.current_sheet
|
|
649
|
+
return print_no_sheet_selected unless sheet_name
|
|
650
|
+
|
|
651
|
+
token = current_token
|
|
652
|
+
return print_no_auth_mode unless token
|
|
653
|
+
|
|
654
|
+
perform_push(spreadsheet_id, sheet_name, token)
|
|
655
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
656
|
+
warn "drill: #{error.message}"
|
|
657
|
+
1
|
|
658
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
659
|
+
print_sheets_api_error(error, sheet_name)
|
|
660
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
661
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
662
|
+
1
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def perform_push(spreadsheet_id, sheet_name, token)
|
|
666
|
+
rows = push_rows
|
|
667
|
+
Lexdrill::SheetsClient.overwrite_sheet(spreadsheet_id, sheet_name, rows, token)
|
|
668
|
+
puts "pushed #{rows.size} word(s) to #{sheet_name.inspect}"
|
|
444
669
|
0
|
|
445
670
|
end
|
|
446
671
|
|
|
447
|
-
def
|
|
448
|
-
|
|
672
|
+
def push_rows
|
|
673
|
+
Lexdrill::WordList.words.map { |word| [word] }
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def run_pull
|
|
677
|
+
spreadsheet_id = Lexdrill::Workbooks.current_id
|
|
678
|
+
return print_no_workbook_selected unless spreadsheet_id
|
|
679
|
+
|
|
680
|
+
sheet_name = Lexdrill::Workbooks.current_sheet
|
|
681
|
+
return print_no_sheet_selected unless sheet_name
|
|
682
|
+
|
|
683
|
+
token = current_token
|
|
684
|
+
return print_no_auth_mode unless token
|
|
685
|
+
|
|
686
|
+
perform_pull(spreadsheet_id, sheet_name, token)
|
|
687
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
688
|
+
warn "drill: #{error.message}"
|
|
689
|
+
1
|
|
690
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
691
|
+
print_sheets_api_error(error, sheet_name)
|
|
692
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
693
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
449
694
|
1
|
|
450
695
|
end
|
|
451
696
|
|
|
452
|
-
def
|
|
453
|
-
|
|
697
|
+
def perform_pull(spreadsheet_id, sheet_name, token)
|
|
698
|
+
words = Lexdrill::SheetsClient.read_column(spreadsheet_id, sheet_name, token)
|
|
699
|
+
return print_empty_pull(sheet_name) if words.empty?
|
|
700
|
+
|
|
701
|
+
File.write(Lexdrill::WordList::PATH, "#{words.join("\n")}\n", encoding: "UTF-8")
|
|
702
|
+
puts "pulled #{words.size} word(s) from #{sheet_name.inspect}"
|
|
703
|
+
0
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def print_empty_pull(sheet_name)
|
|
707
|
+
warn "drill: #{sheet_name.inspect} has no data to pull"
|
|
454
708
|
1
|
|
455
709
|
end
|
|
456
710
|
|
|
@@ -458,7 +712,7 @@ class Lexdrill::CLI
|
|
|
458
712
|
status = error.status
|
|
459
713
|
message = error.message
|
|
460
714
|
case status
|
|
461
|
-
when 404 then warn "drill: spreadsheet not found or not accessible (check `drill
|
|
715
|
+
when 404 then warn "drill: spreadsheet not found or not accessible (check `drill wb index`)"
|
|
462
716
|
when 403 then warn "drill: access denied — make sure the spreadsheet is shared with the right account"
|
|
463
717
|
when 400 then print_400_error(message, sheet_name)
|
|
464
718
|
else warn "drill: Google Sheets API error (#{status}): #{message}"
|
data/lib/lexdrill/http_client.rb
CHANGED
|
@@ -18,9 +18,17 @@ module Lexdrill::HTTPClient
|
|
|
18
18
|
|
|
19
19
|
class NetworkError < StandardError; end
|
|
20
20
|
|
|
21
|
+
# Net::HTTP's 2-arg instance `#post(path, body)` sends no Content-Type
|
|
22
|
+
# header at all — most token endpoints tolerate that (defaulting to
|
|
23
|
+
# form-urlencoded), but not all do, so this sets it explicitly.
|
|
21
24
|
def self.post_form(url, params)
|
|
22
25
|
uri = URI(url)
|
|
23
|
-
perform(uri)
|
|
26
|
+
perform(uri) do |http|
|
|
27
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
28
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
29
|
+
request.body = URI.encode_www_form(params)
|
|
30
|
+
http.request(request)
|
|
31
|
+
end
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
def self.json_post(url, body:, headers: {})
|
data/lib/lexdrill/inspector.rb
CHANGED
|
@@ -15,13 +15,18 @@ module Lexdrill::Inspector
|
|
|
15
15
|
Beat: #{beat_summary}
|
|
16
16
|
Rand: #{rand_summary}
|
|
17
17
|
Color: #{Lexdrill::Color.current}
|
|
18
|
-
|
|
18
|
+
Auth mode: #{Lexdrill::AuthMode.current || 'not configured'}
|
|
19
|
+
Workbook: #{workbook_summary}
|
|
20
|
+
Sheet: #{Lexdrill::Workbooks.current_sheet || 'not selected'}
|
|
19
21
|
Config dir: #{Lexdrill::Config::DIR}
|
|
20
22
|
REPORT
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
def self.
|
|
24
|
-
Lexdrill::
|
|
25
|
+
def self.workbook_summary
|
|
26
|
+
name = Lexdrill::Workbooks.current_name
|
|
27
|
+
return "not selected" unless name
|
|
28
|
+
|
|
29
|
+
"#{name} (#{Lexdrill::Workbooks.current_url})"
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
def self.words_summary
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Formats a shown word for display, on one line:
|
|
4
|
-
# "{index}/{total}
|
|
4
|
+
# "{SEPARATOR}{index}/{total} {word}" — index is the word's own 1-based
|
|
5
5
|
# position in the list, re-derived through Lexdrill::Beat so it stays
|
|
6
|
-
# meaningful even when a rhythm repeats steps. The
|
|
7
|
-
#
|
|
8
|
-
# show count (see Lexdrill::Colorizer).
|
|
6
|
+
# meaningful even when a rhythm repeats steps. The separator and
|
|
7
|
+
# counter/total are painted yellow, as one unit; the word is colored by
|
|
8
|
+
# its show count (see Lexdrill::Colorizer).
|
|
9
9
|
module Lexdrill::LineFormatter
|
|
10
10
|
SEPARATOR = "⟳"
|
|
11
11
|
|
|
12
12
|
def self.format(word)
|
|
13
13
|
total = Lexdrill::WordList.words.size
|
|
14
14
|
count = Lexdrill::Stats.counts.fetch(word, 0)
|
|
15
|
-
prefix = Lexdrill::Colorizer.paint_yellow("#{loop_index(total) + 1}/#{total}
|
|
15
|
+
prefix = Lexdrill::Colorizer.paint_yellow("#{SEPARATOR}#{loop_index(total) + 1}/#{total}")
|
|
16
16
|
"#{prefix} #{Lexdrill::Colorizer.paint_by_count(word, count)}"
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -4,10 +4,12 @@ require "uri"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
# Thin wrapper around the Google Sheets API v4 REST endpoints needed by
|
|
7
|
-
# `drill
|
|
7
|
+
# `drill push`/`drill pull`: create the target tab if it doesn't already
|
|
8
8
|
# exist, clear its contents, write new rows into it, then auto-fit column
|
|
9
9
|
# A's width to the content so long phrases aren't visually truncated; or,
|
|
10
|
-
# for
|
|
10
|
+
# for pull, read column A back out of an existing tab. Also covers
|
|
11
|
+
# workbook/tab management for `drill wb`/`drill sh`: the workbook's title,
|
|
12
|
+
# the list of tab titles, and creating/deleting a tab.
|
|
11
13
|
module Lexdrill::SheetsClient
|
|
12
14
|
BASE_URL = "https://sheets.googleapis.com/v4/spreadsheets"
|
|
13
15
|
|
|
@@ -42,13 +44,27 @@ module Lexdrill::SheetsClient
|
|
|
42
44
|
end
|
|
43
45
|
private_class_method :find_sheet
|
|
44
46
|
|
|
47
|
+
# The sheetId for a tab by title, or nil if no tab has that title —
|
|
48
|
+
# for `drill sh use`/`drill sh remove`, which need the numeric id (not
|
|
49
|
+
# just the name) to build a direct link or issue a delete request.
|
|
50
|
+
def self.find_sheet_id(spreadsheet_id, sheet_name, access_token)
|
|
51
|
+
find_sheet(spreadsheet_id, sheet_name, access_token)&.[]("sheetId")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Creates a new, empty tab, for `drill sh add`. Returns its sheetId.
|
|
45
55
|
def self.add_sheet(spreadsheet_id, sheet_name, access_token)
|
|
46
56
|
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
47
57
|
body = { "requests" => [{ "addSheet" => { "properties" => { "title" => sheet_name } } }] }
|
|
48
58
|
result = handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
|
|
49
59
|
result.dig("replies", 0, "addSheet", "properties", "sheetId")
|
|
50
60
|
end
|
|
51
|
-
|
|
61
|
+
|
|
62
|
+
# Deletes a tab by its numeric sheetId, for `drill sh remove`.
|
|
63
|
+
def self.delete_sheet(spreadsheet_id, sheet_id, access_token)
|
|
64
|
+
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
65
|
+
body = { "requests" => [{ "deleteSheet" => { "sheetId" => sheet_id } }] }
|
|
66
|
+
handle_response(Lexdrill::HTTPClient.json_post(url, body: body, headers: auth_header(access_token)))
|
|
67
|
+
end
|
|
52
68
|
|
|
53
69
|
def self.autofit_first_column(spreadsheet_id, sheet_id, access_token)
|
|
54
70
|
url = "#{BASE_URL}/#{spreadsheet_id}:batchUpdate"
|
|
@@ -77,13 +93,20 @@ module Lexdrill::SheetsClient
|
|
|
77
93
|
data.fetch("values", []).filter_map { |row| row[0]&.strip }.reject(&:empty?)
|
|
78
94
|
end
|
|
79
95
|
|
|
80
|
-
# The titles of every tab in the workbook, for `drill
|
|
96
|
+
# The titles of every tab in the workbook, for `drill sh index`.
|
|
81
97
|
def self.sheet_titles(spreadsheet_id, access_token)
|
|
82
98
|
url = "#{BASE_URL}/#{spreadsheet_id}?fields=sheets.properties.title"
|
|
83
99
|
data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
|
|
84
100
|
data.fetch("sheets", []).map { |sheet| sheet.dig("properties", "title") }
|
|
85
101
|
end
|
|
86
102
|
|
|
103
|
+
# The workbook's own title, for naming it automatically in `drill wb add`.
|
|
104
|
+
def self.spreadsheet_title(spreadsheet_id, access_token)
|
|
105
|
+
url = "#{BASE_URL}/#{spreadsheet_id}?fields=properties.title"
|
|
106
|
+
data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
|
|
107
|
+
data.dig("properties", "title")
|
|
108
|
+
end
|
|
109
|
+
|
|
87
110
|
def self.auth_header(token)
|
|
88
111
|
{ "Authorization" => "Bearer #{token}" }
|
|
89
112
|
end
|
data/lib/lexdrill/version.rb
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Global list of known Google Sheets workbooks (name => spreadsheet id),
|
|
6
|
+
# which one is currently active, and — per workbook — which tab is
|
|
7
|
+
# currently active within it. Lives at ~/.drill/workbooks.json. Names are
|
|
8
|
+
# the spreadsheet's own title (see `drill wb add`), not chosen by hand.
|
|
9
|
+
module Lexdrill::Workbooks
|
|
10
|
+
PATH = Lexdrill::Config.path("workbooks.json")
|
|
11
|
+
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
12
|
+
|
|
13
|
+
def self.extract_id(url)
|
|
14
|
+
url[URL_PATTERN, 1]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.names
|
|
18
|
+
load.fetch("workbooks").keys
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.add(name, spreadsheet_id)
|
|
22
|
+
data = load
|
|
23
|
+
data["workbooks"][name] = { "id" => spreadsheet_id }
|
|
24
|
+
data["current"] ||= name
|
|
25
|
+
save(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.remove(name)
|
|
29
|
+
data = load
|
|
30
|
+
return false unless data["workbooks"].delete(name)
|
|
31
|
+
|
|
32
|
+
data["current"] = nil if data["current"] == name
|
|
33
|
+
save(data)
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.use(name)
|
|
38
|
+
data = load
|
|
39
|
+
return false unless data["workbooks"].key?(name)
|
|
40
|
+
|
|
41
|
+
data["current"] = name
|
|
42
|
+
save(data)
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.current_name
|
|
47
|
+
load["current"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.current_id
|
|
51
|
+
current_entry&.fetch("id")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.url_for(name)
|
|
55
|
+
entry = load.fetch("workbooks")[name]
|
|
56
|
+
entry && "https://docs.google.com/spreadsheets/d/#{entry.fetch('id')}/edit"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.current_url
|
|
60
|
+
name = current_name
|
|
61
|
+
name && url_for(name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.set_current_sheet(sheet_name, sheet_id)
|
|
65
|
+
data = load
|
|
66
|
+
workbooks = data["workbooks"]
|
|
67
|
+
name = data["current"]
|
|
68
|
+
return false unless name && workbooks.key?(name)
|
|
69
|
+
|
|
70
|
+
entry = workbooks[name]
|
|
71
|
+
entry["current_sheet"] = sheet_name
|
|
72
|
+
entry["current_sheet_id"] = sheet_id
|
|
73
|
+
save(data)
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.current_sheet
|
|
78
|
+
current_entry&.[]("current_sheet")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.current_sheet_id
|
|
82
|
+
current_entry&.[]("current_sheet_id")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.current_entry
|
|
86
|
+
name = current_name
|
|
87
|
+
name && load.fetch("workbooks")[name]
|
|
88
|
+
end
|
|
89
|
+
private_class_method :current_entry
|
|
90
|
+
|
|
91
|
+
def self.load
|
|
92
|
+
return { "current" => nil, "workbooks" => {} } unless File.exist?(PATH)
|
|
93
|
+
|
|
94
|
+
data = JSON.parse(File.read(PATH, encoding: "UTF-8"))
|
|
95
|
+
data["workbooks"] ||= {}
|
|
96
|
+
data
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
{ "current" => nil, "workbooks" => {} }
|
|
99
|
+
end
|
|
100
|
+
private_class_method :load
|
|
101
|
+
|
|
102
|
+
def self.save(data)
|
|
103
|
+
File.write(PATH, JSON.generate(data), encoding: "UTF-8")
|
|
104
|
+
end
|
|
105
|
+
private_class_method :save
|
|
106
|
+
end
|
data/lib/lexdrill.rb
CHANGED
|
@@ -9,9 +9,9 @@ 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/
|
|
13
|
-
require_relative "lexdrill/
|
|
14
|
-
require_relative "lexdrill/
|
|
12
|
+
require_relative "lexdrill/auth_mode"
|
|
13
|
+
require_relative "lexdrill/workbooks"
|
|
14
|
+
require_relative "lexdrill/browser"
|
|
15
15
|
require_relative "lexdrill/http_client"
|
|
16
16
|
require_relative "lexdrill/google_auth"
|
|
17
17
|
require_relative "lexdrill/service_account_auth"
|
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.26.1
|
|
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-16 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.
|
|
@@ -25,7 +25,9 @@ files:
|
|
|
25
25
|
- bin/drill
|
|
26
26
|
- lexdrill.gemspec
|
|
27
27
|
- lib/lexdrill.rb
|
|
28
|
+
- lib/lexdrill/auth_mode.rb
|
|
28
29
|
- lib/lexdrill/beat.rb
|
|
30
|
+
- lib/lexdrill/browser.rb
|
|
29
31
|
- lib/lexdrill/cli.rb
|
|
30
32
|
- lib/lexdrill/color.rb
|
|
31
33
|
- lib/lexdrill/colorizer.rb
|
|
@@ -36,10 +38,7 @@ files:
|
|
|
36
38
|
- lib/lexdrill/http_client.rb
|
|
37
39
|
- lib/lexdrill/inspector.rb
|
|
38
40
|
- lib/lexdrill/line_formatter.rb
|
|
39
|
-
- lib/lexdrill/oauth_remote.rb
|
|
40
41
|
- lib/lexdrill/rand.rb
|
|
41
|
-
- lib/lexdrill/remote.rb
|
|
42
|
-
- lib/lexdrill/remote_target.rb
|
|
43
42
|
- lib/lexdrill/service_account_auth.rb
|
|
44
43
|
- lib/lexdrill/sheets_client.rb
|
|
45
44
|
- lib/lexdrill/shell_snippet.rb
|
|
@@ -47,6 +46,7 @@ files:
|
|
|
47
46
|
- lib/lexdrill/toggle.rb
|
|
48
47
|
- lib/lexdrill/version.rb
|
|
49
48
|
- lib/lexdrill/word_list.rb
|
|
49
|
+
- lib/lexdrill/workbooks.rb
|
|
50
50
|
homepage: https://github.com/kislak/lexdrill
|
|
51
51
|
licenses:
|
|
52
52
|
- MIT
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Global "which Google Sheet to export to via the OAuth device-flow login"
|
|
4
|
-
# setting. Stored as a plain spreadsheet id, parsed out of a normal
|
|
5
|
-
# Google Sheets share URL. Set via `drill oauth <url>`. See
|
|
6
|
-
# Lexdrill::Remote for the separate, service-account-based flow.
|
|
7
|
-
module Lexdrill::OauthRemote
|
|
8
|
-
PATH = Lexdrill::Config.path("oauth-remote")
|
|
9
|
-
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
10
|
-
|
|
11
|
-
def self.set(url)
|
|
12
|
-
id = extract_id(url)
|
|
13
|
-
raise ArgumentError, "no spreadsheet id found in #{url.inspect}" unless id
|
|
14
|
-
|
|
15
|
-
File.write(PATH, id)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.extract_id(url)
|
|
19
|
-
url[URL_PATTERN, 1]
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def self.configured?
|
|
23
|
-
File.exist?(PATH)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def self.spreadsheet_id
|
|
27
|
-
File.read(PATH).strip if configured?
|
|
28
|
-
end
|
|
29
|
-
end
|
data/lib/lexdrill/remote.rb
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Global "which Google Sheet to export to via a service account" setting.
|
|
4
|
-
# Stored as a plain spreadsheet id, parsed out of a normal Google Sheets
|
|
5
|
-
# share URL. Set via `drill remote <url>` — the user shares that
|
|
6
|
-
# 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 = Lexdrill::Config.path("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
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Resolves which configured Google Sheet target (drill remote / drill
|
|
4
|
-
# oauth) is currently active — whichever was set more recently — so
|
|
5
|
-
# `drill export`/`drill import`, `drill sheet`, and `drill inspect` all
|
|
6
|
-
# agree on a single answer.
|
|
7
|
-
module Lexdrill::RemoteTarget
|
|
8
|
-
def self.kind
|
|
9
|
-
remote_set = Lexdrill::Remote.configured?
|
|
10
|
-
oauth_set = Lexdrill::OauthRemote.configured?
|
|
11
|
-
return :remote if remote_set && (!oauth_set || newer?(Lexdrill::Remote::PATH, Lexdrill::OauthRemote::PATH))
|
|
12
|
-
return :oauth if oauth_set
|
|
13
|
-
|
|
14
|
-
nil
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def self.spreadsheet_id
|
|
18
|
-
case kind
|
|
19
|
-
when :remote then Lexdrill::Remote.spreadsheet_id
|
|
20
|
-
when :oauth then Lexdrill::OauthRemote.spreadsheet_id
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def self.url
|
|
25
|
-
id = spreadsheet_id
|
|
26
|
-
"https://docs.google.com/spreadsheets/d/#{id}/edit" if id
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def self.newer?(path, other_path)
|
|
30
|
-
File.mtime(path) >= File.mtime(other_path)
|
|
31
|
-
end
|
|
32
|
-
private_class_method :newer?
|
|
33
|
-
end
|