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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03c53271701afc42f6f8b239bd2fb8c85d5d88a55d72c7781573504241d1ad1c
4
- data.tar.gz: 231518970e268aa36342feda3885f0771293a0042df918c28b17a63a136ab001
3
+ metadata.gz: 1d3dc4600b6ad7303df0b607b7b9ded50b00cf4b3a7ca98e4fa05ca0e1663121
4
+ data.tar.gz: 90c8bedb7c4145f633b88506cd190f840ec9f87b5bbc1286803aa264da56eb8d
5
5
  SHA512:
6
- metadata.gz: b1b115d135527fbc032faa1ad8ea63b48708eda8ce9a9ce8501d59f95f315ab191545a4b8d355d0ab54c2b924ee1267a4873239bc779c8e5290e6f76d16d539f
7
- data.tar.gz: 3a038930e2e0a901b1d09227bda97588d7eb2073418156c05151e663413cb0f01c20fbf0457335d5386c6ff20c17e629a2d30c070c41167aea1c132f888499a0
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 position in the list)
52
- and the drill sign, together in yellow, a space, then the word (colored by
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 apple
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
- `drill export <sheet-name>` mirrors your word list (text only, no stats)
180
- into a tab of a Google Sheet you own; `drill import <sheet-name>` reads it
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
- - **`drill remote <url>`** a **service account** you create yourself. No
184
- interactive sign-in, ever; you share the spreadsheet with the service
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 <url>`** — your **personal Google login**, via a one-time
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
- If both are configured, whichever you set **more recently** wins running
192
- `drill oauth <url>` after `drill remote <url>` switches `export`/`import`
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
- Either way, `export` always **overwrites** the named tab (creating it
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 the
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
- 5. Open your target Google Sheet → **Share** → paste that email → grant
224
- **Editor** access → uncheck "Notify people" → Share.
225
- 6. Save the downloaded key file to `~/.drill/gcp-service-account.json` on
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 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
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
- #### Option B: personal login (`drill oauth`)
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 export` starts asking
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 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
269
- drill export Sheet1
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
- The first `export`/`import` prints a URL and a short code visit it, sign
273
- in with the Google account that has edit access to that spreadsheet, and
274
- approve. Every call after that is silent (the cached token refreshes
275
- itself automatically).
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 remote state |
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 open` | Open the list file in `$EDITOR`/`$VISUAL` (falls back to `vi`) |
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 <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 |
295
- | `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 |
296
- | `drill wb` | Print a link to whichever workbook (spreadsheet) is currently active (`drill remote`/`drill oauth`) |
297
- | `drill sheets` | List the tab names in the currently active workbook |
298
- | `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) |
299
- | `drill import <sheet-name>` | Replace the local word list with column A of the named tab |
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
- run_open: %w[open],
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
- run_sheets: %w[sheets],
25
- run_export: %w[export],
26
- run_import: %w[import]
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 end of the list
73
- drill list Show how many times each item has been shown
74
- drill open Open the list file in $EDITOR/$VISUAL (falls back to vi)
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 <url> Set the Google Sheet used by a local service account key
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 <url> Set the Google Sheet used by the OAuth (personal-login) flow
81
- drill wb Print a link to the currently active workbook (spreadsheet)
82
- drill sheets List the tab names in the currently active workbook
83
- drill export <sheet-name> Export the word list text to the given tab (overwrites its
84
- contents); uses whichever of drill remote/drill oauth was set
85
- more recently (oauth opens a one-time sign-in on first use)
86
- drill import <sheet-name> Replace the local word list with column A of the given tab
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 run_open
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
- url = argv[1]
303
- return print_remote_usage unless url
331
+ Lexdrill::AuthMode.set(Lexdrill::AuthMode::REMOTE)
332
+ puts "auth mode set: remote (service account)"
333
+ 0
334
+ end
304
335
 
305
- Lexdrill::Remote.set(url)
306
- puts "remote spreadsheet set: #{Lexdrill::Remote.spreadsheet_id}"
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 print_remote_usage
313
- warn "usage: drill remote <google-sheets-url>"
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 print_invalid_remote_url(url)
318
- warn "drill: could not find a spreadsheet id in #{url.inspect}"
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 run_oauth
323
- url = argv[1]
324
- return print_oauth_usage unless url
369
+ def run_wb_index
370
+ names = Lexdrill::Workbooks.names
371
+ return print_no_workbooks if names.empty?
325
372
 
326
- Lexdrill::OauthRemote.set(url)
327
- puts "oauth spreadsheet set: #{Lexdrill::OauthRemote.spreadsheet_id}"
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
- def print_oauth_usage
334
- warn "usage: drill oauth <google-sheets-url>"
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 print_invalid_oauth_url(url)
339
- warn "drill: could not find a spreadsheet id in #{url.inspect}"
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
- # Uses whichever of `drill remote` (service account) / `drill oauth`
344
- # (personal login) was set more recently, so switching between them with
345
- # either command always takes effect. Returns [spreadsheet_id,
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
- [spreadsheet_id, kind == :remote ? Lexdrill::ServiceAccountAuth.fetch_token! : Lexdrill::GoogleAuth.ensure_token!]
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 run_wb
356
- url = Lexdrill::RemoteTarget.url
357
- return print_no_remote unless url
419
+ def print_wb_add_usage
420
+ warn "usage: drill wb add <url>"
421
+ 1
422
+ end
358
423
 
359
- puts url
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 run_sheets
364
- target = sheets_target
365
- return print_no_remote unless target
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
- spreadsheet_id, token = target
368
- Lexdrill::SheetsClient.sheet_titles(spreadsheet_id, token).each { |title| puts title }
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 run_export
381
- sheet_name = argv[1]
382
- return print_export_usage unless sheet_name
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
- target = sheets_target
385
- return print_no_remote unless target
513
+ def run_sh_add
514
+ name = argv[2]
515
+ return print_sh_add_usage unless name
386
516
 
387
- perform_export(sheet_name, *target)
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, sheet_name)
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 perform_export(sheet_name, spreadsheet_id, token)
399
- rows = export_rows
400
- Lexdrill::SheetsClient.overwrite_sheet(spreadsheet_id, sheet_name, rows, token)
401
- puts "exported #{rows.size} word(s) to #{sheet_name.inspect}"
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 export_rows
406
- Lexdrill::WordList.words.map { |word| [word] }
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 print_export_usage
410
- warn "usage: drill export <sheet-name>"
576
+ def print_sh_remove_usage
577
+ warn "usage: drill sh remove <name>"
411
578
  1
412
579
  end
413
580
 
414
- def print_no_remote
415
- warn "drill: no remote spreadsheet configured; run `drill remote <url>` (service account) " \
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 run_import
421
- sheet_name = argv[1]
422
- return print_import_usage unless sheet_name
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
- target = sheets_target
425
- return print_no_remote unless target
603
+ token = current_token
604
+ return print_no_auth_mode unless token
426
605
 
427
- perform_import(sheet_name, *target)
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, sheet_name)
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 perform_import(sheet_name, spreadsheet_id, token)
439
- words = Lexdrill::SheetsClient.read_column(spreadsheet_id, sheet_name, token)
440
- return print_empty_import(sheet_name) if words.empty?
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
- File.write(Lexdrill::WordList::PATH, "#{words.join("\n")}\n", encoding: "UTF-8")
443
- puts "imported #{words.size} word(s) from #{sheet_name.inspect}"
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 print_import_usage
448
- warn "usage: drill import <sheet-name>"
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 print_empty_import(sheet_name)
453
- warn "drill: #{sheet_name.inspect} has no data to import"
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 remote`/`drill oauth`)"
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}"
@@ -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) { |http| http.post(uri.path, URI.encode_www_form(params)) }
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: {})
@@ -15,13 +15,18 @@ module Lexdrill::Inspector
15
15
  Beat: #{beat_summary}
16
16
  Rand: #{rand_summary}
17
17
  Color: #{Lexdrill::Color.current}
18
- Remote: #{remote_summary}
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.remote_summary
24
- Lexdrill::RemoteTarget.url || "not configured"
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}{SEPARATOR} {word}" — index is the word's own 1-based
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 counter/total and the
7
- # separator are painted yellow, as one unit; the word is colored by its
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}#{SEPARATOR}")
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 export`/`drill import`: create the target tab if it doesn't already
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 import, read column A back out of an existing tab.
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
- private_class_method :add_sheet
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 sheets`.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lexdrill
4
- VERSION = "0.21.0"
4
+ VERSION = "0.26.1"
5
5
  end
@@ -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/remote"
13
- require_relative "lexdrill/oauth_remote"
14
- require_relative "lexdrill/remote_target"
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.21.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-14 00:00:00.000000000 Z
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
@@ -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