qwerty-cli 0.0.1-alpha.7 → 0.0.1-alpha.9

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.
Files changed (49) hide show
  1. package/dist/ConfigEditor-GXFVIJP3.js +2 -0
  2. package/dist/ConfigEditor-GXFVIJP3.js.map +1 -0
  3. package/dist/DictBrowser-SZVB5W25.js +2 -0
  4. package/dist/DictBrowser-SZVB5W25.js.map +1 -0
  5. package/dist/HelpScreen-OUP5G5UG.js +2 -0
  6. package/dist/HelpScreen-OUP5G5UG.js.map +1 -0
  7. package/dist/PracticeScreen-LLUTKFXL.js +2 -0
  8. package/dist/PracticeScreen-LLUTKFXL.js.map +1 -0
  9. package/dist/StatsViewer-EY2N2LP3.js +2 -0
  10. package/dist/StatsViewer-EY2N2LP3.js.map +1 -0
  11. package/dist/WordLookup-UPEDLVKF.js +2 -0
  12. package/dist/WordLookup-UPEDLVKF.js.map +1 -0
  13. package/dist/chunk-2GTGXODM.js +2 -0
  14. package/dist/chunk-2GTGXODM.js.map +1 -0
  15. package/dist/chunk-2MRNI465.js +2 -0
  16. package/dist/chunk-2MRNI465.js.map +1 -0
  17. package/dist/chunk-6KRVNT2S.js +4 -0
  18. package/dist/chunk-6KRVNT2S.js.map +1 -0
  19. package/dist/chunk-6QICLHIY.js +2 -0
  20. package/dist/chunk-6QICLHIY.js.map +1 -0
  21. package/dist/chunk-ELWVQGDK.js +2 -0
  22. package/dist/chunk-ELWVQGDK.js.map +1 -0
  23. package/dist/chunk-MPE25TTQ.js +2 -0
  24. package/dist/chunk-MPE25TTQ.js.map +1 -0
  25. package/dist/chunk-QEX27D7F.js +2 -0
  26. package/dist/chunk-QEX27D7F.js.map +1 -0
  27. package/dist/chunk-RF5SVFBO.js +3 -0
  28. package/dist/chunk-RF5SVFBO.js.map +1 -0
  29. package/dist/chunk-TP77EGJ2.js +2 -0
  30. package/dist/chunk-TP77EGJ2.js.map +1 -0
  31. package/dist/chunk-UPA4JFCH.js +2 -0
  32. package/dist/chunk-UPA4JFCH.js.map +1 -0
  33. package/dist/chunk-UPYHZMDS.js +2 -0
  34. package/dist/chunk-UPYHZMDS.js.map +1 -0
  35. package/dist/cli.js +1 -3654
  36. package/dist/cli.js.map +1 -1
  37. package/dist/config.impl-IYJ4ZUPE.js +2 -0
  38. package/dist/config.impl-IYJ4ZUPE.js.map +1 -0
  39. package/dist/dict.impl-Y66SRRZL.js +4 -0
  40. package/dist/dict.impl-Y66SRRZL.js.map +1 -0
  41. package/dist/menu.impl-L5KAWNMC.js +2 -0
  42. package/dist/menu.impl-L5KAWNMC.js.map +1 -0
  43. package/dist/practice.impl-NYUJO5ER.js +2 -0
  44. package/dist/practice.impl-NYUJO5ER.js.map +1 -0
  45. package/dist/stats.impl-IXVF3Q5Y.js +7 -0
  46. package/dist/stats.impl-IXVF3Q5Y.js.map +1 -0
  47. package/dist/word.impl-C4AYZ3NC.js +2 -0
  48. package/dist/word.impl-C4AYZ3NC.js.map +1 -0
  49. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -1,3655 +1,2 @@
1
- // src/cli.ts
2
- import { Command as Command7 } from "commander";
3
-
4
- // package.json
5
- var package_default = {
6
- name: "qwerty-cli",
7
- version: "0.0.1-alpha.7",
8
- description: "Terminal clone of qwerty-learner: typing practice for English vocabulary, with chapters, dictation, mistake book, and audio.",
9
- type: "module",
10
- bin: {
11
- qwerty: "bin/qwerty.mjs"
12
- },
13
- files: [
14
- "dist",
15
- "bin",
16
- "assets"
17
- ],
18
- scripts: {
19
- build: "tsup",
20
- dev: "tsup --watch",
21
- typecheck: "tsc --noEmit",
22
- test: "vitest run",
23
- "test:watch": "vitest",
24
- "sync-registry": "tsx scripts/sync-registry.ts",
25
- prepublishOnly: "pnpm build"
26
- },
27
- engines: {
28
- node: ">=20"
29
- },
30
- keywords: [
31
- "qwerty",
32
- "typing",
33
- "cli",
34
- "vocabulary",
35
- "ink",
36
- "terminal"
37
- ],
38
- license: "MIT",
39
- dependencies: {
40
- chalk: "^5.3.0",
41
- commander: "^12.1.0",
42
- ink: "^5.0.1",
43
- "p-queue": "^8.0.1",
44
- react: "^18.3.1",
45
- undici: "^6.19.8",
46
- zod: "^3.23.8"
47
- },
48
- devDependencies: {
49
- "@types/node": "^20.14.10",
50
- "@types/react": "^18.3.3",
51
- "ts-morph": "^23.0.0",
52
- tsup: "^8.2.4",
53
- tsx: "^4.19.0",
54
- typescript: "^5.5.4",
55
- vitest: "^2.0.5"
56
- },
57
- pnpm: {
58
- onlyBuiltDependencies: [
59
- "esbuild"
60
- ]
61
- }
62
- };
63
-
64
- // src/commands/config.ts
65
- import { Command } from "commander";
66
- import chalk from "chalk";
67
-
68
- // src/infra/config-store.ts
69
- import { z } from "zod";
70
-
71
- // src/infra/paths.ts
72
- import { homedir } from "os";
73
- import { join, resolve } from "path";
74
- import { mkdir } from "fs/promises";
75
- import { fileURLToPath } from "url";
76
- var ROOT = join(homedir(), ".qwerty-cli");
77
- var paths = {
78
- root: ROOT,
79
- config: join(ROOT, "config.json"),
80
- registry: join(ROOT, "registry.json"),
81
- dictsDir: join(ROOT, "dicts"),
82
- dictFile: (id) => join(ROOT, "dicts", `${id}.json`),
83
- dictMeta: (id) => join(ROOT, "dicts", `${id}.meta.json`),
84
- stats: join(ROOT, "stats.jsonl"),
85
- mistakes: join(ROOT, "mistakes.json"),
86
- audioCacheDir: join(ROOT, "cache", "audio"),
87
- audioCache: (word, accent) => join(ROOT, "cache", "audio", `${encodeURIComponent(word.toLowerCase())}-${accent}.mp3`)
88
- };
89
- async function ensureDirs() {
90
- await mkdir(paths.dictsDir, { recursive: true });
91
- await mkdir(paths.audioCacheDir, { recursive: true });
92
- }
93
- function packageAssetsDir() {
94
- const here = fileURLToPath(import.meta.url);
95
- return resolve(here, "..", "..", "assets");
96
- }
97
-
98
- // src/infra/fs-store.ts
99
- import { mkdir as mkdir2, readFile, rename, writeFile, appendFile, access } from "fs/promises";
100
- import { dirname } from "path";
101
- import { randomBytes } from "crypto";
102
- async function exists(path) {
103
- try {
104
- await access(path);
105
- return true;
106
- } catch {
107
- return false;
108
- }
109
- }
110
- async function readJson(path) {
111
- try {
112
- const buf = await readFile(path, "utf8");
113
- return JSON.parse(buf);
114
- } catch (err) {
115
- if (err.code === "ENOENT") return null;
116
- throw err;
117
- }
118
- }
119
- async function writeJsonAtomic(path, value) {
120
- await mkdir2(dirname(path), { recursive: true });
121
- const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
122
- await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
123
- await rename(tmp, path);
124
- }
125
- async function appendJsonl(path, line) {
126
- await mkdir2(dirname(path), { recursive: true });
127
- await appendFile(path, JSON.stringify(line) + "\n", "utf8");
128
- }
129
- async function readJsonl(path) {
130
- try {
131
- const buf = await readFile(path, "utf8");
132
- return buf.split("\n").filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
133
- } catch (err) {
134
- if (err.code === "ENOENT") return [];
135
- throw err;
136
- }
137
- }
138
-
139
- // src/infra/config-store.ts
140
- var ConfigSchema = z.object({
141
- mirror: z.enum(["jsdelivr", "github"]).default("jsdelivr"),
142
- accent: z.enum(["us", "uk"]).default("us"),
143
- chapterSize: z.number().int().positive().max(200).default(20),
144
- sounds: z.object({
145
- master: z.boolean().default(true),
146
- keystroke: z.boolean().default(true),
147
- feedback: z.boolean().default(true),
148
- keySoundName: z.string().default("default")
149
- }).default({ master: true, keystroke: true, feedback: true, keySoundName: "default" }),
150
- autoplayPronunciation: z.boolean().default(true),
151
- defaultMode: z.enum(["order", "dictation", "review", "random", "loop"]).default("order"),
152
- defaultDict: z.string().optional(),
153
- language: z.enum(["auto", "zh", "en"]).default("auto"),
154
- stealth: z.enum(["off", "menu", "default"]).default("off")
155
- });
156
- var DEFAULTS = ConfigSchema.parse({});
157
- async function loadConfig() {
158
- const raw = await readJson(paths.config);
159
- if (!raw) return DEFAULTS;
160
- const result = ConfigSchema.safeParse(raw);
161
- if (!result.success) {
162
- throw new Error(`Invalid config at ${paths.config}: ${result.error.message}`);
163
- }
164
- return result.data;
165
- }
166
- async function saveConfig(cfg) {
167
- await writeJsonAtomic(paths.config, cfg);
168
- }
169
- function getByPath(cfg, path) {
170
- const parts = path.split(".");
171
- let cur = cfg;
172
- for (const p of parts) {
173
- if (cur === null || typeof cur !== "object") return void 0;
174
- cur = cur[p];
175
- }
176
- return cur;
177
- }
178
- function setByPath(cfg, path, rawValue) {
179
- const parts = path.split(".");
180
- if (parts.length === 0) throw new Error("Empty config key");
181
- const clone = JSON.parse(JSON.stringify(cfg));
182
- let cur = clone;
183
- for (let i = 0; i < parts.length - 1; i++) {
184
- const k = parts[i];
185
- const next = cur[k];
186
- if (typeof next !== "object" || next === null) {
187
- throw new Error(`Cannot set ${path}: ${parts.slice(0, i + 1).join(".")} is not an object`);
188
- }
189
- cur = next;
190
- }
191
- const leaf = parts[parts.length - 1];
192
- cur[leaf] = coerce(rawValue);
193
- const validated = ConfigSchema.safeParse(clone);
194
- if (!validated.success) {
195
- throw new Error(`Invalid value for ${path}: ${validated.error.issues[0]?.message ?? "unknown"}`);
196
- }
197
- return validated.data;
198
- }
199
- function coerce(v) {
200
- if (v === "true") return true;
201
- if (v === "false") return false;
202
- if (v === "null") return null;
203
- if (/^-?\d+$/.test(v)) return Number(v);
204
- if (/^-?\d+\.\d+$/.test(v)) return Number(v);
205
- return v;
206
- }
207
-
208
- // src/commands/config.ts
209
- function buildConfigCommand() {
210
- const cmd = new Command("config").description("Manage CLI configuration");
211
- cmd.command("list").description("Show the effective merged config").action(async () => {
212
- const cfg = await loadConfig();
213
- console.log(JSON.stringify(cfg, null, 2));
214
- });
215
- cmd.command("get <key>").description("Get a config value by dotted path (e.g. sounds.keystroke)").action(async (key) => {
216
- const cfg = await loadConfig();
217
- const v = getByPath(cfg, key);
218
- if (v === void 0) {
219
- console.error(chalk.red(`Key not found: ${key}`));
220
- process.exitCode = 1;
221
- return;
222
- }
223
- console.log(typeof v === "string" ? v : JSON.stringify(v));
224
- });
225
- cmd.command("set <key> <value>").description("Set a config value by dotted path").action(async (key, value) => {
226
- const cfg = await loadConfig();
227
- const next = setByPath(cfg, key, value);
228
- await saveConfig(next);
229
- console.log(chalk.green(`Set ${key} = ${value}`));
230
- });
231
- return cmd;
232
- }
233
-
234
- // src/commands/dict.ts
235
- import { Command as Command2 } from "commander";
236
- import chalk2 from "chalk";
237
-
238
- // src/domain/dictionary.ts
239
- import { z as z2 } from "zod";
240
- var DictionaryEntrySchema = z2.object({
241
- id: z2.string(),
242
- name: z2.string(),
243
- description: z2.string().default(""),
244
- category: z2.string().default(""),
245
- tags: z2.array(z2.string()).default([]),
246
- url: z2.string(),
247
- length: z2.number().int().nonnegative().default(0),
248
- language: z2.string().default("en"),
249
- languageCategory: z2.string().default("en"),
250
- defaultPronIndex: z2.number().int().optional()
251
- });
252
- var RegistrySchema = z2.array(DictionaryEntrySchema);
253
- var WordSchema = z2.object({
254
- name: z2.string(),
255
- trans: z2.array(z2.string()).default([]),
256
- usphone: z2.string().optional(),
257
- ukphone: z2.string().optional(),
258
- notation: z2.string().optional()
259
- }).passthrough();
260
- var WordArraySchema = z2.array(WordSchema);
261
- function filterRegistry(registry, query, opts = {}) {
262
- const q = query.trim().toLowerCase();
263
- return registry.filter((d) => {
264
- if (opts.category && d.category !== opts.category) return false;
265
- if (opts.language && d.language !== opts.language) return false;
266
- if (!q) return true;
267
- return d.id.toLowerCase().includes(q) || d.name.toLowerCase().includes(q) || d.description.toLowerCase().includes(q) || d.category.toLowerCase().includes(q) || d.tags.some((t) => t.toLowerCase().includes(q));
268
- });
269
- }
270
-
271
- // src/infra/registry-store.ts
272
- import { readFile as readFile2 } from "fs/promises";
273
- import { join as join2 } from "path";
274
- var cached = null;
275
- async function loadRegistry() {
276
- if (cached) return cached;
277
- const userOverride = await readJson(paths.registry);
278
- if (userOverride) {
279
- const parsed2 = RegistrySchema.safeParse(userOverride);
280
- if (parsed2.success) {
281
- cached = parsed2.data;
282
- return cached;
283
- }
284
- console.warn(`Ignoring corrupt user registry at ${paths.registry}: ${parsed2.error.message}`);
285
- }
286
- const bundled = await readFile2(join2(packageAssetsDir(), "registry.snapshot.json"), "utf8");
287
- const parsed = RegistrySchema.parse(JSON.parse(bundled));
288
- cached = parsed;
289
- return cached;
290
- }
291
- async function findEntry(id) {
292
- const reg = await loadRegistry();
293
- return reg.find((d) => d.id === id);
294
- }
295
-
296
- // src/infra/dict-downloader.ts
297
- import { createHash } from "crypto";
298
- import { stat, copyFile } from "fs/promises";
299
- import { request } from "undici";
300
- var JSDELIVR_BASE = "https://cdn.jsdelivr.net/gh/RealKai42/qwerty-learner@master";
301
- var GITHUB_RAW_BASE = "https://raw.githubusercontent.com/RealKai42/qwerty-learner/master";
302
- async function fetchWithRetry(url, retries = 2) {
303
- let lastErr;
304
- for (let i = 0; i <= retries; i++) {
305
- try {
306
- const res = await request(url, { headersTimeout: 1e4, bodyTimeout: 3e4 });
307
- if (res.statusCode >= 400) throw new Error(`HTTP ${res.statusCode}`);
308
- return await res.body.text();
309
- } catch (err) {
310
- lastErr = err;
311
- if (i < retries) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
312
- }
313
- }
314
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
315
- }
316
- function buildUrls(mirror, relUrl) {
317
- const path = relUrl.startsWith("/") ? relUrl : `/${relUrl}`;
318
- const repoPath = `/public${path}`;
319
- const jsd = `${JSDELIVR_BASE}${repoPath}`;
320
- const gh = `${GITHUB_RAW_BASE}${repoPath}`;
321
- return mirror === "jsdelivr" ? [jsd, gh] : [gh, jsd];
322
- }
323
- async function pullDictionary(id) {
324
- const entry = await findEntry(id);
325
- if (!entry) throw new Error(`Unknown dictionary id: ${id}`);
326
- const cfg = await loadConfig();
327
- await ensureDirs();
328
- const urls = buildUrls(cfg.mirror, entry.url);
329
- let body = null;
330
- let lastErr;
331
- for (const u of urls) {
332
- try {
333
- body = await fetchWithRetry(u);
334
- break;
335
- } catch (err) {
336
- lastErr = err;
337
- }
338
- }
339
- if (!body) {
340
- throw new Error(
341
- `Failed to download dictionary ${id} from any mirror: ${lastErr instanceof Error ? lastErr.message : lastErr}`
342
- );
343
- }
344
- let json;
345
- try {
346
- json = JSON.parse(body);
347
- } catch (err) {
348
- throw new Error(`Dictionary ${id} is not valid JSON: ${err.message}`);
349
- }
350
- const parsed = WordArraySchema.safeParse(json);
351
- if (!parsed.success) {
352
- throw new Error(`Dictionary ${id} failed schema validation: ${parsed.error.issues[0]?.message}`);
353
- }
354
- const sha = createHash("sha256").update(body).digest("hex");
355
- await writeJsonAtomic(paths.dictFile(id), parsed.data);
356
- const meta = { sha256: sha, size: body.length, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), id };
357
- await writeJsonAtomic(paths.dictMeta(id), meta);
358
- return { words: parsed.data, size: body.length };
359
- }
360
- async function importDictionary(file, id) {
361
- if (!/^[a-z0-9-]+$/.test(id)) {
362
- throw new Error(`Invalid id "${id}". Must match /^[a-z0-9-]+$/`);
363
- }
364
- await ensureDirs();
365
- if (!await exists(file)) throw new Error(`File not found: ${file}`);
366
- const raw = await readJson(file);
367
- const parsed = WordArraySchema.safeParse(raw);
368
- if (!parsed.success) {
369
- throw new Error(`Import rejected: ${parsed.error.issues[0]?.message}`);
370
- }
371
- await copyFile(file, paths.dictFile(id));
372
- const size = (await stat(paths.dictFile(id))).size;
373
- const sha = createHash("sha256").update(JSON.stringify(parsed.data)).digest("hex");
374
- const meta = { sha256: sha, size, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), id };
375
- await writeJsonAtomic(paths.dictMeta(id), meta);
376
- return { words: parsed.data, size };
377
- }
378
- async function loadLocalDictionary(id) {
379
- const data = await readJson(paths.dictFile(id));
380
- if (!data) return null;
381
- const parsed = WordArraySchema.safeParse(data);
382
- if (!parsed.success) throw new Error(`Local dictionary ${id} corrupt: ${parsed.error.message}`);
383
- return parsed.data;
384
- }
385
- async function isLocallyAvailable(id) {
386
- return exists(paths.dictFile(id));
387
- }
388
- async function ensureDictionary(id) {
389
- const local = await loadLocalDictionary(id);
390
- if (local) return local;
391
- const { words } = await pullDictionary(id);
392
- return words;
393
- }
394
- async function removeDictionary(id) {
395
- const { unlink } = await import("fs/promises");
396
- let removed = false;
397
- for (const p of [paths.dictFile(id), paths.dictMeta(id)]) {
398
- try {
399
- await unlink(p);
400
- removed = true;
401
- } catch (err) {
402
- if (err.code !== "ENOENT") throw err;
403
- }
404
- }
405
- return removed;
406
- }
407
-
408
- // src/commands/dict.ts
409
- function fmtBytes(n) {
410
- if (n < 1024) return `${n} B`;
411
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
412
- return `${(n / 1024 / 1024).toFixed(2)} MB`;
413
- }
414
- async function renderTable(entries) {
415
- const flagged = await Promise.all(
416
- entries.map(async (e) => ({ entry: e, local: await isLocallyAvailable(e.id) }))
417
- );
418
- const widths = {
419
- local: 3,
420
- id: Math.max(2, ...flagged.map((f) => f.entry.id.length)),
421
- name: Math.max(4, ...flagged.map((f) => f.entry.name.length)),
422
- category: Math.max(8, ...flagged.map((f) => f.entry.category.length)),
423
- length: Math.max(5, ...flagged.map((f) => String(f.entry.length).length))
424
- };
425
- const pad = (s, n) => s + " ".repeat(Math.max(0, n - visibleWidth(s)));
426
- const header = [
427
- pad(" ", widths.local),
428
- chalk2.bold(pad("ID", widths.id)),
429
- chalk2.bold(pad("Name", widths.name)),
430
- chalk2.bold(pad("Category", widths.category)),
431
- chalk2.bold(pad("Words", widths.length)),
432
- chalk2.bold("Description")
433
- ].join(" ");
434
- console.log(header);
435
- for (const { entry, local } of flagged) {
436
- const mark = local ? chalk2.green(" \u2713") : " ";
437
- console.log(
438
- [
439
- pad(mark, widths.local),
440
- pad(entry.id, widths.id),
441
- pad(entry.name, widths.name),
442
- chalk2.dim(pad(entry.category, widths.category)),
443
- pad(String(entry.length), widths.length),
444
- chalk2.dim(entry.description)
445
- ].join(" ")
446
- );
447
- }
448
- }
449
- function visibleWidth(s) {
450
- let w = 0;
451
- const plain = s.replace(/\x1b\[[0-9;]*m/g, "");
452
- for (const ch of plain) {
453
- const code = ch.codePointAt(0);
454
- w += code > 11904 && code < 64256 ? 2 : 1;
455
- }
456
- return w;
457
- }
458
- function buildDictCommand() {
459
- const cmd = new Command2("dict").description("Manage dictionaries");
460
- cmd.command("list").description("List dictionaries (\u2713 = locally available)").option("-c, --category <category>", "filter by category").option("--local-only", "show only locally downloaded dictionaries").action(async (opts) => {
461
- const reg = await loadRegistry();
462
- let entries = opts.category ? reg.filter((e) => e.category === opts.category) : reg;
463
- if (opts.localOnly) {
464
- const flags = await Promise.all(entries.map((e) => isLocallyAvailable(e.id)));
465
- entries = entries.filter((_, i) => flags[i]);
466
- }
467
- await renderTable(entries);
468
- console.log(chalk2.dim(`
469
- ${entries.length} dictionaries`));
470
- });
471
- cmd.command("search <keyword>").description("Search the upstream registry by name/description/category/tags").option("-c, --category <category>", "restrict to category").option("-l, --language <language>", "restrict to language").action(async (keyword, opts) => {
472
- const reg = await loadRegistry();
473
- const matches = filterRegistry(reg, keyword, opts);
474
- if (matches.length === 0) {
475
- console.log(chalk2.yellow(`No dictionaries matched "${keyword}"`));
476
- return;
477
- }
478
- await renderTable(matches);
479
- console.log(chalk2.dim(`
480
- ${matches.length} matches`));
481
- });
482
- cmd.command("pull <id>").description("Download an upstream dictionary into the local cache").action(async (id) => {
483
- try {
484
- const { words, size } = await pullDictionary(id);
485
- console.log(chalk2.green(`Saved ${words.length} words (${fmtBytes(size)}) \u2192 dict ${chalk2.bold(id)}`));
486
- } catch (err) {
487
- console.error(chalk2.red(err.message));
488
- process.exitCode = 1;
489
- }
490
- });
491
- cmd.command("import <file>").description("Import a local qwerty-native JSON dictionary").requiredOption("--id <id>", "dictionary id (lowercase, digits, dashes)").action(async (file, opts) => {
492
- try {
493
- const { words, size } = await importDictionary(file, opts.id);
494
- console.log(chalk2.green(`Imported ${words.length} words (${fmtBytes(size)}) as ${chalk2.bold(opts.id)}`));
495
- } catch (err) {
496
- console.error(chalk2.red(err.message));
497
- process.exitCode = 1;
498
- }
499
- });
500
- cmd.command("rm <id>").description("Remove a local dictionary").action(async (id) => {
501
- const removed = await removeDictionary(id);
502
- if (removed) console.log(chalk2.green(`Removed ${id}`));
503
- else console.log(chalk2.yellow(`Nothing to remove for ${id}`));
504
- });
505
- return cmd;
506
- }
507
-
508
- // src/commands/practice.ts
509
- import { Command as Command3 } from "commander";
510
- import chalk4 from "chalk";
511
- import { render } from "ink";
512
- import { createElement } from "react";
513
-
514
- // src/ui/App.tsx
515
- import { useRef as useRef4 } from "react";
516
- import { useApp as useApp4, useInput as useInput10 } from "ink";
517
-
518
- // src/ui/nav.tsx
519
- import { createContext, useContext, useState, useCallback } from "react";
520
- import { jsx } from "react/jsx-runtime";
521
- var NavContext = createContext(null);
522
- function NavProvider({ initial, children }) {
523
- const [stack, setStack] = useState([initial]);
524
- const navigate = useCallback((frame) => {
525
- setStack((s) => [...s, frame]);
526
- }, []);
527
- const replace = useCallback((frame) => {
528
- setStack((s) => s.length === 0 ? [frame] : [...s.slice(0, -1), frame]);
529
- }, []);
530
- const back = useCallback(() => {
531
- setStack((s) => s.length > 1 ? s.slice(0, -1) : s);
532
- }, []);
533
- const reset = useCallback((frame) => {
534
- setStack([frame]);
535
- }, []);
536
- const current = stack[stack.length - 1];
537
- return /* @__PURE__ */ jsx(NavContext.Provider, { value: { current, stack, navigate, replace, back, reset }, children });
538
- }
539
- function useNav() {
540
- const ctx = useContext(NavContext);
541
- if (!ctx) throw new Error("useNav must be used inside NavProvider");
542
- return ctx;
543
- }
544
-
545
- // src/ui/Fullscreen.tsx
546
- import { useEffect, useState as useState2 } from "react";
547
- import { Box, useStdout } from "ink";
548
- import { jsx as jsx2 } from "react/jsx-runtime";
549
- var ENTER = "\x1B[?1049h\x1B[?25l\x1B[2J\x1B[H";
550
- var LEAVE = "\x1B[?25h\x1B[?1049l";
551
- function shouldUse() {
552
- return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
553
- }
554
- function Fullscreen({ children }) {
555
- const { stdout } = useStdout();
556
- const [size, setSize] = useState2(() => ({
557
- rows: stdout?.rows ?? 24,
558
- cols: stdout?.columns ?? 80
559
- }));
560
- useEffect(() => {
561
- if (!shouldUse()) return;
562
- process.stdout.write(ENTER);
563
- const onResize = () => {
564
- setSize({ rows: process.stdout.rows ?? 24, cols: process.stdout.columns ?? 80 });
565
- };
566
- process.stdout.on("resize", onResize);
567
- const leave = () => {
568
- try {
569
- process.stdout.write(LEAVE);
570
- } catch {
571
- }
572
- };
573
- const onSignal = () => {
574
- leave();
575
- process.exit(130);
576
- };
577
- process.once("SIGINT", onSignal);
578
- process.once("SIGTERM", onSignal);
579
- process.once("exit", leave);
580
- return () => {
581
- process.off("SIGINT", onSignal);
582
- process.off("SIGTERM", onSignal);
583
- process.off("exit", leave);
584
- process.stdout.off("resize", onResize);
585
- leave();
586
- };
587
- }, []);
588
- return /* @__PURE__ */ jsx2(Box, { width: size.cols, height: size.rows, flexDirection: "column", children });
589
- }
590
-
591
- // src/ui/audio-context.tsx
592
- import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState3 } from "react";
593
-
594
- // src/infra/audio.ts
595
- import { spawn } from "child_process";
596
- import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
597
- import { join as join3, dirname as dirname2 } from "path";
598
- import { request as request2 } from "undici";
599
- import PQueue from "p-queue";
600
- var CANDIDATES = [
601
- { kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
602
- { kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
603
- { kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
604
- { kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
605
- { kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
606
- {
607
- kind: "powershell",
608
- cmd: "powershell",
609
- args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
610
- supports: "wav"
611
- }
612
- ];
613
- var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
614
- var runtime = null;
615
- async function isExecutable(cmd) {
616
- return new Promise((resolve2) => {
617
- const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
618
- probe.on("error", () => resolve2(false));
619
- probe.on("exit", () => resolve2(true));
620
- setTimeout(() => {
621
- probe.kill();
622
- resolve2(false);
623
- }, 500);
624
- });
625
- }
626
- async function detect() {
627
- let wav = null;
628
- let mp3 = null;
629
- for (const p of CANDIDATES) {
630
- if (!await isExecutable(p.cmd)) continue;
631
- if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
632
- if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
633
- if (wav && mp3) break;
634
- }
635
- return { wav, mp3 };
636
- }
637
- async function initAudio(disabledByConfig) {
638
- if (runtime) return runtime;
639
- if (disabledByConfig) {
640
- runtime = {
641
- disabled: true,
642
- wavPlayer: null,
643
- mp3Player: null,
644
- warning: null,
645
- keyQueue: new PQueue({ concurrency: 1 }),
646
- feedbackQueue: new PQueue({ concurrency: 1 }),
647
- pronQueue: new PQueue({ concurrency: 1 })
648
- };
649
- return runtime;
650
- }
651
- const { wav, mp3 } = await detect();
652
- let warning = null;
653
- if (!wav && !mp3) {
654
- warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
655
- } else if (!mp3) {
656
- warning = "No MP3 player found; word pronunciations will be skipped.";
657
- }
658
- runtime = {
659
- disabled: !wav && !mp3,
660
- wavPlayer: wav,
661
- mp3Player: mp3,
662
- warning,
663
- keyQueue: new PQueue({ concurrency: 2 }),
664
- feedbackQueue: new PQueue({ concurrency: 1 }),
665
- pronQueue: new PQueue({ concurrency: 1 })
666
- };
667
- return runtime;
668
- }
669
- function spawnPlay(player, file) {
670
- try {
671
- const child = spawn(player.cmd, player.args(file), {
672
- detached: true,
673
- stdio: "ignore"
674
- });
675
- child.on("error", () => {
676
- });
677
- child.unref();
678
- } catch {
679
- }
680
- }
681
- function playFile(file, kind) {
682
- if (!runtime || runtime.disabled) return;
683
- const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
684
- if (!player) return;
685
- spawnPlay(player, file);
686
- }
687
- function playKeystroke() {
688
- if (!runtime || runtime.disabled) return;
689
- if (runtime.keyQueue.size >= 2) return;
690
- void runtime.keyQueue.add(async () => {
691
- playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
692
- await new Promise((r) => setTimeout(r, 30));
693
- });
694
- }
695
- function playCorrect() {
696
- if (!runtime || runtime.disabled) return;
697
- void runtime.feedbackQueue.add(async () => {
698
- playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
699
- await new Promise((r) => setTimeout(r, 50));
700
- });
701
- }
702
- function playWrong() {
703
- if (!runtime || runtime.disabled) return;
704
- void runtime.feedbackQueue.add(async () => {
705
- playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
706
- await new Promise((r) => setTimeout(r, 50));
707
- });
708
- }
709
- async function downloadPronunciation(word, accent) {
710
- const cacheFile = paths.audioCache(word, accent);
711
- if (await exists(cacheFile)) return cacheFile;
712
- await mkdir3(dirname2(cacheFile), { recursive: true });
713
- const type = accent === "us" ? 2 : 1;
714
- const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
715
- try {
716
- const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
717
- if (res.statusCode >= 400) return null;
718
- const buf = Buffer.from(await res.body.arrayBuffer());
719
- if (buf.length < 1024) return null;
720
- const tmp = `${cacheFile}.tmp`;
721
- await writeFile2(tmp, buf);
722
- await rename2(tmp, cacheFile);
723
- return cacheFile;
724
- } catch {
725
- return null;
726
- }
727
- }
728
- async function playPronunciation(word, accent) {
729
- if (!runtime || runtime.disabled || !runtime.mp3Player) return;
730
- await ensureDirs();
731
- await runtime.pronQueue.add(async () => {
732
- const file = await downloadPronunciation(word, accent);
733
- if (file) playFile(file, "mp3");
734
- });
735
- }
736
- async function prefetchPronunciation(word, accent) {
737
- if (!runtime || runtime.disabled || !runtime.mp3Player) return;
738
- await ensureDirs();
739
- void runtime.pronQueue.add(async () => {
740
- await downloadPronunciation(word, accent);
741
- });
742
- }
743
- function audioWarning() {
744
- return runtime?.warning ?? null;
745
- }
746
-
747
- // src/ui/audio-context.tsx
748
- import { jsx as jsx3 } from "react/jsx-runtime";
749
- var AudioStatusContext = createContext2({ warning: null, ready: false });
750
- function AudioStatusProvider({
751
- disabled,
752
- children
753
- }) {
754
- const [status, setStatus] = useState3({ warning: null, ready: false });
755
- useEffect2(() => {
756
- let cancelled = false;
757
- initAudio(disabled).then(() => {
758
- if (cancelled) return;
759
- setStatus({ warning: audioWarning(), ready: true });
760
- }).catch(() => {
761
- if (cancelled) return;
762
- setStatus({ warning: null, ready: true });
763
- });
764
- return () => {
765
- cancelled = true;
766
- };
767
- }, [disabled]);
768
- return /* @__PURE__ */ jsx3(AudioStatusContext.Provider, { value: status, children });
769
- }
770
- function useAudioStatus() {
771
- return useContext2(AudioStatusContext);
772
- }
773
-
774
- // src/ui/app-state.tsx
775
- import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState4 } from "react";
776
- import { jsx as jsx4 } from "react/jsx-runtime";
777
- var AppStateContext = createContext3(null);
778
- function AppStateProvider({
779
- initialCfg,
780
- children
781
- }) {
782
- const [cfg, setCfgState] = useState4(initialCfg);
783
- const setCfg = useCallback2(async (next) => {
784
- setCfgState(next);
785
- await saveConfig(next);
786
- }, []);
787
- return /* @__PURE__ */ jsx4(AppStateContext.Provider, { value: { cfg, setCfg }, children });
788
- }
789
- function useAppState() {
790
- const ctx = useContext3(AppStateContext);
791
- if (!ctx) throw new Error("useAppState must be used inside AppStateProvider");
792
- return ctx;
793
- }
794
-
795
- // src/i18n/context.tsx
796
- import { createContext as createContext4, useContext as useContext4, useMemo } from "react";
797
-
798
- // src/i18n/strings.ts
799
- var en = {
800
- app: {
801
- title: "qwerty",
802
- subtitle: "typing practice for the terminal"
803
- },
804
- common: {
805
- back: "back",
806
- quit: "quit",
807
- on: "on",
808
- off: "off",
809
- cancel: "cancel"
810
- },
811
- mainMenu: {
812
- items: {
813
- practiceLabel: "Practice",
814
- practiceHintWith: (name) => `start ${name}`,
815
- practiceHintNone: "pick a dictionary",
816
- dictLabel: "Dictionaries",
817
- dictHint: "browse, pull, set default",
818
- wordLabel: "Word lookup",
819
- wordHint: "search local dicts",
820
- statsLabel: "Stats",
821
- statsHint: "history & trends",
822
- configLabel: "Config",
823
- configHint: "edit preferences",
824
- stealthLabel: "Stealth",
825
- stealthHint: "quiet practice mode",
826
- quitLabel: "Quit",
827
- quitHint: "Esc or Ctrl+C also exits"
828
- },
829
- hint: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump",
830
- helpHint: "? help"
831
- },
832
- dict: {
833
- title: "Dictionaries",
834
- loading: "loading dictionaries\u2026",
835
- entries: (n) => `${n} entries`,
836
- filterPlaceholder: "type to filter",
837
- local: "local \u2713",
838
- notLocal: "not local",
839
- defaultMark: "default \u2605",
840
- tagsLabel: (tags) => `tags: ${tags}`,
841
- wordsLabel: (n) => `${n} words`,
842
- pulling: (id) => `pulling ${id}\u2026`,
843
- removing: (id) => `removing ${id}\u2026`,
844
- errorOn: (id, msg) => `error on ${id}: ${msg}`,
845
- footer: "\u2191/\u2193 select \xB7 Enter actions \xB7 Ctrl+K more \xB7 Esc back",
846
- action: {
847
- title: "current dictionary",
848
- setDefault: "set as default",
849
- practice: "practice now",
850
- delete: "delete local"
851
- },
852
- command: {
853
- title: "more actions",
854
- pull: "pull selected",
855
- import: "import .json",
856
- refreshList: "update dictionary list"
857
- }
858
- },
859
- config: {
860
- title: "Config",
861
- fields: {
862
- defaultDict: "default dict",
863
- defaultMode: "default mode",
864
- accent: "accent",
865
- mirror: "dict mirror",
866
- chapterSize: "chapter size",
867
- autoplayPronunciation: "autoplay pronunciation",
868
- soundsMaster: "sounds master",
869
- soundsKeystroke: "sounds keystroke",
870
- soundsFeedback: "sounds feedback",
871
- soundsKeySound: "sounds key sound",
872
- language: "language",
873
- stealth: "stealth mode"
874
- },
875
- enumValues: {
876
- stealth: { off: "off", menu: "show in menu", default: "default practice" }
877
- },
878
- hints: {
879
- editing: "type to edit \xB7 Enter save \xB7 Esc cancel",
880
- bool: "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back",
881
- enum: "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back",
882
- dictRef: "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back",
883
- stringOrInt: "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back"
884
- }
885
- },
886
- stats: {
887
- title: "Stats \xB7 overview",
888
- loading: "loading stats\u2026",
889
- none: "No practice history yet.",
890
- nonePractice: "Run a practice session first.",
891
- lifetime: "lifetime",
892
- sessions: "sessions",
893
- words: "words",
894
- errors: "errors",
895
- wpm: "wpm",
896
- accuracy: "accuracy",
897
- streak: "streak",
898
- last: (n) => `last ${n} days (\u2190/\u2192 cycle window)`,
899
- cycleWindow: "\u2190/\u2192 cycle window \xB7 Esc back",
900
- recent: "recent sessions",
901
- topMistakes: "top mistakes",
902
- footer: "\u2190/\u2192 cycle window \xB7 Esc back",
903
- maxLabel: "max",
904
- recentUnits: { words: "w", errors: "err", wpm: "wpm" },
905
- multiDictSuffix: (n) => ` +${n} more`
906
- },
907
- word: {
908
- title: "Word lookup",
909
- indexing: "indexing local dictionaries\u2026",
910
- none: "No local dictionaries.",
911
- pullFirst: "Pull one in Dictionaries first.",
912
- countAcross: (n) => `${n} words across local dicts`,
913
- noMatches: (q) => `no matches for "${q}"`,
914
- inDict: (name) => `in: ${name}`,
915
- mistakes: (n, date) => `mistakes: ${n} (last ${date})`,
916
- footer: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back"
917
- },
918
- practice: {
919
- loading: "loading\u2026",
920
- paused: "PAUSED",
921
- chapterComplete: "CHAPTER COMPLETE",
922
- chapterLabel: (c, t) => `chapter ${c}/${t}`,
923
- reviewLabel: "review",
924
- statusBar: {
925
- mode: "mode",
926
- accent: "accent"
927
- },
928
- modes: {
929
- order: "order",
930
- dictation: "dictation",
931
- review: "review",
932
- random: "random",
933
- loop: "loop"
934
- },
935
- accents: {
936
- us: "us",
937
- uk: "uk"
938
- },
939
- statCards: {
940
- words: "words",
941
- errors: "errors",
942
- wpm: "wpm",
943
- accuracy: "accuracy",
944
- elapsed: (t) => `elapsed ${t}`
945
- },
946
- pause: {
947
- title: "PAUSED",
948
- chapter: (c, t) => `chapter ${c}/${t}`,
949
- progress: (completed, total) => `${completed}/${total}`,
950
- hint: "Enter resume \xB7 Esc back to menu"
951
- },
952
- summary: {
953
- loopAgain: "again",
954
- nextChapter: "next chapter",
955
- reviewMistakes: "review mistakes",
956
- backMenu: "back to menu"
957
- },
958
- footers: {
959
- typing: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay"
960
- },
961
- errors: {
962
- noMistakes: "No mistakes to review yet. Practice some chapters first.",
963
- dictEmpty: (id) => `Dictionary ${id} is empty.`,
964
- unknown: "Unknown error"
965
- }
966
- },
967
- audio: {
968
- noPlayer: "! No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled."
969
- },
970
- report: {
971
- title: "Session summary",
972
- duration: "duration",
973
- practiced: "practiced",
974
- chapters: "chapters",
975
- words: "words",
976
- accuracy: "accuracy",
977
- wpm: "wpm",
978
- newMistakes: "new mistakes",
979
- farewell: "see you next time.",
980
- notPracticed: "no practice this run"
981
- },
982
- help: {
983
- title: "Help",
984
- subtitle: "all shortcuts",
985
- sections: {
986
- main: "main menu",
987
- practice: "practice",
988
- dict: "dictionaries",
989
- config: "config",
990
- stats: "stats",
991
- word: "word lookup",
992
- global: "global"
993
- },
994
- keys: {
995
- navigate: "\u2191/\u2193 navigate items",
996
- select: "Enter confirm / continue",
997
- letterJump: "letter jump to menu item",
998
- pause: "Esc pause practice",
999
- skip: "Ctrl+N skip current word (neutral)",
1000
- replay: "Tab replay pronunciation",
1001
- resume: "Enter resume from pause",
1002
- backMenu: "Esc back to previous screen",
1003
- backScreen: "Esc close panel or back",
1004
- nextChapter: "Enter next chapter",
1005
- reviewMistakes: "m review mistakes",
1006
- filter: "type to filter list",
1007
- itemActions: "Enter open actions panel",
1008
- moreActions: "Ctrl+K more actions panel",
1009
- cycleWindow: "\u2190/\u2192 cycle day window",
1010
- stealthToggle: "Ctrl+I toggle stealth info row",
1011
- helpScreen: "? open this help screen",
1012
- quit: "Ctrl+C quit immediately"
1013
- },
1014
- footer: "Esc back"
1015
- },
1016
- stealth: {
1017
- paused: "paused",
1018
- chapterDone: "chapter done",
1019
- resumeHint: "Enter resume \xB7 Esc menu",
1020
- nextHint: "Enter next \xB7 Esc menu",
1021
- infoFmt: (dict, chapter, completed, total, wpm, accPct) => `${dict} \xB7 ${chapter} \xB7 ${completed}/${total} \xB7 ${wpm} wpm \xB7 ${accPct}%`
1022
- }
1023
- };
1024
- var zh = {
1025
- app: {
1026
- title: "qwerty",
1027
- subtitle: "\u7EC8\u7AEF\u952E\u76D8\u7EC3\u4E60"
1028
- },
1029
- common: {
1030
- back: "\u8FD4\u56DE",
1031
- quit: "\u9000\u51FA",
1032
- on: "\u5F00",
1033
- off: "\u5173",
1034
- cancel: "\u53D6\u6D88"
1035
- },
1036
- mainMenu: {
1037
- items: {
1038
- practiceLabel: "\u7EC3\u4E60",
1039
- practiceHintWith: (name) => `\u5F00\u59CB ${name}`,
1040
- practiceHintNone: "\u8BF7\u5148\u9009\u8BCD\u5178",
1041
- dictLabel: "\u8BCD\u5178",
1042
- dictHint: "\u6D4F\u89C8\u3001\u4E0B\u8F7D\u3001\u8BBE\u4E3A\u9ED8\u8BA4",
1043
- wordLabel: "\u67E5\u8BCD",
1044
- wordHint: "\u5728\u672C\u5730\u8BCD\u5178\u4E2D\u641C\u7D22",
1045
- statsLabel: "\u7EDF\u8BA1",
1046
- statsHint: "\u5386\u53F2\u4E0E\u8D8B\u52BF",
1047
- configLabel: "\u8BBE\u7F6E",
1048
- configHint: "\u4FEE\u6539\u504F\u597D",
1049
- stealthLabel: "\u6478\u9C7C",
1050
- stealthHint: "\u5B89\u9759\u7EC3\u4E60\u6A21\u5F0F",
1051
- quitLabel: "\u9000\u51FA",
1052
- quitHint: "Esc \u6216 Ctrl+C \u9000\u51FA"
1053
- },
1054
- hint: "\u2191/\u2193 \u79FB\u52A8 \xB7 Enter \u786E\u8BA4 \xB7 \u5B57\u6BCD\u76F4\u8FBE",
1055
- helpHint: "? \u5E2E\u52A9"
1056
- },
1057
- dict: {
1058
- title: "\u8BCD\u5178",
1059
- loading: "\u52A0\u8F7D\u8BCD\u5178\u4E2D\u2026",
1060
- entries: (n) => `${n} \u90E8\u8BCD\u5178`,
1061
- filterPlaceholder: "\u8F93\u5165\u8FC7\u6EE4",
1062
- local: "\u5DF2\u4E0B\u8F7D \u2713",
1063
- notLocal: "\u672A\u4E0B\u8F7D",
1064
- defaultMark: "\u9ED8\u8BA4 \u2605",
1065
- tagsLabel: (tags) => `\u6807\u7B7E:${tags}`,
1066
- wordsLabel: (n) => `${n} \u8BCD`,
1067
- pulling: (id) => `\u62C9\u53D6 ${id} \u4E2D\u2026`,
1068
- removing: (id) => `\u5220\u9664 ${id} \u4E2D\u2026`,
1069
- errorOn: (id, msg) => `${id} \u51FA\u9519:${msg}`,
1070
- footer: "\u2191/\u2193 \u9009\u62E9 \xB7 Enter \u64CD\u4F5C \xB7 Ctrl+K \u66F4\u591A \xB7 Esc \u8FD4\u56DE",
1071
- action: {
1072
- title: "\u5F53\u524D\u8BCD\u5178",
1073
- setDefault: "\u8BBE\u4E3A\u9ED8\u8BA4",
1074
- practice: "\u7ACB\u5373\u7EC3\u4E60",
1075
- delete: "\u5220\u9664\u672C\u5730"
1076
- },
1077
- command: {
1078
- title: "\u66F4\u591A\u529F\u80FD",
1079
- pull: "\u62C9\u53D6\u9009\u4E2D",
1080
- import: "\u5BFC\u5165 .json",
1081
- refreshList: "\u66F4\u65B0\u8BCD\u5178\u5217\u8868"
1082
- }
1083
- },
1084
- config: {
1085
- title: "\u8BBE\u7F6E",
1086
- fields: {
1087
- defaultDict: "\u9ED8\u8BA4\u8BCD\u5178",
1088
- defaultMode: "\u9ED8\u8BA4\u6A21\u5F0F",
1089
- accent: "\u53D1\u97F3",
1090
- mirror: "\u8BCD\u5178\u955C\u50CF\u6E90",
1091
- chapterSize: "\u7AE0\u8282\u5355\u8BCD\u6570",
1092
- autoplayPronunciation: "\u81EA\u52A8\u64AD\u653E\u53D1\u97F3",
1093
- soundsMaster: "\u97F3\u6548\u603B\u5F00\u5173",
1094
- soundsKeystroke: "\u6309\u952E\u97F3",
1095
- soundsFeedback: "\u53CD\u9988\u97F3",
1096
- soundsKeySound: "\u6309\u952E\u97F3\u8272",
1097
- language: "\u8BED\u8A00",
1098
- stealth: "\u6478\u9C7C\u6A21\u5F0F"
1099
- },
1100
- enumValues: {
1101
- stealth: { off: "\u5173\u95ED", menu: "\u4E3B\u83DC\u5355\u663E\u793A", default: "\u9ED8\u8BA4\u7EC3\u4E60\u6A21\u5F0F" }
1102
- },
1103
- hints: {
1104
- editing: "\u8F93\u5165\u4FEE\u6539 \xB7 Enter \u4FDD\u5B58 \xB7 Esc \u53D6\u6D88",
1105
- bool: "\u7A7A\u683C\u5207\u6362 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1106
- enum: "\u2190/\u2192 \u5207\u6362 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1107
- dictRef: "Enter \u9009\u8BCD\u5178 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE",
1108
- stringOrInt: "Enter \u7F16\u8F91 \xB7 \u2191/\u2193 \u79FB\u52A8 \xB7 Esc \u8FD4\u56DE"
1109
- }
1110
- },
1111
- stats: {
1112
- title: "\u7EDF\u8BA1 \xB7 \u6982\u89C8",
1113
- loading: "\u52A0\u8F7D\u7EDF\u8BA1\u4E2D\u2026",
1114
- none: "\u8FD8\u6CA1\u6709\u7EC3\u4E60\u8BB0\u5F55\u3002",
1115
- nonePractice: "\u5148\u6765\u4E00\u6B21\u7EC3\u4E60\u5427\u3002",
1116
- lifetime: "\u7D2F\u8BA1",
1117
- sessions: "\u4F1A\u8BDD",
1118
- words: "\u8BCD\u6570",
1119
- errors: "\u9519\u8BEF",
1120
- wpm: "\u901F\u5EA6",
1121
- accuracy: "\u51C6\u786E\u7387",
1122
- streak: "\u8FDE\u7EED\u5929\u6570",
1123
- last: (n) => `\u6700\u8FD1 ${n} \u5929 (\u2190/\u2192 \u5207\u6362\u7A97\u53E3)`,
1124
- cycleWindow: "\u2190/\u2192 \u5207\u6362\u7A97\u53E3 \xB7 Esc \u8FD4\u56DE",
1125
- recent: "\u6700\u8FD1\u4F1A\u8BDD",
1126
- topMistakes: "\u9AD8\u9891\u9519\u8BCD",
1127
- footer: "\u2190/\u2192 \u5207\u6362\u7A97\u53E3 \xB7 Esc \u8FD4\u56DE",
1128
- maxLabel: "\u6700\u5927",
1129
- recentUnits: { words: "\u8BCD", errors: "\u9519", wpm: "\u901F" },
1130
- multiDictSuffix: (n) => ` \u7B49 ${n} \u90E8`
1131
- },
1132
- word: {
1133
- title: "\u67E5\u8BCD",
1134
- indexing: "\u7D22\u5F15\u672C\u5730\u8BCD\u5178\u4E2D\u2026",
1135
- none: "\u6CA1\u6709\u672C\u5730\u8BCD\u5178\u3002",
1136
- pullFirst: "\u5148\u5728\u300C\u8BCD\u5178\u300D\u4E2D\u62C9\u53D6\u4E00\u90E8\u3002",
1137
- countAcross: (n) => `\u672C\u5730\u8BCD\u5178\u5171 ${n} \u8BCD`,
1138
- noMatches: (q) => `\u6CA1\u6709\u5339\u914D\u300C${q}\u300D\u7684\u8BCD`,
1139
- inDict: (name) => `\u6765\u6E90:${name}`,
1140
- mistakes: (n, date) => `\u9519\u8FC7 ${n} \u6B21 (\u6700\u8FD1 ${date})`,
1141
- footer: "\u8F93\u5165\u8FC7\u6EE4 \xB7 \u2191/\u2193 \u9009\u62E9 \xB7 Esc \u8FD4\u56DE"
1142
- },
1143
- practice: {
1144
- loading: "\u52A0\u8F7D\u4E2D\u2026",
1145
- paused: "\u5DF2\u6682\u505C",
1146
- chapterComplete: "\u672C\u7AE0\u5B8C\u6210",
1147
- chapterLabel: (c, t) => `\u7B2C ${c}/${t} \u7AE0`,
1148
- reviewLabel: "\u590D\u4E60",
1149
- statusBar: {
1150
- mode: "\u6A21\u5F0F",
1151
- accent: "\u53D1\u97F3"
1152
- },
1153
- modes: {
1154
- order: "\u987A\u5E8F",
1155
- dictation: "\u9ED8\u5199",
1156
- review: "\u590D\u4E60",
1157
- random: "\u4E71\u5E8F",
1158
- loop: "\u5FAA\u73AF"
1159
- },
1160
- accents: {
1161
- us: "\u7F8E",
1162
- uk: "\u82F1"
1163
- },
1164
- statCards: {
1165
- words: "\u8BCD\u6570",
1166
- errors: "\u9519\u8BEF",
1167
- wpm: "\u901F\u5EA6",
1168
- accuracy: "\u51C6\u786E\u7387",
1169
- elapsed: (t) => `\u8017\u65F6 ${t}`
1170
- },
1171
- pause: {
1172
- title: "\u5DF2\u6682\u505C",
1173
- chapter: (c, t) => `\u7B2C ${c}/${t} \u7AE0`,
1174
- progress: (completed, total) => `${completed}/${total}`,
1175
- hint: "Enter \u7EE7\u7EED \xB7 Esc \u8FD4\u56DE\u83DC\u5355"
1176
- },
1177
- summary: {
1178
- loopAgain: "\u518D\u6765\u4E00\u904D",
1179
- nextChapter: "\u4E0B\u4E00\u7AE0",
1180
- reviewMistakes: "\u590D\u4E60\u9519\u8BCD",
1181
- backMenu: "\u8FD4\u56DE\u83DC\u5355"
1182
- },
1183
- footers: {
1184
- typing: "Ctrl+N \u8DF3\u8FC7 \xB7 Esc \u6682\u505C \xB7 Tab \u91CD\u64AD"
1185
- },
1186
- errors: {
1187
- noMistakes: "\u9519\u8BCD\u672C\u662F\u7A7A\u7684\u3002\u5148\u7EC3\u4E60\u51E0\u7AE0\u5427\u3002",
1188
- dictEmpty: (id) => `\u8BCD\u5178 ${id} \u662F\u7A7A\u7684\u3002`,
1189
- unknown: "\u672A\u77E5\u9519\u8BEF"
1190
- }
1191
- },
1192
- audio: {
1193
- noPlayer: "! \u672A\u5728 PATH \u4E2D\u627E\u5230\u97F3\u9891\u64AD\u653E\u5668(\u5C1D\u8BD5 afplay/ffplay/mpg123/paplay/aplay/powershell)\u3002\u97F3\u6548\u5DF2\u7981\u7528\u3002"
1194
- },
1195
- report: {
1196
- title: "\u672C\u6B21\u4F1A\u8BDD",
1197
- duration: "\u603B\u65F6\u957F",
1198
- practiced: "\u7EC3\u4E60\u7528\u65F6",
1199
- chapters: "\u5B8C\u6210\u7AE0\u8282",
1200
- words: "\u8BCD\u6570",
1201
- accuracy: "\u51C6\u786E\u7387",
1202
- wpm: "\u901F\u5EA6",
1203
- newMistakes: "\u65B0\u9519\u8BCD",
1204
- farewell: "\u4E0B\u6B21\u89C1\u3002",
1205
- notPracticed: "\u672C\u6B21\u672A\u7EC3\u4E60"
1206
- },
1207
- help: {
1208
- title: "\u5E2E\u52A9",
1209
- subtitle: "\u5168\u90E8\u5FEB\u6377\u952E",
1210
- sections: {
1211
- main: "\u4E3B\u83DC\u5355",
1212
- practice: "\u7EC3\u4E60",
1213
- dict: "\u8BCD\u5178",
1214
- config: "\u8BBE\u7F6E",
1215
- stats: "\u7EDF\u8BA1",
1216
- word: "\u67E5\u8BCD",
1217
- global: "\u5168\u5C40"
1218
- },
1219
- keys: {
1220
- navigate: "\u2191/\u2193 \u79FB\u52A8\u9009\u9879",
1221
- select: "Enter \u786E\u8BA4 / \u7EE7\u7EED",
1222
- letterJump: "\u5B57\u6BCD\u952E \u76F4\u8FBE\u83DC\u5355\u9879",
1223
- pause: "Esc \u6682\u505C\u7EC3\u4E60",
1224
- skip: "Ctrl+N \u8DF3\u8FC7\u5F53\u524D\u8BCD(\u4E0D\u8BA1\u9519)",
1225
- replay: "Tab \u91CD\u64AD\u53D1\u97F3",
1226
- resume: "Enter \u7EE7\u7EED\u7EC3\u4E60",
1227
- backMenu: "Esc \u8FD4\u56DE\u4E0A\u4E00\u5C4F",
1228
- backScreen: "Esc \u5173\u95ED\u9762\u677F / \u8FD4\u56DE",
1229
- nextChapter: "Enter \u4E0B\u4E00\u7AE0",
1230
- reviewMistakes: "m \u590D\u4E60\u9519\u8BCD",
1231
- filter: "\u8F93\u5165 \u8FC7\u6EE4\u5217\u8868",
1232
- itemActions: "Enter \u5F39\u51FA\u52A8\u4F5C\u9762\u677F",
1233
- moreActions: "Ctrl+K \u5F39\u51FA\u66F4\u591A\u529F\u80FD",
1234
- cycleWindow: "\u2190/\u2192 \u5207\u6362\u65E5\u7A97\u53E3",
1235
- stealthToggle: "Ctrl+I \u5207\u6362\u6478\u9C7C\u4FE1\u606F\u884C",
1236
- helpScreen: "? \u6253\u5F00\u672C\u5E2E\u52A9\u9875",
1237
- quit: "Ctrl+C \u7ACB\u5373\u9000\u51FA"
1238
- },
1239
- footer: "Esc \u8FD4\u56DE"
1240
- },
1241
- stealth: {
1242
- paused: "paused",
1243
- chapterDone: "chapter done",
1244
- resumeHint: "Enter resume \xB7 Esc menu",
1245
- nextHint: "Enter next \xB7 Esc menu",
1246
- infoFmt: (dict, chapter, completed, total, wpm, accPct) => `${dict} \xB7 ${chapter} \xB7 ${completed}/${total} \xB7 ${wpm} wpm \xB7 ${accPct}%`
1247
- }
1248
- };
1249
-
1250
- // src/i18n/locale.ts
1251
- function pickFromString(s) {
1252
- if (!s) return null;
1253
- const lower = s.toLowerCase();
1254
- if (lower.startsWith("zh")) return "zh";
1255
- if (lower.startsWith("en")) return "en";
1256
- return null;
1257
- }
1258
- function detectLocale(pref) {
1259
- if (pref === "zh" || pref === "en") return pref;
1260
- const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE;
1261
- const fromEnv = pickFromString(env);
1262
- if (fromEnv) return fromEnv;
1263
- try {
1264
- const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale;
1265
- const fromIntl = pickFromString(intlLocale);
1266
- if (fromIntl) return fromIntl;
1267
- } catch {
1268
- }
1269
- return "en";
1270
- }
1271
-
1272
- // src/i18n/context.tsx
1273
- import { jsx as jsx5 } from "react/jsx-runtime";
1274
- var StringsContext = createContext4(null);
1275
- function StringsProvider({
1276
- pref,
1277
- children
1278
- }) {
1279
- const value = useMemo(() => {
1280
- const lang = detectLocale(pref);
1281
- return { lang, t: lang === "zh" ? zh : en };
1282
- }, [pref]);
1283
- return /* @__PURE__ */ jsx5(StringsContext.Provider, { value, children });
1284
- }
1285
- function useStrings() {
1286
- const ctx = useContext4(StringsContext);
1287
- if (!ctx) throw new Error("useStrings must be used inside StringsProvider");
1288
- return ctx.t;
1289
- }
1290
- function pickStrings(pref) {
1291
- const lang = detectLocale(pref);
1292
- return { lang, t: lang === "zh" ? zh : en };
1293
- }
1294
-
1295
- // src/ui/registry-context.tsx
1296
- import { createContext as createContext5, useContext as useContext5, useEffect as useEffect3, useState as useState5 } from "react";
1297
- import { jsx as jsx6 } from "react/jsx-runtime";
1298
- var RegistryContext = createContext5(null);
1299
- function RegistryProvider({ children }) {
1300
- const [registry, setRegistry] = useState5(null);
1301
- const [byId, setById] = useState5(/* @__PURE__ */ new Map());
1302
- useEffect3(() => {
1303
- let cancelled = false;
1304
- (async () => {
1305
- try {
1306
- const reg = await loadRegistry();
1307
- if (cancelled) return;
1308
- const map = /* @__PURE__ */ new Map();
1309
- for (const e of reg) map.set(e.id, e);
1310
- setRegistry(reg);
1311
- setById(map);
1312
- } catch {
1313
- if (!cancelled) {
1314
- setRegistry([]);
1315
- setById(/* @__PURE__ */ new Map());
1316
- }
1317
- }
1318
- })();
1319
- return () => {
1320
- cancelled = true;
1321
- };
1322
- }, []);
1323
- return /* @__PURE__ */ jsx6(RegistryContext.Provider, { value: { registry, byId }, children });
1324
- }
1325
- function useRegistry() {
1326
- const ctx = useContext5(RegistryContext);
1327
- if (!ctx) throw new Error("useRegistry must be used inside RegistryProvider");
1328
- return ctx;
1329
- }
1330
- function useDictName(id) {
1331
- const { byId } = useRegistry();
1332
- if (!id) return "";
1333
- const entry = byId.get(id);
1334
- return entry?.name ?? id;
1335
- }
1336
-
1337
- // src/ui/screens/MainMenu.tsx
1338
- import { useState as useState6 } from "react";
1339
- import { Box as Box3, Text as Text2, useApp, useInput } from "ink";
1340
-
1341
- // src/ui/components/BigWord.tsx
1342
- import { Box as Box2, Text, useStdout as useStdout2 } from "ink";
1343
- import { jsx as jsx7, jsxs } from "react/jsx-runtime";
1344
- var PALETTE = {
1345
- accent: "#5eead4",
1346
- muted: "#6b7280",
1347
- text: "#e5e7eb",
1348
- primary: "#7dcfff",
1349
- success: "#86efac",
1350
- warning: "#fbbf24",
1351
- error: "#f87171"
1352
- };
1353
- function BigWord({ target, typed, error = false, hideTarget = false }) {
1354
- const { stdout } = useStdout2();
1355
- const cols = stdout?.columns ?? 80;
1356
- const chars = [...target];
1357
- const typedChars = [...typed];
1358
- const sep = cols >= 80 ? " " : cols >= 60 ? " " : " ";
1359
- return /* @__PURE__ */ jsxs(Box2, { flexDirection: "column", alignItems: "center", paddingY: 1, children: [
1360
- /* @__PURE__ */ jsx7(Box2, { children: chars.map((ch, i) => {
1361
- const isTyped = i < typedChars.length;
1362
- const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
1363
- const color = error ? PALETTE.error : isTyped ? PALETTE.accent : PALETTE.muted;
1364
- return /* @__PURE__ */ jsxs(Text, { bold: true, color, children: [
1365
- display,
1366
- i < chars.length - 1 ? sep : ""
1367
- ] }, i);
1368
- }) }),
1369
- /* @__PURE__ */ jsx7(Box2, { children: chars.map((ch, i) => {
1370
- const isTyped = i < typedChars.length;
1371
- const trackChar = isTyped ? "\u2501" : "\u2500";
1372
- const color = error ? PALETTE.error : isTyped ? PALETTE.accent : PALETTE.muted;
1373
- return /* @__PURE__ */ jsxs(Text, { color, children: [
1374
- trackChar,
1375
- i < chars.length - 1 ? sep : ""
1376
- ] }, i);
1377
- }) })
1378
- ] });
1379
- }
1380
-
1381
- // src/util/text.ts
1382
- var ANSI_RE = /\x1b\[[0-9;]*m/g;
1383
- function stripAnsi(s) {
1384
- return s.replace(ANSI_RE, "");
1385
- }
1386
- function visibleWidth2(s) {
1387
- const plain = stripAnsi(s);
1388
- let w = 0;
1389
- for (const ch of plain) {
1390
- const code = ch.codePointAt(0);
1391
- w += code > 11904 && code < 64256 ? 2 : 1;
1392
- }
1393
- return w;
1394
- }
1395
-
1396
- // src/util/dict-name.ts
1397
- function truncateName(name, max) {
1398
- if (visibleWidth2(name) <= max) return name;
1399
- let out = "";
1400
- let w = 0;
1401
- for (const ch of name) {
1402
- const code = ch.codePointAt(0);
1403
- const cw = code > 11904 && code < 64256 ? 2 : 1;
1404
- if (w + cw > max - 1) break;
1405
- out += ch;
1406
- w += cw;
1407
- }
1408
- return out + "\u2026";
1409
- }
1410
-
1411
- // src/ui/screens/MainMenu.tsx
1412
- import { jsx as jsx8, jsxs as jsxs2 } from "react/jsx-runtime";
1413
- function MainMenu({ cfg }) {
1414
- const [selected, setSelected] = useState6(0);
1415
- const { exit } = useApp();
1416
- const nav = useNav();
1417
- const audio = useAudioStatus();
1418
- const t = useStrings();
1419
- const defaultDictName = useDictName(cfg.defaultDict);
1420
- const m = t.mainMenu.items;
1421
- const startPractice = (stealth) => {
1422
- if (cfg.defaultDict) {
1423
- nav.navigate({
1424
- name: "practice",
1425
- params: {
1426
- dictId: cfg.defaultDict,
1427
- chapterIndex: 0,
1428
- mode: cfg.defaultMode,
1429
- stealth
1430
- }
1431
- });
1432
- } else {
1433
- nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
1434
- }
1435
- };
1436
- const items = [
1437
- {
1438
- key: "p",
1439
- label: m.practiceLabel,
1440
- hint: cfg.defaultDict ? m.practiceHintWith(truncateName(defaultDictName, 24)) : m.practiceHintNone,
1441
- run: () => startPractice(cfg.stealth === "default")
1442
- }
1443
- ];
1444
- if (cfg.stealth === "menu" || cfg.stealth === "default") {
1445
- items.push({
1446
- key: "b",
1447
- label: m.stealthLabel,
1448
- hint: m.stealthHint,
1449
- run: () => startPractice(true)
1450
- });
1451
- }
1452
- items.push(
1453
- { key: "d", label: m.dictLabel, hint: m.dictHint, run: () => nav.navigate({ name: "dict" }) },
1454
- { key: "w", label: m.wordLabel, hint: m.wordHint, run: () => nav.navigate({ name: "word" }) },
1455
- { key: "s", label: m.statsLabel, hint: m.statsHint, run: () => nav.navigate({ name: "stats" }) },
1456
- { key: "c", label: m.configLabel, hint: m.configHint, run: () => nav.navigate({ name: "config" }) },
1457
- { key: "q", label: m.quitLabel, hint: m.quitHint, run: () => exit() }
1458
- );
1459
- const labelW = Math.max(...items.map((it) => visibleWidth2(it.label))) + 4;
1460
- useInput((input, key) => {
1461
- if (key.escape) {
1462
- exit();
1463
- return;
1464
- }
1465
- if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
1466
- if (key.downArrow) setSelected((i) => (i + 1) % items.length);
1467
- if (key.return) {
1468
- items[selected].run();
1469
- return;
1470
- }
1471
- if (input === "?") {
1472
- nav.navigate({ name: "help" });
1473
- return;
1474
- }
1475
- for (const it of items) {
1476
- if (input === it.key) {
1477
- it.run();
1478
- return;
1479
- }
1480
- }
1481
- });
1482
- return /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
1483
- /* @__PURE__ */ jsxs2(Box3, { children: [
1484
- /* @__PURE__ */ jsx8(Text2, { bold: true, color: PALETTE.accent, children: t.app.title }),
1485
- /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
1486
- " \xB7 ",
1487
- t.app.subtitle
1488
- ] })
1489
- ] }),
1490
- /* @__PURE__ */ jsx8(Box3, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
1491
- const active = i === selected;
1492
- const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(it.label)));
1493
- return /* @__PURE__ */ jsxs2(Box3, { children: [
1494
- /* @__PURE__ */ jsx8(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
1495
- /* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
1496
- "[",
1497
- it.key,
1498
- "]"
1499
- ] }),
1500
- /* @__PURE__ */ jsx8(Text2, { children: " " }),
1501
- /* @__PURE__ */ jsxs2(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
1502
- it.label,
1503
- pad
1504
- ] }),
1505
- /* @__PURE__ */ jsx8(Text2, { color: PALETTE.muted, children: it.hint })
1506
- ] }, it.key);
1507
- }) }),
1508
- /* @__PURE__ */ jsx8(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
1509
- t.mainMenu.hint,
1510
- " \xB7 ",
1511
- t.mainMenu.helpHint
1512
- ] }) }),
1513
- audio.warning && /* @__PURE__ */ jsx8(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text2, { color: PALETTE.warning, children: t.audio.noPlayer }) })
1514
- ] });
1515
- }
1516
-
1517
- // src/ui/screens/PracticeScreen.tsx
1518
- import { useState as useState8, useEffect as useEffect6, useRef as useRef3 } from "react";
1519
- import { Box as Box5, Text as Text4, useApp as useApp3, useInput as useInput3 } from "ink";
1520
-
1521
- // src/util/shuffle.ts
1522
- function shuffle(arr, rng = Math.random) {
1523
- const out = [...arr];
1524
- for (let i = out.length - 1; i > 0; i--) {
1525
- const j = Math.floor(rng() * (i + 1));
1526
- const tmp = out[i];
1527
- out[i] = out[j];
1528
- out[j] = tmp;
1529
- }
1530
- return out;
1531
- }
1532
- function mulberry32(seed) {
1533
- let t = seed >>> 0;
1534
- return () => {
1535
- t = t + 1831565813 >>> 0;
1536
- let r = Math.imul(t ^ t >>> 15, 1 | t);
1537
- r = r + Math.imul(r ^ r >>> 7, 61 | r) ^ r;
1538
- return ((r ^ r >>> 14) >>> 0) / 4294967296;
1539
- };
1540
- }
1541
-
1542
- // src/domain/chapters.ts
1543
- function chunkChapters(words, chapterSize) {
1544
- if (chapterSize <= 0) throw new Error("chapterSize must be positive");
1545
- const chunks = [];
1546
- for (let i = 0; i < words.length; i += chapterSize) {
1547
- chunks.push(words.slice(i, i + chapterSize));
1548
- }
1549
- return chunks;
1550
- }
1551
- function buildPlaylist(chapter, mode, seed) {
1552
- if (mode === "random") {
1553
- const rng = seed === void 0 ? Math.random : mulberry32(seed);
1554
- return shuffle(chapter, rng);
1555
- }
1556
- return chapter;
1557
- }
1558
-
1559
- // src/domain/input-buffer.ts
1560
- function initialState(target) {
1561
- return { target, typed: "", errorsThisWord: 0 };
1562
- }
1563
- function reduce(state2, ev) {
1564
- switch (ev.type) {
1565
- case "reset":
1566
- return { state: { ...state2, typed: "" }, effect: "none" };
1567
- case "backspace": {
1568
- if (state2.typed.length === 0) return { state: state2, effect: "none" };
1569
- return { state: { ...state2, typed: state2.typed.slice(0, -1) }, effect: "none" };
1570
- }
1571
- case "char": {
1572
- const candidate = state2.typed + ev.ch;
1573
- const targetUpToCandidate = [...state2.target].slice(0, [...candidate].length).join("");
1574
- if (candidate === targetUpToCandidate) {
1575
- if (candidate.length === state2.target.length) {
1576
- return { state: { ...state2, typed: candidate }, effect: "correct" };
1577
- }
1578
- return { state: { ...state2, typed: candidate }, effect: "progress" };
1579
- }
1580
- return {
1581
- state: { ...state2, typed: "", errorsThisWord: state2.errorsThisWord + 1 },
1582
- effect: "wrong"
1583
- };
1584
- }
1585
- }
1586
- }
1587
-
1588
- // src/domain/session.ts
1589
- function startSession(playlist, now = Date.now()) {
1590
- if (playlist.length === 0) {
1591
- return { startedAt: now, results: [], current: null, finishedAt: now, playlist };
1592
- }
1593
- return {
1594
- startedAt: now,
1595
- results: [],
1596
- current: { wordIndex: 0, wordStartedAt: now, input: initialState(playlist[0].name) },
1597
- finishedAt: null,
1598
- playlist
1599
- };
1600
- }
1601
- function feedSession(session, ev, now = Date.now()) {
1602
- if (!session.current) return { session, effect: "none" };
1603
- const { state: state2, effect } = reduce(session.current.input, ev);
1604
- if (effect === "correct") {
1605
- const finished = {
1606
- word: state2.target,
1607
- errors: state2.errorsThisWord,
1608
- durationMs: now - session.current.wordStartedAt
1609
- };
1610
- const nextIndex = session.current.wordIndex + 1;
1611
- const results = [...session.results, finished];
1612
- if (nextIndex >= session.playlist.length) {
1613
- return {
1614
- session: { ...session, results, current: null, finishedAt: now },
1615
- effect
1616
- };
1617
- }
1618
- return {
1619
- session: {
1620
- ...session,
1621
- results,
1622
- current: {
1623
- wordIndex: nextIndex,
1624
- wordStartedAt: now,
1625
- input: initialState(session.playlist[nextIndex].name)
1626
- }
1627
- },
1628
- effect
1629
- };
1630
- }
1631
- return {
1632
- session: {
1633
- ...session,
1634
- current: { ...session.current, input: state2 }
1635
- },
1636
- effect
1637
- };
1638
- }
1639
- function skipSession(session, now = Date.now()) {
1640
- if (!session.current) return { session, effect: "none" };
1641
- const result = {
1642
- word: session.current.input.target,
1643
- errors: 0,
1644
- durationMs: now - session.current.wordStartedAt,
1645
- skipped: true
1646
- };
1647
- const nextIndex = session.current.wordIndex + 1;
1648
- const results = [...session.results, result];
1649
- if (nextIndex >= session.playlist.length) {
1650
- return {
1651
- session: { ...session, results, current: null, finishedAt: now },
1652
- effect: "skipped"
1653
- };
1654
- }
1655
- return {
1656
- session: {
1657
- ...session,
1658
- results,
1659
- current: {
1660
- wordIndex: nextIndex,
1661
- wordStartedAt: now,
1662
- input: initialState(session.playlist[nextIndex].name)
1663
- }
1664
- },
1665
- effect: "skipped"
1666
- };
1667
- }
1668
- function sessionSummary(session) {
1669
- const errors = session.results.reduce((a, r) => a + r.errors, 0);
1670
- const durationMs = (session.finishedAt ?? Date.now()) - session.startedAt;
1671
- const perWordErrors = {};
1672
- for (const r of session.results) {
1673
- if (r.errors > 0) perWordErrors[r.word] = (perWordErrors[r.word] ?? 0) + r.errors;
1674
- }
1675
- return { wordCount: session.results.length, errors, durationMs, perWordErrors };
1676
- }
1677
-
1678
- // src/domain/mistakes.ts
1679
- import { z as z3 } from "zod";
1680
- var MistakeBookSchema = z3.record(
1681
- z3.string(),
1682
- z3.object({
1683
- count: z3.number().int().nonnegative(),
1684
- lastSeen: z3.string(),
1685
- dictIds: z3.array(z3.string()).default([])
1686
- })
1687
- );
1688
- async function loadMistakes() {
1689
- const raw = await readJson(paths.mistakes);
1690
- if (!raw) return {};
1691
- const parsed = MistakeBookSchema.safeParse(raw);
1692
- if (!parsed.success) {
1693
- console.warn("Mistake book is corrupt; starting fresh");
1694
- return {};
1695
- }
1696
- return parsed.data;
1697
- }
1698
- async function saveMistakes(book) {
1699
- await writeJsonAtomic(paths.mistakes, book);
1700
- }
1701
- function bump(book, word, dictId, delta = 1) {
1702
- const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
1703
- const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
1704
- return {
1705
- ...book,
1706
- [word]: {
1707
- count: prev.count + delta,
1708
- lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
1709
- dictIds
1710
- }
1711
- };
1712
- }
1713
- function topN(book, n) {
1714
- return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
1715
- }
1716
-
1717
- // src/ui/hooks/useWordLoop.ts
1718
- import { useEffect as useEffect4, useReducer, useRef, useState as useState7 } from "react";
1719
- import { useInput as useInput2, useApp as useApp2 } from "ink";
1720
- function reducer(state2, action) {
1721
- if (action.type === "start") {
1722
- return { session: startSession(action.playlist, action.now), lastEffect: null };
1723
- }
1724
- if (action.type === "skip") {
1725
- const r = skipSession(state2.session, action.now);
1726
- return { session: r.session, lastEffect: r.effect };
1727
- }
1728
- if (action.type === "event") {
1729
- if (action.key.backspace || action.key.delete) {
1730
- const r = feedSession(state2.session, { type: "backspace" }, action.now);
1731
- return { session: r.session, lastEffect: r.effect };
1732
- }
1733
- if (action.input.length === 0) return state2;
1734
- let session = state2.session;
1735
- let lastEffect = state2.lastEffect;
1736
- for (const c of action.input) {
1737
- const r = feedSession(session, { type: "char", ch: c }, action.now);
1738
- session = r.session;
1739
- lastEffect = r.effect;
1740
- if (session.finishedAt !== null) break;
1741
- }
1742
- return { session, lastEffect };
1743
- }
1744
- return state2;
1745
- }
1746
- function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
1747
- const [state2, dispatch] = useReducer(reducer, void 0, () => ({
1748
- session: startSession(playlist, Date.now()),
1749
- lastEffect: null
1750
- }));
1751
- const completedRef = useRef(false);
1752
- const [tick, setTick] = useState7(0);
1753
- const { exit } = useApp2();
1754
- useInput2(
1755
- (input, key) => {
1756
- if (key.ctrl && input === "c") {
1757
- exit();
1758
- return;
1759
- }
1760
- if (key.ctrl && input === "n") {
1761
- onSkip?.();
1762
- dispatch({ type: "skip", now: Date.now() });
1763
- return;
1764
- }
1765
- if (key.escape) {
1766
- onEscape?.();
1767
- return;
1768
- }
1769
- if (key.tab) {
1770
- onTab?.();
1771
- return;
1772
- }
1773
- if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) return;
1774
- if (key.ctrl || key.meta) return;
1775
- const cleaned = [...input].filter((c) => {
1776
- const code = c.codePointAt(0);
1777
- return code === 32 || code >= 33 && code !== 127;
1778
- }).join("");
1779
- if (cleaned.length === 0) return;
1780
- dispatch({ type: "event", input: cleaned, key, now: Date.now() });
1781
- },
1782
- { isActive: enabled }
1783
- );
1784
- useEffect4(() => {
1785
- if (state2.session.finishedAt !== null && !completedRef.current) {
1786
- completedRef.current = true;
1787
- onComplete(state2.session);
1788
- }
1789
- }, [state2.session, onComplete]);
1790
- useEffect4(() => {
1791
- if (state2.session.finishedAt !== null) return;
1792
- const id = setInterval(() => setTick((t) => t + 1), 1e3);
1793
- return () => clearInterval(id);
1794
- }, [state2.session.finishedAt]);
1795
- return { session: state2.session, lastEffect: state2.lastEffect, tick };
1796
- }
1797
-
1798
- // src/ui/hooks/useAudio.ts
1799
- import { useEffect as useEffect5, useRef as useRef2 } from "react";
1800
- function useAudio(opts) {
1801
- const initedRef = useRef2(false);
1802
- useEffect5(() => {
1803
- if (initedRef.current) return;
1804
- initedRef.current = true;
1805
- initAudio(!opts.enabled).catch(() => void 0);
1806
- }, [opts.enabled]);
1807
- return {
1808
- keystroke: () => opts.enabled && playKeystroke(),
1809
- correct: () => opts.enabled && playCorrect(),
1810
- wrong: () => opts.enabled && playWrong(),
1811
- pronounce: (word) => {
1812
- if (!opts.enabled) return;
1813
- if (opts.autoplayPronunciation) void playPronunciation(word, opts.accent);
1814
- },
1815
- prefetch: (word) => {
1816
- if (!opts.enabled) return;
1817
- void prefetchPronunciation(word, opts.accent);
1818
- }
1819
- };
1820
- }
1821
-
1822
- // src/ui/hooks/useSessionPersistence.ts
1823
- import { useCallback as useCallback3 } from "react";
1824
-
1825
- // src/domain/stats.ts
1826
- import { z as z4 } from "zod";
1827
- var SessionRecordSchema = z4.object({
1828
- ts: z4.string(),
1829
- dictId: z4.string(),
1830
- chapter: z4.number().int().nonnegative(),
1831
- mode: z4.string(),
1832
- wordCount: z4.number().int().nonnegative(),
1833
- errors: z4.number().int().nonnegative(),
1834
- durationMs: z4.number().int().nonnegative(),
1835
- perWordErrors: z4.record(z4.string(), z4.number().int().nonnegative()).default({})
1836
- });
1837
- async function appendSession(record) {
1838
- await appendJsonl(paths.stats, record);
1839
- }
1840
- async function loadSessions() {
1841
- const rows = await readJsonl(paths.stats);
1842
- return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
1843
- }
1844
- function computeWPM(record) {
1845
- if (record.durationMs === 0) return 0;
1846
- const minutes = record.durationMs / 6e4;
1847
- return Math.round(record.wordCount / minutes * 10) / 10;
1848
- }
1849
- function accuracy(record) {
1850
- if (record.wordCount === 0) return 1;
1851
- const wordsWithErrors = Object.keys(record.perWordErrors).length;
1852
- return Math.max(0, Math.min(1, (record.wordCount - wordsWithErrors) / record.wordCount));
1853
- }
1854
- function dailyStreak(sessions, now = /* @__PURE__ */ new Date()) {
1855
- if (sessions.length === 0) return 0;
1856
- const days = /* @__PURE__ */ new Set();
1857
- for (const s of sessions) days.add(s.ts.slice(0, 10));
1858
- let streak = 0;
1859
- const cur = new Date(now);
1860
- while (true) {
1861
- const key = cur.toISOString().slice(0, 10);
1862
- if (!days.has(key)) break;
1863
- streak++;
1864
- cur.setUTCDate(cur.getUTCDate() - 1);
1865
- }
1866
- return streak;
1867
- }
1868
- var SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
1869
- function sparkline(values) {
1870
- if (values.length === 0) return "";
1871
- const max = Math.max(...values, 1);
1872
- const min = Math.min(...values, 0);
1873
- const range = Math.max(1, max - min);
1874
- return values.map((v) => {
1875
- const idx = Math.floor((v - min) / range * (SPARK.length - 1));
1876
- return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
1877
- }).join("");
1878
- }
1879
- function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
1880
- const out = [];
1881
- const byDay = /* @__PURE__ */ new Map();
1882
- for (const s of sessions) {
1883
- const key = s.ts.slice(0, 10);
1884
- const arr = byDay.get(key) ?? [];
1885
- arr.push(s);
1886
- byDay.set(key, arr);
1887
- }
1888
- const cur = new Date(now);
1889
- cur.setUTCDate(cur.getUTCDate() - (days - 1));
1890
- for (let i = 0; i < days; i++) {
1891
- const key = cur.toISOString().slice(0, 10);
1892
- const todays = byDay.get(key) ?? [];
1893
- if (todays.length === 0) {
1894
- out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
1895
- } else {
1896
- const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
1897
- const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
1898
- out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
1899
- }
1900
- cur.setUTCDate(cur.getUTCDate() + 1);
1901
- }
1902
- return out;
1903
- }
1904
-
1905
- // src/infra/session-tracker.ts
1906
- var state = {
1907
- startedAt: null,
1908
- chapters: []
1909
- };
1910
- function start(now = Date.now()) {
1911
- if (state.startedAt === null) state.startedAt = now;
1912
- }
1913
- function addChapter(entry) {
1914
- if (state.startedAt === null) state.startedAt = Date.now();
1915
- state.chapters.push(entry);
1916
- }
1917
- function report(now = Date.now()) {
1918
- const chapters = state.chapters;
1919
- const wordCount = chapters.reduce((a, c) => a + c.wordCount, 0);
1920
- const errors = chapters.reduce((a, c) => a + c.errors, 0);
1921
- const practiceMs = chapters.reduce((a, c) => a + c.durationMs, 0);
1922
- const minutes = practiceMs / 6e4;
1923
- const wpm = minutes > 0 ? Math.round(wordCount / minutes * 10) / 10 : 0;
1924
- const errorWordSet = /* @__PURE__ */ new Set();
1925
- for (const c of chapters) {
1926
- for (const w of Object.keys(c.perWordErrors)) errorWordSet.add(w);
1927
- }
1928
- const accuracy2 = wordCount === 0 ? 1 : Math.max(0, (wordCount - errorWordSet.size) / wordCount);
1929
- return {
1930
- startedAt: state.startedAt,
1931
- totalDurationMs: state.startedAt === null ? 0 : now - state.startedAt,
1932
- chaptersCompleted: chapters.length,
1933
- wordCount,
1934
- errors,
1935
- wpm,
1936
- accuracy: accuracy2,
1937
- newMistakeWords: errorWordSet.size,
1938
- practiceMs
1939
- };
1940
- }
1941
-
1942
- // src/ui/hooks/useSessionPersistence.ts
1943
- function useSessionPersistence(meta) {
1944
- return useCallback3(
1945
- async (summary) => {
1946
- const rec = {
1947
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1948
- dictId: meta.dictId,
1949
- chapter: meta.chapterIndex,
1950
- mode: meta.mode,
1951
- wordCount: summary.wordCount,
1952
- errors: summary.errors,
1953
- durationMs: summary.durationMs,
1954
- perWordErrors: summary.perWordErrors
1955
- };
1956
- await appendSession(rec);
1957
- addChapter({
1958
- dictId: meta.dictId,
1959
- chapterIndex: meta.chapterIndex,
1960
- mode: meta.mode,
1961
- wordCount: summary.wordCount,
1962
- errors: summary.errors,
1963
- durationMs: summary.durationMs,
1964
- perWordErrors: summary.perWordErrors
1965
- });
1966
- const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
1967
- if (dirty.length === 0) return;
1968
- let book = await loadMistakes();
1969
- for (const [word, n] of dirty) book = bump(book, word, meta.dictId, n);
1970
- await saveMistakes(book);
1971
- },
1972
- [meta.dictId, meta.chapterIndex, meta.mode]
1973
- );
1974
- }
1975
-
1976
- // src/ui/screens/StealthPracticeLayout.tsx
1977
- import { Box as Box4, Text as Text3 } from "ink";
1978
- import { jsx as jsx9, jsxs as jsxs3 } from "react/jsx-runtime";
1979
- var TYPED = "#d4d4d4";
1980
- var UNTYPED = "#808080";
1981
- var DIM = "#6b6b6b";
1982
- function fmtTime(ms) {
1983
- const total = Math.floor(ms / 1e3);
1984
- const m = Math.floor(total / 60);
1985
- const s = total % 60;
1986
- return `${m}:${String(s).padStart(2, "0")}`;
1987
- }
1988
- function StealthTyping(props) {
1989
- const t = useStrings();
1990
- const target = [...props.target];
1991
- const typed = [...props.typed];
1992
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", width: "100%", height: "100%", paddingX: 4, paddingY: 3, children: [
1993
- /* @__PURE__ */ jsx9(Box4, { flexGrow: 1 }),
1994
- /* @__PURE__ */ jsxs3(Box4, { children: [
1995
- /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: "[" }),
1996
- target.map((ch, i) => {
1997
- const isTyped = i < typed.length;
1998
- const display = props.hideTarget && !isTyped ? "_" : isTyped ? typed[i] : ch;
1999
- const color = isTyped ? TYPED : UNTYPED;
2000
- return /* @__PURE__ */ jsx9(Text3, { color, inverse: props.error && isTyped && i === typed.length - 1, children: display }, i);
2001
- }),
2002
- /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: "]" })
2003
- ] }),
2004
- /* @__PURE__ */ jsxs3(Box4, { children: [
2005
- props.phonetic && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: props.phonetic }),
2006
- props.phonetic && props.translation.length > 0 && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: " \xB7 " }),
2007
- props.translation.length > 0 && /* @__PURE__ */ jsx9(Text3, { color: DIM, children: props.translation.slice(0, 1).join("") })
2008
- ] }),
2009
- /* @__PURE__ */ jsx9(Box4, { children: props.info.visible ? /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.infoFmt(
2010
- props.info.dictName,
2011
- props.info.chapterLabel,
2012
- props.info.completed,
2013
- props.info.total,
2014
- props.info.wpm,
2015
- props.info.accPct
2016
- ) }) : /* @__PURE__ */ jsx9(Text3, { children: " " }) }),
2017
- /* @__PURE__ */ jsx9(Box4, { flexGrow: 3 })
2018
- ] });
2019
- }
2020
- function StealthPaused() {
2021
- const t = useStrings();
2022
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", width: "100%", height: "100%", paddingX: 4, paddingY: 3, children: [
2023
- /* @__PURE__ */ jsx9(Box4, { flexGrow: 1 }),
2024
- /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: t.stealth.paused }) }),
2025
- /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.resumeHint }) }),
2026
- /* @__PURE__ */ jsx9(Box4, { flexGrow: 3 })
2027
- ] });
2028
- }
2029
- function StealthSummary(props) {
2030
- const t = useStrings();
2031
- const line = `${t.stealth.chapterDone} \xB7 ${props.wordCount}w \xB7 ${props.wpm}wpm \xB7 ${props.accPct}% \xB7 ${fmtTime(props.durationMs)}`;
2032
- return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", width: "100%", height: "100%", paddingX: 4, paddingY: 3, children: [
2033
- /* @__PURE__ */ jsx9(Box4, { flexGrow: 1 }),
2034
- /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: UNTYPED, children: line }) }),
2035
- /* @__PURE__ */ jsx9(Box4, { children: /* @__PURE__ */ jsx9(Text3, { color: DIM, children: t.stealth.nextHint }) }),
2036
- /* @__PURE__ */ jsx9(Box4, { flexGrow: 3 })
2037
- ] });
2038
- }
2039
-
2040
- // src/ui/screens/PracticeScreen.tsx
2041
- import { jsx as jsx10, jsxs as jsxs4 } from "react/jsx-runtime";
2042
- function PracticeScreen({ params }) {
2043
- const { dictId, chapterIndex, mode } = params;
2044
- const { cfg } = useAppState();
2045
- const t = useStrings();
2046
- const [phase, setPhase] = useState8("loading");
2047
- const [loaded, setLoaded] = useState8(null);
2048
- const [errorMsg, setErrorMsg] = useState8(null);
2049
- useEffect6(() => {
2050
- let cancelled = false;
2051
- setPhase("loading");
2052
- setLoaded(null);
2053
- setErrorMsg(null);
2054
- (async () => {
2055
- try {
2056
- const words = await ensureDictionary(dictId);
2057
- if (cancelled) return;
2058
- if (mode === "review") {
2059
- const book = await loadMistakes();
2060
- if (cancelled) return;
2061
- const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
2062
- if (reviewWords.length === 0) {
2063
- setErrorMsg(t.practice.errors.noMistakes);
2064
- setPhase("error");
2065
- return;
2066
- }
2067
- setLoaded({ playlist: reviewWords, totalChapters: 1 });
2068
- setPhase("typing");
2069
- return;
2070
- }
2071
- const chapters = chunkChapters(words, cfg.chapterSize);
2072
- if (chapters.length === 0) {
2073
- setErrorMsg(t.practice.errors.dictEmpty(dictId));
2074
- setPhase("error");
2075
- return;
2076
- }
2077
- const idx = Math.max(0, Math.min(chapters.length - 1, chapterIndex));
2078
- const playlist = buildPlaylist(chapters[idx], mode);
2079
- setLoaded({ playlist, totalChapters: chapters.length });
2080
- setPhase("typing");
2081
- } catch (err) {
2082
- if (cancelled) return;
2083
- setErrorMsg(err.message);
2084
- setPhase("error");
2085
- }
2086
- })();
2087
- return () => {
2088
- cancelled = true;
2089
- };
2090
- }, [dictId, chapterIndex, mode, cfg.chapterSize, t]);
2091
- if (phase === "loading") {
2092
- return /* @__PURE__ */ jsx10(Centered, { text: t.practice.loading, color: PALETTE.muted });
2093
- }
2094
- if (phase === "error") {
2095
- return /* @__PURE__ */ jsx10(ErrorView, { msg: errorMsg ?? t.practice.errors.unknown });
2096
- }
2097
- if (!loaded) return null;
2098
- return /* @__PURE__ */ jsx10(
2099
- PracticeRunner,
2100
- {
2101
- params,
2102
- loaded,
2103
- phase,
2104
- setPhase
2105
- },
2106
- `${dictId}-${chapterIndex}-${mode}-${params.stealth ? "s" : "n"}`
2107
- );
2108
- }
2109
- function PracticeRunner({
2110
- params,
2111
- loaded,
2112
- phase,
2113
- setPhase
2114
- }) {
2115
- const { dictId, chapterIndex, mode } = params;
2116
- const stealth = params.stealth === true;
2117
- const { cfg } = useAppState();
2118
- const nav = useNav();
2119
- const { exit } = useApp3();
2120
- const goBack = () => nav.stack.length > 1 ? nav.back() : exit();
2121
- const persist = useSessionPersistence({ dictId, chapterIndex, mode });
2122
- const dictName = useDictName(dictId);
2123
- const audio = useAudio({
2124
- enabled: !stealth && cfg.sounds.master,
2125
- accent: cfg.accent,
2126
- autoplayPronunciation: !stealth && cfg.autoplayPronunciation
2127
- });
2128
- const finishedRef = useRef3(false);
2129
- const lastEffectRef = useRef3(null);
2130
- const lastIndexRef = useRef3(-1);
2131
- const [infoVisible, setInfoVisible] = useState8(false);
2132
- const { session, lastEffect, tick } = useWordLoop({
2133
- playlist: loaded.playlist,
2134
- enabled: phase === "typing",
2135
- onComplete: (s) => {
2136
- if (finishedRef.current) return;
2137
- finishedRef.current = true;
2138
- setPhase("summary");
2139
- Promise.resolve(persist(sessionSummary(s))).catch((err) => {
2140
- console.error("Failed to persist session:", err);
2141
- });
2142
- },
2143
- onEscape: () => setPhase(phase === "paused" ? "typing" : "paused"),
2144
- onTab: stealth ? void 0 : () => {
2145
- const cur = session.current ? loaded.playlist[session.current.wordIndex] : void 0;
2146
- if (cur) void audio.pronounce(cur.name);
2147
- }
2148
- });
2149
- useEffect6(() => {
2150
- if (stealth) return;
2151
- if (lastEffect === null) return;
2152
- if (lastEffect === lastEffectRef.current) return;
2153
- lastEffectRef.current = lastEffect;
2154
- if (lastEffect === "wrong" && cfg.sounds.feedback) audio.wrong();
2155
- if (lastEffect === "progress" && cfg.sounds.keystroke) audio.keystroke();
2156
- if (lastEffect === "correct") {
2157
- if (cfg.sounds.feedback) audio.correct();
2158
- if (cfg.sounds.keystroke) audio.keystroke();
2159
- }
2160
- }, [stealth, lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
2161
- useEffect6(() => {
2162
- if (stealth) return;
2163
- const idx = session.current?.wordIndex ?? -1;
2164
- if (idx === -1) return;
2165
- if (idx === lastIndexRef.current) return;
2166
- lastIndexRef.current = idx;
2167
- const cur = loaded.playlist[idx];
2168
- const next = loaded.playlist[idx + 1];
2169
- if (cur && cfg.autoplayPronunciation) audio.pronounce(cur.name);
2170
- if (next) audio.prefetch(next.name);
2171
- }, [stealth, session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
2172
- void tick;
2173
- useInput3(
2174
- (input, key) => {
2175
- if (key.ctrl && input === "i") {
2176
- setInfoVisible((v) => !v);
2177
- return;
2178
- }
2179
- },
2180
- { isActive: stealth && phase === "typing" }
2181
- );
2182
- useInput3(
2183
- (_input, key) => {
2184
- if (key.return) {
2185
- setPhase("typing");
2186
- return;
2187
- }
2188
- if (key.escape) {
2189
- goBack();
2190
- return;
2191
- }
2192
- },
2193
- { isActive: phase === "paused" }
2194
- );
2195
- useInput3(
2196
- (input, key) => {
2197
- if (key.escape) {
2198
- goBack();
2199
- return;
2200
- }
2201
- if (key.return) {
2202
- const nextIdx = chapterIndex + 1;
2203
- if (mode === "loop") {
2204
- nav.replace({
2205
- name: "practice",
2206
- params: { dictId, chapterIndex, mode, stealth: params.stealth }
2207
- });
2208
- } else if (mode === "review" || nextIdx >= loaded.totalChapters) {
2209
- goBack();
2210
- } else {
2211
- nav.replace({
2212
- name: "practice",
2213
- params: { dictId, chapterIndex: nextIdx, mode, stealth: params.stealth }
2214
- });
2215
- }
2216
- return;
2217
- }
2218
- if (input === "m") {
2219
- nav.replace({
2220
- name: "practice",
2221
- params: { dictId, chapterIndex: 0, mode: "review", stealth: params.stealth }
2222
- });
2223
- return;
2224
- }
2225
- },
2226
- { isActive: phase === "summary" }
2227
- );
2228
- const completed = session.results.length;
2229
- const errors = session.results.reduce((a, r) => a + r.errors, 0);
2230
- const elapsedMs = Date.now() - session.startedAt;
2231
- const minutes = elapsedMs / 6e4;
2232
- const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
2233
- const summary = phase === "summary" ? sessionSummary(session) : null;
2234
- if (stealth) {
2235
- if (phase === "paused") return /* @__PURE__ */ jsx10(StealthPaused, {});
2236
- if (phase === "summary" && summary) {
2237
- const sMinutes = summary.durationMs / 6e4;
2238
- const sWpm = sMinutes > 0 ? Math.round(summary.wordCount / sMinutes * 10) / 10 : 0;
2239
- const sErrWords = Object.keys(summary.perWordErrors).length;
2240
- const sAcc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - sErrWords) / summary.wordCount);
2241
- const sAccPct = Math.round(sAcc * 1e3) / 10;
2242
- return /* @__PURE__ */ jsx10(
2243
- StealthSummary,
2244
- {
2245
- wordCount: summary.wordCount,
2246
- errors: summary.errors,
2247
- durationMs: summary.durationMs,
2248
- wpm: sWpm,
2249
- accPct: sAccPct
2250
- }
2251
- );
2252
- }
2253
- const currentWord2 = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
2254
- const inputState2 = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
2255
- const errWords = new Set(
2256
- session.results.filter((r) => r.errors > 0).map((r) => r.word)
2257
- ).size;
2258
- const accFrac = completed === 0 ? 1 : Math.max(0, (completed - errWords) / completed);
2259
- const accPct = Math.round(accFrac * 1e3) / 10;
2260
- const chapterLabel = mode === "review" ? "review" : `ch ${chapterIndex + 1}/${loaded.totalChapters}`;
2261
- return /* @__PURE__ */ jsx10(
2262
- StealthTyping,
2263
- {
2264
- target: currentWord2?.name ?? "",
2265
- typed: inputState2.typed,
2266
- hideTarget: mode === "dictation",
2267
- phonetic: pickPhonetic(currentWord2, cfg.accent),
2268
- translation: currentWord2?.trans ?? [],
2269
- error: lastEffect === "wrong",
2270
- info: {
2271
- visible: infoVisible,
2272
- dictName: truncateName(dictName, 24),
2273
- chapterLabel,
2274
- completed,
2275
- total: loaded.playlist.length,
2276
- wpm,
2277
- accPct
2278
- }
2279
- }
2280
- );
2281
- }
2282
- if (phase === "paused") {
2283
- return /* @__PURE__ */ jsx10(
2284
- PausedView,
2285
- {
2286
- dictName,
2287
- chapterIndex,
2288
- totalChapters: loaded.totalChapters,
2289
- mode,
2290
- completed,
2291
- total: loaded.playlist.length
2292
- }
2293
- );
2294
- }
2295
- if (phase === "summary" && summary) {
2296
- return /* @__PURE__ */ jsx10(
2297
- SummaryView,
2298
- {
2299
- dictName,
2300
- chapterIndex,
2301
- totalChapters: loaded.totalChapters,
2302
- mode,
2303
- summary
2304
- }
2305
- );
2306
- }
2307
- const currentWord = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
2308
- const inputState = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
2309
- return /* @__PURE__ */ jsx10(
2310
- TypingLayout,
2311
- {
2312
- dictName,
2313
- chapterIndex,
2314
- totalChapters: loaded.totalChapters,
2315
- mode,
2316
- accent: cfg.accent,
2317
- completed,
2318
- total: loaded.playlist.length,
2319
- errors,
2320
- wpm,
2321
- elapsedMs,
2322
- target: currentWord?.name ?? "",
2323
- typed: inputState.typed,
2324
- flashError: lastEffect === "wrong",
2325
- hideTarget: mode === "dictation",
2326
- phonetic: pickPhonetic(currentWord, cfg.accent),
2327
- translation: currentWord?.trans ?? []
2328
- }
2329
- );
2330
- }
2331
- function pickPhonetic(word, accent) {
2332
- if (!word) return null;
2333
- const p = accent === "us" ? word.usphone : word.ukphone;
2334
- return p ? `/${p}/` : null;
2335
- }
2336
- function fmtTime2(ms) {
2337
- const total = Math.floor(ms / 1e3);
2338
- const m = Math.floor(total / 60);
2339
- const s = total % 60;
2340
- return `${m}:${String(s).padStart(2, "0")}`;
2341
- }
2342
- function TypingLayout(props) {
2343
- const t = useStrings();
2344
- const progressFrac = props.total === 0 ? 0 : props.completed / props.total;
2345
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2346
- /* @__PURE__ */ jsx10(
2347
- StatusBar,
2348
- {
2349
- dictName: props.dictName,
2350
- chapterIndex: props.chapterIndex,
2351
- totalChapters: props.totalChapters,
2352
- mode: props.mode,
2353
- accent: props.accent,
2354
- completed: props.completed,
2355
- total: props.total,
2356
- elapsedMs: props.elapsedMs
2357
- }
2358
- ),
2359
- /* @__PURE__ */ jsxs4(Box5, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
2360
- /* @__PURE__ */ jsx10(
2361
- BigWord,
2362
- {
2363
- target: props.target,
2364
- typed: props.typed,
2365
- error: props.flashError,
2366
- hideTarget: props.hideTarget
2367
- }
2368
- ),
2369
- props.phonetic && /* @__PURE__ */ jsx10(Box5, { marginTop: 3, children: /* @__PURE__ */ jsx10(Text4, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
2370
- props.translation.length > 0 && /* @__PURE__ */ jsx10(Box5, { marginTop: 2, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((tr, i) => /* @__PURE__ */ jsx10(Text4, { color: PALETTE.primary, children: tr }, i)) })
2371
- ] }),
2372
- /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
2373
- /* @__PURE__ */ jsx10(ProgressBar, { frac: progressFrac }),
2374
- /* @__PURE__ */ jsx10(Box5, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
2375
- props.completed,
2376
- "/",
2377
- props.total,
2378
- " \xB7 ",
2379
- fmtTime2(props.elapsedMs),
2380
- " \xB7 ",
2381
- props.wpm,
2382
- " ",
2383
- t.practice.statCards.wpm,
2384
- " \xB7 ",
2385
- props.errors,
2386
- " ",
2387
- t.practice.statCards.errors
2388
- ] }) }),
2389
- /* @__PURE__ */ jsx10(Box5, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.footers.typing }) })
2390
- ] })
2391
- ] });
2392
- }
2393
- function StatusBar(props) {
2394
- const t = useStrings();
2395
- const modeName = t.practice.modes[props.mode];
2396
- const accentName = t.practice.accents[props.accent];
2397
- const name = truncateName(props.dictName, 20);
2398
- const left = props.mode === "review" ? `${name} \xB7 ${t.practice.reviewLabel} \xB7 ${accentName}` : `${name} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName} \xB7 ${accentName}`;
2399
- const right = `${props.completed}/${props.total} \xB7 ${fmtTime2(props.elapsedMs)}`;
2400
- return /* @__PURE__ */ jsxs4(Box5, { children: [
2401
- /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: left }),
2402
- /* @__PURE__ */ jsx10(Box5, { flexGrow: 1 }),
2403
- /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: right })
2404
- ] });
2405
- }
2406
- function ProgressBar({ frac }) {
2407
- const cols = process.stdout.columns ?? 80;
2408
- const width = Math.max(20, Math.min(72, cols - 16));
2409
- const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
2410
- const empty = width - filled;
2411
- return /* @__PURE__ */ jsxs4(Box5, { justifyContent: "center", children: [
2412
- /* @__PURE__ */ jsx10(Text4, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
2413
- /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
2414
- ] });
2415
- }
2416
- function PausedView(props) {
2417
- const t = useStrings();
2418
- const frac = props.total === 0 ? 0 : props.completed / props.total;
2419
- const subtitle = props.mode === "review" ? `${truncateName(props.dictName, 20)} \xB7 ${t.practice.reviewLabel}` : `${truncateName(props.dictName, 20)} \xB7 ${t.practice.pause.chapter(props.chapterIndex + 1, props.totalChapters)}`;
2420
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2421
- /* @__PURE__ */ jsx10(Text4, { bold: true, color: PALETTE.warning, children: t.practice.pause.title }),
2422
- /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: subtitle }) }),
2423
- /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(ProgressBar, { frac }) }),
2424
- /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.pause.progress(props.completed, props.total) }) }),
2425
- /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.pause.hint }) })
2426
- ] });
2427
- }
2428
- function ErrorView({ msg }) {
2429
- const t = useStrings();
2430
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2431
- /* @__PURE__ */ jsx10(Text4, { color: PALETTE.error, children: msg }),
2432
- /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
2433
- "Esc ",
2434
- t.common.back
2435
- ] }) }),
2436
- /* @__PURE__ */ jsx10(BackKey, {})
2437
- ] });
2438
- }
2439
- function BackKey() {
2440
- const nav = useNav();
2441
- useInput3((_input, key) => {
2442
- if (key.escape) nav.back();
2443
- });
2444
- return null;
2445
- }
2446
- function Centered({ text, color }) {
2447
- return /* @__PURE__ */ jsx10(Box5, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text4, { color, children: text }) });
2448
- }
2449
- function SummaryView(props) {
2450
- const { summary } = props;
2451
- const minutes = summary.durationMs / 6e4;
2452
- const wpm = minutes > 0 ? Math.round(summary.wordCount / minutes * 10) / 10 : 0;
2453
- const errorWords = Object.keys(summary.perWordErrors).length;
2454
- const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
2455
- const accPct = Math.round(acc * 1e3) / 10;
2456
- const t = useStrings();
2457
- const modeName = t.practice.modes[props.mode];
2458
- const name = truncateName(props.dictName, 20);
2459
- const subtitle = props.mode === "review" ? `${name} \xB7 ${t.practice.reviewLabel}` : `${name} \xB7 ${t.practice.chapterLabel(props.chapterIndex + 1, props.totalChapters)} \xB7 ${modeName}`;
2460
- const nextLabel = props.mode === "loop" ? t.practice.summary.loopAgain : props.mode === "review" || props.chapterIndex + 1 >= props.totalChapters ? t.practice.summary.backMenu : t.practice.summary.nextChapter;
2461
- const footer = `Enter ${nextLabel} \xB7 m ${t.practice.summary.reviewMistakes} \xB7 Esc ${t.practice.summary.backMenu}`;
2462
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
2463
- /* @__PURE__ */ jsx10(Text4, { bold: true, color: PALETTE.success, children: t.practice.chapterComplete }),
2464
- /* @__PURE__ */ jsx10(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: subtitle }) }),
2465
- /* @__PURE__ */ jsxs4(Box5, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
2466
- /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.words, value: String(summary.wordCount), color: PALETTE.text }),
2467
- /* @__PURE__ */ jsx10(
2468
- StatCard,
2469
- {
2470
- label: t.practice.statCards.errors,
2471
- value: String(summary.errors),
2472
- color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
2473
- }
2474
- ),
2475
- /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.wpm, value: String(wpm), color: PALETTE.accent }),
2476
- /* @__PURE__ */ jsx10(StatCard, { label: t.practice.statCards.accuracy, value: `${accPct}%`, color: PALETTE.accent })
2477
- ] }),
2478
- /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: t.practice.statCards.elapsed(fmtTime2(summary.durationMs)) }) }),
2479
- /* @__PURE__ */ jsx10(Box5, { flexGrow: 1 }),
2480
- /* @__PURE__ */ jsx10(Box5, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: footer }) })
2481
- ] });
2482
- }
2483
- function StatCard({ label, value, color }) {
2484
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", alignItems: "center", marginX: 3, children: [
2485
- /* @__PURE__ */ jsx10(Text4, { bold: true, color, children: value }),
2486
- /* @__PURE__ */ jsx10(Text4, { color: PALETTE.muted, children: label })
2487
- ] });
2488
- }
2489
-
2490
- // src/ui/screens/DictBrowser.tsx
2491
- import { useEffect as useEffect7, useMemo as useMemo2, useState as useState10 } from "react";
2492
- import { Box as Box7, Text as Text6, useInput as useInput5, useStdout as useStdout3 } from "ink";
2493
-
2494
- // src/ui/components/ActionPanel.tsx
2495
- import { useState as useState9 } from "react";
2496
- import { Box as Box6, Text as Text5, useInput as useInput4 } from "ink";
2497
- import { jsx as jsx11, jsxs as jsxs5 } from "react/jsx-runtime";
2498
- function ActionPanel({ title, items, onClose }) {
2499
- const enabledIndices = items.map((it, i) => it.disabled ? -1 : i).filter((i) => i >= 0);
2500
- const initial = enabledIndices[0] ?? 0;
2501
- const [selected, setSelected] = useState9(initial);
2502
- useInput4((input, key) => {
2503
- if (key.escape) {
2504
- onClose();
2505
- return;
2506
- }
2507
- if (key.upArrow) {
2508
- const cur = enabledIndices.indexOf(selected);
2509
- const next = enabledIndices[(cur - 1 + enabledIndices.length) % enabledIndices.length];
2510
- if (next !== void 0) setSelected(next);
2511
- return;
2512
- }
2513
- if (key.downArrow) {
2514
- const cur = enabledIndices.indexOf(selected);
2515
- const next = enabledIndices[(cur + 1) % enabledIndices.length];
2516
- if (next !== void 0) setSelected(next);
2517
- return;
2518
- }
2519
- if (key.return) {
2520
- const item = items[selected];
2521
- if (item && !item.disabled) {
2522
- void item.run();
2523
- }
2524
- return;
2525
- }
2526
- for (let i = 0; i < items.length; i++) {
2527
- const it = items[i];
2528
- if (it.disabled) continue;
2529
- if (it.key && input === it.key) {
2530
- void it.run();
2531
- return;
2532
- }
2533
- }
2534
- });
2535
- const maxLabel = Math.max(...items.map((it) => it.label.length));
2536
- const width = Math.max(maxLabel + 8, title.length + 4, 24);
2537
- return /* @__PURE__ */ jsxs5(
2538
- Box6,
2539
- {
2540
- flexDirection: "column",
2541
- borderStyle: "round",
2542
- borderColor: PALETTE.accent,
2543
- paddingX: 2,
2544
- paddingY: 1,
2545
- width,
2546
- children: [
2547
- /* @__PURE__ */ jsx11(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx11(Text5, { bold: true, color: PALETTE.accent, children: title }) }),
2548
- items.map((it, i) => {
2549
- const active = i === selected;
2550
- const color = it.disabled ? PALETTE.muted : active ? PALETTE.text : PALETTE.muted;
2551
- return /* @__PURE__ */ jsxs5(Box6, { children: [
2552
- /* @__PURE__ */ jsx11(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2553
- /* @__PURE__ */ jsx11(Text5, { bold: active, color, children: it.label })
2554
- ] }, i);
2555
- }),
2556
- /* @__PURE__ */ jsx11(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text5, { color: PALETTE.muted, children: "\u2191/\u2193 \xB7 Enter \xB7 Esc" }) })
2557
- ]
2558
- }
2559
- );
2560
- }
2561
-
2562
- // src/ui/screens/DictBrowser.tsx
2563
- import { Fragment, jsx as jsx12, jsxs as jsxs6 } from "react/jsx-runtime";
2564
- function DictBrowser({ params }) {
2565
- const nav = useNav();
2566
- const { cfg, setCfg } = useAppState();
2567
- const t = useStrings();
2568
- const { stdout } = useStdout3();
2569
- const [rows, setRows] = useState10([]);
2570
- const [loading, setLoading] = useState10(true);
2571
- const [selected, setSelected] = useState10(0);
2572
- const [filter, setFilter] = useState10("");
2573
- const [pending, setPending] = useState10(null);
2574
- const [tick, setTick] = useState10(0);
2575
- const [panel, setPanel] = useState10(null);
2576
- const refresh = async () => {
2577
- const reg = await loadRegistry();
2578
- const flagged = await Promise.all(
2579
- reg.map(async (e) => ({ entry: e, local: await isLocallyAvailable(e.id) }))
2580
- );
2581
- setRows(flagged);
2582
- setLoading(false);
2583
- };
2584
- useEffect7(() => {
2585
- void refresh();
2586
- }, [tick]);
2587
- const filtered = useMemo2(
2588
- () => filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows,
2589
- [filter, rows]
2590
- );
2591
- const safeSelected = Math.max(0, Math.min(filtered.length - 1, selected));
2592
- const current = filtered[safeSelected];
2593
- const rowsTotal = stdout?.rows ?? 24;
2594
- const visibleH = Math.max(6, rowsTotal - 8);
2595
- const half = Math.floor(visibleH / 2);
2596
- const start2 = Math.max(0, Math.min(filtered.length - visibleH, safeSelected - half));
2597
- const end = Math.min(filtered.length, start2 + visibleH);
2598
- const goPractice = (id) => {
2599
- nav.replace({
2600
- name: "practice",
2601
- params: {
2602
- dictId: id,
2603
- chapterIndex: 0,
2604
- mode: cfg.defaultMode,
2605
- stealth: cfg.stealth === "default"
2606
- }
2607
- });
2608
- };
2609
- const doSetDefault = async (id, navigate = true) => {
2610
- await setCfg({ ...cfg, defaultDict: id });
2611
- setPanel(null);
2612
- if (navigate) {
2613
- if (params?.pickerMode === "choose-then-practice") {
2614
- goPractice(id);
2615
- } else {
2616
- nav.back();
2617
- }
2618
- }
2619
- };
2620
- const doDelete = (id) => {
2621
- setPanel(null);
2622
- setPending({ kind: "removing", id });
2623
- void (async () => {
2624
- try {
2625
- await removeDictionary(id);
2626
- setPending(null);
2627
- setTick((n) => n + 1);
2628
- } catch (err) {
2629
- setPending({ kind: "error", id, msg: err.message });
2630
- }
2631
- })();
2632
- };
2633
- const doPull = (id) => {
2634
- setPanel(null);
2635
- setPending({ kind: "pulling", id });
2636
- void (async () => {
2637
- try {
2638
- await pullDictionary(id);
2639
- setPending(null);
2640
- setTick((n) => n + 1);
2641
- } catch (err) {
2642
- setPending({ kind: "error", id, msg: err.message });
2643
- }
2644
- })();
2645
- };
2646
- const doRefreshList = () => {
2647
- setPanel(null);
2648
- setPending({ kind: "refreshing" });
2649
- setTick((n) => n + 1);
2650
- setPending(null);
2651
- };
2652
- useInput5((input, key) => {
2653
- if (panel !== null) return;
2654
- if (key.escape) {
2655
- nav.back();
2656
- return;
2657
- }
2658
- if (key.upArrow) {
2659
- setSelected((i) => Math.max(0, i - 1));
2660
- return;
2661
- }
2662
- if (key.downArrow) {
2663
- setSelected((i) => Math.min(filtered.length - 1, i + 1));
2664
- return;
2665
- }
2666
- if (key.ctrl && input === "k") {
2667
- setPanel("more");
2668
- return;
2669
- }
2670
- if (key.return) {
2671
- if (current) setPanel("item");
2672
- return;
2673
- }
2674
- if (key.backspace || key.delete) {
2675
- setFilter((f) => f.slice(0, -1));
2676
- setSelected(0);
2677
- return;
2678
- }
2679
- if (input && !key.ctrl && !key.meta && input.length === 1) {
2680
- setFilter((f) => f + input);
2681
- setSelected(0);
2682
- }
2683
- });
2684
- if (loading) {
2685
- return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.loading }) });
2686
- }
2687
- const itemPanelItems = current ? [
2688
- {
2689
- label: t.dict.action.setDefault,
2690
- run: () => void doSetDefault(current.entry.id, params?.pickerMode !== void 0)
2691
- },
2692
- {
2693
- label: t.dict.action.practice,
2694
- run: () => goPractice(current.entry.id)
2695
- },
2696
- {
2697
- label: t.dict.action.delete,
2698
- disabled: !current.local,
2699
- run: () => doDelete(current.entry.id)
2700
- },
2701
- { label: t.common.cancel, run: () => setPanel(null) }
2702
- ] : [];
2703
- const morePanelItems = [
2704
- {
2705
- label: t.dict.command.pull,
2706
- disabled: !current,
2707
- run: () => current && doPull(current.entry.id)
2708
- },
2709
- {
2710
- label: t.dict.command.import,
2711
- disabled: true,
2712
- run: () => void 0
2713
- },
2714
- { label: t.dict.command.refreshList, run: () => doRefreshList() },
2715
- { label: t.common.cancel, run: () => setPanel(null) }
2716
- ];
2717
- if (panel === "item" && current) {
2718
- return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(
2719
- ActionPanel,
2720
- {
2721
- title: `${t.dict.action.title} \xB7 ${current.entry.name}`,
2722
- items: itemPanelItems,
2723
- onClose: () => setPanel(null)
2724
- }
2725
- ) });
2726
- }
2727
- if (panel === "more") {
2728
- return /* @__PURE__ */ jsx12(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx12(
2729
- ActionPanel,
2730
- {
2731
- title: t.dict.command.title,
2732
- items: morePanelItems,
2733
- onClose: () => setPanel(null)
2734
- }
2735
- ) });
2736
- }
2737
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2738
- /* @__PURE__ */ jsxs6(Box7, { children: [
2739
- /* @__PURE__ */ jsx12(Text6, { bold: true, color: PALETTE.accent, children: t.dict.title }),
2740
- /* @__PURE__ */ jsx12(Box7, { flexGrow: 1 }),
2741
- /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: filter ? `${t.dict.filterPlaceholder}: ${filter}_` : `${t.dict.filterPlaceholder}_` }),
2742
- /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
2743
- " ",
2744
- t.dict.entries(filtered.length)
2745
- ] })
2746
- ] }),
2747
- /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, flexGrow: 1, children: [
2748
- /* @__PURE__ */ jsx12(Box7, { flexDirection: "column", width: "75%", paddingRight: 1, children: filtered.slice(start2, end).map((row, vi) => {
2749
- const i = start2 + vi;
2750
- const active = i === safeSelected;
2751
- const isDefault = cfg.defaultDict === row.entry.id;
2752
- return /* @__PURE__ */ jsxs6(Box7, { children: [
2753
- /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }) }),
2754
- /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }) }),
2755
- /* @__PURE__ */ jsx12(Box7, { width: 2, children: /* @__PURE__ */ jsx12(Text6, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }) }),
2756
- /* @__PURE__ */ jsx12(Box7, { flexGrow: 1, children: /* @__PURE__ */ jsx12(Text6, { bold: active, color: active ? PALETTE.text : PALETTE.muted, wrap: "truncate", children: row.entry.name }) }),
2757
- /* @__PURE__ */ jsx12(Box7, { width: 6, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) }) })
2758
- ] }, row.entry.id);
2759
- }) }),
2760
- /* @__PURE__ */ jsx12(Box7, { flexDirection: "column", width: "25%", paddingLeft: 1, children: current && /* @__PURE__ */ jsxs6(Fragment, { children: [
2761
- /* @__PURE__ */ jsx12(Text6, { bold: true, color: PALETTE.text, wrap: "wrap", children: current.entry.name }),
2762
- /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: current.entry.id }),
2763
- /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, wrap: "wrap", children: [
2764
- current.entry.language,
2765
- " \xB7 ",
2766
- current.entry.category
2767
- ] }) }),
2768
- /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.wordsLabel(current.entry.length) }) }),
2769
- current.entry.description && /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.primary, wrap: "wrap", children: current.entry.description }) }),
2770
- current.entry.tags.length > 0 && /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, wrap: "wrap", children: t.dict.tagsLabel(current.entry.tags.join(", ")) }) }),
2771
- /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? t.dict.local : t.dict.notLocal }) }),
2772
- cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx12(Box7, { children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.success, children: t.dict.defaultMark }) })
2773
- ] }) })
2774
- ] }),
2775
- pending && /* @__PURE__ */ jsxs6(Box7, { marginTop: 1, children: [
2776
- pending.kind === "pulling" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.warning, children: t.dict.pulling(pending.id) }),
2777
- pending.kind === "removing" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.warning, children: t.dict.removing(pending.id) }),
2778
- pending.kind === "refreshing" && /* @__PURE__ */ jsxs6(Text6, { color: PALETTE.warning, children: [
2779
- t.dict.command.refreshList,
2780
- "\u2026"
2781
- ] }),
2782
- pending.kind === "error" && /* @__PURE__ */ jsx12(Text6, { color: PALETTE.error, children: t.dict.errorOn(pending.id, pending.msg) })
2783
- ] }),
2784
- /* @__PURE__ */ jsx12(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text6, { color: PALETTE.muted, children: t.dict.footer }) })
2785
- ] });
2786
- }
2787
-
2788
- // src/ui/screens/ConfigEditor.tsx
2789
- import { useState as useState11 } from "react";
2790
- import { Box as Box8, Text as Text7, useInput as useInput6 } from "ink";
2791
- import { jsx as jsx13, jsxs as jsxs7 } from "react/jsx-runtime";
2792
- var FIELDS = [
2793
- { kind: "dictRef", path: "defaultDict", labelKey: "defaultDict" },
2794
- { kind: "enum", path: "defaultMode", labelKey: "defaultMode", options: ["order", "dictation", "review", "random", "loop"] },
2795
- { kind: "enum", path: "accent", labelKey: "accent", options: ["us", "uk"] },
2796
- { kind: "enum", path: "language", labelKey: "language", options: ["auto", "zh", "en"] },
2797
- { kind: "enum", path: "mirror", labelKey: "mirror", options: ["jsdelivr", "github"] },
2798
- { kind: "enum", path: "stealth", labelKey: "stealth", options: ["off", "menu", "default"] },
2799
- { kind: "int", path: "chapterSize", labelKey: "chapterSize", min: 1, max: 200 },
2800
- { kind: "bool", path: "autoplayPronunciation", labelKey: "autoplayPronunciation" },
2801
- { kind: "bool", path: "sounds.master", labelKey: "soundsMaster" },
2802
- { kind: "bool", path: "sounds.keystroke", labelKey: "soundsKeystroke" },
2803
- { kind: "bool", path: "sounds.feedback", labelKey: "soundsFeedback" },
2804
- { kind: "string", path: "sounds.keySoundName", labelKey: "soundsKeySound" }
2805
- ];
2806
- function getByPath2(cfg, path) {
2807
- return path.split(".").reduce((acc, k) => {
2808
- if (acc && typeof acc === "object") return acc[k];
2809
- return void 0;
2810
- }, cfg);
2811
- }
2812
- function ConfigEditor() {
2813
- const nav = useNav();
2814
- const { cfg, setCfg } = useAppState();
2815
- const t = useStrings();
2816
- const defaultDictName = useDictName(cfg.defaultDict);
2817
- const [selected, setSelected] = useState11(0);
2818
- const [editing, setEditing] = useState11(false);
2819
- const [draft, setDraft] = useState11("");
2820
- const [error, setError] = useState11(null);
2821
- const field = FIELDS[selected];
2822
- const currentValue = getByPath2(cfg, field.path);
2823
- const commit = async (raw) => {
2824
- try {
2825
- const next = setByPath(cfg, field.path, raw);
2826
- await setCfg(next);
2827
- setEditing(false);
2828
- setError(null);
2829
- } catch (err) {
2830
- setError(err.message);
2831
- }
2832
- };
2833
- useInput6((input, key) => {
2834
- if (editing && field.kind === "string") {
2835
- if (key.escape) {
2836
- setEditing(false);
2837
- setError(null);
2838
- return;
2839
- }
2840
- if (key.return) {
2841
- void commit(draft);
2842
- return;
2843
- }
2844
- if (key.backspace || key.delete) {
2845
- setDraft((d) => d.slice(0, -1));
2846
- return;
2847
- }
2848
- if (input && !key.ctrl && !key.meta) setDraft((d) => d + input);
2849
- return;
2850
- }
2851
- if (editing && field.kind === "int") {
2852
- if (key.escape) {
2853
- setEditing(false);
2854
- setError(null);
2855
- return;
2856
- }
2857
- if (key.return) {
2858
- void commit(draft);
2859
- return;
2860
- }
2861
- if (key.backspace || key.delete) {
2862
- setDraft((d) => d.slice(0, -1));
2863
- return;
2864
- }
2865
- if (/^[0-9]$/.test(input)) setDraft((d) => d + input);
2866
- return;
2867
- }
2868
- if (key.escape) {
2869
- nav.back();
2870
- return;
2871
- }
2872
- if (key.upArrow) {
2873
- setSelected((i) => (i - 1 + FIELDS.length) % FIELDS.length);
2874
- setError(null);
2875
- return;
2876
- }
2877
- if (key.downArrow) {
2878
- setSelected((i) => (i + 1) % FIELDS.length);
2879
- setError(null);
2880
- return;
2881
- }
2882
- if (field.kind === "bool" && (input === " " || key.return)) {
2883
- void commit(currentValue ? "false" : "true");
2884
- return;
2885
- }
2886
- if (field.kind === "enum" && (key.leftArrow || key.rightArrow)) {
2887
- const idx = field.options.indexOf(String(currentValue));
2888
- const delta = key.rightArrow ? 1 : -1;
2889
- const next = field.options[(idx + delta + field.options.length) % field.options.length];
2890
- void commit(next);
2891
- return;
2892
- }
2893
- if (field.kind === "dictRef" && key.return) {
2894
- nav.navigate({ name: "dict", params: { pickerMode: "set-default" } });
2895
- return;
2896
- }
2897
- if ((field.kind === "string" || field.kind === "int") && key.return) {
2898
- setDraft(String(currentValue ?? ""));
2899
- setEditing(true);
2900
- setError(null);
2901
- }
2902
- });
2903
- const labelW = Math.max(...FIELDS.map((f) => visibleWidth2(t.config.fields[f.labelKey]))) + 4;
2904
- return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
2905
- /* @__PURE__ */ jsx13(Text7, { bold: true, color: PALETTE.accent, children: t.config.title }),
2906
- /* @__PURE__ */ jsx13(Box8, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
2907
- const active = i === selected;
2908
- const value = getByPath2(cfg, f.path);
2909
- const display = renderValue(
2910
- f,
2911
- value,
2912
- active && editing ? draft : null,
2913
- t,
2914
- f.path === "defaultDict" ? defaultDictName : ""
2915
- );
2916
- const label = t.config.fields[f.labelKey];
2917
- const pad = " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
2918
- return /* @__PURE__ */ jsxs7(Box8, { children: [
2919
- /* @__PURE__ */ jsx13(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
2920
- /* @__PURE__ */ jsxs7(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: [
2921
- label,
2922
- pad
2923
- ] }),
2924
- /* @__PURE__ */ jsx13(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
2925
- ] }, f.path);
2926
- }) }),
2927
- error && /* @__PURE__ */ jsx13(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
2928
- "! ",
2929
- error
2930
- ] }) }),
2931
- /* @__PURE__ */ jsx13(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text7, { color: PALETTE.muted, children: hintFor(field, editing, t) }) })
2932
- ] });
2933
- }
2934
- function renderValue(field, value, draft, t, dictDisplayName) {
2935
- if (draft !== null) return `${draft}_`;
2936
- if (field.kind === "bool") return value ? `\u2713 ${t.common.on}` : `\u2717 ${t.common.off}`;
2937
- if (field.kind === "dictRef") {
2938
- if (!value) return "\u2014";
2939
- return truncateName(dictDisplayName || String(value), 24);
2940
- }
2941
- if (field.kind === "enum") {
2942
- if (field.path === "stealth") {
2943
- const v = String(value);
2944
- const label = t.config.enumValues.stealth[v] ?? String(value);
2945
- return `< ${label} >`;
2946
- }
2947
- return `< ${value} >`;
2948
- }
2949
- return String(value ?? "");
2950
- }
2951
- function hintFor(field, editing, t) {
2952
- if (editing) return t.config.hints.editing;
2953
- if (field.kind === "bool") return t.config.hints.bool;
2954
- if (field.kind === "enum") return t.config.hints.enum;
2955
- if (field.kind === "dictRef") return t.config.hints.dictRef;
2956
- return t.config.hints.stringOrInt;
2957
- }
2958
-
2959
- // src/ui/screens/StatsViewer.tsx
2960
- import { useEffect as useEffect8, useState as useState12 } from "react";
2961
- import { Box as Box9, Text as Text8, useInput as useInput7 } from "ink";
2962
- import { jsx as jsx14, jsxs as jsxs8 } from "react/jsx-runtime";
2963
- var DAY_WINDOWS = [7, 14, 30, 90];
2964
- function StatsViewer() {
2965
- const nav = useNav();
2966
- const t = useStrings();
2967
- const [sessions, setSessions] = useState12(null);
2968
- const [book, setBook] = useState12(null);
2969
- const [windowIdx, setWindowIdx] = useState12(1);
2970
- useEffect8(() => {
2971
- void (async () => {
2972
- const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
2973
- setSessions(s);
2974
- setBook(b);
2975
- })();
2976
- }, []);
2977
- useInput7((_input, key) => {
2978
- if (key.escape) {
2979
- nav.back();
2980
- return;
2981
- }
2982
- if (key.rightArrow) setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
2983
- if (key.leftArrow) setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
2984
- });
2985
- if (!sessions || !book) {
2986
- return /* @__PURE__ */ jsx14(Box9, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.loading }) });
2987
- }
2988
- if (sessions.length === 0) {
2989
- return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
2990
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.none }),
2991
- /* @__PURE__ */ jsx14(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.nonePractice }) }),
2992
- /* @__PURE__ */ jsx14(Box9, { marginTop: 2, children: /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
2993
- "Esc ",
2994
- t.common.back
2995
- ] }) })
2996
- ] });
2997
- }
2998
- const days = DAY_WINDOWS[windowIdx];
2999
- const buckets = dailyBuckets(sessions, days);
3000
- const streak = dailyStreak(sessions);
3001
- const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
3002
- const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
3003
- const totalMs = sessions.reduce((a, s) => a + s.durationMs, 0);
3004
- const firstTryWords = sessions.reduce(
3005
- (a, s) => a + (s.wordCount - Object.keys(s.perWordErrors).length),
3006
- 0
3007
- );
3008
- const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
3009
- const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
3010
- const recent = sessions.slice(-5).reverse();
3011
- const top = topN(book, 8);
3012
- const wpms = buckets.map((b) => b.wpm);
3013
- const accs = buckets.map((b) => b.accuracy * 100);
3014
- const ses = buckets.map((b) => b.sessions);
3015
- return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3016
- /* @__PURE__ */ jsx14(Text8, { bold: true, color: PALETTE.accent, children: t.stats.title }),
3017
- /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, flexDirection: "column", children: [
3018
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.lifetime }),
3019
- /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, children: [
3020
- /* @__PURE__ */ jsx14(Stat, { label: t.stats.sessions, value: String(sessions.length) }),
3021
- /* @__PURE__ */ jsx14(Stat, { label: t.stats.words, value: String(totalWords) }),
3022
- /* @__PURE__ */ jsx14(Stat, { label: t.stats.errors, value: String(totalErrors) }),
3023
- /* @__PURE__ */ jsx14(Stat, { label: t.stats.wpm, value: String(overallWpm), accent: true }),
3024
- /* @__PURE__ */ jsx14(Stat, { label: t.stats.accuracy, value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
3025
- /* @__PURE__ */ jsx14(Stat, { label: t.stats.streak, value: `${streak}d`, accent: true })
3026
- ] })
3027
- ] }),
3028
- /* @__PURE__ */ jsxs8(Box9, { marginTop: 2, flexDirection: "column", children: [
3029
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.last(days) }),
3030
- /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: PALETTE.muted, paddingX: 1, children: [
3031
- /* @__PURE__ */ jsx14(
3032
- SparkRow,
3033
- {
3034
- label: t.stats.wpm,
3035
- values: wpms,
3036
- maxLabel: t.stats.maxLabel
3037
- }
3038
- ),
3039
- /* @__PURE__ */ jsx14(
3040
- SparkRow,
3041
- {
3042
- label: t.stats.accuracy,
3043
- values: accs,
3044
- maxLabel: t.stats.maxLabel,
3045
- suffix: "%"
3046
- }
3047
- ),
3048
- /* @__PURE__ */ jsx14(
3049
- SparkRow,
3050
- {
3051
- label: t.stats.sessions,
3052
- values: ses,
3053
- maxLabel: t.stats.maxLabel
3054
- }
3055
- )
3056
- ] })
3057
- ] }),
3058
- /* @__PURE__ */ jsxs8(Box9, { marginTop: 2, flexDirection: "column", children: [
3059
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.recent }),
3060
- recent.map((s, i) => /* @__PURE__ */ jsx14(RecentRow, { session: s, units: t.stats.recentUnits }, i))
3061
- ] }),
3062
- top.length > 0 && /* @__PURE__ */ jsxs8(Box9, { marginTop: 2, flexDirection: "column", children: [
3063
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.topMistakes }),
3064
- top.map(([word, entry]) => /* @__PURE__ */ jsx14(
3065
- MistakeRow,
3066
- {
3067
- word,
3068
- count: entry.count,
3069
- dictIds: entry.dictIds,
3070
- multiSuffix: t.stats.multiDictSuffix
3071
- },
3072
- word
3073
- ))
3074
- ] }),
3075
- /* @__PURE__ */ jsx14(Box9, { flexGrow: 1 }),
3076
- /* @__PURE__ */ jsx14(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: t.stats.footer }) })
3077
- ] });
3078
- }
3079
- function SparkRow({
3080
- label,
3081
- values,
3082
- maxLabel,
3083
- suffix = ""
3084
- }) {
3085
- const max = values.length === 0 ? 0 : Math.max(...values);
3086
- const maxDisplay = `${Math.round(max)}${suffix}`;
3087
- return /* @__PURE__ */ jsxs8(Box9, { children: [
3088
- /* @__PURE__ */ jsx14(Box9, { width: 10, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.muted, children: label }) }),
3089
- /* @__PURE__ */ jsx14(Box9, { flexGrow: 1, children: /* @__PURE__ */ jsx14(Text8, { color: PALETTE.accent, children: sparkline(values) }) }),
3090
- /* @__PURE__ */ jsx14(Box9, { marginLeft: 2, children: /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3091
- maxLabel,
3092
- " ",
3093
- maxDisplay
3094
- ] }) })
3095
- ] });
3096
- }
3097
- function RecentRow({
3098
- session,
3099
- units
3100
- }) {
3101
- const name = useDictName(session.dictId);
3102
- const display = truncateName(name, 14);
3103
- return /* @__PURE__ */ jsxs8(Box9, { children: [
3104
- /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3105
- " ",
3106
- session.ts.replace("T", " ").slice(0, 16),
3107
- " "
3108
- ] }),
3109
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.text, children: display.padEnd(14) }),
3110
- /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3111
- " ",
3112
- "ch",
3113
- String(session.chapter + 1).padStart(3),
3114
- " ",
3115
- session.mode.padEnd(9),
3116
- " ",
3117
- String(session.wordCount).padStart(3),
3118
- units.words,
3119
- " ",
3120
- session.errors,
3121
- units.errors,
3122
- " ",
3123
- computeWPM(session),
3124
- units.wpm,
3125
- " ",
3126
- Math.round(accuracy(session) * 1e3) / 10,
3127
- "%"
3128
- ] })
3129
- ] });
3130
- }
3131
- function MistakeRow({
3132
- word,
3133
- count,
3134
- dictIds,
3135
- multiSuffix
3136
- }) {
3137
- const firstId = dictIds[0] ?? "";
3138
- const firstName = useDictName(firstId);
3139
- const suffix = dictIds.length > 1 ? multiSuffix(dictIds.length - 1) : "";
3140
- return /* @__PURE__ */ jsxs8(Box9, { children: [
3141
- /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.error, children: [
3142
- " ",
3143
- String(count).padStart(3),
3144
- " "
3145
- ] }),
3146
- /* @__PURE__ */ jsx14(Text8, { color: PALETTE.text, children: word.padEnd(20) }),
3147
- /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3148
- truncateName(firstName, 20),
3149
- suffix
3150
- ] })
3151
- ] });
3152
- }
3153
- function Stat({ label, value, accent = false }) {
3154
- return /* @__PURE__ */ jsxs8(Box9, { marginRight: 3, children: [
3155
- /* @__PURE__ */ jsxs8(Text8, { color: PALETTE.muted, children: [
3156
- label,
3157
- " "
3158
- ] }),
3159
- /* @__PURE__ */ jsx14(Text8, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
3160
- ] });
3161
- }
3162
-
3163
- // src/ui/screens/WordLookup.tsx
3164
- import { useEffect as useEffect9, useState as useState13 } from "react";
3165
- import { Box as Box10, Text as Text9, useInput as useInput8 } from "ink";
3166
- import { readdir } from "fs/promises";
3167
- import { Fragment as Fragment2, jsx as jsx15, jsxs as jsxs9 } from "react/jsx-runtime";
3168
- async function listLocalDictIds() {
3169
- try {
3170
- const files = await readdir(paths.dictsDir);
3171
- return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
3172
- } catch {
3173
- return [];
3174
- }
3175
- }
3176
- function WordLookup() {
3177
- const nav = useNav();
3178
- const t = useStrings();
3179
- const [query, setQuery] = useState13("");
3180
- const [allWords, setAllWords] = useState13([]);
3181
- const [book, setBook] = useState13({});
3182
- const [loading, setLoading] = useState13(true);
3183
- const [selected, setSelected] = useState13(0);
3184
- useEffect9(() => {
3185
- void (async () => {
3186
- const ids = await listLocalDictIds();
3187
- const collected = [];
3188
- for (const id of ids) {
3189
- const words = await loadLocalDictionary(id);
3190
- if (!words) continue;
3191
- for (const w of words) collected.push({ dictId: id, word: w });
3192
- }
3193
- setAllWords(collected);
3194
- setBook(await loadMistakes());
3195
- setLoading(false);
3196
- })();
3197
- }, []);
3198
- const q = query.toLowerCase().trim();
3199
- const filtered = q ? allWords.filter((h) => h.word.name.toLowerCase().includes(q)).slice(0, 50) : [];
3200
- useInput8((input, key) => {
3201
- if (key.escape) {
3202
- nav.back();
3203
- return;
3204
- }
3205
- if (key.upArrow) {
3206
- setSelected((i) => Math.max(0, i - 1));
3207
- return;
3208
- }
3209
- if (key.downArrow) {
3210
- setSelected((i) => Math.min(filtered.length - 1, i + 1));
3211
- return;
3212
- }
3213
- if (key.backspace || key.delete) {
3214
- setQuery((s) => s.slice(0, -1));
3215
- setSelected(0);
3216
- return;
3217
- }
3218
- if (input && !key.ctrl && !key.meta && input.trim().length > 0) {
3219
- setQuery((s) => s + input);
3220
- setSelected(0);
3221
- }
3222
- });
3223
- if (loading) {
3224
- return /* @__PURE__ */ jsx15(Box10, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.indexing }) });
3225
- }
3226
- if (allWords.length === 0) {
3227
- return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
3228
- /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.none }),
3229
- /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.pullFirst }) }),
3230
- /* @__PURE__ */ jsx15(Box10, { marginTop: 2, children: /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.muted, children: [
3231
- "[Esc] ",
3232
- t.common.back
3233
- ] }) })
3234
- ] });
3235
- }
3236
- const current = filtered[selected];
3237
- return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3238
- /* @__PURE__ */ jsxs9(Box10, { children: [
3239
- /* @__PURE__ */ jsx15(Text9, { bold: true, color: PALETTE.accent, children: t.word.title }),
3240
- /* @__PURE__ */ jsx15(Box10, { flexGrow: 1 }),
3241
- /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.countAcross(allWords.length) })
3242
- ] }),
3243
- /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, children: [
3244
- /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: "> " }),
3245
- /* @__PURE__ */ jsx15(Text9, { color: PALETTE.text, children: query }),
3246
- /* @__PURE__ */ jsx15(Text9, { color: PALETTE.accent, children: "_" })
3247
- ] }),
3248
- /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, flexGrow: 1, children: [
3249
- /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", width: "40%", children: [
3250
- filtered.map((h, i) => /* @__PURE__ */ jsx15(HitRow, { hit: h, active: i === selected }, `${h.dictId}-${h.word.name}-${i}`)),
3251
- filtered.length === 0 && q && /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.noMatches(query) })
3252
- ] }),
3253
- /* @__PURE__ */ jsx15(Box10, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsx15(Detail, { hit: current, book }) })
3254
- ] }),
3255
- /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.footer }) })
3256
- ] });
3257
- }
3258
- function HitRow({ hit, active }) {
3259
- const name = useDictName(hit.dictId);
3260
- return /* @__PURE__ */ jsxs9(Box10, { children: [
3261
- /* @__PURE__ */ jsx15(Text9, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
3262
- /* @__PURE__ */ jsx15(Text9, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: hit.word.name.padEnd(20) }),
3263
- /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: truncateName(name, 18) })
3264
- ] });
3265
- }
3266
- function Detail({ hit, book }) {
3267
- const t = useStrings();
3268
- const name = useDictName(hit.dictId);
3269
- return /* @__PURE__ */ jsxs9(Fragment2, { children: [
3270
- /* @__PURE__ */ jsx15(Text9, { bold: true, color: PALETTE.text, children: hit.word.name }),
3271
- /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, children: [
3272
- hit.word.usphone && /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.muted, children: [
3273
- "US /",
3274
- hit.word.usphone,
3275
- "/ "
3276
- ] }),
3277
- hit.word.ukphone && /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.muted, children: [
3278
- "UK /",
3279
- hit.word.ukphone,
3280
- "/"
3281
- ] })
3282
- ] }),
3283
- /* @__PURE__ */ jsx15(Box10, { marginTop: 1, flexDirection: "column", children: (hit.word.trans ?? []).map((tr, i) => /* @__PURE__ */ jsxs9(Text9, { color: PALETTE.primary, children: [
3284
- "\xB7 ",
3285
- tr
3286
- ] }, i)) }),
3287
- /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.muted, children: t.word.inDict(truncateName(name, 22)) }) }),
3288
- book[hit.word.name] && /* @__PURE__ */ jsx15(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text9, { color: PALETTE.error, children: t.word.mistakes(book[hit.word.name].count, book[hit.word.name].lastSeen.slice(0, 10)) }) })
3289
- ] });
3290
- }
3291
-
3292
- // src/ui/screens/HelpScreen.tsx
3293
- import { Box as Box11, Text as Text10, useInput as useInput9 } from "ink";
3294
- import { jsx as jsx16, jsxs as jsxs10 } from "react/jsx-runtime";
3295
- function HelpScreen() {
3296
- const nav = useNav();
3297
- const t = useStrings();
3298
- useInput9((_input, key) => {
3299
- if (key.escape) nav.back();
3300
- });
3301
- const k = t.help.keys;
3302
- const sections = [
3303
- { title: t.help.sections.global, keys: [k.helpScreen, k.quit] },
3304
- { title: t.help.sections.main, keys: [k.navigate, k.select, k.letterJump, k.helpScreen] },
3305
- {
3306
- title: t.help.sections.practice,
3307
- keys: [k.pause, k.skip, k.replay, k.resume, k.nextChapter, k.reviewMistakes, k.stealthToggle, k.backMenu]
3308
- },
3309
- {
3310
- title: t.help.sections.dict,
3311
- keys: [k.navigate, k.filter, k.itemActions, k.moreActions, k.backScreen]
3312
- },
3313
- { title: t.help.sections.config, keys: [k.navigate, k.select, k.backMenu] },
3314
- { title: t.help.sections.stats, keys: [k.cycleWindow, k.backMenu] },
3315
- { title: t.help.sections.word, keys: [k.filter, k.navigate, k.backMenu] }
3316
- ];
3317
- return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
3318
- /* @__PURE__ */ jsxs10(Box11, { children: [
3319
- /* @__PURE__ */ jsx16(Text10, { bold: true, color: PALETTE.accent, children: t.help.title }),
3320
- /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: " \xB7 " }),
3321
- /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.help.subtitle })
3322
- ] }),
3323
- /* @__PURE__ */ jsx16(Box11, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: sections.map((sec) => /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", marginTop: 1, children: [
3324
- /* @__PURE__ */ jsx16(Text10, { bold: true, color: PALETTE.text, children: sec.title }),
3325
- sec.keys.map((line, i) => /* @__PURE__ */ jsx16(Box11, { children: /* @__PURE__ */ jsxs10(Text10, { color: PALETTE.muted, children: [
3326
- " \xB7 ",
3327
- line
3328
- ] }) }, i))
3329
- ] }, sec.title)) }),
3330
- /* @__PURE__ */ jsx16(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx16(Text10, { color: PALETTE.muted, children: t.help.footer }) })
3331
- ] });
3332
- }
3333
-
3334
- // src/ui/App.tsx
3335
- import { jsx as jsx17 } from "react/jsx-runtime";
3336
- function App({ initial, initialCfg }) {
3337
- return /* @__PURE__ */ jsx17(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx17(LangBridge, { children: /* @__PURE__ */ jsx17(RegistryProvider, { children: /* @__PURE__ */ jsx17(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx17(NavProvider, { initial, children: /* @__PURE__ */ jsx17(Fullscreen, { children: /* @__PURE__ */ jsx17(Router, {}) }) }) }) }) }) });
3338
- }
3339
- function LangBridge({ children }) {
3340
- const { cfg } = useAppState();
3341
- return /* @__PURE__ */ jsx17(StringsProvider, { pref: cfg.language, children });
3342
- }
3343
- function screenKey(frame) {
3344
- if (frame.name === "practice") {
3345
- const p = frame.params;
3346
- return `practice:${p.dictId}:${p.chapterIndex}:${p.mode}:${p.stealth ? "s" : "n"}`;
3347
- }
3348
- return frame.name;
3349
- }
3350
- function Router() {
3351
- const nav = useNav();
3352
- const { cfg } = useAppState();
3353
- const { exit } = useApp4();
3354
- const lastKeyRef = useRef4(null);
3355
- useInput10((input, key2) => {
3356
- if (key2.ctrl && input === "c") exit();
3357
- });
3358
- const frame = nav.current;
3359
- const key = screenKey(frame);
3360
- if (lastKeyRef.current !== key) {
3361
- if (process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[H");
3362
- lastKeyRef.current = key;
3363
- }
3364
- switch (frame.name) {
3365
- case "main":
3366
- return /* @__PURE__ */ jsx17(MainMenu, { cfg });
3367
- case "practice":
3368
- return /* @__PURE__ */ jsx17(PracticeScreen, { params: frame.params });
3369
- case "dict":
3370
- return /* @__PURE__ */ jsx17(DictBrowser, { params: frame.params });
3371
- case "config":
3372
- return /* @__PURE__ */ jsx17(ConfigEditor, {});
3373
- case "stats":
3374
- return /* @__PURE__ */ jsx17(StatsViewer, {});
3375
- case "word":
3376
- return /* @__PURE__ */ jsx17(WordLookup, {});
3377
- case "help":
3378
- return /* @__PURE__ */ jsx17(HelpScreen, {});
3379
- }
3380
- }
3381
-
3382
- // src/util/report.ts
3383
- import chalk3 from "chalk";
3384
- var LEAVE_ALTSCREEN = "\x1B[?25h\x1B[?1049l";
3385
- function ensureMainScreen() {
3386
- if (process.stdout.isTTY) process.stdout.write(LEAVE_ALTSCREEN);
3387
- }
3388
- function fmtDuration(ms, lang) {
3389
- const total = Math.floor(ms / 1e3);
3390
- const m = Math.floor(total / 60);
3391
- const s = total % 60;
3392
- if (lang === "zh") {
3393
- if (m === 0) return `${s} \u79D2`;
3394
- return `${m} \u5206 ${s} \u79D2`;
3395
- }
3396
- if (m === 0) return `${s}s`;
3397
- return `${m}m ${s}s`;
3398
- }
3399
- function printSessionReport(r, t, lang) {
3400
- if (r.startedAt === null && r.chaptersCompleted === 0) return;
3401
- if (r.chaptersCompleted === 0) {
3402
- console.log();
3403
- console.log(chalk3.bold.cyan(t.report.title));
3404
- const labelW2 = Math.max(visibleWidth2(t.report.duration), visibleWidth2(t.report.notPracticed)) + 2;
3405
- const pad2 = (label) => label + " ".repeat(Math.max(0, labelW2 - visibleWidth2(label)));
3406
- console.log(` ${chalk3.dim(pad2(t.report.duration))} ${fmtDuration(r.totalDurationMs, lang)}`);
3407
- console.log(` ${chalk3.dim(t.report.notPracticed)}`);
3408
- console.log();
3409
- console.log(chalk3.dim(` ${t.report.farewell}`));
3410
- console.log();
3411
- return;
3412
- }
3413
- const accPct = Math.round(r.accuracy * 1e3) / 10;
3414
- const labels = [
3415
- t.report.duration,
3416
- t.report.practiced,
3417
- t.report.chapters,
3418
- t.report.words,
3419
- t.report.accuracy,
3420
- t.report.wpm
3421
- ];
3422
- if (r.newMistakeWords > 0) labels.push(t.report.newMistakes);
3423
- const labelW = Math.max(...labels.map(visibleWidth2)) + 2;
3424
- const pad = (label) => label + " ".repeat(Math.max(0, labelW - visibleWidth2(label)));
3425
- console.log();
3426
- console.log(chalk3.bold.cyan(t.report.title));
3427
- console.log(` ${chalk3.dim(pad(t.report.duration))} ${fmtDuration(r.totalDurationMs, lang)}`);
3428
- console.log(` ${chalk3.dim(pad(t.report.practiced))} ${fmtDuration(r.practiceMs, lang)}`);
3429
- console.log(` ${chalk3.dim(pad(t.report.chapters))} ${r.chaptersCompleted}`);
3430
- console.log(` ${chalk3.dim(pad(t.report.words))} ${r.wordCount}`);
3431
- console.log(` ${chalk3.dim(pad(t.report.accuracy))} ${accPct}%`);
3432
- console.log(` ${chalk3.dim(pad(t.report.wpm))} ${r.wpm}`);
3433
- if (r.newMistakeWords > 0) {
3434
- console.log(` ${chalk3.dim(pad(t.report.newMistakes))} ${r.newMistakeWords}`);
3435
- }
3436
- console.log();
3437
- console.log(chalk3.dim(` ${t.report.farewell}`));
3438
- console.log();
3439
- }
3440
-
3441
- // src/commands/practice.ts
3442
- var MODES = ["order", "dictation", "review", "random", "loop"];
3443
- function isMode(v) {
3444
- return MODES.includes(v);
3445
- }
3446
- async function runPractice(dictIdArg, options) {
3447
- if (!process.stdout.isTTY) {
3448
- console.error(chalk4.red("Practice requires an interactive TTY."));
3449
- process.exitCode = 1;
3450
- return;
3451
- }
3452
- const cfg = await loadConfig();
3453
- const dictId = dictIdArg ?? cfg.defaultDict;
3454
- if (!dictId) {
3455
- console.error(chalk4.red("No dictionary specified. Pass an id or set config.defaultDict."));
3456
- process.exitCode = 1;
3457
- return;
3458
- }
3459
- const mode = options.mode ?? cfg.defaultMode;
3460
- if (!isMode(mode)) {
3461
- console.error(chalk4.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
3462
- process.exitCode = 1;
3463
- return;
3464
- }
3465
- const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
3466
- const stealth = options.stealth === true || cfg.stealth === "default";
3467
- start();
3468
- const { waitUntilExit } = render(
3469
- createElement(App, {
3470
- initial: { name: "practice", params: { dictId, chapterIndex, mode, stealth } },
3471
- initialCfg: cfg
3472
- }),
3473
- { patchConsole: false, exitOnCtrlC: false }
3474
- );
3475
- await waitUntilExit();
3476
- ensureMainScreen();
3477
- const { lang, t } = pickStrings(cfg.language);
3478
- printSessionReport(report(), t, lang);
3479
- }
3480
- function buildPracticeCommand() {
3481
- return new Command3("practice").argument("[dictId]", "dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").option("--stealth", "enter stealth mode (minimal UI, no sound)").action(
3482
- async (dictIdArg, options) => {
3483
- await runPractice(dictIdArg, options);
3484
- }
3485
- );
3486
- }
3487
-
3488
- // src/commands/stats.ts
3489
- import { Command as Command4 } from "commander";
3490
- import chalk5 from "chalk";
3491
- function buildStatsCommand() {
3492
- return new Command4("stats").description("Show practice history and trends").option("-d, --days <n>", "window size for trend (default 14)", "14").option("--top <n>", "how many top mistakes to show (default 10)", "10").action(async (opts) => {
3493
- const days = Math.max(1, Number(opts.days) || 14);
3494
- const topCount = Math.max(1, Number(opts.top) || 10);
3495
- const sessions = await loadSessions();
3496
- const book = await loadMistakes();
3497
- if (sessions.length === 0) {
3498
- console.log(chalk5.yellow("No practice history yet. Run `qwerty practice <dict>` to get started."));
3499
- return;
3500
- }
3501
- const buckets = dailyBuckets(sessions, days);
3502
- const streak = dailyStreak(sessions);
3503
- const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
3504
- const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
3505
- const totalMs = sessions.reduce((a, s) => a + s.durationMs, 0);
3506
- const firstTryWords = sessions.reduce(
3507
- (a, s) => a + (s.wordCount - Object.keys(s.perWordErrors).length),
3508
- 0
3509
- );
3510
- const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
3511
- const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
3512
- console.log(chalk5.bold("\nLifetime"));
3513
- console.log(` ${chalk5.dim("sessions")} ${sessions.length} ${chalk5.dim("words")} ${totalWords} ${chalk5.dim("errors")} ${totalErrors}`);
3514
- console.log(` ${chalk5.dim("avg wpm")} ${overallWpm} ${chalk5.dim("avg accuracy")} ${Math.round(overallAcc * 1e3) / 10}% ${chalk5.dim("streak")} ${chalk5.bold(streak)}d`);
3515
- console.log(chalk5.bold(`
3516
- Last ${days} days`));
3517
- console.log(` ${chalk5.dim("wpm ")} ${sparkline(buckets.map((b) => b.wpm))} ${chalk5.dim("max")} ${Math.round(Math.max(...buckets.map((b) => b.wpm)))}`);
3518
- console.log(` ${chalk5.dim("accuracy")} ${sparkline(buckets.map((b) => b.accuracy * 100))} ${chalk5.dim("range")} ${Math.round(Math.min(...buckets.map((b) => b.accuracy * 100)))}-${Math.round(Math.max(...buckets.map((b) => b.accuracy * 100)))}%`);
3519
- console.log(` ${chalk5.dim("sessions")} ${sparkline(buckets.map((b) => b.sessions))}`);
3520
- const recent = sessions.slice(-5).reverse();
3521
- console.log(chalk5.bold("\nLast 5 sessions"));
3522
- for (const s of recent) {
3523
- const wpm = computeWPM(s);
3524
- const acc = Math.round(accuracy(s) * 1e3) / 10;
3525
- console.log(
3526
- ` ${chalk5.dim(s.ts.replace("T", " ").slice(0, 16))} ${chalk5.cyan(s.dictId.padEnd(14))} ch${String(s.chapter + 1).padStart(3)} ${s.mode.padEnd(9)} ${String(s.wordCount).padStart(3)}w ${s.errors}err ${wpm}wpm ${acc}%`
3527
- );
3528
- }
3529
- const top = topN(book, topCount);
3530
- if (top.length > 0) {
3531
- console.log(chalk5.bold(`
3532
- Top ${top.length} mistakes`));
3533
- for (const [word, entry] of top) {
3534
- console.log(` ${chalk5.red(String(entry.count).padStart(3))} ${chalk5.bold(word.padEnd(20))} ${chalk5.dim(entry.dictIds.join(", "))}`);
3535
- }
3536
- } else {
3537
- console.log(chalk5.bold("\nTop mistakes"));
3538
- console.log(chalk5.dim(" none \u2014 keep going"));
3539
- }
3540
- console.log();
3541
- });
3542
- }
3543
-
3544
- // src/commands/word.ts
3545
- import { Command as Command5 } from "commander";
3546
- import chalk6 from "chalk";
3547
- import { readdir as readdir2 } from "fs/promises";
3548
- async function listLocalDictIds2() {
3549
- try {
3550
- const files = await readdir2(paths.dictsDir);
3551
- return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
3552
- } catch {
3553
- return [];
3554
- }
3555
- }
3556
- function buildWordCommand() {
3557
- return new Command5("word").argument("<keyword>").description("Look up a word across local dictionaries").option("--exact", "require exact (case-insensitive) match").action(async (keyword, opts) => {
3558
- const q = keyword.toLowerCase();
3559
- const ids = await listLocalDictIds2();
3560
- if (ids.length === 0) {
3561
- console.log(chalk6.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
3562
- return;
3563
- }
3564
- const hits = [];
3565
- for (const id of ids) {
3566
- const words = await loadLocalDictionary(id);
3567
- if (!words) continue;
3568
- for (const w of words) {
3569
- const wl = w.name.toLowerCase();
3570
- const match = opts.exact ? wl === q : wl.includes(q);
3571
- if (match) hits.push({ dictId: id, word: w });
3572
- }
3573
- }
3574
- if (hits.length === 0) {
3575
- console.log(chalk6.yellow(`No matches for "${keyword}" in ${ids.length} local dictionaries`));
3576
- return;
3577
- }
3578
- const byName = /* @__PURE__ */ new Map();
3579
- for (const h of hits) {
3580
- const arr = byName.get(h.word.name) ?? [];
3581
- arr.push(h);
3582
- byName.set(h.word.name, arr);
3583
- }
3584
- const book = await loadMistakes();
3585
- for (const [name, group] of byName) {
3586
- const first = group[0].word;
3587
- console.log();
3588
- console.log(chalk6.bold.white(name));
3589
- const us = first.usphone ? `US /${first.usphone}/` : "";
3590
- const uk = first.ukphone ? `UK /${first.ukphone}/` : "";
3591
- if (us || uk) console.log(chalk6.dim(` ${[us, uk].filter(Boolean).join(" ")}`));
3592
- for (const t of first.trans ?? []) console.log(chalk6.cyan(` \xB7 ${t}`));
3593
- const sources = await Promise.all(
3594
- group.map(async (h) => {
3595
- const reg = await findEntry(h.dictId);
3596
- return reg?.name ?? h.dictId;
3597
- })
3598
- );
3599
- console.log(chalk6.dim(` in: ${sources.join(", ")}`));
3600
- const mistake = book[name];
3601
- if (mistake) {
3602
- console.log(chalk6.dim(` mistakes: ${mistake.count} (last ${mistake.lastSeen.slice(0, 10)})`));
3603
- }
3604
- }
3605
- console.log();
3606
- });
3607
- }
3608
-
3609
- // src/commands/stealth.ts
3610
- import { Command as Command6 } from "commander";
3611
- function buildStealthCommand() {
3612
- return new Command6("boss").alias("stealth").description("Start practice in stealth mode (minimal UI, looks like plain terminal output)").argument("[dictId]", "dictionary id; falls back to config.defaultDict").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(
3613
- async (dictIdArg, options) => {
3614
- await runPractice(dictIdArg, { ...options, stealth: true });
3615
- }
3616
- );
3617
- }
3618
-
3619
- // src/commands/menu.ts
3620
- import { render as render2 } from "ink";
3621
- import { createElement as createElement2 } from "react";
3622
- async function runMainMenu() {
3623
- if (!process.stdout.isTTY) {
3624
- console.log("qwerty-cli \u2014 run `qwerty --help` for available commands.");
3625
- return;
3626
- }
3627
- const cfg = await loadConfig();
3628
- start();
3629
- const { waitUntilExit } = render2(
3630
- createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
3631
- { patchConsole: false, exitOnCtrlC: false }
3632
- );
3633
- await waitUntilExit();
3634
- ensureMainScreen();
3635
- const { lang, t } = pickStrings(cfg.language);
3636
- printSessionReport(report(), t, lang);
3637
- }
3638
-
3639
- // src/cli.ts
3640
- var program = new Command7();
3641
- program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(package_default.version);
3642
- program.addCommand(buildPracticeCommand());
3643
- program.addCommand(buildStealthCommand());
3644
- program.addCommand(buildDictCommand());
3645
- program.addCommand(buildWordCommand());
3646
- program.addCommand(buildStatsCommand());
3647
- program.addCommand(buildConfigCommand());
3648
- program.action(async () => {
3649
- await runMainMenu();
3650
- });
3651
- program.parseAsync(process.argv).catch((err) => {
3652
- console.error(err instanceof Error ? err.message : err);
3653
- process.exit(1);
3654
- });
1
+ import{Command as h}from"commander";import{Command as l}from"commander";function r(){let t=new l("config").description("Manage CLI configuration");return t.command("list").description("Show the effective merged config").action(async()=>{let{configList:o}=await import("./config.impl-IYJ4ZUPE.js");await o()}),t.command("get <key>").description("Get a config value by dotted path (e.g. sounds.keystroke)").action(async o=>{let{configGet:a}=await import("./config.impl-IYJ4ZUPE.js");await a(o)}),t.command("set <key> <value>").description("Set a config value by dotted path").action(async(o,a)=>{let{configSet:n}=await import("./config.impl-IYJ4ZUPE.js");await n(o,a)}),t}import{Command as u}from"commander";function e(){let t=new u("dict").description("Manage dictionaries");return t.command("list").description("List dictionaries (\u2713 = locally available)").option("-c, --category <category>","filter by category").option("--local-only","show only locally downloaded dictionaries").action(async o=>{let{dictList:a}=await import("./dict.impl-Y66SRRZL.js");await a(o)}),t.command("search <keyword>").description("Search the upstream registry by name/description/category/tags").option("-c, --category <category>","restrict to category").option("-l, --language <language>","restrict to language").action(async(o,a)=>{let{dictSearch:n}=await import("./dict.impl-Y66SRRZL.js");await n(o,a)}),t.command("pull <id>").description("Download an upstream dictionary into the local cache").action(async o=>{let{dictPull:a}=await import("./dict.impl-Y66SRRZL.js");await a(o)}),t.command("import <file>").description("Import a local qwerty-native JSON dictionary").requiredOption("--id <id>","dictionary id (lowercase, digits, dashes)").action(async(o,a)=>{let{dictImport:n}=await import("./dict.impl-Y66SRRZL.js");await n(o,a)}),t.command("rm <id>").description("Remove a local dictionary").action(async o=>{let{dictRemove:a}=await import("./dict.impl-Y66SRRZL.js");await a(o)}),t}import{Command as g}from"commander";function c(){return new g("practice").argument("[dictId]","dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>","chapter number (1-based)","1").option("-m, --mode <mode>","order | dictation | review | random | loop").option("--stealth","enter stealth mode (minimal UI, no sound)").action(async(t,o)=>{let{runPractice:a}=await import("./practice.impl-NYUJO5ER.js");await a(t,o)})}import{Command as y}from"commander";function d(){return new y("stats").description("Show practice history and trends").option("-d, --days <n>","window size for trend (default 14)","14").option("--top <n>","how many top mistakes to show (default 10)","10").action(async t=>{let{runStats:o}=await import("./stats.impl-IXVF3Q5Y.js");await o(t)})}import{Command as w}from"commander";function m(){return new w("word").argument("<keyword>").description("Look up a word across local dictionaries").option("--exact","require exact (case-insensitive) match").action(async(t,o)=>{let{runWordLookup:a}=await import("./word.impl-C4AYZ3NC.js");await a(t,o)})}import{Command as f}from"commander";function s(){return new f("boss").alias("stealth").description("Start practice in stealth mode (minimal UI, looks like plain terminal output)").argument("[dictId]","dictionary id; falls back to config.defaultDict").option("-c, --chapter <n>","chapter number (1-based)","1").option("-m, --mode <mode>","order | dictation | review | random | loop").action(async(t,o)=>{let{runPractice:a}=await import("./practice.impl-NYUJO5ER.js");await a(t,{...o,stealth:!0})})}async function p(){if(!process.stdout.isTTY){console.log("qwerty-cli \u2014 run `qwerty --help` for available commands.");return}let{runMainMenuImpl:t}=await import("./menu.impl-L5KAWNMC.js");await t()}var i=new h;i.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version("0.0.1-alpha.9");i.addCommand(c());i.addCommand(s());i.addCommand(e());i.addCommand(m());i.addCommand(d());i.addCommand(r());i.action(async()=>{await p()});i.parseAsync(process.argv).catch(t=>{console.error(t instanceof Error?t.message:t),process.exit(1)});
3655
2
  //# sourceMappingURL=cli.js.map