lexdrill 0.17.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +33 -38
- data/lib/lexdrill/beat.rb +6 -7
- data/lib/lexdrill/cli.rb +46 -42
- data/lib/lexdrill/color.rb +27 -0
- data/lib/lexdrill/colorizer.rb +7 -6
- data/lib/lexdrill/config.rb +11 -17
- data/lib/lexdrill/default_words.rb +1 -2
- data/lib/lexdrill/google_auth.rb +1 -1
- data/lib/lexdrill/inspector.rb +7 -2
- data/lib/lexdrill/line_formatter.rb +11 -21
- data/lib/lexdrill/oauth_remote.rb +4 -4
- data/lib/lexdrill/rand.rb +3 -3
- data/lib/lexdrill/remote.rb +4 -4
- data/lib/lexdrill/remote_target.rb +33 -0
- data/lib/lexdrill/service_account_auth.rb +1 -1
- data/lib/lexdrill/sheets_client.rb +7 -0
- data/lib/lexdrill/stats.rb +7 -8
- data/lib/lexdrill/toggle.rb +3 -4
- data/lib/lexdrill/version.rb +1 -1
- data/lib/lexdrill/word_list.rb +10 -12
- data/lib/lexdrill.rb +2 -1
- metadata +4 -3
- data/lib/lexdrill/format.rb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03c53271701afc42f6f8b239bd2fb8c85d5d88a55d72c7781573504241d1ad1c
|
|
4
|
+
data.tar.gz: 231518970e268aa36342feda3885f0771293a0042df918c28b17a63a136ab001
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1b115d135527fbc032faa1ad8ea63b48708eda8ce9a9ce8501d59f95f315ab191545a4b8d355d0ab54c2b924ee1267a4873239bc779c8e5290e6f76d16d539f
|
|
7
|
+
data.tar.gz: 3a038930e2e0a901b1d09227bda97588d7eb2073418156c05151e663413cb0f01c20fbf0457335d5386c6ff20c17e629a2d30c070c41167aea1c132f888499a0
|
data/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
The `lexdrill` gem installs a `drill` command that prints a vocabulary word or
|
|
4
4
|
phrase on demand, tracking how often each one has been shown.
|
|
5
5
|
|
|
6
|
+

|
|
7
|
+
|
|
6
8
|
## Installation
|
|
7
9
|
|
|
8
10
|
```bash
|
|
@@ -31,8 +33,9 @@ this — rvm keeps gem executable directories on `PATH` automatically.
|
|
|
31
33
|
|
|
32
34
|
## Usage
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
All of lexdrill's state lives in one place: `~/.drill/` (mode `0700`). It's a
|
|
37
|
+
single global config — no per-project lists, no environment variable
|
|
38
|
+
overrides. Create `~/.drill/words` yourself — one word or phrase per line:
|
|
36
39
|
|
|
37
40
|
```
|
|
38
41
|
apple
|
|
@@ -40,27 +43,18 @@ banana
|
|
|
40
43
|
cherry
|
|
41
44
|
```
|
|
42
45
|
|
|
43
|
-
If you don't create one, `drill next` seeds `~/.drill
|
|
46
|
+
If you don't create one, `drill next` seeds `~/.drill/words` with a default
|
|
44
47
|
starter list (a set of NLP presuppositions) the first time it runs, so
|
|
45
48
|
there's always something to drill.
|
|
46
49
|
|
|
47
50
|
Run `drill next` to print the current word and advance to the next one.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
**simple** — the drill sign (always blue), a space, then the word (colored
|
|
52
|
-
by its show count — see "Mastery" below), all on one line:
|
|
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:
|
|
53
54
|
```
|
|
54
|
-
⟳ apple
|
|
55
|
+
1/6⟳ apple
|
|
55
56
|
```
|
|
56
|
-
|
|
57
|
-
**full** — `counter/total⟳[loop_start-loop_end]` on one line, the word on
|
|
58
|
-
the next, colored by the word's show count:
|
|
59
|
-
```
|
|
60
|
-
1/6⟳[1-3]
|
|
61
|
-
apple
|
|
62
|
-
```
|
|
63
|
-
(word 1 of 6 total; currently in the loop spanning words 1-3)
|
|
57
|
+
(word 1 of 6 total)
|
|
64
58
|
|
|
65
59
|
### Shell integration (one-time setup)
|
|
66
60
|
|
|
@@ -131,9 +125,7 @@ before moving on:
|
|
|
131
125
|
drill beat 3 2 # loop size 3, repeat each loop 2 times
|
|
132
126
|
```
|
|
133
127
|
|
|
134
|
-
`drill beat none` turns it back off (plain word-by-word again).
|
|
135
|
-
**global** setting — it applies everywhere, independent of which project's
|
|
136
|
-
`.drill.txt` is currently active.
|
|
128
|
+
`drill beat none` turns it back off (plain word-by-word again).
|
|
137
129
|
|
|
138
130
|
There are also named shortcuts for common loop sizes, one word/phrase apart:
|
|
139
131
|
|
|
@@ -169,16 +161,18 @@ exits 0.
|
|
|
169
161
|
|
|
170
162
|
### Mastery (color by show count)
|
|
171
163
|
|
|
172
|
-
Every time `next` shows a word it's tracked in
|
|
173
|
-
[`drill stats`](#commands) below).
|
|
174
|
-
color follows a blue → red gradient, one step per
|
|
175
|
-
glance, blue words are fresh and red words are heavily drilled.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
164
|
+
Every time `next` shows a word it's tracked in `~/.drill/stats` (see
|
|
165
|
+
[`drill stats`](#commands) below). By default (`drill color default`), the
|
|
166
|
+
word's color follows a blue → red gradient, one step per 10 shows — so at
|
|
167
|
+
a glance, blue words are fresh and red words are heavily drilled. Once a
|
|
168
|
+
word hits 120 shows it's considered mastered and `next` stops selecting it
|
|
169
|
+
(it still appears in `drill list`/`drill stats`, just no longer comes up
|
|
170
|
+
automatically). If every word in the list has been mastered, `next`
|
|
171
|
+
reports that on stderr and exits 1 instead of showing anything.
|
|
172
|
+
|
|
173
|
+
Prefer a vivid random color every time instead of the gradient? Run
|
|
174
|
+
`drill color random` (and `drill color default` to switch back — this is
|
|
175
|
+
the default, so it's also the way to undo `random`).
|
|
182
176
|
|
|
183
177
|
### Google Sheets export
|
|
184
178
|
|
|
@@ -202,7 +196,7 @@ Either way, `export` always **overwrites** the named tab (creating it
|
|
|
202
196
|
first if it doesn't exist yet) with the current word list, one phrase per
|
|
203
197
|
row, so it stays an exact mirror even if the list shrinks. `import` is the
|
|
204
198
|
reverse: it reads column A of the tab (ignoring any other columns, like an
|
|
205
|
-
old export's show counts) and **replaces** your local
|
|
199
|
+
old export's show counts) and **replaces** your local `~/.drill/words` —
|
|
206
200
|
useful for editing the list in Sheets and pulling changes back down, or
|
|
207
201
|
seeding a fresh machine from an existing sheet.
|
|
208
202
|
|
|
@@ -228,9 +222,9 @@ a real secret you must keep local.
|
|
|
228
222
|
field in the downloaded JSON).
|
|
229
223
|
5. Open your target Google Sheet → **Share** → paste that email → grant
|
|
230
224
|
**Editor** access → uncheck "Notify people" → Share.
|
|
231
|
-
6. Save the downloaded key file to `~/.drill
|
|
232
|
-
your machine (`drill` reads it from that fixed path
|
|
233
|
-
|
|
225
|
+
6. Save the downloaded key file to `~/.drill/gcp-service-account.json` on
|
|
226
|
+
your machine (`drill` reads it from that fixed path; it's never embedded
|
|
227
|
+
in the gem or committed anywhere).
|
|
234
228
|
|
|
235
229
|
```bash
|
|
236
230
|
drill remote 'https://docs.google.com/spreadsheets/d/1opBP4APL5SUvepm9qwjIYRNtDZdoY1Ee87F5PWdxaMg/edit?usp=sharing'
|
|
@@ -245,7 +239,7 @@ short-lived JWT with the local key file.
|
|
|
245
239
|
Uses the OAuth 2.0 **Device Authorization Grant** ("visit this URL, enter
|
|
246
240
|
this code," the same style of flow `gcloud auth login` uses) — you approve
|
|
247
241
|
access to your own account once, and the resulting token is cached locally
|
|
248
|
-
at `~/.drill
|
|
242
|
+
at `~/.drill/gcp-token.json` (mode `0600`), never published or shared.
|
|
249
243
|
|
|
250
244
|
The OAuth client id/secret embedded in `lib/lexdrill/google_auth.rb` are
|
|
251
245
|
**not** secret for this use — Google's own docs say client credentials for
|
|
@@ -286,11 +280,11 @@ itself automatically).
|
|
|
286
280
|
|---|---|
|
|
287
281
|
| `drill next` | Print the current word and advance |
|
|
288
282
|
| `drill start` / `drill stop` | Pause/resume the automatic per-prompt hook (doesn't affect manual `next`) |
|
|
289
|
-
| `drill inspect` | Show the active
|
|
283
|
+
| `drill inspect` | Show the active config directory, word count, counter value, toggle, beat, rand, color, and remote state |
|
|
290
284
|
| `drill hook zsh\|bash` | Print the shell integration snippet (used above) |
|
|
291
285
|
| `drill beat <2-8> <repetitions>` / `drill beat none` | Set or disable the rhythm |
|
|
292
286
|
| `drill polka\|waltz\|rock\|jazz\|jiga\|balkan\|samba <repetitions>` | Shorthand for a fixed loop size (see table above) |
|
|
293
|
-
| `drill
|
|
287
|
+
| `drill color random\|default` | Color each shown word randomly, or by its show count (`default`) |
|
|
294
288
|
| `drill add <text>` | Append a new item to the end of the list |
|
|
295
289
|
| `drill list` | Show how many times each item has been shown, numbered |
|
|
296
290
|
| `drill open` | Open the list file in `$EDITOR`/`$VISUAL` (falls back to `vi`) |
|
|
@@ -299,6 +293,7 @@ itself automatically).
|
|
|
299
293
|
| `drill go <number>` | Jump so the next `drill next` shows item `<number>` (1-based, see `drill list`) — prints nothing itself; refuses a graduated item; has no effect while `drill beat rand` is active, since that mode ignores the counter entirely |
|
|
300
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 |
|
|
301
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 |
|
|
302
|
-
| `drill
|
|
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 |
|
|
303
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) |
|
|
304
299
|
| `drill import <sheet-name>` | Replace the local word list with column A of the named tab |
|
data/lib/lexdrill/beat.rb
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
# Global rhythm config: repeats loops of `loop_size` consecutive words
|
|
6
|
-
# `repetitions` times before advancing to the next loop.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# a uniformly random word each time instead.
|
|
6
|
+
# `repetitions` times before advancing to the next loop. Disabled (plain
|
|
7
|
+
# word-by-word advance) unless explicitly configured. `drill beat rand`
|
|
8
|
+
# selects a third mode (marked by the literal contents "rand") where
|
|
9
|
+
# WordList.next ignores the counter/rhythm entirely and picks a uniformly
|
|
10
|
+
# random word each time instead.
|
|
12
11
|
module Lexdrill::Beat
|
|
13
|
-
PATH =
|
|
12
|
+
PATH = Lexdrill::Config.path("beat")
|
|
14
13
|
MIN_LOOP_SIZE = 2
|
|
15
14
|
MAX_LOOP_SIZE = 8
|
|
16
15
|
DEFAULT_REPETITIONS = 8
|
data/lib/lexdrill/cli.rb
CHANGED
|
@@ -11,7 +11,7 @@ class Lexdrill::CLI
|
|
|
11
11
|
run_inspect: %w[inspect],
|
|
12
12
|
run_beat: %w[beat],
|
|
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
16
|
run_list: %w[list],
|
|
17
17
|
run_open: %w[open],
|
|
@@ -20,7 +20,8 @@ class Lexdrill::CLI
|
|
|
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],
|
|
24
25
|
run_export: %w[export],
|
|
25
26
|
run_import: %w[import]
|
|
26
27
|
}.freeze
|
|
@@ -67,7 +68,7 @@ class Lexdrill::CLI
|
|
|
67
68
|
drill beat rand Ignore the counter/rhythm; show a random item each time
|
|
68
69
|
drill polka|waltz|rock|jazz|jiga|balkan|samba [repetitions]
|
|
69
70
|
Shorthand for a fixed loop size (2 through 8, in order)
|
|
70
|
-
drill
|
|
71
|
+
drill color random|default Color each word randomly, or by its show count (default)
|
|
71
72
|
drill add <text> Append a new item to the end of the list
|
|
72
73
|
drill list Show how many times each item has been shown
|
|
73
74
|
drill open Open the list file in $EDITOR/$VISUAL (falls back to vi)
|
|
@@ -75,9 +76,10 @@ class Lexdrill::CLI
|
|
|
75
76
|
drill rand <n> drill next shows a word ~1-in-n times (n=1 is every time)
|
|
76
77
|
drill go <number> Jump so the next `next` shows item <number> (1-based, see drill list)
|
|
77
78
|
drill remote <url> Set the Google Sheet used by a local service account key
|
|
78
|
-
(~/.drill
|
|
79
|
+
(~/.drill/gcp-service-account.json) — no interactive sign-in
|
|
79
80
|
drill oauth <url> Set the Google Sheet used by the OAuth (personal-login) flow
|
|
80
|
-
drill
|
|
81
|
+
drill wb Print a link to the currently active workbook (spreadsheet)
|
|
82
|
+
drill sheets List the tab names in the currently active workbook
|
|
81
83
|
drill export <sheet-name> Export the word list text to the given tab (overwrites its
|
|
82
84
|
contents); uses whichever of drill remote/drill oauth was set
|
|
83
85
|
more recently (oauth opens a one-time sign-in on first use)
|
|
@@ -92,7 +94,7 @@ class Lexdrill::CLI
|
|
|
92
94
|
word = Lexdrill::WordList.next
|
|
93
95
|
return print_next_failure unless word
|
|
94
96
|
|
|
95
|
-
puts
|
|
97
|
+
puts Lexdrill::LineFormatter.format(word)
|
|
96
98
|
0
|
|
97
99
|
end
|
|
98
100
|
|
|
@@ -107,14 +109,6 @@ class Lexdrill::CLI
|
|
|
107
109
|
1
|
|
108
110
|
end
|
|
109
111
|
|
|
110
|
-
def colored_line(word)
|
|
111
|
-
text = Lexdrill::LineFormatter.format(word)
|
|
112
|
-
return text if Lexdrill::Format.simple?
|
|
113
|
-
|
|
114
|
-
count = Lexdrill::Stats.counts.fetch(word, 0)
|
|
115
|
-
Lexdrill::Colorizer.paint_by_count(text, count)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
112
|
def print_no_words(path)
|
|
119
113
|
warn "drill: no words found in #{path}"
|
|
120
114
|
1
|
|
@@ -203,16 +197,22 @@ class Lexdrill::CLI
|
|
|
203
197
|
1
|
|
204
198
|
end
|
|
205
199
|
|
|
206
|
-
def
|
|
200
|
+
def run_color
|
|
207
201
|
mode = argv[1]
|
|
208
|
-
return
|
|
202
|
+
return print_color_usage unless mode
|
|
203
|
+
return print_invalid_color(mode) unless Lexdrill::Color::VALID.include?(mode)
|
|
209
204
|
|
|
210
|
-
Lexdrill::
|
|
205
|
+
Lexdrill::Color.set(mode)
|
|
211
206
|
0
|
|
212
207
|
end
|
|
213
208
|
|
|
214
|
-
def
|
|
215
|
-
warn "usage: drill
|
|
209
|
+
def print_color_usage
|
|
210
|
+
warn "usage: drill color <#{Lexdrill::Color::VALID.join('|')}>"
|
|
211
|
+
1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def print_invalid_color(mode)
|
|
215
|
+
warn "drill: unknown color mode #{mode.inspect} (expected #{Lexdrill::Color::VALID.join(' or ')})"
|
|
216
216
|
1
|
|
217
217
|
end
|
|
218
218
|
|
|
@@ -345,37 +345,36 @@ class Lexdrill::CLI
|
|
|
345
345
|
# either command always takes effect. Returns [spreadsheet_id,
|
|
346
346
|
# access_token], or nil if neither is configured.
|
|
347
347
|
def sheets_target
|
|
348
|
-
kind =
|
|
349
|
-
spreadsheet_id =
|
|
348
|
+
kind = Lexdrill::RemoteTarget.kind
|
|
349
|
+
spreadsheet_id = Lexdrill::RemoteTarget.spreadsheet_id
|
|
350
350
|
return unless spreadsheet_id
|
|
351
351
|
|
|
352
352
|
[spreadsheet_id, kind == :remote ? Lexdrill::ServiceAccountAuth.fetch_token! : Lexdrill::GoogleAuth.ensure_token!]
|
|
353
353
|
end
|
|
354
354
|
|
|
355
|
-
def
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
when :oauth then Lexdrill::OauthRemote.spreadsheet_id
|
|
359
|
-
end
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
def latest_remote_kind
|
|
363
|
-
remote_set = Lexdrill::Remote.configured?
|
|
364
|
-
oauth_set = Lexdrill::OauthRemote.configured?
|
|
365
|
-
return :remote if remote_set && (!oauth_set || newer?(Lexdrill::Remote::PATH, Lexdrill::OauthRemote::PATH))
|
|
366
|
-
return :oauth if oauth_set
|
|
355
|
+
def run_wb
|
|
356
|
+
url = Lexdrill::RemoteTarget.url
|
|
357
|
+
return print_no_remote unless url
|
|
367
358
|
|
|
368
|
-
|
|
359
|
+
puts url
|
|
360
|
+
0
|
|
369
361
|
end
|
|
370
362
|
|
|
371
|
-
def
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
spreadsheet_id = spreadsheet_id_for(latest_remote_kind)
|
|
375
|
-
return print_no_remote unless spreadsheet_id
|
|
363
|
+
def run_sheets
|
|
364
|
+
target = sheets_target
|
|
365
|
+
return print_no_remote unless target
|
|
376
366
|
|
|
377
|
-
|
|
367
|
+
spreadsheet_id, token = target
|
|
368
|
+
Lexdrill::SheetsClient.sheet_titles(spreadsheet_id, token).each { |title| puts title }
|
|
378
369
|
0
|
|
370
|
+
rescue Lexdrill::GoogleAuth::AuthError, Lexdrill::ServiceAccountAuth::AuthError => error
|
|
371
|
+
warn "drill: #{error.message}"
|
|
372
|
+
1
|
|
373
|
+
rescue Lexdrill::SheetsClient::ApiError => error
|
|
374
|
+
print_sheets_api_error(error)
|
|
375
|
+
rescue Lexdrill::HTTPClient::NetworkError => error
|
|
376
|
+
warn "drill: network error talking to Google (#{error.message})"
|
|
377
|
+
1
|
|
379
378
|
end
|
|
380
379
|
|
|
381
380
|
def run_export
|
|
@@ -455,18 +454,23 @@ class Lexdrill::CLI
|
|
|
455
454
|
1
|
|
456
455
|
end
|
|
457
456
|
|
|
458
|
-
def print_sheets_api_error(error, sheet_name)
|
|
457
|
+
def print_sheets_api_error(error, sheet_name = nil)
|
|
459
458
|
status = error.status
|
|
460
459
|
message = error.message
|
|
461
460
|
case status
|
|
462
461
|
when 404 then warn "drill: spreadsheet not found or not accessible (check `drill remote`/`drill oauth`)"
|
|
463
462
|
when 403 then warn "drill: access denied — make sure the spreadsheet is shared with the right account"
|
|
464
|
-
when 400 then
|
|
463
|
+
when 400 then print_400_error(message, sheet_name)
|
|
465
464
|
else warn "drill: Google Sheets API error (#{status}): #{message}"
|
|
466
465
|
end
|
|
467
466
|
1
|
|
468
467
|
end
|
|
469
468
|
|
|
469
|
+
def print_400_error(message, sheet_name)
|
|
470
|
+
hint = " (is #{sheet_name.inspect} a real tab in the spreadsheet?)" if sheet_name
|
|
471
|
+
warn "drill: #{message}#{hint}"
|
|
472
|
+
end
|
|
473
|
+
|
|
470
474
|
def print_unknown_command(command)
|
|
471
475
|
warn "drill: unknown command #{command.inspect}"
|
|
472
476
|
print_help
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Global "how to color a shown word" setting: "default" colors by show
|
|
4
|
+
# count (see Lexdrill::Colorizer's blue -> red gradient); "random" picks a
|
|
5
|
+
# vivid random color every time instead. An absent file (or any
|
|
6
|
+
# unrecognized value) means "default".
|
|
7
|
+
module Lexdrill::Color
|
|
8
|
+
PATH = Lexdrill::Config.path("color")
|
|
9
|
+
RANDOM = "random"
|
|
10
|
+
DEFAULT = "default"
|
|
11
|
+
VALID = [RANDOM, DEFAULT].freeze
|
|
12
|
+
|
|
13
|
+
def self.set(mode)
|
|
14
|
+
File.write(PATH, mode)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.current
|
|
18
|
+
return DEFAULT unless File.exist?(PATH)
|
|
19
|
+
|
|
20
|
+
value = File.read(PATH).strip
|
|
21
|
+
VALID.include?(value) ? value : DEFAULT
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.random?
|
|
25
|
+
current == RANDOM
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/lexdrill/colorizer.rb
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
# Wraps text in an ANSI color code. `paint_by_count` maps a word's show
|
|
4
4
|
# count onto a blue -> red truecolor gradient, one step per
|
|
5
5
|
# Lexdrill::Stats::BUCKET_SIZE shows, so a word's color reflects how many
|
|
6
|
-
# times it's been drilled —
|
|
7
|
-
#
|
|
6
|
+
# times it's been drilled — unless `drill color random` is set (see
|
|
7
|
+
# Lexdrill::Color), in which case a vivid random color is used every time
|
|
8
|
+
# instead.
|
|
8
9
|
module Lexdrill::Colorizer
|
|
9
|
-
|
|
10
|
+
YELLOW = 33
|
|
10
11
|
GRADIENT_STEPS = Lexdrill::Stats::GRADUATION_THRESHOLD / Lexdrill::Stats::BUCKET_SIZE
|
|
11
12
|
HUE_DEGREES = 360
|
|
12
13
|
# Which of {peak, trough, 0} each RGB channel takes in each 60-degree hue
|
|
@@ -20,12 +21,12 @@ module Lexdrill::Colorizer
|
|
|
20
21
|
%i[peak zero trough]
|
|
21
22
|
].freeze
|
|
22
23
|
|
|
23
|
-
def self.
|
|
24
|
-
wrap(text,
|
|
24
|
+
def self.paint_yellow(text)
|
|
25
|
+
wrap(text, YELLOW)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def self.paint_by_count(text, count)
|
|
28
|
-
code =
|
|
29
|
+
code = Lexdrill::Color.random? ? random_code : gradient_code(count)
|
|
29
30
|
wrap(text, code)
|
|
30
31
|
end
|
|
31
32
|
|
data/lib/lexdrill/config.rb
CHANGED
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
# All of lexdrill's persisted state lives under this one directory in the
|
|
6
|
+
# user's home folder — a single global config, with no per-project or
|
|
7
|
+
# per-directory overrides.
|
|
3
8
|
module Lexdrill::Config
|
|
4
|
-
|
|
5
|
-
COUNTER_FILENAME = ".drill.counter"
|
|
6
|
-
STATS_FILENAME = ".drill.stats"
|
|
9
|
+
DIR = File.join(Dir.home, ".drill")
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
end
|
|
11
|
+
# 0700: this directory can hold real secrets (OAuth refresh token, service
|
|
12
|
+
# account key), same reasoning as their own individual 0600 chmods.
|
|
13
|
+
FileUtils.mkdir_p(DIR, mode: 0o700)
|
|
12
14
|
|
|
13
|
-
def self.
|
|
14
|
-
|
|
15
|
+
def self.path(filename)
|
|
16
|
+
File.join(DIR, filename)
|
|
15
17
|
end
|
|
16
|
-
|
|
17
|
-
def self.home_drill_path
|
|
18
|
-
File.join(Dir.home, FILENAME)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
DRILL_PATH = File.join(base_path, FILENAME)
|
|
22
|
-
COUNTER_PATH = File.join(base_path, COUNTER_FILENAME)
|
|
23
|
-
STATS_PATH = File.join(base_path, STATS_FILENAME)
|
|
24
18
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The starter list seeded into
|
|
4
|
-
# LEXDRILL_PATH override nor a project-local .drill.txt exists yet.
|
|
3
|
+
# The starter list seeded into the words file on first run.
|
|
5
4
|
module Lexdrill::DefaultWords
|
|
6
5
|
WORDS = [
|
|
7
6
|
"The map is not the territory",
|
data/lib/lexdrill/google_auth.rb
CHANGED
|
@@ -14,7 +14,7 @@ require "fileutils"
|
|
|
14
14
|
# PATH below, obtained only after this specific user's own interactive
|
|
15
15
|
# consent, which never leaves this machine and is never published.
|
|
16
16
|
module Lexdrill::GoogleAuth
|
|
17
|
-
PATH =
|
|
17
|
+
PATH = Lexdrill::Config.path("gcp-token.json")
|
|
18
18
|
|
|
19
19
|
CLIENT_ID = "608056429593-ncdgh4bnfpuf9486l2rlt7g763h1gl6g.apps.googleusercontent.com"
|
|
20
20
|
CLIENT_SECRET = "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"
|
data/lib/lexdrill/inspector.rb
CHANGED
|
@@ -14,11 +14,16 @@ module Lexdrill::Inspector
|
|
|
14
14
|
Toggle: #{toggle_summary}
|
|
15
15
|
Beat: #{beat_summary}
|
|
16
16
|
Rand: #{rand_summary}
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Color: #{Lexdrill::Color.current}
|
|
18
|
+
Remote: #{remote_summary}
|
|
19
|
+
Config dir: #{Lexdrill::Config::DIR}
|
|
19
20
|
REPORT
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
def self.remote_summary
|
|
24
|
+
Lexdrill::RemoteTarget.url || "not configured"
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
def self.words_summary
|
|
23
28
|
return "missing" unless File.exist?(Lexdrill::WordList::PATH)
|
|
24
29
|
|
|
@@ -1,34 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Formats a shown word for display
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
3
|
+
# Formats a shown word for display, on one line:
|
|
4
|
+
# "{index}/{total}{SEPARATOR} {word}" — index is the word's own 1-based
|
|
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).
|
|
9
9
|
module Lexdrill::LineFormatter
|
|
10
10
|
SEPARATOR = "⟳"
|
|
11
11
|
|
|
12
12
|
def self.format(word)
|
|
13
|
-
return simple(word) if Lexdrill::Format.simple?
|
|
14
|
-
|
|
15
|
-
full(word)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.simple(word)
|
|
19
|
-
count = Lexdrill::Stats.counts.fetch(word, 0)
|
|
20
|
-
"#{Lexdrill::Colorizer.paint_blue(SEPARATOR)} #{Lexdrill::Colorizer.paint_by_count(word, count)}"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def self.full(word)
|
|
24
13
|
total = Lexdrill::WordList.words.size
|
|
25
|
-
|
|
26
|
-
"#{
|
|
14
|
+
count = Lexdrill::Stats.counts.fetch(word, 0)
|
|
15
|
+
prefix = Lexdrill::Colorizer.paint_yellow("#{loop_index(total) + 1}/#{total}#{SEPARATOR}")
|
|
16
|
+
"#{prefix} #{Lexdrill::Colorizer.paint_by_count(word, count)}"
|
|
27
17
|
end
|
|
28
18
|
|
|
29
|
-
def self.
|
|
19
|
+
def self.loop_index(total)
|
|
30
20
|
counter = Lexdrill::Counter.new(Lexdrill::WordList::COUNTER_PATH)
|
|
31
21
|
step_used = counter.value - 1
|
|
32
|
-
Lexdrill::Beat.loop_info(total, step_used)
|
|
22
|
+
Lexdrill::Beat.loop_info(total, step_used).index
|
|
33
23
|
end
|
|
34
24
|
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Global "which Google Sheet to export to via the OAuth device-flow login"
|
|
4
|
-
# setting.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
7
|
module Lexdrill::OauthRemote
|
|
8
|
-
PATH =
|
|
8
|
+
PATH = Lexdrill::Config.path("oauth-remote")
|
|
9
9
|
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
10
10
|
|
|
11
11
|
def self.set(url)
|
data/lib/lexdrill/rand.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
# Global "how often to actually show a word" setting, applied by
|
|
4
4
|
# `drill next` itself (both automatic hook-triggered calls and manual
|
|
5
|
-
# invocations respect it equally).
|
|
6
|
-
#
|
|
5
|
+
# invocations respect it equally). Stored as a plain integer; defaults to
|
|
6
|
+
# 1 (show every time).
|
|
7
7
|
module Lexdrill::Rand
|
|
8
|
-
PATH =
|
|
8
|
+
PATH = Lexdrill::Config.path("rand")
|
|
9
9
|
DEFAULT = 1
|
|
10
10
|
|
|
11
11
|
def self.value
|
data/lib/lexdrill/remote.rb
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Global "which Google Sheet to export to via a service account" setting.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
7
|
# Sheets, so no interactive Google sign-in is needed at export/import time.
|
|
8
8
|
# See Lexdrill::OauthRemote for the separate, personal-login-based flow.
|
|
9
9
|
module Lexdrill::Remote
|
|
10
|
-
PATH =
|
|
10
|
+
PATH = Lexdrill::Config.path("remote")
|
|
11
11
|
URL_PATTERN = %r{/spreadsheets/d/([a-zA-Z0-9_-]+)}
|
|
12
12
|
|
|
13
13
|
def self.set(url)
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
|
@@ -13,7 +13,7 @@ require "base64"
|
|
|
13
13
|
# published, and lexdrill never writes or transmits it anywhere except in
|
|
14
14
|
# the signed JWT sent directly to Google's own token endpoint.
|
|
15
15
|
module Lexdrill::ServiceAccountAuth
|
|
16
|
-
PATH =
|
|
16
|
+
PATH = Lexdrill::Config.path("gcp-service-account.json")
|
|
17
17
|
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
18
18
|
SCOPE = "https://www.googleapis.com/auth/spreadsheets"
|
|
19
19
|
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
@@ -77,6 +77,13 @@ module Lexdrill::SheetsClient
|
|
|
77
77
|
data.fetch("values", []).filter_map { |row| row[0]&.strip }.reject(&:empty?)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# The titles of every tab in the workbook, for `drill sheets`.
|
|
81
|
+
def self.sheet_titles(spreadsheet_id, access_token)
|
|
82
|
+
url = "#{BASE_URL}/#{spreadsheet_id}?fields=sheets.properties.title"
|
|
83
|
+
data = handle_response(Lexdrill::HTTPClient.json_get(url, headers: auth_header(access_token)))
|
|
84
|
+
data.fetch("sheets", []).map { |sheet| sheet.dig("properties", "title") }
|
|
85
|
+
end
|
|
86
|
+
|
|
80
87
|
def self.auth_header(token)
|
|
81
88
|
{ "Authorization" => "Bearer #{token}" }
|
|
82
89
|
end
|
data/lib/lexdrill/stats.rb
CHANGED
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
# Tracks how many times each word/phrase has been shown by `next`, persisted
|
|
6
|
-
# as a word => count JSON object
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# stops selecting it.
|
|
6
|
+
# as a word => count JSON object in the stats file. Every BUCKET_SIZE shows
|
|
7
|
+
# moves a word into the next display-color bucket (see Lexdrill::Colorizer);
|
|
8
|
+
# once a word reaches GRADUATION_THRESHOLD shows it's considered mastered
|
|
9
|
+
# and `next` stops selecting it.
|
|
11
10
|
module Lexdrill::Stats
|
|
12
|
-
PATH = Lexdrill::Config
|
|
13
|
-
BUCKET_SIZE =
|
|
14
|
-
GRADUATION_THRESHOLD =
|
|
11
|
+
PATH = Lexdrill::Config.path("stats")
|
|
12
|
+
BUCKET_SIZE = 10
|
|
13
|
+
GRADUATION_THRESHOLD = 120
|
|
15
14
|
|
|
16
15
|
def self.record(word)
|
|
17
16
|
data = load
|
data/lib/lexdrill/toggle.rb
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
|
-
# Global pause/resume switch for the shell hook
|
|
6
|
-
#
|
|
7
|
-
# presence means stopped.
|
|
5
|
+
# Global pause/resume switch for the shell hook. Enabled by default; the
|
|
6
|
+
# marker file's presence means stopped.
|
|
8
7
|
module Lexdrill::Toggle
|
|
9
|
-
PATH =
|
|
8
|
+
PATH = Lexdrill::Config.path("disabled")
|
|
10
9
|
|
|
11
10
|
def self.enabled?
|
|
12
11
|
!File.exist?(PATH)
|
data/lib/lexdrill/version.rb
CHANGED
data/lib/lexdrill/word_list.rb
CHANGED
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Reads vocabulary words/phrases from
|
|
3
|
+
# Reads vocabulary words/phrases from the words file, one per line, and
|
|
4
4
|
# advances a persisted counter to show "the current word" each time. On a
|
|
5
|
-
# fresh install with
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# remain in `.words` for `list`/`stats` to report.
|
|
5
|
+
# fresh install, seeds the file with a default starter list instead of
|
|
6
|
+
# coming up empty. Words that have graduated (see Lexdrill::Stats) are
|
|
7
|
+
# excluded from `.next` but remain in `.words` for `list`/`stats` to report.
|
|
9
8
|
class Lexdrill::WordList
|
|
10
|
-
PATH = Lexdrill::Config
|
|
11
|
-
COUNTER_PATH = Lexdrill::Config
|
|
12
|
-
HOME_PATH = Lexdrill::Config.home_drill_path
|
|
9
|
+
PATH = Lexdrill::Config.path("words")
|
|
10
|
+
COUNTER_PATH = Lexdrill::Config.path("counter")
|
|
13
11
|
|
|
14
12
|
def self.words
|
|
15
|
-
|
|
13
|
+
seed_default
|
|
16
14
|
@words ||= File.exist?(PATH) && File.readlines(PATH, encoding: "UTF-8").map(&:strip).reject(&:empty?)
|
|
17
15
|
@words ||= []
|
|
18
16
|
end
|
|
19
17
|
|
|
20
|
-
def self.
|
|
21
|
-
return if
|
|
18
|
+
def self.seed_default
|
|
19
|
+
return if File.exist?(PATH)
|
|
22
20
|
|
|
23
21
|
File.write(PATH, Lexdrill::DefaultWords::TEXT, encoding: "UTF-8")
|
|
24
22
|
end
|
|
25
|
-
private_class_method :
|
|
23
|
+
private_class_method :seed_default
|
|
26
24
|
|
|
27
25
|
def self.next
|
|
28
26
|
active = active_words
|
data/lib/lexdrill.rb
CHANGED
|
@@ -11,13 +11,14 @@ require_relative "lexdrill/toggle"
|
|
|
11
11
|
require_relative "lexdrill/rand"
|
|
12
12
|
require_relative "lexdrill/remote"
|
|
13
13
|
require_relative "lexdrill/oauth_remote"
|
|
14
|
+
require_relative "lexdrill/remote_target"
|
|
14
15
|
require_relative "lexdrill/http_client"
|
|
15
16
|
require_relative "lexdrill/google_auth"
|
|
16
17
|
require_relative "lexdrill/service_account_auth"
|
|
17
18
|
require_relative "lexdrill/sheets_client"
|
|
18
19
|
require_relative "lexdrill/shell_snippet"
|
|
19
20
|
require_relative "lexdrill/inspector"
|
|
21
|
+
require_relative "lexdrill/color"
|
|
20
22
|
require_relative "lexdrill/colorizer"
|
|
21
|
-
require_relative "lexdrill/format"
|
|
22
23
|
require_relative "lexdrill/line_formatter"
|
|
23
24
|
require_relative "lexdrill/cli"
|
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.21.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Siarhei Kisliak
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-14 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.
|
|
@@ -27,11 +27,11 @@ files:
|
|
|
27
27
|
- lib/lexdrill.rb
|
|
28
28
|
- lib/lexdrill/beat.rb
|
|
29
29
|
- lib/lexdrill/cli.rb
|
|
30
|
+
- lib/lexdrill/color.rb
|
|
30
31
|
- lib/lexdrill/colorizer.rb
|
|
31
32
|
- lib/lexdrill/config.rb
|
|
32
33
|
- lib/lexdrill/counter.rb
|
|
33
34
|
- lib/lexdrill/default_words.rb
|
|
34
|
-
- lib/lexdrill/format.rb
|
|
35
35
|
- lib/lexdrill/google_auth.rb
|
|
36
36
|
- lib/lexdrill/http_client.rb
|
|
37
37
|
- lib/lexdrill/inspector.rb
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- lib/lexdrill/oauth_remote.rb
|
|
40
40
|
- lib/lexdrill/rand.rb
|
|
41
41
|
- lib/lexdrill/remote.rb
|
|
42
|
+
- lib/lexdrill/remote_target.rb
|
|
42
43
|
- lib/lexdrill/service_account_auth.rb
|
|
43
44
|
- lib/lexdrill/sheets_client.rb
|
|
44
45
|
- lib/lexdrill/shell_snippet.rb
|
data/lib/lexdrill/format.rb
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Global display mode for `next`'s output: "simple" (the drill sign + word)
|
|
4
|
-
# or "full" (position + loop info). Lives at ~/.drill.format; defaults to
|
|
5
|
-
# "simple".
|
|
6
|
-
module Lexdrill::Format
|
|
7
|
-
PATH = File.join(Dir.home, ".drill.format")
|
|
8
|
-
SIMPLE = "simple"
|
|
9
|
-
FULL = "full"
|
|
10
|
-
VALID = [SIMPLE, FULL].freeze
|
|
11
|
-
|
|
12
|
-
def self.current
|
|
13
|
-
return SIMPLE unless File.exist?(PATH)
|
|
14
|
-
|
|
15
|
-
value = File.read(PATH).strip
|
|
16
|
-
VALID.include?(value) ? value : SIMPLE
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def self.set(mode)
|
|
20
|
-
File.write(PATH, mode)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def self.simple?
|
|
24
|
-
current == SIMPLE
|
|
25
|
-
end
|
|
26
|
-
end
|