qwerty-cli 0.0.1-alpha.0 → 0.0.1-alpha.5
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.
- package/dist/cli.js +1626 -717
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
// src/cli.ts
|
|
2
2
|
import { Command as Command6 } from "commander";
|
|
3
3
|
|
|
4
|
+
// package.json
|
|
5
|
+
var package_default = {
|
|
6
|
+
name: "qwerty-cli",
|
|
7
|
+
version: "0.0.1-alpha.5",
|
|
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
|
+
"ink-big-text": "^2.0.0",
|
|
44
|
+
"p-queue": "^8.0.1",
|
|
45
|
+
react: "^18.3.1",
|
|
46
|
+
undici: "^6.19.8",
|
|
47
|
+
zod: "^3.23.8"
|
|
48
|
+
},
|
|
49
|
+
devDependencies: {
|
|
50
|
+
"@types/node": "^20.14.10",
|
|
51
|
+
"@types/react": "^18.3.3",
|
|
52
|
+
"ts-morph": "^23.0.0",
|
|
53
|
+
tsup: "^8.2.4",
|
|
54
|
+
tsx: "^4.19.0",
|
|
55
|
+
typescript: "^5.5.4",
|
|
56
|
+
vitest: "^2.0.5"
|
|
57
|
+
},
|
|
58
|
+
pnpm: {
|
|
59
|
+
onlyBuiltDependencies: [
|
|
60
|
+
"esbuild"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
4
65
|
// src/commands/config.ts
|
|
5
66
|
import { Command } from "commander";
|
|
6
67
|
import chalk from "chalk";
|
|
@@ -449,6 +510,396 @@ import chalk3 from "chalk";
|
|
|
449
510
|
import { render } from "ink";
|
|
450
511
|
import { createElement } from "react";
|
|
451
512
|
|
|
513
|
+
// src/ui/App.tsx
|
|
514
|
+
import { useApp as useApp4, useInput as useInput8 } from "ink";
|
|
515
|
+
|
|
516
|
+
// src/ui/nav.tsx
|
|
517
|
+
import { createContext, useContext, useState, useCallback } from "react";
|
|
518
|
+
import { jsx } from "react/jsx-runtime";
|
|
519
|
+
var NavContext = createContext(null);
|
|
520
|
+
function NavProvider({ initial, children }) {
|
|
521
|
+
const [stack, setStack] = useState([initial]);
|
|
522
|
+
const navigate = useCallback((frame) => {
|
|
523
|
+
setStack((s) => [...s, frame]);
|
|
524
|
+
}, []);
|
|
525
|
+
const replace = useCallback((frame) => {
|
|
526
|
+
setStack((s) => s.length === 0 ? [frame] : [...s.slice(0, -1), frame]);
|
|
527
|
+
}, []);
|
|
528
|
+
const back = useCallback(() => {
|
|
529
|
+
setStack((s) => s.length > 1 ? s.slice(0, -1) : s);
|
|
530
|
+
}, []);
|
|
531
|
+
const reset = useCallback((frame) => {
|
|
532
|
+
setStack([frame]);
|
|
533
|
+
}, []);
|
|
534
|
+
const current = stack[stack.length - 1];
|
|
535
|
+
return /* @__PURE__ */ jsx(NavContext.Provider, { value: { current, stack, navigate, replace, back, reset }, children });
|
|
536
|
+
}
|
|
537
|
+
function useNav() {
|
|
538
|
+
const ctx = useContext(NavContext);
|
|
539
|
+
if (!ctx) throw new Error("useNav must be used inside NavProvider");
|
|
540
|
+
return ctx;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/ui/Fullscreen.tsx
|
|
544
|
+
import { useEffect } from "react";
|
|
545
|
+
import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
|
|
546
|
+
var ENTER = "\x1B[?1049h\x1B[?25l";
|
|
547
|
+
var LEAVE = "\x1B[?25h\x1B[?1049l";
|
|
548
|
+
function shouldUse() {
|
|
549
|
+
return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
|
|
550
|
+
}
|
|
551
|
+
function Fullscreen({ children }) {
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (!shouldUse()) return;
|
|
554
|
+
process.stdout.write(ENTER);
|
|
555
|
+
const leave = () => {
|
|
556
|
+
try {
|
|
557
|
+
process.stdout.write(LEAVE);
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const onSignal = () => {
|
|
562
|
+
leave();
|
|
563
|
+
process.exit(130);
|
|
564
|
+
};
|
|
565
|
+
process.once("SIGINT", onSignal);
|
|
566
|
+
process.once("SIGTERM", onSignal);
|
|
567
|
+
process.once("exit", leave);
|
|
568
|
+
return () => {
|
|
569
|
+
process.off("SIGINT", onSignal);
|
|
570
|
+
process.off("SIGTERM", onSignal);
|
|
571
|
+
process.off("exit", leave);
|
|
572
|
+
leave();
|
|
573
|
+
};
|
|
574
|
+
}, []);
|
|
575
|
+
return /* @__PURE__ */ jsx2(Fragment, { children });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/ui/audio-context.tsx
|
|
579
|
+
import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState2 } from "react";
|
|
580
|
+
|
|
581
|
+
// src/infra/audio.ts
|
|
582
|
+
import { spawn } from "child_process";
|
|
583
|
+
import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
584
|
+
import { join as join3, dirname as dirname2 } from "path";
|
|
585
|
+
import { request as request2 } from "undici";
|
|
586
|
+
import PQueue from "p-queue";
|
|
587
|
+
var CANDIDATES = [
|
|
588
|
+
{ kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
|
|
589
|
+
{ kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
|
|
590
|
+
{ kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
|
|
591
|
+
{ kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
|
|
592
|
+
{ kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
|
|
593
|
+
{
|
|
594
|
+
kind: "powershell",
|
|
595
|
+
cmd: "powershell",
|
|
596
|
+
args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
|
|
597
|
+
supports: "wav"
|
|
598
|
+
}
|
|
599
|
+
];
|
|
600
|
+
var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
|
|
601
|
+
var runtime = null;
|
|
602
|
+
async function isExecutable(cmd) {
|
|
603
|
+
return new Promise((resolve2) => {
|
|
604
|
+
const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
|
|
605
|
+
probe.on("error", () => resolve2(false));
|
|
606
|
+
probe.on("exit", () => resolve2(true));
|
|
607
|
+
setTimeout(() => {
|
|
608
|
+
probe.kill();
|
|
609
|
+
resolve2(false);
|
|
610
|
+
}, 500);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
async function detect() {
|
|
614
|
+
let wav = null;
|
|
615
|
+
let mp3 = null;
|
|
616
|
+
for (const p of CANDIDATES) {
|
|
617
|
+
if (!await isExecutable(p.cmd)) continue;
|
|
618
|
+
if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
|
|
619
|
+
if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
|
|
620
|
+
if (wav && mp3) break;
|
|
621
|
+
}
|
|
622
|
+
return { wav, mp3 };
|
|
623
|
+
}
|
|
624
|
+
async function initAudio(disabledByConfig) {
|
|
625
|
+
if (runtime) return runtime;
|
|
626
|
+
if (disabledByConfig) {
|
|
627
|
+
runtime = {
|
|
628
|
+
disabled: true,
|
|
629
|
+
wavPlayer: null,
|
|
630
|
+
mp3Player: null,
|
|
631
|
+
warning: null,
|
|
632
|
+
keyQueue: new PQueue({ concurrency: 1 }),
|
|
633
|
+
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
634
|
+
pronQueue: new PQueue({ concurrency: 1 })
|
|
635
|
+
};
|
|
636
|
+
return runtime;
|
|
637
|
+
}
|
|
638
|
+
const { wav, mp3 } = await detect();
|
|
639
|
+
let warning = null;
|
|
640
|
+
if (!wav && !mp3) {
|
|
641
|
+
warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
|
|
642
|
+
} else if (!mp3) {
|
|
643
|
+
warning = "No MP3 player found; word pronunciations will be skipped.";
|
|
644
|
+
}
|
|
645
|
+
runtime = {
|
|
646
|
+
disabled: !wav && !mp3,
|
|
647
|
+
wavPlayer: wav,
|
|
648
|
+
mp3Player: mp3,
|
|
649
|
+
warning,
|
|
650
|
+
keyQueue: new PQueue({ concurrency: 2 }),
|
|
651
|
+
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
652
|
+
pronQueue: new PQueue({ concurrency: 1 })
|
|
653
|
+
};
|
|
654
|
+
return runtime;
|
|
655
|
+
}
|
|
656
|
+
function spawnPlay(player, file) {
|
|
657
|
+
try {
|
|
658
|
+
const child = spawn(player.cmd, player.args(file), {
|
|
659
|
+
detached: true,
|
|
660
|
+
stdio: "ignore"
|
|
661
|
+
});
|
|
662
|
+
child.on("error", () => {
|
|
663
|
+
});
|
|
664
|
+
child.unref();
|
|
665
|
+
} catch {
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
function playFile(file, kind) {
|
|
669
|
+
if (!runtime || runtime.disabled) return;
|
|
670
|
+
const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
|
|
671
|
+
if (!player) return;
|
|
672
|
+
spawnPlay(player, file);
|
|
673
|
+
}
|
|
674
|
+
function playKeystroke() {
|
|
675
|
+
if (!runtime || runtime.disabled) return;
|
|
676
|
+
if (runtime.keyQueue.size >= 2) return;
|
|
677
|
+
void runtime.keyQueue.add(async () => {
|
|
678
|
+
playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
|
|
679
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
function playCorrect() {
|
|
683
|
+
if (!runtime || runtime.disabled) return;
|
|
684
|
+
void runtime.feedbackQueue.add(async () => {
|
|
685
|
+
playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
|
|
686
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function playWrong() {
|
|
690
|
+
if (!runtime || runtime.disabled) return;
|
|
691
|
+
void runtime.feedbackQueue.add(async () => {
|
|
692
|
+
playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
|
|
693
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
async function downloadPronunciation(word, accent) {
|
|
697
|
+
const cacheFile = paths.audioCache(word, accent);
|
|
698
|
+
if (await exists(cacheFile)) return cacheFile;
|
|
699
|
+
await mkdir3(dirname2(cacheFile), { recursive: true });
|
|
700
|
+
const type = accent === "us" ? 2 : 1;
|
|
701
|
+
const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
|
|
702
|
+
try {
|
|
703
|
+
const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
|
|
704
|
+
if (res.statusCode >= 400) return null;
|
|
705
|
+
const buf = Buffer.from(await res.body.arrayBuffer());
|
|
706
|
+
if (buf.length < 1024) return null;
|
|
707
|
+
const tmp = `${cacheFile}.tmp`;
|
|
708
|
+
await writeFile2(tmp, buf);
|
|
709
|
+
await rename2(tmp, cacheFile);
|
|
710
|
+
return cacheFile;
|
|
711
|
+
} catch {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function playPronunciation(word, accent) {
|
|
716
|
+
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
717
|
+
await ensureDirs();
|
|
718
|
+
await runtime.pronQueue.add(async () => {
|
|
719
|
+
const file = await downloadPronunciation(word, accent);
|
|
720
|
+
if (file) playFile(file, "mp3");
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
async function prefetchPronunciation(word, accent) {
|
|
724
|
+
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
725
|
+
await ensureDirs();
|
|
726
|
+
void runtime.pronQueue.add(async () => {
|
|
727
|
+
await downloadPronunciation(word, accent);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
function audioWarning() {
|
|
731
|
+
return runtime?.warning ?? null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/ui/audio-context.tsx
|
|
735
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
736
|
+
var AudioStatusContext = createContext2({ warning: null, ready: false });
|
|
737
|
+
function AudioStatusProvider({
|
|
738
|
+
disabled,
|
|
739
|
+
children
|
|
740
|
+
}) {
|
|
741
|
+
const [status, setStatus] = useState2({ warning: null, ready: false });
|
|
742
|
+
useEffect2(() => {
|
|
743
|
+
let cancelled = false;
|
|
744
|
+
initAudio(disabled).then(() => {
|
|
745
|
+
if (cancelled) return;
|
|
746
|
+
setStatus({ warning: audioWarning(), ready: true });
|
|
747
|
+
}).catch(() => {
|
|
748
|
+
if (cancelled) return;
|
|
749
|
+
setStatus({ warning: null, ready: true });
|
|
750
|
+
});
|
|
751
|
+
return () => {
|
|
752
|
+
cancelled = true;
|
|
753
|
+
};
|
|
754
|
+
}, [disabled]);
|
|
755
|
+
return /* @__PURE__ */ jsx3(AudioStatusContext.Provider, { value: status, children });
|
|
756
|
+
}
|
|
757
|
+
function useAudioStatus() {
|
|
758
|
+
return useContext2(AudioStatusContext);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/ui/app-state.tsx
|
|
762
|
+
import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState3 } from "react";
|
|
763
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
764
|
+
var AppStateContext = createContext3(null);
|
|
765
|
+
function AppStateProvider({
|
|
766
|
+
initialCfg,
|
|
767
|
+
children
|
|
768
|
+
}) {
|
|
769
|
+
const [cfg, setCfgState] = useState3(initialCfg);
|
|
770
|
+
const setCfg = useCallback2(async (next) => {
|
|
771
|
+
setCfgState(next);
|
|
772
|
+
await saveConfig(next);
|
|
773
|
+
}, []);
|
|
774
|
+
return /* @__PURE__ */ jsx4(AppStateContext.Provider, { value: { cfg, setCfg }, children });
|
|
775
|
+
}
|
|
776
|
+
function useAppState() {
|
|
777
|
+
const ctx = useContext3(AppStateContext);
|
|
778
|
+
if (!ctx) throw new Error("useAppState must be used inside AppStateProvider");
|
|
779
|
+
return ctx;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/ui/screens/MainMenu.tsx
|
|
783
|
+
import { useState as useState4 } from "react";
|
|
784
|
+
import { Box as Box2, Text as Text2, useApp, useInput } from "ink";
|
|
785
|
+
|
|
786
|
+
// src/ui/components/BigWord.tsx
|
|
787
|
+
import { Box, Text, useStdout } from "ink";
|
|
788
|
+
import BigText from "ink-big-text";
|
|
789
|
+
import { jsx as jsx5, jsxs } from "react/jsx-runtime";
|
|
790
|
+
var PALETTE = {
|
|
791
|
+
accent: "#5eead4",
|
|
792
|
+
muted: "#6b7280",
|
|
793
|
+
text: "#e5e7eb",
|
|
794
|
+
primary: "#7dcfff",
|
|
795
|
+
success: "#86efac",
|
|
796
|
+
warning: "#fbbf24",
|
|
797
|
+
error: "#f87171"
|
|
798
|
+
};
|
|
799
|
+
function BigWord({ target, typed, error = false, hideTarget = false }) {
|
|
800
|
+
const { stdout } = useStdout();
|
|
801
|
+
const cols = stdout?.columns ?? 80;
|
|
802
|
+
const chars = [...target];
|
|
803
|
+
const typedChars = [...typed];
|
|
804
|
+
const useBig = cols >= 60 && process.env.QWERTY_NO_BIGTEXT !== "1";
|
|
805
|
+
if (!useBig) {
|
|
806
|
+
return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", children: chars.map((ch, i) => {
|
|
807
|
+
const isTyped = i < typedChars.length;
|
|
808
|
+
const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
|
|
809
|
+
const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
|
|
810
|
+
return /* @__PURE__ */ jsxs(Text, { bold: isTyped, color, underline: !isTyped && error, children: [
|
|
811
|
+
display,
|
|
812
|
+
" "
|
|
813
|
+
] }, i);
|
|
814
|
+
}) });
|
|
815
|
+
}
|
|
816
|
+
return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", flexDirection: "row", children: chars.map((ch, i) => {
|
|
817
|
+
const isTyped = i < typedChars.length;
|
|
818
|
+
const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
|
|
819
|
+
const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
|
|
820
|
+
return /* @__PURE__ */ jsx5(BigText, { text: display, font: "tiny", colors: [color], space: false }, i);
|
|
821
|
+
}) });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/ui/screens/MainMenu.tsx
|
|
825
|
+
import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
826
|
+
function MainMenu({ cfg }) {
|
|
827
|
+
const [selected, setSelected] = useState4(0);
|
|
828
|
+
const { exit } = useApp();
|
|
829
|
+
const nav = useNav();
|
|
830
|
+
const audio = useAudioStatus();
|
|
831
|
+
const items = [
|
|
832
|
+
{
|
|
833
|
+
key: "p",
|
|
834
|
+
label: "Practice",
|
|
835
|
+
hint: cfg.defaultDict ? `start ${cfg.defaultDict}` : "pick a dictionary",
|
|
836
|
+
run: () => {
|
|
837
|
+
if (cfg.defaultDict) {
|
|
838
|
+
nav.navigate({
|
|
839
|
+
name: "practice",
|
|
840
|
+
params: { dictId: cfg.defaultDict, chapterIndex: 0, mode: cfg.defaultMode }
|
|
841
|
+
});
|
|
842
|
+
} else {
|
|
843
|
+
nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
{ key: "d", label: "Dictionaries", hint: "browse, pull, set default", run: () => nav.navigate({ name: "dict" }) },
|
|
848
|
+
{ key: "w", label: "Word lookup", hint: "search local dicts", run: () => nav.navigate({ name: "word" }) },
|
|
849
|
+
{ key: "s", label: "Stats", hint: "history & trends", run: () => nav.navigate({ name: "stats" }) },
|
|
850
|
+
{ key: "c", label: "Config", hint: "edit preferences", run: () => nav.navigate({ name: "config" }) },
|
|
851
|
+
{ key: "q", label: "Quit", hint: "Ctrl+C also exits", run: () => exit() }
|
|
852
|
+
];
|
|
853
|
+
useInput((input, key) => {
|
|
854
|
+
if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
|
|
855
|
+
if (key.downArrow) setSelected((i) => (i + 1) % items.length);
|
|
856
|
+
if (key.return) {
|
|
857
|
+
items[selected].run();
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
for (const it of items) {
|
|
861
|
+
if (input === it.key) {
|
|
862
|
+
it.run();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
|
|
868
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
869
|
+
/* @__PURE__ */ jsx6(Text2, { bold: true, color: PALETTE.accent, children: "qwerty" }),
|
|
870
|
+
/* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: " \xB7 typing practice for the terminal" })
|
|
871
|
+
] }),
|
|
872
|
+
/* @__PURE__ */ jsx6(Box2, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
|
|
873
|
+
const active = i === selected;
|
|
874
|
+
return /* @__PURE__ */ jsxs2(Box2, { children: [
|
|
875
|
+
/* @__PURE__ */ jsx6(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
876
|
+
/* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
|
|
877
|
+
"[",
|
|
878
|
+
it.key,
|
|
879
|
+
"]"
|
|
880
|
+
] }),
|
|
881
|
+
/* @__PURE__ */ jsx6(Text2, { children: " " }),
|
|
882
|
+
/* @__PURE__ */ jsx6(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: it.label.padEnd(14) }),
|
|
883
|
+
/* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: it.hint })
|
|
884
|
+
] }, it.key);
|
|
885
|
+
}) }),
|
|
886
|
+
/* @__PURE__ */ jsx6(Box2, { marginTop: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
|
|
887
|
+
"default dict: ",
|
|
888
|
+
cfg.defaultDict ?? "(none \u2014 pick one in Dictionaries)"
|
|
889
|
+
] }) }),
|
|
890
|
+
/* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump" }) }),
|
|
891
|
+
audio.warning && /* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.warning, children: [
|
|
892
|
+
"! ",
|
|
893
|
+
audio.warning
|
|
894
|
+
] }) })
|
|
895
|
+
] });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// src/ui/screens/PracticeScreen.tsx
|
|
899
|
+
import { useState as useState6, useEffect as useEffect5, useRef as useRef3 } from "react";
|
|
900
|
+
import { Box as Box3, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
|
|
901
|
+
import BigText2 from "ink-big-text";
|
|
902
|
+
|
|
452
903
|
// src/util/shuffle.ts
|
|
453
904
|
function shuffle(arr, rng = Math.random) {
|
|
454
905
|
const out = [...arr];
|
|
@@ -479,9 +930,6 @@ function chunkChapters(words, chapterSize) {
|
|
|
479
930
|
}
|
|
480
931
|
return chunks;
|
|
481
932
|
}
|
|
482
|
-
function chapterCount(totalWords, chapterSize) {
|
|
483
|
-
return Math.ceil(totalWords / chapterSize);
|
|
484
|
-
}
|
|
485
933
|
function buildPlaylist(chapter, mode, seed) {
|
|
486
934
|
if (mode === "random") {
|
|
487
935
|
const rng = seed === void 0 ? Math.random : mulberry32(seed);
|
|
@@ -490,160 +938,33 @@ function buildPlaylist(chapter, mode, seed) {
|
|
|
490
938
|
return chapter;
|
|
491
939
|
}
|
|
492
940
|
|
|
493
|
-
// src/domain/
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
ts: z3.string(),
|
|
497
|
-
dictId: z3.string(),
|
|
498
|
-
chapter: z3.number().int().nonnegative(),
|
|
499
|
-
mode: z3.string(),
|
|
500
|
-
wordCount: z3.number().int().nonnegative(),
|
|
501
|
-
errors: z3.number().int().nonnegative(),
|
|
502
|
-
durationMs: z3.number().int().nonnegative(),
|
|
503
|
-
perWordErrors: z3.record(z3.string(), z3.number().int().nonnegative()).default({})
|
|
504
|
-
});
|
|
505
|
-
async function appendSession(record) {
|
|
506
|
-
await appendJsonl(paths.stats, record);
|
|
507
|
-
}
|
|
508
|
-
async function loadSessions() {
|
|
509
|
-
const rows = await readJsonl(paths.stats);
|
|
510
|
-
return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
|
|
511
|
-
}
|
|
512
|
-
function computeWPM(record) {
|
|
513
|
-
if (record.durationMs === 0) return 0;
|
|
514
|
-
const minutes = record.durationMs / 6e4;
|
|
515
|
-
return Math.round(record.wordCount / minutes * 10) / 10;
|
|
941
|
+
// src/domain/input-buffer.ts
|
|
942
|
+
function initialState(target) {
|
|
943
|
+
return { target, typed: "", errorsThisWord: 0 };
|
|
516
944
|
}
|
|
517
|
-
function
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const min = Math.min(...values, 0);
|
|
541
|
-
const range = Math.max(1, max - min);
|
|
542
|
-
return values.map((v) => {
|
|
543
|
-
const idx = Math.floor((v - min) / range * (SPARK.length - 1));
|
|
544
|
-
return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
|
|
545
|
-
}).join("");
|
|
546
|
-
}
|
|
547
|
-
function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
|
|
548
|
-
const out = [];
|
|
549
|
-
const byDay = /* @__PURE__ */ new Map();
|
|
550
|
-
for (const s of sessions) {
|
|
551
|
-
const key = s.ts.slice(0, 10);
|
|
552
|
-
const arr = byDay.get(key) ?? [];
|
|
553
|
-
arr.push(s);
|
|
554
|
-
byDay.set(key, arr);
|
|
555
|
-
}
|
|
556
|
-
const cur = new Date(now);
|
|
557
|
-
cur.setUTCDate(cur.getUTCDate() - (days - 1));
|
|
558
|
-
for (let i = 0; i < days; i++) {
|
|
559
|
-
const key = cur.toISOString().slice(0, 10);
|
|
560
|
-
const todays = byDay.get(key) ?? [];
|
|
561
|
-
if (todays.length === 0) {
|
|
562
|
-
out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
|
|
563
|
-
} else {
|
|
564
|
-
const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
|
|
565
|
-
const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
|
|
566
|
-
out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
|
|
567
|
-
}
|
|
568
|
-
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
569
|
-
}
|
|
570
|
-
return out;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// src/domain/mistakes.ts
|
|
574
|
-
import { z as z4 } from "zod";
|
|
575
|
-
var MistakeBookSchema = z4.record(
|
|
576
|
-
z4.string(),
|
|
577
|
-
z4.object({
|
|
578
|
-
count: z4.number().int().nonnegative(),
|
|
579
|
-
lastSeen: z4.string(),
|
|
580
|
-
dictIds: z4.array(z4.string()).default([])
|
|
581
|
-
})
|
|
582
|
-
);
|
|
583
|
-
async function loadMistakes() {
|
|
584
|
-
const raw = await readJson(paths.mistakes);
|
|
585
|
-
if (!raw) return {};
|
|
586
|
-
const parsed = MistakeBookSchema.safeParse(raw);
|
|
587
|
-
if (!parsed.success) {
|
|
588
|
-
console.warn("Mistake book is corrupt; starting fresh");
|
|
589
|
-
return {};
|
|
590
|
-
}
|
|
591
|
-
return parsed.data;
|
|
592
|
-
}
|
|
593
|
-
async function saveMistakes(book) {
|
|
594
|
-
await writeJsonAtomic(paths.mistakes, book);
|
|
595
|
-
}
|
|
596
|
-
function bump(book, word, dictId, delta = 1) {
|
|
597
|
-
const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
|
|
598
|
-
const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
|
|
599
|
-
return {
|
|
600
|
-
...book,
|
|
601
|
-
[word]: {
|
|
602
|
-
count: prev.count + delta,
|
|
603
|
-
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
604
|
-
dictIds
|
|
605
|
-
}
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
function topN(book, n) {
|
|
609
|
-
return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// src/ui/screens/PracticeScreen.tsx
|
|
613
|
-
import { useState as useState2, useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
614
|
-
import { Box as Box4, Text as Text5, useApp as useApp2, useInput as useInput2 } from "ink";
|
|
615
|
-
|
|
616
|
-
// src/ui/hooks/useWordLoop.ts
|
|
617
|
-
import { useEffect, useReducer, useRef, useState } from "react";
|
|
618
|
-
import { useInput, useApp } from "ink";
|
|
619
|
-
|
|
620
|
-
// src/domain/input-buffer.ts
|
|
621
|
-
function initialState(target) {
|
|
622
|
-
return { target, typed: "", errorsThisWord: 0 };
|
|
623
|
-
}
|
|
624
|
-
function reduce(state, ev) {
|
|
625
|
-
switch (ev.type) {
|
|
626
|
-
case "reset":
|
|
627
|
-
return { state: { ...state, typed: "" }, effect: "none" };
|
|
628
|
-
case "backspace": {
|
|
629
|
-
if (state.typed.length === 0) return { state, effect: "none" };
|
|
630
|
-
return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
|
|
631
|
-
}
|
|
632
|
-
case "char": {
|
|
633
|
-
const candidate = state.typed + ev.ch;
|
|
634
|
-
const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
|
|
635
|
-
if (candidate === targetUpToCandidate) {
|
|
636
|
-
if (candidate.length === state.target.length) {
|
|
637
|
-
return { state: { ...state, typed: candidate }, effect: "correct" };
|
|
638
|
-
}
|
|
639
|
-
return { state: { ...state, typed: candidate }, effect: "progress" };
|
|
640
|
-
}
|
|
641
|
-
return {
|
|
642
|
-
state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
|
|
643
|
-
effect: "wrong"
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
}
|
|
945
|
+
function reduce(state, ev) {
|
|
946
|
+
switch (ev.type) {
|
|
947
|
+
case "reset":
|
|
948
|
+
return { state: { ...state, typed: "" }, effect: "none" };
|
|
949
|
+
case "backspace": {
|
|
950
|
+
if (state.typed.length === 0) return { state, effect: "none" };
|
|
951
|
+
return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
|
|
952
|
+
}
|
|
953
|
+
case "char": {
|
|
954
|
+
const candidate = state.typed + ev.ch;
|
|
955
|
+
const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
|
|
956
|
+
if (candidate === targetUpToCandidate) {
|
|
957
|
+
if (candidate.length === state.target.length) {
|
|
958
|
+
return { state: { ...state, typed: candidate }, effect: "correct" };
|
|
959
|
+
}
|
|
960
|
+
return { state: { ...state, typed: candidate }, effect: "progress" };
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
|
|
964
|
+
effect: "wrong"
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
}
|
|
647
968
|
}
|
|
648
969
|
|
|
649
970
|
// src/domain/session.ts
|
|
@@ -697,6 +1018,35 @@ function feedSession(session, ev, now = Date.now()) {
|
|
|
697
1018
|
effect
|
|
698
1019
|
};
|
|
699
1020
|
}
|
|
1021
|
+
function skipSession(session, now = Date.now()) {
|
|
1022
|
+
if (!session.current) return { session, effect: "none" };
|
|
1023
|
+
const result = {
|
|
1024
|
+
word: session.current.input.target,
|
|
1025
|
+
errors: 0,
|
|
1026
|
+
durationMs: now - session.current.wordStartedAt,
|
|
1027
|
+
skipped: true
|
|
1028
|
+
};
|
|
1029
|
+
const nextIndex = session.current.wordIndex + 1;
|
|
1030
|
+
const results = [...session.results, result];
|
|
1031
|
+
if (nextIndex >= session.playlist.length) {
|
|
1032
|
+
return {
|
|
1033
|
+
session: { ...session, results, current: null, finishedAt: now },
|
|
1034
|
+
effect: "skipped"
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
session: {
|
|
1039
|
+
...session,
|
|
1040
|
+
results,
|
|
1041
|
+
current: {
|
|
1042
|
+
wordIndex: nextIndex,
|
|
1043
|
+
wordStartedAt: now,
|
|
1044
|
+
input: initialState(session.playlist[nextIndex].name)
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
effect: "skipped"
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
700
1050
|
function sessionSummary(session) {
|
|
701
1051
|
const errors = session.results.reduce((a, r) => a + r.errors, 0);
|
|
702
1052
|
const durationMs = (session.finishedAt ?? Date.now()) - session.startedAt;
|
|
@@ -707,11 +1057,56 @@ function sessionSummary(session) {
|
|
|
707
1057
|
return { wordCount: session.results.length, errors, durationMs, perWordErrors };
|
|
708
1058
|
}
|
|
709
1059
|
|
|
1060
|
+
// src/domain/mistakes.ts
|
|
1061
|
+
import { z as z3 } from "zod";
|
|
1062
|
+
var MistakeBookSchema = z3.record(
|
|
1063
|
+
z3.string(),
|
|
1064
|
+
z3.object({
|
|
1065
|
+
count: z3.number().int().nonnegative(),
|
|
1066
|
+
lastSeen: z3.string(),
|
|
1067
|
+
dictIds: z3.array(z3.string()).default([])
|
|
1068
|
+
})
|
|
1069
|
+
);
|
|
1070
|
+
async function loadMistakes() {
|
|
1071
|
+
const raw = await readJson(paths.mistakes);
|
|
1072
|
+
if (!raw) return {};
|
|
1073
|
+
const parsed = MistakeBookSchema.safeParse(raw);
|
|
1074
|
+
if (!parsed.success) {
|
|
1075
|
+
console.warn("Mistake book is corrupt; starting fresh");
|
|
1076
|
+
return {};
|
|
1077
|
+
}
|
|
1078
|
+
return parsed.data;
|
|
1079
|
+
}
|
|
1080
|
+
async function saveMistakes(book) {
|
|
1081
|
+
await writeJsonAtomic(paths.mistakes, book);
|
|
1082
|
+
}
|
|
1083
|
+
function bump(book, word, dictId, delta = 1) {
|
|
1084
|
+
const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
|
|
1085
|
+
const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
|
|
1086
|
+
return {
|
|
1087
|
+
...book,
|
|
1088
|
+
[word]: {
|
|
1089
|
+
count: prev.count + delta,
|
|
1090
|
+
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1091
|
+
dictIds
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function topN(book, n) {
|
|
1096
|
+
return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
710
1099
|
// src/ui/hooks/useWordLoop.ts
|
|
1100
|
+
import { useEffect as useEffect3, useReducer, useRef, useState as useState5 } from "react";
|
|
1101
|
+
import { useInput as useInput2, useApp as useApp2 } from "ink";
|
|
711
1102
|
function reducer(state, action) {
|
|
712
1103
|
if (action.type === "start") {
|
|
713
1104
|
return { session: startSession(action.playlist, action.now), lastEffect: null };
|
|
714
1105
|
}
|
|
1106
|
+
if (action.type === "skip") {
|
|
1107
|
+
const r = skipSession(state.session, action.now);
|
|
1108
|
+
return { session: r.session, lastEffect: r.effect };
|
|
1109
|
+
}
|
|
715
1110
|
if (action.type === "event") {
|
|
716
1111
|
if (action.key.backspace || action.key.delete) {
|
|
717
1112
|
const r = feedSession(state.session, { type: "backspace" }, action.now);
|
|
@@ -730,20 +1125,25 @@ function reducer(state, action) {
|
|
|
730
1125
|
}
|
|
731
1126
|
return state;
|
|
732
1127
|
}
|
|
733
|
-
function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true }) {
|
|
1128
|
+
function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
|
|
734
1129
|
const [state, dispatch] = useReducer(reducer, void 0, () => ({
|
|
735
1130
|
session: startSession(playlist, Date.now()),
|
|
736
1131
|
lastEffect: null
|
|
737
1132
|
}));
|
|
738
1133
|
const completedRef = useRef(false);
|
|
739
|
-
const [tick, setTick] =
|
|
740
|
-
const { exit } =
|
|
741
|
-
|
|
1134
|
+
const [tick, setTick] = useState5(0);
|
|
1135
|
+
const { exit } = useApp2();
|
|
1136
|
+
useInput2(
|
|
742
1137
|
(input, key) => {
|
|
743
1138
|
if (key.ctrl && input === "c") {
|
|
744
1139
|
exit();
|
|
745
1140
|
return;
|
|
746
1141
|
}
|
|
1142
|
+
if (key.ctrl && input === "n") {
|
|
1143
|
+
onSkip?.();
|
|
1144
|
+
dispatch({ type: "skip", now: Date.now() });
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
747
1147
|
if (key.escape) {
|
|
748
1148
|
onEscape?.();
|
|
749
1149
|
return;
|
|
@@ -763,13 +1163,13 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true })
|
|
|
763
1163
|
},
|
|
764
1164
|
{ isActive: enabled }
|
|
765
1165
|
);
|
|
766
|
-
|
|
1166
|
+
useEffect3(() => {
|
|
767
1167
|
if (state.session.finishedAt !== null && !completedRef.current) {
|
|
768
1168
|
completedRef.current = true;
|
|
769
1169
|
onComplete(state.session);
|
|
770
1170
|
}
|
|
771
1171
|
}, [state.session, onComplete]);
|
|
772
|
-
|
|
1172
|
+
useEffect3(() => {
|
|
773
1173
|
if (state.session.finishedAt !== null) return;
|
|
774
1174
|
const id = setInterval(() => setTick((t) => t + 1), 1e3);
|
|
775
1175
|
return () => clearInterval(id);
|
|
@@ -778,172 +1178,14 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true })
|
|
|
778
1178
|
}
|
|
779
1179
|
|
|
780
1180
|
// src/ui/hooks/useAudio.ts
|
|
781
|
-
import { useEffect as
|
|
782
|
-
|
|
783
|
-
// src/infra/audio.ts
|
|
784
|
-
import { spawn } from "child_process";
|
|
785
|
-
import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
786
|
-
import { join as join3, dirname as dirname2 } from "path";
|
|
787
|
-
import { request as request2 } from "undici";
|
|
788
|
-
import PQueue from "p-queue";
|
|
789
|
-
var CANDIDATES = [
|
|
790
|
-
{ kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
|
|
791
|
-
{ kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
|
|
792
|
-
{ kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
|
|
793
|
-
{ kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
|
|
794
|
-
{ kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
|
|
795
|
-
{
|
|
796
|
-
kind: "powershell",
|
|
797
|
-
cmd: "powershell",
|
|
798
|
-
args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
|
|
799
|
-
supports: "wav"
|
|
800
|
-
}
|
|
801
|
-
];
|
|
802
|
-
var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
|
|
803
|
-
var runtime = null;
|
|
804
|
-
async function isExecutable(cmd) {
|
|
805
|
-
return new Promise((resolve2) => {
|
|
806
|
-
const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
|
|
807
|
-
probe.on("error", () => resolve2(false));
|
|
808
|
-
probe.on("exit", () => resolve2(true));
|
|
809
|
-
setTimeout(() => {
|
|
810
|
-
probe.kill();
|
|
811
|
-
resolve2(false);
|
|
812
|
-
}, 500);
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
async function detect() {
|
|
816
|
-
let wav = null;
|
|
817
|
-
let mp3 = null;
|
|
818
|
-
for (const p of CANDIDATES) {
|
|
819
|
-
if (!await isExecutable(p.cmd)) continue;
|
|
820
|
-
if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
|
|
821
|
-
if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
|
|
822
|
-
if (wav && mp3) break;
|
|
823
|
-
}
|
|
824
|
-
return { wav, mp3 };
|
|
825
|
-
}
|
|
826
|
-
async function initAudio(disabledByConfig) {
|
|
827
|
-
if (runtime) return runtime;
|
|
828
|
-
if (disabledByConfig) {
|
|
829
|
-
runtime = {
|
|
830
|
-
disabled: true,
|
|
831
|
-
wavPlayer: null,
|
|
832
|
-
mp3Player: null,
|
|
833
|
-
warning: null,
|
|
834
|
-
keyQueue: new PQueue({ concurrency: 1 }),
|
|
835
|
-
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
836
|
-
pronQueue: new PQueue({ concurrency: 1 })
|
|
837
|
-
};
|
|
838
|
-
return runtime;
|
|
839
|
-
}
|
|
840
|
-
const { wav, mp3 } = await detect();
|
|
841
|
-
let warning = null;
|
|
842
|
-
if (!wav && !mp3) {
|
|
843
|
-
warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
|
|
844
|
-
} else if (!mp3) {
|
|
845
|
-
warning = "No MP3 player found; word pronunciations will be skipped.";
|
|
846
|
-
}
|
|
847
|
-
runtime = {
|
|
848
|
-
disabled: !wav && !mp3,
|
|
849
|
-
wavPlayer: wav,
|
|
850
|
-
mp3Player: mp3,
|
|
851
|
-
warning,
|
|
852
|
-
keyQueue: new PQueue({ concurrency: 2 }),
|
|
853
|
-
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
854
|
-
pronQueue: new PQueue({ concurrency: 1 })
|
|
855
|
-
};
|
|
856
|
-
return runtime;
|
|
857
|
-
}
|
|
858
|
-
function spawnPlay(player, file) {
|
|
859
|
-
try {
|
|
860
|
-
const child = spawn(player.cmd, player.args(file), {
|
|
861
|
-
detached: true,
|
|
862
|
-
stdio: "ignore"
|
|
863
|
-
});
|
|
864
|
-
child.on("error", () => {
|
|
865
|
-
});
|
|
866
|
-
child.unref();
|
|
867
|
-
} catch {
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
function playFile(file, kind) {
|
|
871
|
-
if (!runtime || runtime.disabled) return;
|
|
872
|
-
const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
|
|
873
|
-
if (!player) return;
|
|
874
|
-
spawnPlay(player, file);
|
|
875
|
-
}
|
|
876
|
-
function playKeystroke() {
|
|
877
|
-
if (!runtime || runtime.disabled) return;
|
|
878
|
-
if (runtime.keyQueue.size >= 2) return;
|
|
879
|
-
void runtime.keyQueue.add(async () => {
|
|
880
|
-
playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
|
|
881
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
function playCorrect() {
|
|
885
|
-
if (!runtime || runtime.disabled) return;
|
|
886
|
-
void runtime.feedbackQueue.add(async () => {
|
|
887
|
-
playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
|
|
888
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
function playWrong() {
|
|
892
|
-
if (!runtime || runtime.disabled) return;
|
|
893
|
-
void runtime.feedbackQueue.add(async () => {
|
|
894
|
-
playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
|
|
895
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
async function downloadPronunciation(word, accent) {
|
|
899
|
-
const cacheFile = paths.audioCache(word, accent);
|
|
900
|
-
if (await exists(cacheFile)) return cacheFile;
|
|
901
|
-
await mkdir3(dirname2(cacheFile), { recursive: true });
|
|
902
|
-
const type = accent === "us" ? 2 : 1;
|
|
903
|
-
const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
|
|
904
|
-
try {
|
|
905
|
-
const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
|
|
906
|
-
if (res.statusCode >= 400) return null;
|
|
907
|
-
const buf = Buffer.from(await res.body.arrayBuffer());
|
|
908
|
-
if (buf.length < 1024) return null;
|
|
909
|
-
const tmp = `${cacheFile}.tmp`;
|
|
910
|
-
await writeFile2(tmp, buf);
|
|
911
|
-
await rename2(tmp, cacheFile);
|
|
912
|
-
return cacheFile;
|
|
913
|
-
} catch {
|
|
914
|
-
return null;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
async function playPronunciation(word, accent) {
|
|
918
|
-
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
919
|
-
await ensureDirs();
|
|
920
|
-
await runtime.pronQueue.add(async () => {
|
|
921
|
-
const file = await downloadPronunciation(word, accent);
|
|
922
|
-
if (file) playFile(file, "mp3");
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
async function prefetchPronunciation(word, accent) {
|
|
926
|
-
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
927
|
-
await ensureDirs();
|
|
928
|
-
void runtime.pronQueue.add(async () => {
|
|
929
|
-
await downloadPronunciation(word, accent);
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
function audioWarning() {
|
|
933
|
-
return runtime?.warning ?? null;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// src/ui/hooks/useAudio.ts
|
|
1181
|
+
import { useEffect as useEffect4, useRef as useRef2 } from "react";
|
|
937
1182
|
function useAudio(opts) {
|
|
938
1183
|
const initedRef = useRef2(false);
|
|
939
|
-
|
|
1184
|
+
useEffect4(() => {
|
|
940
1185
|
if (initedRef.current) return;
|
|
941
1186
|
initedRef.current = true;
|
|
942
|
-
initAudio(!opts.enabled).
|
|
943
|
-
|
|
944
|
-
if (w) opts.onWarning?.(w);
|
|
945
|
-
}).catch(() => void 0);
|
|
946
|
-
}, [opts]);
|
|
1187
|
+
initAudio(!opts.enabled).catch(() => void 0);
|
|
1188
|
+
}, [opts.enabled]);
|
|
947
1189
|
return {
|
|
948
1190
|
keystroke: () => opts.enabled && playKeystroke(),
|
|
949
1191
|
correct: () => opts.enabled && playCorrect(),
|
|
@@ -959,267 +1201,1082 @@ function useAudio(opts) {
|
|
|
959
1201
|
};
|
|
960
1202
|
}
|
|
961
1203
|
|
|
962
|
-
// src/ui/
|
|
963
|
-
import {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1204
|
+
// src/ui/hooks/useSessionPersistence.ts
|
|
1205
|
+
import { useCallback as useCallback3 } from "react";
|
|
1206
|
+
|
|
1207
|
+
// src/domain/stats.ts
|
|
1208
|
+
import { z as z4 } from "zod";
|
|
1209
|
+
var SessionRecordSchema = z4.object({
|
|
1210
|
+
ts: z4.string(),
|
|
1211
|
+
dictId: z4.string(),
|
|
1212
|
+
chapter: z4.number().int().nonnegative(),
|
|
1213
|
+
mode: z4.string(),
|
|
1214
|
+
wordCount: z4.number().int().nonnegative(),
|
|
1215
|
+
errors: z4.number().int().nonnegative(),
|
|
1216
|
+
durationMs: z4.number().int().nonnegative(),
|
|
1217
|
+
perWordErrors: z4.record(z4.string(), z4.number().int().nonnegative()).default({})
|
|
1218
|
+
});
|
|
1219
|
+
async function appendSession(record) {
|
|
1220
|
+
await appendJsonl(paths.stats, record);
|
|
1221
|
+
}
|
|
1222
|
+
async function loadSessions() {
|
|
1223
|
+
const rows = await readJsonl(paths.stats);
|
|
1224
|
+
return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
|
|
1225
|
+
}
|
|
1226
|
+
function computeWPM(record) {
|
|
1227
|
+
if (record.durationMs === 0) return 0;
|
|
1228
|
+
const minutes = record.durationMs / 6e4;
|
|
1229
|
+
return Math.round(record.wordCount / minutes * 10) / 10;
|
|
1230
|
+
}
|
|
1231
|
+
function accuracy(record) {
|
|
1232
|
+
if (record.wordCount === 0) return 1;
|
|
1233
|
+
const wordsWithErrors = Object.keys(record.perWordErrors).length;
|
|
1234
|
+
return Math.max(0, Math.min(1, (record.wordCount - wordsWithErrors) / record.wordCount));
|
|
1235
|
+
}
|
|
1236
|
+
function dailyStreak(sessions, now = /* @__PURE__ */ new Date()) {
|
|
1237
|
+
if (sessions.length === 0) return 0;
|
|
1238
|
+
const days = /* @__PURE__ */ new Set();
|
|
1239
|
+
for (const s of sessions) days.add(s.ts.slice(0, 10));
|
|
1240
|
+
let streak = 0;
|
|
1241
|
+
const cur = new Date(now);
|
|
1242
|
+
while (true) {
|
|
1243
|
+
const key = cur.toISOString().slice(0, 10);
|
|
1244
|
+
if (!days.has(key)) break;
|
|
1245
|
+
streak++;
|
|
1246
|
+
cur.setUTCDate(cur.getUTCDate() - 1);
|
|
1247
|
+
}
|
|
1248
|
+
return streak;
|
|
1249
|
+
}
|
|
1250
|
+
var SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
1251
|
+
function sparkline(values) {
|
|
1252
|
+
if (values.length === 0) return "";
|
|
1253
|
+
const max = Math.max(...values, 1);
|
|
1254
|
+
const min = Math.min(...values, 0);
|
|
1255
|
+
const range = Math.max(1, max - min);
|
|
1256
|
+
return values.map((v) => {
|
|
1257
|
+
const idx = Math.floor((v - min) / range * (SPARK.length - 1));
|
|
1258
|
+
return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
|
|
1259
|
+
}).join("");
|
|
1260
|
+
}
|
|
1261
|
+
function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
|
|
1262
|
+
const out = [];
|
|
1263
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
1264
|
+
for (const s of sessions) {
|
|
1265
|
+
const key = s.ts.slice(0, 10);
|
|
1266
|
+
const arr = byDay.get(key) ?? [];
|
|
1267
|
+
arr.push(s);
|
|
1268
|
+
byDay.set(key, arr);
|
|
1269
|
+
}
|
|
1270
|
+
const cur = new Date(now);
|
|
1271
|
+
cur.setUTCDate(cur.getUTCDate() - (days - 1));
|
|
1272
|
+
for (let i = 0; i < days; i++) {
|
|
1273
|
+
const key = cur.toISOString().slice(0, 10);
|
|
1274
|
+
const todays = byDay.get(key) ?? [];
|
|
1275
|
+
if (todays.length === 0) {
|
|
1276
|
+
out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
|
|
1277
|
+
} else {
|
|
1278
|
+
const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
|
|
1279
|
+
const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
|
|
1280
|
+
out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
|
|
1281
|
+
}
|
|
1282
|
+
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
1283
|
+
}
|
|
1284
|
+
return out;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/ui/hooks/useSessionPersistence.ts
|
|
1288
|
+
function useSessionPersistence(meta) {
|
|
1289
|
+
return useCallback3(
|
|
1290
|
+
async (summary) => {
|
|
1291
|
+
const rec = {
|
|
1292
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1293
|
+
dictId: meta.dictId,
|
|
1294
|
+
chapter: meta.chapterIndex,
|
|
1295
|
+
mode: meta.mode,
|
|
1296
|
+
wordCount: summary.wordCount,
|
|
1297
|
+
errors: summary.errors,
|
|
1298
|
+
durationMs: summary.durationMs,
|
|
1299
|
+
perWordErrors: summary.perWordErrors
|
|
1300
|
+
};
|
|
1301
|
+
await appendSession(rec);
|
|
1302
|
+
const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
|
|
1303
|
+
if (dirty.length === 0) return;
|
|
1304
|
+
let book = await loadMistakes();
|
|
1305
|
+
for (const [word, n] of dirty) book = bump(book, word, meta.dictId, n);
|
|
1306
|
+
await saveMistakes(book);
|
|
1307
|
+
},
|
|
1308
|
+
[meta.dictId, meta.chapterIndex, meta.mode]
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/ui/screens/PracticeScreen.tsx
|
|
1313
|
+
import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
1314
|
+
function PracticeScreen({ params }) {
|
|
1315
|
+
const { dictId, chapterIndex, mode } = params;
|
|
1316
|
+
const { cfg } = useAppState();
|
|
1317
|
+
const nav = useNav();
|
|
1318
|
+
const [phase, setPhase] = useState6("loading");
|
|
1319
|
+
const [loaded, setLoaded] = useState6(null);
|
|
1320
|
+
const [errorMsg, setErrorMsg] = useState6(null);
|
|
1321
|
+
useEffect5(() => {
|
|
1322
|
+
let cancelled = false;
|
|
1323
|
+
setPhase("loading");
|
|
1324
|
+
setLoaded(null);
|
|
1325
|
+
setErrorMsg(null);
|
|
1326
|
+
(async () => {
|
|
1327
|
+
try {
|
|
1328
|
+
const words = await ensureDictionary(dictId);
|
|
1329
|
+
if (cancelled) return;
|
|
1330
|
+
if (mode === "review") {
|
|
1331
|
+
const book = await loadMistakes();
|
|
1332
|
+
if (cancelled) return;
|
|
1333
|
+
const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
|
|
1334
|
+
if (reviewWords.length === 0) {
|
|
1335
|
+
setErrorMsg("No mistakes to review yet. Practice some chapters first.");
|
|
1336
|
+
setPhase("error");
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
setLoaded({ playlist: reviewWords, totalChapters: 1 });
|
|
1340
|
+
setPhase("typing");
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const chapters = chunkChapters(words, cfg.chapterSize);
|
|
1344
|
+
if (chapters.length === 0) {
|
|
1345
|
+
setErrorMsg(`Dictionary ${dictId} is empty.`);
|
|
1346
|
+
setPhase("error");
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const idx = Math.max(0, Math.min(chapters.length - 1, chapterIndex));
|
|
1350
|
+
const playlist = buildPlaylist(chapters[idx], mode);
|
|
1351
|
+
setLoaded({ playlist, totalChapters: chapters.length });
|
|
1352
|
+
setPhase("typing");
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
if (cancelled) return;
|
|
1355
|
+
setErrorMsg(err.message);
|
|
1356
|
+
setPhase("error");
|
|
1357
|
+
}
|
|
1358
|
+
})();
|
|
1359
|
+
return () => {
|
|
1360
|
+
cancelled = true;
|
|
1361
|
+
};
|
|
1362
|
+
}, [dictId, chapterIndex, mode, cfg.chapterSize]);
|
|
1363
|
+
if (phase === "loading") {
|
|
1364
|
+
return /* @__PURE__ */ jsx7(Centered, { text: "loading\u2026", color: PALETTE.muted });
|
|
1365
|
+
}
|
|
1366
|
+
if (phase === "error") {
|
|
1367
|
+
return /* @__PURE__ */ jsx7(ErrorView, { msg: errorMsg ?? "Unknown error" });
|
|
1368
|
+
}
|
|
1369
|
+
if (!loaded) return null;
|
|
1370
|
+
return /* @__PURE__ */ jsx7(
|
|
1371
|
+
PracticeRunner,
|
|
1372
|
+
{
|
|
1373
|
+
params,
|
|
1374
|
+
loaded,
|
|
1375
|
+
phase,
|
|
1376
|
+
setPhase
|
|
1377
|
+
},
|
|
1378
|
+
`${dictId}-${chapterIndex}-${mode}`
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
function PracticeRunner({
|
|
1382
|
+
params,
|
|
1383
|
+
loaded,
|
|
1384
|
+
phase,
|
|
1385
|
+
setPhase
|
|
1386
|
+
}) {
|
|
1387
|
+
const { dictId, chapterIndex, mode } = params;
|
|
1388
|
+
const { cfg } = useAppState();
|
|
1389
|
+
const nav = useNav();
|
|
1390
|
+
const { exit } = useApp3();
|
|
1391
|
+
const goBack = () => nav.stack.length > 1 ? nav.back() : exit();
|
|
1392
|
+
const persist = useSessionPersistence({ dictId, chapterIndex, mode });
|
|
1393
|
+
const audio = useAudio({
|
|
1394
|
+
enabled: cfg.sounds.master,
|
|
1395
|
+
accent: cfg.accent,
|
|
1396
|
+
autoplayPronunciation: cfg.autoplayPronunciation
|
|
1397
|
+
});
|
|
1398
|
+
const finishedRef = useRef3(false);
|
|
1399
|
+
const lastEffectRef = useRef3(null);
|
|
1400
|
+
const lastIndexRef = useRef3(-1);
|
|
1401
|
+
const { session, lastEffect, tick } = useWordLoop({
|
|
1402
|
+
playlist: loaded.playlist,
|
|
1403
|
+
enabled: phase === "typing",
|
|
1404
|
+
onComplete: (s) => {
|
|
1405
|
+
if (finishedRef.current) return;
|
|
1406
|
+
finishedRef.current = true;
|
|
1407
|
+
setPhase("summary");
|
|
1408
|
+
Promise.resolve(persist(sessionSummary(s))).catch((err) => {
|
|
1409
|
+
console.error("Failed to persist session:", err);
|
|
1410
|
+
});
|
|
1411
|
+
},
|
|
1412
|
+
onEscape: () => setPhase(phase === "paused" ? "typing" : "paused"),
|
|
1413
|
+
onTab: () => {
|
|
1414
|
+
const cur = session.current ? loaded.playlist[session.current.wordIndex] : void 0;
|
|
1415
|
+
if (cur) void audio.pronounce(cur.name);
|
|
972
1416
|
}
|
|
973
|
-
|
|
974
|
-
|
|
1417
|
+
});
|
|
1418
|
+
useEffect5(() => {
|
|
1419
|
+
if (lastEffect === null) return;
|
|
1420
|
+
if (lastEffect === lastEffectRef.current) return;
|
|
1421
|
+
lastEffectRef.current = lastEffect;
|
|
1422
|
+
if (lastEffect === "wrong" && cfg.sounds.feedback) audio.wrong();
|
|
1423
|
+
if (lastEffect === "progress" && cfg.sounds.keystroke) audio.keystroke();
|
|
1424
|
+
if (lastEffect === "correct") {
|
|
1425
|
+
if (cfg.sounds.feedback) audio.correct();
|
|
1426
|
+
if (cfg.sounds.keystroke) audio.keystroke();
|
|
975
1427
|
}
|
|
976
|
-
|
|
977
|
-
|
|
1428
|
+
}, [lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
|
|
1429
|
+
useEffect5(() => {
|
|
1430
|
+
const idx = session.current?.wordIndex ?? -1;
|
|
1431
|
+
if (idx === -1) return;
|
|
1432
|
+
if (idx === lastIndexRef.current) return;
|
|
1433
|
+
lastIndexRef.current = idx;
|
|
1434
|
+
const cur = loaded.playlist[idx];
|
|
1435
|
+
const next = loaded.playlist[idx + 1];
|
|
1436
|
+
if (cur && cfg.autoplayPronunciation) audio.pronounce(cur.name);
|
|
1437
|
+
if (next) audio.prefetch(next.name);
|
|
1438
|
+
}, [session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
|
|
1439
|
+
void tick;
|
|
1440
|
+
useInput3(
|
|
1441
|
+
(input) => {
|
|
1442
|
+
if (input === "r") setPhase("typing");
|
|
1443
|
+
if (input === "q") goBack();
|
|
1444
|
+
},
|
|
1445
|
+
{ isActive: phase === "paused" }
|
|
1446
|
+
);
|
|
1447
|
+
useInput3(
|
|
1448
|
+
(input) => {
|
|
1449
|
+
if (input === "q") {
|
|
1450
|
+
goBack();
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
if (input === "n") {
|
|
1454
|
+
const nextIdx = chapterIndex + 1;
|
|
1455
|
+
if (mode === "loop") {
|
|
1456
|
+
nav.replace({ name: "practice", params: { dictId, chapterIndex, mode } });
|
|
1457
|
+
} else if (mode === "review" || nextIdx >= loaded.totalChapters) {
|
|
1458
|
+
goBack();
|
|
1459
|
+
} else {
|
|
1460
|
+
nav.replace({ name: "practice", params: { dictId, chapterIndex: nextIdx, mode } });
|
|
1461
|
+
}
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (input === "m") {
|
|
1465
|
+
nav.replace({ name: "practice", params: { dictId, chapterIndex: 0, mode: "review" } });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
},
|
|
1469
|
+
{ isActive: phase === "summary" }
|
|
1470
|
+
);
|
|
1471
|
+
if (phase === "paused") return /* @__PURE__ */ jsx7(PausedView, {});
|
|
1472
|
+
if (phase === "summary") {
|
|
1473
|
+
return /* @__PURE__ */ jsx7(
|
|
1474
|
+
SummaryView,
|
|
1475
|
+
{
|
|
1476
|
+
dictId,
|
|
1477
|
+
chapterIndex,
|
|
1478
|
+
totalChapters: loaded.totalChapters,
|
|
1479
|
+
mode,
|
|
1480
|
+
summary: sessionSummary(session)
|
|
1481
|
+
}
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
const currentWord = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
|
|
1485
|
+
const inputState = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
|
|
1486
|
+
const elapsedMs = Date.now() - session.startedAt;
|
|
1487
|
+
const completed = session.results.length;
|
|
1488
|
+
const errors = session.results.reduce((a, r) => a + r.errors, 0);
|
|
1489
|
+
const minutes = elapsedMs / 6e4;
|
|
1490
|
+
const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
|
|
1491
|
+
return /* @__PURE__ */ jsx7(
|
|
1492
|
+
TypingLayout,
|
|
1493
|
+
{
|
|
1494
|
+
dictId,
|
|
1495
|
+
chapterIndex,
|
|
1496
|
+
totalChapters: loaded.totalChapters,
|
|
1497
|
+
mode,
|
|
1498
|
+
accent: cfg.accent,
|
|
1499
|
+
completed,
|
|
1500
|
+
total: loaded.playlist.length,
|
|
1501
|
+
errors,
|
|
1502
|
+
wpm,
|
|
1503
|
+
elapsedMs,
|
|
1504
|
+
target: currentWord?.name ?? "",
|
|
1505
|
+
typed: inputState.typed,
|
|
1506
|
+
flashError: lastEffect === "wrong",
|
|
1507
|
+
hideTarget: mode === "dictation",
|
|
1508
|
+
phonetic: pickPhonetic(currentWord, cfg.accent),
|
|
1509
|
+
translation: currentWord?.trans ?? []
|
|
1510
|
+
}
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
function pickPhonetic(word, accent) {
|
|
1514
|
+
if (!word) return null;
|
|
1515
|
+
const p = accent === "us" ? word.usphone : word.ukphone;
|
|
1516
|
+
return p ? `/${p}/` : null;
|
|
1517
|
+
}
|
|
1518
|
+
function fmtTime(ms) {
|
|
1519
|
+
const total = Math.floor(ms / 1e3);
|
|
1520
|
+
const m = Math.floor(total / 60);
|
|
1521
|
+
const s = total % 60;
|
|
1522
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
1523
|
+
}
|
|
1524
|
+
function TypingLayout(props) {
|
|
1525
|
+
const progressFrac = props.total === 0 ? 0 : props.completed / props.total;
|
|
1526
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1527
|
+
/* @__PURE__ */ jsx7(
|
|
1528
|
+
StatusBar,
|
|
1529
|
+
{
|
|
1530
|
+
dictId: props.dictId,
|
|
1531
|
+
chapterIndex: props.chapterIndex,
|
|
1532
|
+
totalChapters: props.totalChapters,
|
|
1533
|
+
mode: props.mode,
|
|
1534
|
+
accent: props.accent,
|
|
1535
|
+
completed: props.completed,
|
|
1536
|
+
total: props.total,
|
|
1537
|
+
elapsedMs: props.elapsedMs
|
|
1538
|
+
}
|
|
1539
|
+
),
|
|
1540
|
+
/* @__PURE__ */ jsxs3(Box3, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
|
|
1541
|
+
/* @__PURE__ */ jsx7(
|
|
1542
|
+
BigWord,
|
|
1543
|
+
{
|
|
1544
|
+
target: props.target,
|
|
1545
|
+
typed: props.typed,
|
|
1546
|
+
error: props.flashError,
|
|
1547
|
+
hideTarget: props.hideTarget
|
|
1548
|
+
}
|
|
1549
|
+
),
|
|
1550
|
+
props.phonetic && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
|
|
1551
|
+
props.translation.length > 0 && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((t, i) => /* @__PURE__ */ jsx7(Text3, { color: PALETTE.primary, children: t }, i)) })
|
|
1552
|
+
] }),
|
|
1553
|
+
/* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
1554
|
+
/* @__PURE__ */ jsx7(ProgressBar, { frac: progressFrac }),
|
|
1555
|
+
/* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
|
|
1556
|
+
props.completed,
|
|
1557
|
+
"/",
|
|
1558
|
+
props.total,
|
|
1559
|
+
" \xB7 ",
|
|
1560
|
+
fmtTime(props.elapsedMs),
|
|
1561
|
+
" \xB7 ",
|
|
1562
|
+
props.wpm,
|
|
1563
|
+
" wpm \xB7 ",
|
|
1564
|
+
props.errors,
|
|
1565
|
+
" errors"
|
|
1566
|
+
] }) }),
|
|
1567
|
+
/* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay \xB7 Ctrl+C quit" }) })
|
|
1568
|
+
] })
|
|
1569
|
+
] });
|
|
1570
|
+
}
|
|
1571
|
+
function StatusBar(props) {
|
|
1572
|
+
const left = props.mode === "review" ? `${props.dictId} \xB7 review \xB7 ${props.accent}` : `${props.dictId} \xB7 ch ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode} \xB7 ${props.accent}`;
|
|
1573
|
+
const right = `${props.completed}/${props.total} \xB7 ${fmtTime(props.elapsedMs)}`;
|
|
1574
|
+
return /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
1575
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: left }),
|
|
1576
|
+
/* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
|
|
1577
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: right })
|
|
1578
|
+
] });
|
|
1579
|
+
}
|
|
1580
|
+
function ProgressBar({ frac }) {
|
|
1581
|
+
const cols = process.stdout.columns ?? 80;
|
|
1582
|
+
const width = Math.max(20, Math.min(72, cols - 16));
|
|
1583
|
+
const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
|
|
1584
|
+
const empty = width - filled;
|
|
1585
|
+
return /* @__PURE__ */ jsxs3(Box3, { justifyContent: "center", children: [
|
|
1586
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
|
|
1587
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
|
|
1588
|
+
] });
|
|
978
1589
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
function Phonetic({ word, accent }) {
|
|
984
|
-
const phon = accent === "us" ? word.usphone : word.ukphone;
|
|
985
|
-
if (!phon) return null;
|
|
986
|
-
return /* @__PURE__ */ jsxs(Text2, { dimColor: true, children: [
|
|
987
|
-
"/",
|
|
988
|
-
phon,
|
|
989
|
-
"/"
|
|
1590
|
+
function PausedView() {
|
|
1591
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
1592
|
+
/* @__PURE__ */ jsx7(BigText2, { text: "paused", font: "tiny", colors: [PALETTE.warning] }),
|
|
1593
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[r] resume \xB7 [q] quit to menu" }) })
|
|
990
1594
|
] });
|
|
991
1595
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: word.trans.map((t, i) => /* @__PURE__ */ jsx2(Text3, { color: "cyan", children: t }, i)) });
|
|
1596
|
+
function ErrorView({ msg }) {
|
|
1597
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
1598
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.error, children: msg }),
|
|
1599
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[q] back to menu" }) }),
|
|
1600
|
+
/* @__PURE__ */ jsx7(BackKey, {})
|
|
1601
|
+
] });
|
|
999
1602
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
return /* @__PURE__ */
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1603
|
+
function BackKey() {
|
|
1604
|
+
const nav = useNav();
|
|
1605
|
+
useInput3((input, key) => {
|
|
1606
|
+
if (input === "q" || key.escape) nav.back();
|
|
1607
|
+
});
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
function Centered({ text, color }) {
|
|
1611
|
+
return /* @__PURE__ */ jsx7(Box3, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx7(Text3, { color, children: text }) });
|
|
1612
|
+
}
|
|
1613
|
+
function SummaryView(props) {
|
|
1614
|
+
const { summary } = props;
|
|
1615
|
+
const minutes = summary.durationMs / 6e4;
|
|
1616
|
+
const wpm = minutes > 0 ? Math.round(summary.wordCount / minutes * 10) / 10 : 0;
|
|
1617
|
+
const errorWords = Object.keys(summary.perWordErrors).length;
|
|
1618
|
+
const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
|
|
1619
|
+
const accPct = Math.round(acc * 1e3) / 10;
|
|
1620
|
+
const subtitle = props.mode === "review" ? `${props.dictId} \xB7 review` : `${props.dictId} \xB7 chapter ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode}`;
|
|
1621
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
|
|
1622
|
+
/* @__PURE__ */ jsx7(BigText2, { text: "complete", font: "tiny", colors: [PALETTE.success] }),
|
|
1623
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: subtitle }) }),
|
|
1624
|
+
/* @__PURE__ */ jsxs3(Box3, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
|
|
1625
|
+
/* @__PURE__ */ jsx7(StatCard, { label: "words", value: String(summary.wordCount), color: PALETTE.text }),
|
|
1626
|
+
/* @__PURE__ */ jsx7(
|
|
1627
|
+
StatCard,
|
|
1628
|
+
{
|
|
1629
|
+
label: "errors",
|
|
1630
|
+
value: String(summary.errors),
|
|
1631
|
+
color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
|
|
1632
|
+
}
|
|
1633
|
+
),
|
|
1634
|
+
/* @__PURE__ */ jsx7(StatCard, { label: "wpm", value: String(wpm), color: PALETTE.accent }),
|
|
1635
|
+
/* @__PURE__ */ jsx7(StatCard, { label: "accuracy", value: `${accPct}%`, color: PALETTE.accent })
|
|
1014
1636
|
] }),
|
|
1015
|
-
/* @__PURE__ */
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
/* @__PURE__ */
|
|
1020
|
-
/* @__PURE__ */
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1637
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
|
|
1638
|
+
"elapsed ",
|
|
1639
|
+
fmtTime(summary.durationMs)
|
|
1640
|
+
] }) }),
|
|
1641
|
+
/* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
|
|
1642
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[n] next chapter \xB7 [m] review mistakes \xB7 [q] back to menu" }) })
|
|
1643
|
+
] });
|
|
1644
|
+
}
|
|
1645
|
+
function StatCard({ label, value, color }) {
|
|
1646
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", marginX: 2, children: [
|
|
1647
|
+
/* @__PURE__ */ jsx7(BigText2, { text: value, font: "tiny", colors: [color] }),
|
|
1648
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: label })
|
|
1649
|
+
] });
|
|
1026
1650
|
}
|
|
1027
1651
|
|
|
1028
|
-
// src/ui/screens/
|
|
1029
|
-
import {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const
|
|
1034
|
-
const {
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1652
|
+
// src/ui/screens/DictBrowser.tsx
|
|
1653
|
+
import { useEffect as useEffect6, useState as useState7 } from "react";
|
|
1654
|
+
import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
|
|
1655
|
+
import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1656
|
+
function DictBrowser({ params }) {
|
|
1657
|
+
const nav = useNav();
|
|
1658
|
+
const { cfg, setCfg } = useAppState();
|
|
1659
|
+
const [rows, setRows] = useState7([]);
|
|
1660
|
+
const [loading, setLoading] = useState7(true);
|
|
1661
|
+
const [selected, setSelected] = useState7(0);
|
|
1662
|
+
const [filter, setFilter] = useState7("");
|
|
1663
|
+
const [filterFocus, setFilterFocus] = useState7(false);
|
|
1664
|
+
const [pending, setPending] = useState7(null);
|
|
1665
|
+
const [tick, setTick] = useState7(0);
|
|
1666
|
+
const refresh = async () => {
|
|
1667
|
+
const reg = await loadRegistry();
|
|
1668
|
+
const flagged = await Promise.all(
|
|
1669
|
+
reg.map(async (e) => ({ entry: e, local: await isLocallyAvailable(e.id) }))
|
|
1670
|
+
);
|
|
1671
|
+
setRows(flagged);
|
|
1672
|
+
setLoading(false);
|
|
1673
|
+
};
|
|
1674
|
+
useEffect6(() => {
|
|
1675
|
+
void refresh();
|
|
1676
|
+
}, [tick]);
|
|
1677
|
+
const filtered = filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows;
|
|
1678
|
+
const safeSelected = Math.max(0, Math.min(filtered.length - 1, selected));
|
|
1679
|
+
const current = filtered[safeSelected];
|
|
1680
|
+
useInput4((input, key) => {
|
|
1681
|
+
if (filterFocus) {
|
|
1682
|
+
if (key.escape || key.return) {
|
|
1683
|
+
setFilterFocus(false);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (key.backspace || key.delete) {
|
|
1687
|
+
setFilter((f) => f.slice(0, -1));
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1691
|
+
setFilter((f) => f + input);
|
|
1692
|
+
}
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (key.upArrow) setSelected((i) => Math.max(0, i - 1));
|
|
1696
|
+
if (key.downArrow) setSelected((i) => Math.min(filtered.length - 1, i + 1));
|
|
1697
|
+
if (input === "/") {
|
|
1698
|
+
setFilterFocus(true);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (key.escape || input === "b") {
|
|
1702
|
+
nav.back();
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (!current) return;
|
|
1706
|
+
if (key.return) {
|
|
1707
|
+
void (async () => {
|
|
1708
|
+
await setCfg({ ...cfg, defaultDict: current.entry.id });
|
|
1709
|
+
if (params?.pickerMode === "choose-then-practice") {
|
|
1710
|
+
nav.replace({
|
|
1711
|
+
name: "practice",
|
|
1712
|
+
params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
|
|
1713
|
+
});
|
|
1714
|
+
} else {
|
|
1715
|
+
nav.back();
|
|
1716
|
+
}
|
|
1717
|
+
})();
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
if (input === "p") {
|
|
1721
|
+
nav.replace({
|
|
1722
|
+
name: "practice",
|
|
1723
|
+
params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
|
|
1050
1724
|
});
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (input === "r" && current.local) {
|
|
1728
|
+
setPending({ kind: "removing", id: current.entry.id });
|
|
1729
|
+
void (async () => {
|
|
1730
|
+
try {
|
|
1731
|
+
await removeDictionary(current.entry.id);
|
|
1732
|
+
setPending(null);
|
|
1733
|
+
setTick((t) => t + 1);
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
setPending({ kind: "error", id: current.entry.id, msg: err.message });
|
|
1736
|
+
}
|
|
1737
|
+
})();
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (input === "u") {
|
|
1741
|
+
setPending({ kind: "pulling", id: current.entry.id });
|
|
1742
|
+
void (async () => {
|
|
1743
|
+
try {
|
|
1744
|
+
await pullDictionary(current.entry.id);
|
|
1745
|
+
setPending(null);
|
|
1746
|
+
setTick((t) => t + 1);
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
setPending({ kind: "error", id: current.entry.id, msg: err.message });
|
|
1749
|
+
}
|
|
1750
|
+
})();
|
|
1056
1751
|
}
|
|
1057
1752
|
});
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1753
|
+
if (loading) {
|
|
1754
|
+
return /* @__PURE__ */ jsx8(Box4, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "loading dictionaries\u2026" }) });
|
|
1755
|
+
}
|
|
1756
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1757
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1758
|
+
/* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.accent, children: "Dictionaries" }),
|
|
1759
|
+
/* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
|
|
1760
|
+
/* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: filterFocus ? `/ ${filter}_` : filter ? `/ ${filter}` : `${filtered.length} entries` })
|
|
1761
|
+
] }),
|
|
1762
|
+
/* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexGrow: 1, children: [
|
|
1763
|
+
/* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "60%", children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
|
|
1764
|
+
const i = Math.max(0, safeSelected - 8) + vi;
|
|
1765
|
+
const active = i === safeSelected;
|
|
1766
|
+
const isDefault = cfg.defaultDict === row.entry.id;
|
|
1767
|
+
return /* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1768
|
+
/* @__PURE__ */ jsx8(Text4, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
1769
|
+
/* @__PURE__ */ jsx8(Text4, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }),
|
|
1770
|
+
/* @__PURE__ */ jsx8(Text4, { children: " " }),
|
|
1771
|
+
/* @__PURE__ */ jsx8(Text4, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }),
|
|
1772
|
+
/* @__PURE__ */ jsx8(Text4, { children: " " }),
|
|
1773
|
+
/* @__PURE__ */ jsx8(Text4, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: row.entry.id.slice(0, 14).padEnd(15) }),
|
|
1774
|
+
/* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) })
|
|
1775
|
+
] }, row.entry.id);
|
|
1776
|
+
}) }),
|
|
1777
|
+
/* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "40%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
1778
|
+
/* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.text, children: current.entry.name }),
|
|
1779
|
+
/* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: current.entry.id }),
|
|
1780
|
+
/* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
|
|
1781
|
+
current.entry.language,
|
|
1782
|
+
" \xB7 ",
|
|
1783
|
+
current.entry.category,
|
|
1784
|
+
" \xB7 ",
|
|
1785
|
+
current.entry.length,
|
|
1786
|
+
" words"
|
|
1787
|
+
] }) }),
|
|
1788
|
+
/* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.primary, children: current.entry.description || "(no description)" }) }),
|
|
1789
|
+
current.entry.tags.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
|
|
1790
|
+
"tags: ",
|
|
1791
|
+
current.entry.tags.join(", ")
|
|
1792
|
+
] }) }),
|
|
1793
|
+
/* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
|
|
1794
|
+
/* @__PURE__ */ jsx8(Text4, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? "local \u2713" : "not local" }),
|
|
1795
|
+
cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx8(Text4, { color: PALETTE.success, children: " \xB7 default \u2605" })
|
|
1796
|
+
] })
|
|
1797
|
+
] }) })
|
|
1798
|
+
] }),
|
|
1799
|
+
pending && /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
|
|
1800
|
+
pending.kind === "pulling" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
|
|
1801
|
+
"pulling ",
|
|
1802
|
+
pending.id,
|
|
1803
|
+
"\u2026"
|
|
1804
|
+
] }),
|
|
1805
|
+
pending.kind === "removing" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
|
|
1806
|
+
"removing ",
|
|
1807
|
+
pending.id,
|
|
1808
|
+
"\u2026"
|
|
1809
|
+
] }),
|
|
1810
|
+
pending.kind === "error" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.error, children: [
|
|
1811
|
+
"error on ",
|
|
1812
|
+
pending.id,
|
|
1813
|
+
": ",
|
|
1814
|
+
pending.msg
|
|
1815
|
+
] })
|
|
1816
|
+
] }),
|
|
1817
|
+
/* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "\u2191/\u2193 select \xB7 Enter set default \xB7 p practice \xB7 u pull \xB7 r remove \xB7 / filter \xB7 Esc back" }) })
|
|
1818
|
+
] });
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// src/ui/screens/ConfigEditor.tsx
|
|
1822
|
+
import { useState as useState8 } from "react";
|
|
1823
|
+
import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
|
|
1824
|
+
import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
1825
|
+
var FIELDS = [
|
|
1826
|
+
{ kind: "dictRef", path: "defaultDict", label: "default dict" },
|
|
1827
|
+
{ kind: "enum", path: "defaultMode", label: "default mode", options: ["order", "dictation", "review", "random", "loop"] },
|
|
1828
|
+
{ kind: "enum", path: "accent", label: "accent", options: ["us", "uk"] },
|
|
1829
|
+
{ kind: "enum", path: "mirror", label: "mirror", options: ["jsdelivr", "github"] },
|
|
1830
|
+
{ kind: "int", path: "chapterSize", label: "chapter size", min: 1, max: 200 },
|
|
1831
|
+
{ kind: "bool", path: "autoplayPronunciation", label: "autoplay pronunciation" },
|
|
1832
|
+
{ kind: "bool", path: "sounds.master", label: "sounds master" },
|
|
1833
|
+
{ kind: "bool", path: "sounds.keystroke", label: "sounds keystroke" },
|
|
1834
|
+
{ kind: "bool", path: "sounds.feedback", label: "sounds feedback" },
|
|
1835
|
+
{ kind: "string", path: "sounds.keySoundName", label: "sounds key sound" }
|
|
1836
|
+
];
|
|
1837
|
+
function getByPath2(cfg, path) {
|
|
1838
|
+
return path.split(".").reduce((acc, k) => {
|
|
1839
|
+
if (acc && typeof acc === "object") return acc[k];
|
|
1840
|
+
return void 0;
|
|
1841
|
+
}, cfg);
|
|
1842
|
+
}
|
|
1843
|
+
function ConfigEditor() {
|
|
1844
|
+
const nav = useNav();
|
|
1845
|
+
const { cfg, setCfg } = useAppState();
|
|
1846
|
+
const [selected, setSelected] = useState8(0);
|
|
1847
|
+
const [editing, setEditing] = useState8(false);
|
|
1848
|
+
const [draft, setDraft] = useState8("");
|
|
1849
|
+
const [error, setError] = useState8(null);
|
|
1850
|
+
const field = FIELDS[selected];
|
|
1851
|
+
const currentValue = getByPath2(cfg, field.path);
|
|
1852
|
+
const commit = async (raw) => {
|
|
1853
|
+
try {
|
|
1854
|
+
const next = setByPath(cfg, field.path, raw);
|
|
1855
|
+
await setCfg(next);
|
|
1856
|
+
setEditing(false);
|
|
1857
|
+
setError(null);
|
|
1858
|
+
} catch (err) {
|
|
1859
|
+
setError(err.message);
|
|
1067
1860
|
}
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const next = props.playlist[idx + 1];
|
|
1076
|
-
if (cur && props.audio.autoplayPronunciation) audio.pronounce(cur.name);
|
|
1077
|
-
if (next) audio.prefetch(next.name);
|
|
1078
|
-
}, [session.current?.wordIndex, audio, props.audio.autoplayPronunciation, props.playlist]);
|
|
1079
|
-
void tick;
|
|
1080
|
-
useInput2(
|
|
1081
|
-
(input) => {
|
|
1082
|
-
if (input === "q") exit();
|
|
1083
|
-
if (input === "r") setPaused(false);
|
|
1084
|
-
},
|
|
1085
|
-
{ isActive: paused && finished === null }
|
|
1086
|
-
);
|
|
1087
|
-
useInput2(
|
|
1088
|
-
(input) => {
|
|
1089
|
-
if (input === "q") {
|
|
1090
|
-
props.onQuit?.();
|
|
1091
|
-
exit();
|
|
1861
|
+
};
|
|
1862
|
+
useInput5((input, key) => {
|
|
1863
|
+
if (editing && field.kind === "string") {
|
|
1864
|
+
if (key.escape) {
|
|
1865
|
+
setEditing(false);
|
|
1866
|
+
setError(null);
|
|
1867
|
+
return;
|
|
1092
1868
|
}
|
|
1093
|
-
if (
|
|
1094
|
-
|
|
1095
|
-
|
|
1869
|
+
if (key.return) {
|
|
1870
|
+
void commit(draft);
|
|
1871
|
+
return;
|
|
1096
1872
|
}
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1099
|
-
|
|
1873
|
+
if (key.backspace || key.delete) {
|
|
1874
|
+
setDraft((d) => d.slice(0, -1));
|
|
1875
|
+
return;
|
|
1100
1876
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1877
|
+
if (input && !key.ctrl && !key.meta) setDraft((d) => d + input);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (editing && field.kind === "int") {
|
|
1881
|
+
if (key.escape) {
|
|
1882
|
+
setEditing(false);
|
|
1883
|
+
setError(null);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (key.return) {
|
|
1887
|
+
void commit(draft);
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
if (key.backspace || key.delete) {
|
|
1891
|
+
setDraft((d) => d.slice(0, -1));
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
if (/^[0-9]$/.test(input)) setDraft((d) => d + input);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
if (key.escape || input === "b") {
|
|
1898
|
+
nav.back();
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
if (key.upArrow) {
|
|
1902
|
+
setSelected((i) => (i - 1 + FIELDS.length) % FIELDS.length);
|
|
1903
|
+
setError(null);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (key.downArrow) {
|
|
1907
|
+
setSelected((i) => (i + 1) % FIELDS.length);
|
|
1908
|
+
setError(null);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
if (field.kind === "bool" && (input === " " || key.return)) {
|
|
1912
|
+
void commit(currentValue ? "false" : "true");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (field.kind === "enum" && (key.leftArrow || key.rightArrow)) {
|
|
1916
|
+
const idx = field.options.indexOf(String(currentValue));
|
|
1917
|
+
const delta = key.rightArrow ? 1 : -1;
|
|
1918
|
+
const next = field.options[(idx + delta + field.options.length) % field.options.length];
|
|
1919
|
+
void commit(next);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (field.kind === "dictRef" && key.return) {
|
|
1923
|
+
nav.navigate({ name: "dict", params: { pickerMode: "set-default" } });
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if ((field.kind === "string" || field.kind === "int") && key.return) {
|
|
1927
|
+
setDraft(String(currentValue ?? ""));
|
|
1928
|
+
setEditing(true);
|
|
1929
|
+
setError(null);
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1933
|
+
/* @__PURE__ */ jsx9(Text5, { bold: true, color: PALETTE.accent, children: "Config" }),
|
|
1934
|
+
/* @__PURE__ */ jsx9(Box5, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
|
|
1935
|
+
const active = i === selected;
|
|
1936
|
+
const value = getByPath2(cfg, f.path);
|
|
1937
|
+
const display = renderValue(f, value, active && editing ? draft : null);
|
|
1938
|
+
return /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
1939
|
+
/* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
1940
|
+
/* @__PURE__ */ jsx9(Text5, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: f.label.padEnd(28) }),
|
|
1941
|
+
/* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
|
|
1942
|
+
] }, f.path);
|
|
1943
|
+
}) }),
|
|
1944
|
+
error && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
|
|
1945
|
+
"! ",
|
|
1946
|
+
error
|
|
1947
|
+
] }) }),
|
|
1948
|
+
/* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text5, { color: PALETTE.muted, children: hintFor(field, editing) }) })
|
|
1949
|
+
] });
|
|
1950
|
+
}
|
|
1951
|
+
function renderValue(field, value, draft) {
|
|
1952
|
+
if (draft !== null) return `${draft}_`;
|
|
1953
|
+
if (field.kind === "bool") return value ? "\u2713 on" : "\u2717 off";
|
|
1954
|
+
if (field.kind === "dictRef") return String(value ?? "(none)");
|
|
1955
|
+
if (field.kind === "enum") return `< ${value} >`;
|
|
1956
|
+
return String(value ?? "");
|
|
1957
|
+
}
|
|
1958
|
+
function hintFor(field, editing) {
|
|
1959
|
+
if (editing) return "type to edit \xB7 Enter save \xB7 Esc cancel";
|
|
1960
|
+
if (field.kind === "bool") return "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1961
|
+
if (field.kind === "enum") return "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1962
|
+
if (field.kind === "dictRef") return "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1963
|
+
return "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// src/ui/screens/StatsViewer.tsx
|
|
1967
|
+
import { useEffect as useEffect7, useState as useState9 } from "react";
|
|
1968
|
+
import { Box as Box6, Text as Text6, useInput as useInput6 } from "ink";
|
|
1969
|
+
import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1970
|
+
var DAY_WINDOWS = [7, 14, 30, 90];
|
|
1971
|
+
function StatsViewer() {
|
|
1972
|
+
const nav = useNav();
|
|
1973
|
+
const [sessions, setSessions] = useState9(null);
|
|
1974
|
+
const [book, setBook] = useState9(null);
|
|
1975
|
+
const [windowIdx, setWindowIdx] = useState9(1);
|
|
1976
|
+
useEffect7(() => {
|
|
1977
|
+
void (async () => {
|
|
1978
|
+
const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
|
|
1979
|
+
setSessions(s);
|
|
1980
|
+
setBook(b);
|
|
1981
|
+
})();
|
|
1982
|
+
}, []);
|
|
1983
|
+
useInput6((input, key) => {
|
|
1984
|
+
if (key.escape || input === "b" || input === "q") {
|
|
1985
|
+
nav.back();
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (input === "n") setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
|
|
1989
|
+
if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
|
|
1990
|
+
});
|
|
1991
|
+
if (!sessions || !book) {
|
|
1992
|
+
return /* @__PURE__ */ jsx10(Box6, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "loading stats\u2026" }) });
|
|
1993
|
+
}
|
|
1994
|
+
if (sessions.length === 0) {
|
|
1995
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
1996
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "No practice history yet." }),
|
|
1997
|
+
/* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "Run a practice session first." }) }),
|
|
1998
|
+
/* @__PURE__ */ jsx10(Box6, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "[q] back" }) })
|
|
1999
|
+
] });
|
|
2000
|
+
}
|
|
2001
|
+
const days = DAY_WINDOWS[windowIdx];
|
|
2002
|
+
const buckets = dailyBuckets(sessions, days);
|
|
2003
|
+
const streak = dailyStreak(sessions);
|
|
2004
|
+
const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
|
|
2005
|
+
const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
|
|
2006
|
+
const totalMs = sessions.reduce((a, s) => a + s.durationMs, 0);
|
|
2007
|
+
const firstTryWords = sessions.reduce(
|
|
2008
|
+
(a, s) => a + (s.wordCount - Object.keys(s.perWordErrors).length),
|
|
2009
|
+
0
|
|
1103
2010
|
);
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
/* @__PURE__ */
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
"
|
|
1117
|
-
|
|
2011
|
+
const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
|
|
2012
|
+
const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
|
|
2013
|
+
const recent = sessions.slice(-5).reverse();
|
|
2014
|
+
const top = topN(book, 8);
|
|
2015
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
2016
|
+
/* @__PURE__ */ jsx10(Text6, { bold: true, color: PALETTE.accent, children: "Stats" }),
|
|
2017
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
2018
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "lifetime" }),
|
|
2019
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 1, children: [
|
|
2020
|
+
/* @__PURE__ */ jsx10(Stat, { label: "sessions", value: String(sessions.length) }),
|
|
2021
|
+
/* @__PURE__ */ jsx10(Stat, { label: "words", value: String(totalWords) }),
|
|
2022
|
+
/* @__PURE__ */ jsx10(Stat, { label: "errors", value: String(totalErrors) }),
|
|
2023
|
+
/* @__PURE__ */ jsx10(Stat, { label: "wpm", value: String(overallWpm), accent: true }),
|
|
2024
|
+
/* @__PURE__ */ jsx10(Stat, { label: "accuracy", value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
|
|
2025
|
+
/* @__PURE__ */ jsx10(Stat, { label: "streak", value: `${streak}d`, accent: true })
|
|
2026
|
+
] })
|
|
2027
|
+
] }),
|
|
2028
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
|
|
2029
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2030
|
+
"last ",
|
|
2031
|
+
days,
|
|
2032
|
+
" days (n / N to cycle window)"
|
|
1118
2033
|
] }),
|
|
1119
|
-
/* @__PURE__ */
|
|
1120
|
-
/* @__PURE__ */
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
2034
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
2035
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2036
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "wpm ".padEnd(10) }),
|
|
2037
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.wpm)) }),
|
|
2038
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2039
|
+
" max ",
|
|
2040
|
+
Math.round(Math.max(...buckets.map((b) => b.wpm)))
|
|
2041
|
+
] })
|
|
2042
|
+
] }),
|
|
2043
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2044
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "accuracy".padEnd(10) }),
|
|
2045
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
|
|
2046
|
+
] }),
|
|
2047
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2048
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "sessions".padEnd(10) }),
|
|
2049
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
|
|
2050
|
+
] })
|
|
2051
|
+
] })
|
|
2052
|
+
] }),
|
|
2053
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
|
|
2054
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "recent sessions" }),
|
|
2055
|
+
recent.map((s, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2056
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2057
|
+
" ",
|
|
2058
|
+
s.ts.replace("T", " ").slice(0, 16),
|
|
2059
|
+
" "
|
|
2060
|
+
] }),
|
|
2061
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
|
|
2062
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2063
|
+
" ",
|
|
2064
|
+
"ch",
|
|
2065
|
+
String(s.chapter + 1).padStart(3),
|
|
2066
|
+
" ",
|
|
2067
|
+
s.mode.padEnd(9),
|
|
2068
|
+
" ",
|
|
2069
|
+
String(s.wordCount).padStart(3),
|
|
2070
|
+
"w ",
|
|
2071
|
+
s.errors,
|
|
2072
|
+
"err ",
|
|
2073
|
+
computeWPM(s),
|
|
2074
|
+
"wpm ",
|
|
2075
|
+
Math.round(accuracy(s) * 1e3) / 10,
|
|
1129
2076
|
"%"
|
|
1130
2077
|
] })
|
|
1131
|
-
] }
|
|
1132
|
-
|
|
1133
|
-
|
|
2078
|
+
] }, i))
|
|
2079
|
+
] }),
|
|
2080
|
+
top.length > 0 && /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
|
|
2081
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "top mistakes" }),
|
|
2082
|
+
top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2083
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
|
|
2084
|
+
" ",
|
|
2085
|
+
String(entry.count).padStart(3),
|
|
2086
|
+
" "
|
|
2087
|
+
] }),
|
|
2088
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
|
|
2089
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
|
|
2090
|
+
] }, word))
|
|
2091
|
+
] }),
|
|
2092
|
+
/* @__PURE__ */ jsx10(Box6, { flexGrow: 1 }),
|
|
2093
|
+
/* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "n / N cycle window \xB7 q back" }) })
|
|
2094
|
+
] });
|
|
2095
|
+
}
|
|
2096
|
+
function Stat({ label, value, accent = false }) {
|
|
2097
|
+
return /* @__PURE__ */ jsxs6(Box6, { marginRight: 3, children: [
|
|
2098
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2099
|
+
label,
|
|
2100
|
+
" "
|
|
2101
|
+
] }),
|
|
2102
|
+
/* @__PURE__ */ jsx10(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
|
|
2103
|
+
] });
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// src/ui/screens/WordLookup.tsx
|
|
2107
|
+
import { useEffect as useEffect8, useState as useState10 } from "react";
|
|
2108
|
+
import { Box as Box7, Text as Text7, useInput as useInput7 } from "ink";
|
|
2109
|
+
import { readdir } from "fs/promises";
|
|
2110
|
+
import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
2111
|
+
async function listLocalDictIds() {
|
|
2112
|
+
try {
|
|
2113
|
+
const files = await readdir(paths.dictsDir);
|
|
2114
|
+
return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
|
|
2115
|
+
} catch {
|
|
2116
|
+
return [];
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
function WordLookup() {
|
|
2120
|
+
const nav = useNav();
|
|
2121
|
+
const [query, setQuery] = useState10("");
|
|
2122
|
+
const [allWords, setAllWords] = useState10([]);
|
|
2123
|
+
const [book, setBook] = useState10({});
|
|
2124
|
+
const [loading, setLoading] = useState10(true);
|
|
2125
|
+
const [selected, setSelected] = useState10(0);
|
|
2126
|
+
useEffect8(() => {
|
|
2127
|
+
void (async () => {
|
|
2128
|
+
const ids = await listLocalDictIds();
|
|
2129
|
+
const collected = [];
|
|
2130
|
+
for (const id of ids) {
|
|
2131
|
+
const words = await loadLocalDictionary(id);
|
|
2132
|
+
if (!words) continue;
|
|
2133
|
+
for (const w of words) collected.push({ dictId: id, word: w });
|
|
2134
|
+
}
|
|
2135
|
+
setAllWords(collected);
|
|
2136
|
+
setBook(await loadMistakes());
|
|
2137
|
+
setLoading(false);
|
|
2138
|
+
})();
|
|
2139
|
+
}, []);
|
|
2140
|
+
const q = query.toLowerCase().trim();
|
|
2141
|
+
const filtered = q ? allWords.filter((h) => h.word.name.toLowerCase().includes(q)).slice(0, 50) : [];
|
|
2142
|
+
useInput7((input, key) => {
|
|
2143
|
+
if (key.escape) {
|
|
2144
|
+
nav.back();
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
if (key.upArrow) {
|
|
2148
|
+
setSelected((i) => Math.max(0, i - 1));
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
if (key.downArrow) {
|
|
2152
|
+
setSelected((i) => Math.min(filtered.length - 1, i + 1));
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (key.backspace || key.delete) {
|
|
2156
|
+
setQuery((s) => s.slice(0, -1));
|
|
2157
|
+
setSelected(0);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (input && !key.ctrl && !key.meta && input.trim().length > 0) {
|
|
2161
|
+
setQuery((s) => s + input);
|
|
2162
|
+
setSelected(0);
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
if (loading) {
|
|
2166
|
+
return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "indexing local dictionaries\u2026" }) });
|
|
1134
2167
|
}
|
|
1135
|
-
if (
|
|
1136
|
-
return /* @__PURE__ */
|
|
1137
|
-
/* @__PURE__ */
|
|
1138
|
-
/* @__PURE__ */
|
|
2168
|
+
if (allWords.length === 0) {
|
|
2169
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
2170
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "No local dictionaries." }),
|
|
2171
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "Pull one in Dictionaries first." }) }),
|
|
2172
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 2, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "[Esc] back" }) })
|
|
1139
2173
|
] });
|
|
1140
2174
|
}
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
" \xB7 chapter ",
|
|
1150
|
-
props.chapterNumber,
|
|
1151
|
-
"/",
|
|
1152
|
-
props.totalChapters
|
|
1153
|
-
] }),
|
|
1154
|
-
/* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
1155
|
-
" \xB7 mode ",
|
|
1156
|
-
props.mode
|
|
1157
|
-
] }),
|
|
1158
|
-
/* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
1159
|
-
" \xB7 accent ",
|
|
1160
|
-
props.accent
|
|
2175
|
+
const current = filtered[selected];
|
|
2176
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
2177
|
+
/* @__PURE__ */ jsxs7(Box7, { children: [
|
|
2178
|
+
/* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.accent, children: "Word lookup" }),
|
|
2179
|
+
/* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
|
|
2180
|
+
/* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2181
|
+
allWords.length,
|
|
2182
|
+
" words across local dicts"
|
|
1161
2183
|
] })
|
|
1162
2184
|
] }),
|
|
1163
|
-
/* @__PURE__ */
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
2185
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
2186
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "> " }),
|
|
2187
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.text, children: query }),
|
|
2188
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.accent, children: "_" })
|
|
2189
|
+
] }),
|
|
2190
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexGrow: 1, children: [
|
|
2191
|
+
/* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: "40%", children: [
|
|
2192
|
+
filtered.map((h, i) => {
|
|
2193
|
+
const active = i === selected;
|
|
2194
|
+
return /* @__PURE__ */ jsxs7(Box7, { children: [
|
|
2195
|
+
/* @__PURE__ */ jsx11(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
2196
|
+
/* @__PURE__ */ jsx11(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
|
|
2197
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: h.dictId })
|
|
2198
|
+
] }, `${h.dictId}-${h.word.name}-${i}`);
|
|
2199
|
+
}),
|
|
2200
|
+
filtered.length === 0 && q && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2201
|
+
'no matches for "',
|
|
2202
|
+
query,
|
|
2203
|
+
'"'
|
|
2204
|
+
] })
|
|
2205
|
+
] }),
|
|
2206
|
+
/* @__PURE__ */ jsx11(Box7, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment3, { children: [
|
|
2207
|
+
/* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
|
|
2208
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
2209
|
+
current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2210
|
+
"US /",
|
|
2211
|
+
current.word.usphone,
|
|
2212
|
+
"/ "
|
|
2213
|
+
] }),
|
|
2214
|
+
current.word.ukphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2215
|
+
"UK /",
|
|
2216
|
+
current.word.ukphone,
|
|
2217
|
+
"/"
|
|
2218
|
+
] })
|
|
2219
|
+
] }),
|
|
2220
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((t, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
|
|
2221
|
+
"\xB7 ",
|
|
2222
|
+
t
|
|
2223
|
+
] }, i)) }),
|
|
2224
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2225
|
+
"in: ",
|
|
2226
|
+
current.dictId
|
|
2227
|
+
] }) }),
|
|
2228
|
+
book[current.word.name] && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
2229
|
+
/* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
|
|
2230
|
+
"mistakes: ",
|
|
2231
|
+
book[current.word.name].count
|
|
2232
|
+
] }),
|
|
2233
|
+
/* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2234
|
+
" ",
|
|
2235
|
+
"(last ",
|
|
2236
|
+
book[current.word.name].lastSeen.slice(0, 10),
|
|
2237
|
+
")"
|
|
2238
|
+
] })
|
|
2239
|
+
] })
|
|
2240
|
+
] }) })
|
|
2241
|
+
] }),
|
|
2242
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back" }) })
|
|
1169
2243
|
] });
|
|
1170
2244
|
}
|
|
1171
2245
|
|
|
2246
|
+
// src/ui/App.tsx
|
|
2247
|
+
import { jsx as jsx12 } from "react/jsx-runtime";
|
|
2248
|
+
function App({ initial, initialCfg }) {
|
|
2249
|
+
return /* @__PURE__ */ jsx12(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx12(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx12(NavProvider, { initial, children: /* @__PURE__ */ jsx12(Fullscreen, { children: /* @__PURE__ */ jsx12(Router, {}) }) }) }) });
|
|
2250
|
+
}
|
|
2251
|
+
function Router() {
|
|
2252
|
+
const nav = useNav();
|
|
2253
|
+
const { cfg } = useAppState();
|
|
2254
|
+
const { exit } = useApp4();
|
|
2255
|
+
useInput8((input, key) => {
|
|
2256
|
+
if (key.ctrl && input === "c") exit();
|
|
2257
|
+
});
|
|
2258
|
+
const frame = nav.current;
|
|
2259
|
+
switch (frame.name) {
|
|
2260
|
+
case "main":
|
|
2261
|
+
return /* @__PURE__ */ jsx12(MainMenu, { cfg });
|
|
2262
|
+
case "practice":
|
|
2263
|
+
return /* @__PURE__ */ jsx12(PracticeScreen, { params: frame.params });
|
|
2264
|
+
case "dict":
|
|
2265
|
+
return /* @__PURE__ */ jsx12(DictBrowser, { params: frame.params });
|
|
2266
|
+
case "config":
|
|
2267
|
+
return /* @__PURE__ */ jsx12(ConfigEditor, {});
|
|
2268
|
+
case "stats":
|
|
2269
|
+
return /* @__PURE__ */ jsx12(StatsViewer, {});
|
|
2270
|
+
case "word":
|
|
2271
|
+
return /* @__PURE__ */ jsx12(WordLookup, {});
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
1172
2275
|
// src/commands/practice.ts
|
|
1173
2276
|
var MODES = ["order", "dictation", "review", "random", "loop"];
|
|
1174
2277
|
function isMode(v) {
|
|
1175
2278
|
return MODES.includes(v);
|
|
1176
2279
|
}
|
|
1177
|
-
async function runChapter(opts) {
|
|
1178
|
-
let outcome = "done";
|
|
1179
|
-
const onSessionComplete = async (summary) => {
|
|
1180
|
-
const rec = {
|
|
1181
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1182
|
-
dictId: opts.dictId,
|
|
1183
|
-
chapter: opts.chapterIndex,
|
|
1184
|
-
mode: opts.mode,
|
|
1185
|
-
wordCount: summary.wordCount,
|
|
1186
|
-
errors: summary.errors,
|
|
1187
|
-
durationMs: summary.durationMs,
|
|
1188
|
-
perWordErrors: summary.perWordErrors
|
|
1189
|
-
};
|
|
1190
|
-
await appendSession(rec);
|
|
1191
|
-
if (Object.keys(summary.perWordErrors).length > 0) {
|
|
1192
|
-
let book = await loadMistakes();
|
|
1193
|
-
for (const [word, n] of Object.entries(summary.perWordErrors)) {
|
|
1194
|
-
book = bump(book, word, opts.dictId, n);
|
|
1195
|
-
}
|
|
1196
|
-
await saveMistakes(book);
|
|
1197
|
-
}
|
|
1198
|
-
};
|
|
1199
|
-
const { waitUntilExit } = render(
|
|
1200
|
-
createElement(PracticeScreen, {
|
|
1201
|
-
dictId: opts.dictId,
|
|
1202
|
-
chapterNumber: opts.chapterIndex + 1,
|
|
1203
|
-
totalChapters: opts.totalChapters,
|
|
1204
|
-
playlist: opts.playlist,
|
|
1205
|
-
mode: opts.mode,
|
|
1206
|
-
accent: opts.accent,
|
|
1207
|
-
audio: opts.audio,
|
|
1208
|
-
onSessionComplete,
|
|
1209
|
-
onAdvanceChapter: () => {
|
|
1210
|
-
outcome = "next";
|
|
1211
|
-
},
|
|
1212
|
-
onReviewMistakes: () => {
|
|
1213
|
-
outcome = "review";
|
|
1214
|
-
},
|
|
1215
|
-
onQuit: () => {
|
|
1216
|
-
outcome = "quit";
|
|
1217
|
-
}
|
|
1218
|
-
})
|
|
1219
|
-
);
|
|
1220
|
-
await waitUntilExit();
|
|
1221
|
-
return outcome;
|
|
1222
|
-
}
|
|
1223
2280
|
async function runPractice(dictIdArg, options) {
|
|
1224
2281
|
if (!process.stdout.isTTY) {
|
|
1225
2282
|
console.error(chalk3.red("Practice requires an interactive TTY."));
|
|
@@ -1239,78 +2296,15 @@ async function runPractice(dictIdArg, options) {
|
|
|
1239
2296
|
process.exitCode = 1;
|
|
1240
2297
|
return;
|
|
1241
2298
|
}
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const accent = cfg.accent;
|
|
1252
|
-
const audioCfg = {
|
|
1253
|
-
master: cfg.sounds.master,
|
|
1254
|
-
keystroke: cfg.sounds.keystroke,
|
|
1255
|
-
feedback: cfg.sounds.feedback,
|
|
1256
|
-
autoplayPronunciation: cfg.autoplayPronunciation
|
|
1257
|
-
};
|
|
1258
|
-
if (mode === "review") {
|
|
1259
|
-
const book = await loadMistakes();
|
|
1260
|
-
const reviewWords = words.filter((w) => book[w.name]?.count);
|
|
1261
|
-
if (reviewWords.length === 0) {
|
|
1262
|
-
console.log(chalk3.yellow("Mistake book is empty for this dictionary. Practice some chapters first."));
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
await runChapter({
|
|
1266
|
-
dictId,
|
|
1267
|
-
chapterIndex: 0,
|
|
1268
|
-
totalChapters: 1,
|
|
1269
|
-
playlist: reviewWords.slice(0, cfg.chapterSize),
|
|
1270
|
-
mode,
|
|
1271
|
-
accent,
|
|
1272
|
-
audio: audioCfg
|
|
1273
|
-
});
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
while (idx < chapters.length) {
|
|
1277
|
-
const chapter = chapters[idx];
|
|
1278
|
-
const playlist = buildPlaylist(chapter, mode);
|
|
1279
|
-
const result = await runChapter({
|
|
1280
|
-
dictId,
|
|
1281
|
-
chapterIndex: idx,
|
|
1282
|
-
totalChapters: total,
|
|
1283
|
-
playlist,
|
|
1284
|
-
mode,
|
|
1285
|
-
accent,
|
|
1286
|
-
audio: audioCfg
|
|
1287
|
-
});
|
|
1288
|
-
if (result === "next") {
|
|
1289
|
-
if (mode === "loop") continue;
|
|
1290
|
-
idx++;
|
|
1291
|
-
} else if (result === "review") {
|
|
1292
|
-
const book = await loadMistakes();
|
|
1293
|
-
const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
|
|
1294
|
-
if (reviewWords.length === 0) {
|
|
1295
|
-
console.log(chalk3.yellow("No mistakes to review."));
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
await runChapter({
|
|
1299
|
-
dictId,
|
|
1300
|
-
chapterIndex: 0,
|
|
1301
|
-
totalChapters: 1,
|
|
1302
|
-
playlist: reviewWords,
|
|
1303
|
-
mode: "order",
|
|
1304
|
-
accent,
|
|
1305
|
-
audio: audioCfg
|
|
1306
|
-
});
|
|
1307
|
-
return;
|
|
1308
|
-
} else {
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
console.log(chalk3.green(`Reached end of ${dictId} (${total} chapters). Nice work.`));
|
|
1313
|
-
void chapterCount;
|
|
2299
|
+
const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
|
|
2300
|
+
const { waitUntilExit } = render(
|
|
2301
|
+
createElement(App, {
|
|
2302
|
+
initial: { name: "practice", params: { dictId, chapterIndex, mode } },
|
|
2303
|
+
initialCfg: cfg
|
|
2304
|
+
}),
|
|
2305
|
+
{ patchConsole: false, exitOnCtrlC: false }
|
|
2306
|
+
);
|
|
2307
|
+
await waitUntilExit();
|
|
1314
2308
|
}
|
|
1315
2309
|
function buildPracticeCommand() {
|
|
1316
2310
|
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").action(async (dictIdArg, options) => {
|
|
@@ -1377,10 +2371,10 @@ Top ${top.length} mistakes`));
|
|
|
1377
2371
|
// src/commands/word.ts
|
|
1378
2372
|
import { Command as Command5 } from "commander";
|
|
1379
2373
|
import chalk5 from "chalk";
|
|
1380
|
-
import { readdir } from "fs/promises";
|
|
1381
|
-
async function
|
|
2374
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
2375
|
+
async function listLocalDictIds2() {
|
|
1382
2376
|
try {
|
|
1383
|
-
const files = await
|
|
2377
|
+
const files = await readdir2(paths.dictsDir);
|
|
1384
2378
|
return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
|
|
1385
2379
|
} catch {
|
|
1386
2380
|
return [];
|
|
@@ -1389,7 +2383,7 @@ async function listLocalDictIds() {
|
|
|
1389
2383
|
function buildWordCommand() {
|
|
1390
2384
|
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) => {
|
|
1391
2385
|
const q = keyword.toLowerCase();
|
|
1392
|
-
const ids = await
|
|
2386
|
+
const ids = await listLocalDictIds2();
|
|
1393
2387
|
if (ids.length === 0) {
|
|
1394
2388
|
console.log(chalk5.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
|
|
1395
2389
|
return;
|
|
@@ -1442,107 +2436,22 @@ function buildWordCommand() {
|
|
|
1442
2436
|
// src/commands/menu.ts
|
|
1443
2437
|
import { render as render2 } from "ink";
|
|
1444
2438
|
import { createElement as createElement2 } from "react";
|
|
1445
|
-
import chalk6 from "chalk";
|
|
1446
|
-
|
|
1447
|
-
// src/ui/screens/MainMenu.tsx
|
|
1448
|
-
import { useState as useState3 } from "react";
|
|
1449
|
-
import { Box as Box5, Text as Text6, useApp as useApp3, useInput as useInput3 } from "ink";
|
|
1450
|
-
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1451
|
-
var ITEMS = [
|
|
1452
|
-
{ key: "p", label: "Practice", hint: "qwerty practice <dictId>" },
|
|
1453
|
-
{ key: "d", label: "Dictionaries", hint: "qwerty dict list / search / pull" },
|
|
1454
|
-
{ key: "w", label: "Word lookup", hint: "qwerty word <keyword>" },
|
|
1455
|
-
{ key: "s", label: "Stats", hint: "qwerty stats" },
|
|
1456
|
-
{ key: "c", label: "Config", hint: "qwerty config list" },
|
|
1457
|
-
{ key: "q", label: "Quit", hint: "Ctrl+C also exits" }
|
|
1458
|
-
];
|
|
1459
|
-
function MainMenu({
|
|
1460
|
-
defaultDict,
|
|
1461
|
-
onAction
|
|
1462
|
-
}) {
|
|
1463
|
-
const [selected, setSelected] = useState3(0);
|
|
1464
|
-
const { exit } = useApp3();
|
|
1465
|
-
useInput3((input, key) => {
|
|
1466
|
-
if (key.upArrow) setSelected((i) => (i - 1 + ITEMS.length) % ITEMS.length);
|
|
1467
|
-
if (key.downArrow) setSelected((i) => (i + 1) % ITEMS.length);
|
|
1468
|
-
if (key.return) {
|
|
1469
|
-
const item = ITEMS[selected];
|
|
1470
|
-
if (item.key === "q") {
|
|
1471
|
-
exit();
|
|
1472
|
-
return;
|
|
1473
|
-
}
|
|
1474
|
-
if (item.key === "p") onAction("practice");
|
|
1475
|
-
}
|
|
1476
|
-
for (const it of ITEMS) {
|
|
1477
|
-
if (input === it.key) {
|
|
1478
|
-
if (it.key === "q") {
|
|
1479
|
-
exit();
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1482
|
-
if (it.key === "p") onAction("practice");
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
});
|
|
1486
|
-
return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
|
|
1487
|
-
/* @__PURE__ */ jsx5(Text6, { bold: true, color: "cyan", children: "qwerty-cli" }),
|
|
1488
|
-
/* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "typing practice for English vocabulary, in your terminal" }),
|
|
1489
|
-
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", children: ITEMS.map((it, i) => /* @__PURE__ */ jsxs4(Text6, { children: [
|
|
1490
|
-
i === selected ? chalkArrow() : " ",
|
|
1491
|
-
/* @__PURE__ */ jsxs4(Text6, { bold: i === selected, color: i === selected ? "cyan" : void 0, children: [
|
|
1492
|
-
"[",
|
|
1493
|
-
it.key,
|
|
1494
|
-
"]"
|
|
1495
|
-
] }),
|
|
1496
|
-
" ",
|
|
1497
|
-
/* @__PURE__ */ jsx5(Text6, { bold: i === selected, children: it.label.padEnd(14) }),
|
|
1498
|
-
/* @__PURE__ */ jsx5(Text6, { dimColor: true, children: it.hint })
|
|
1499
|
-
] }, it.key)) }),
|
|
1500
|
-
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
|
|
1501
|
-
"default dict: ",
|
|
1502
|
-
defaultDict ?? "none \u2014 set with `qwerty config set defaultDict <id>`"
|
|
1503
|
-
] }) }),
|
|
1504
|
-
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "\u2191/\u2193 navigate \xB7 Enter to select \xB7 letters jump" }) })
|
|
1505
|
-
] });
|
|
1506
|
-
}
|
|
1507
|
-
function chalkArrow() {
|
|
1508
|
-
return "> ";
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
// src/commands/menu.ts
|
|
1512
2439
|
async function runMainMenu() {
|
|
1513
2440
|
if (!process.stdout.isTTY) {
|
|
1514
2441
|
console.log("qwerty-cli \u2014 run `qwerty --help` for available commands.");
|
|
1515
2442
|
return;
|
|
1516
2443
|
}
|
|
1517
2444
|
const cfg = await loadConfig();
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
defaultDict: cfg.defaultDict,
|
|
1522
|
-
onAction: (action) => {
|
|
1523
|
-
chosen = action;
|
|
1524
|
-
unmount();
|
|
1525
|
-
}
|
|
1526
|
-
})
|
|
2445
|
+
const { waitUntilExit } = render2(
|
|
2446
|
+
createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
|
|
2447
|
+
{ patchConsole: false, exitOnCtrlC: false }
|
|
1527
2448
|
);
|
|
1528
2449
|
await waitUntilExit();
|
|
1529
|
-
if (chosen === "practice") {
|
|
1530
|
-
if (cfg.defaultDict) {
|
|
1531
|
-
console.log(chalk6.dim(`Tip: \`qwerty practice ${cfg.defaultDict}\` skips this menu next time.`));
|
|
1532
|
-
await runPractice(cfg.defaultDict, {});
|
|
1533
|
-
} else {
|
|
1534
|
-
console.log(
|
|
1535
|
-
chalk6.yellow(
|
|
1536
|
-
"No default dictionary set. Run `qwerty dict pull <id>` then `qwerty config set defaultDict <id>`."
|
|
1537
|
-
)
|
|
1538
|
-
);
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
2450
|
}
|
|
1542
2451
|
|
|
1543
2452
|
// src/cli.ts
|
|
1544
2453
|
var program = new Command6();
|
|
1545
|
-
program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(
|
|
2454
|
+
program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version(package_default.version);
|
|
1546
2455
|
program.addCommand(buildPracticeCommand());
|
|
1547
2456
|
program.addCommand(buildDictCommand());
|
|
1548
2457
|
program.addCommand(buildWordCommand());
|