vocabflow 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/login.d.ts +0 -1
- package/dist/commands/login.js +0 -1
- package/dist/commands/logout.d.ts +0 -1
- package/dist/commands/logout.js +0 -1
- package/dist/commands/review.d.ts +3 -2
- package/dist/commands/review.js +98 -4
- package/dist/commands/settings.d.ts +0 -1
- package/dist/commands/settings.js +0 -1
- package/dist/commands/status.d.ts +0 -1
- package/dist/commands/status.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +18 -1
- package/dist/lib/api-client.d.ts +0 -1
- package/dist/lib/api-client.js +0 -1
- package/dist/lib/config.d.ts +0 -1
- package/dist/lib/config.js +0 -1
- package/dist/lib/oauth-callback.d.ts +0 -1
- package/dist/lib/oauth-callback.js +24 -13
- package/dist/lib/pronounce.d.ts +20 -0
- package/dist/lib/pronounce.js +230 -0
- package/dist/lib/review-mode.d.ts +0 -1
- package/dist/lib/review-mode.js +0 -1
- package/dist/lib/tty.d.ts +0 -1
- package/dist/lib/tty.js +0 -1
- package/dist/types/review.d.ts +3 -1
- package/dist/types/review.js +0 -1
- package/package.json +6 -2
- package/dist/__tests__/api-client.test.d.ts +0 -2
- package/dist/__tests__/api-client.test.d.ts.map +0 -1
- package/dist/__tests__/api-client.test.js +0 -108
- package/dist/__tests__/api-client.test.js.map +0 -1
- package/dist/__tests__/config.test.d.ts +0 -2
- package/dist/__tests__/config.test.d.ts.map +0 -1
- package/dist/__tests__/config.test.js +0 -65
- package/dist/__tests__/config.test.js.map +0 -1
- package/dist/__tests__/oauth-callback.test.d.ts +0 -2
- package/dist/__tests__/oauth-callback.test.d.ts.map +0 -1
- package/dist/__tests__/oauth-callback.test.js +0 -46
- package/dist/__tests__/oauth-callback.test.js.map +0 -1
- package/dist/__tests__/review-mode.test.d.ts +0 -2
- package/dist/__tests__/review-mode.test.d.ts.map +0 -1
- package/dist/__tests__/review-mode.test.js +0 -152
- package/dist/__tests__/review-mode.test.js.map +0 -1
- package/dist/__tests__/review.test.d.ts +0 -2
- package/dist/__tests__/review.test.d.ts.map +0 -1
- package/dist/__tests__/review.test.js +0 -37
- package/dist/__tests__/review.test.js.map +0 -1
- package/dist/__tests__/session.test.d.ts +0 -2
- package/dist/__tests__/session.test.d.ts.map +0 -1
- package/dist/__tests__/session.test.js +0 -41
- package/dist/__tests__/session.test.js.map +0 -1
- package/dist/__tests__/settings.test.d.ts +0 -2
- package/dist/__tests__/settings.test.d.ts.map +0 -1
- package/dist/__tests__/settings.test.js +0 -26
- package/dist/__tests__/settings.test.js.map +0 -1
- package/dist/commands/login.d.ts.map +0 -1
- package/dist/commands/login.js.map +0 -1
- package/dist/commands/logout.d.ts.map +0 -1
- package/dist/commands/logout.js.map +0 -1
- package/dist/commands/review.d.ts.map +0 -1
- package/dist/commands/review.js.map +0 -1
- package/dist/commands/settings.d.ts.map +0 -1
- package/dist/commands/settings.js.map +0 -1
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lib/api-client.d.ts.map +0 -1
- package/dist/lib/api-client.js.map +0 -1
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/oauth-callback.d.ts.map +0 -1
- package/dist/lib/oauth-callback.js.map +0 -1
- package/dist/lib/review-mode.d.ts.map +0 -1
- package/dist/lib/review-mode.js.map +0 -1
- package/dist/lib/tty.d.ts.map +0 -1
- package/dist/lib/tty.js.map +0 -1
- package/dist/types/review.d.ts.map +0 -1
- package/dist/types/review.js.map +0 -1
- package/src/__tests__/api-client.test.ts +0 -126
- package/src/__tests__/config.test.ts +0 -74
- package/src/__tests__/oauth-callback.test.ts +0 -54
- package/src/__tests__/review-mode.test.ts +0 -179
- package/src/__tests__/review.test.ts +0 -41
- package/src/__tests__/session.test.ts +0 -53
- package/src/__tests__/settings.test.ts +0 -32
- package/src/commands/login.ts +0 -60
- package/src/commands/logout.ts +0 -31
- package/src/commands/review.ts +0 -772
- package/src/commands/settings.ts +0 -48
- package/src/commands/status.ts +0 -32
- package/src/index.ts +0 -70
- package/src/lib/api-client.ts +0 -127
- package/src/lib/config.ts +0 -66
- package/src/lib/oauth-callback.ts +0 -102
- package/src/lib/review-mode.ts +0 -48
- package/src/lib/tty.ts +0 -64
- package/src/types/review.ts +0 -90
- package/tsconfig.json +0 -10
- package/vitest.config.ts +0 -24
package/dist/commands/login.d.ts
CHANGED
package/dist/commands/login.js
CHANGED
package/dist/commands/logout.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
import type { ReviewMode } from '../types/review.js';
|
|
1
|
+
import type { ReviewMode, TodayReviewVO } from '../types/review.js';
|
|
2
|
+
export declare function formatRelativeTime(isoString: string): string;
|
|
3
|
+
export declare function getStatsLine(word: TodayReviewVO, maxWidth: number): string;
|
|
2
4
|
export declare function review(initialMode?: ReviewMode): Promise<void>;
|
|
3
|
-
//# sourceMappingURL=review.d.ts.map
|
package/dist/commands/review.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import picocolors from 'picocolors';
|
|
2
2
|
import { isInteractive, setupRawMode, cleanup, clearScreen, moveCursorTop } from '../lib/tty.js';
|
|
3
3
|
import { requireAuth, reviewApi, isApiSuccess } from '../lib/api-client.js';
|
|
4
|
+
import { detectPronounceCapability, speakWord } from '../lib/pronounce.js';
|
|
4
5
|
import { getModeLabel } from '../lib/review-mode.js';
|
|
5
6
|
const CHOICE_LABELS = ['A', 'B', 'C', 'D'];
|
|
6
7
|
const HIGHLIGHT_OPEN = '\x1b[38;2;217;119;87m'; // #D97757
|
|
@@ -269,6 +270,26 @@ function getPhoneticLine(word, question, loading) {
|
|
|
269
270
|
}
|
|
270
271
|
return picocolors.dim('/.../');
|
|
271
272
|
}
|
|
273
|
+
export function formatRelativeTime(isoString) {
|
|
274
|
+
const diffMs = Date.now() - new Date(isoString).getTime();
|
|
275
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
276
|
+
if (diffMin < 60)
|
|
277
|
+
return `${diffMin}m ago`;
|
|
278
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
279
|
+
if (diffHr < 24)
|
|
280
|
+
return `${diffHr}h ago`;
|
|
281
|
+
return `${Math.floor(diffHr / 24)}d ago`;
|
|
282
|
+
}
|
|
283
|
+
export function getStatsLine(word, maxWidth) {
|
|
284
|
+
const count = word.reviewCount ?? 0;
|
|
285
|
+
const accuracyDisplay = word.accuracyRate ?? '--';
|
|
286
|
+
const parts = [`Reviewed ${count} times`, `Accuracy ${accuracyDisplay}`];
|
|
287
|
+
if (word.lastReviewTime) {
|
|
288
|
+
parts.push(`Last: ${formatRelativeTime(word.lastReviewTime)}`);
|
|
289
|
+
}
|
|
290
|
+
const line = parts.join(' | ');
|
|
291
|
+
return picocolors.dim(truncateLine(line, maxWidth));
|
|
292
|
+
}
|
|
272
293
|
function getQuestionCacheKey(wordId, mode) {
|
|
273
294
|
return `${wordId}:${mode}`;
|
|
274
295
|
}
|
|
@@ -308,6 +329,24 @@ function resolveChoiceOutcome(payload) {
|
|
|
308
329
|
correctAnswer: textValue(data.correctAnswer) || undefined,
|
|
309
330
|
};
|
|
310
331
|
}
|
|
332
|
+
function getPronounceUnavailableMessage(capability) {
|
|
333
|
+
if (capability.reason === 'unsupported-platform') {
|
|
334
|
+
return '当前平台暂不支持发音。';
|
|
335
|
+
}
|
|
336
|
+
if (capability.reason === 'probe-failed') {
|
|
337
|
+
return '发音能力探测失败,请稍后重试。';
|
|
338
|
+
}
|
|
339
|
+
return '当前终端无可用 TTS 命令。';
|
|
340
|
+
}
|
|
341
|
+
function getSpeakFailureMessage(result) {
|
|
342
|
+
if (result.reason === 'timeout') {
|
|
343
|
+
return '发音超时。';
|
|
344
|
+
}
|
|
345
|
+
if (result.detail) {
|
|
346
|
+
return `发音失败: ${result.detail}`;
|
|
347
|
+
}
|
|
348
|
+
return '发音失败。';
|
|
349
|
+
}
|
|
311
350
|
export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
312
351
|
await requireAuth();
|
|
313
352
|
const response = await reviewApi.getTodayReview();
|
|
@@ -331,6 +370,8 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
331
370
|
let confirmDelete = false;
|
|
332
371
|
let notice = null;
|
|
333
372
|
let pendingActionText = null;
|
|
373
|
+
let pronounceCapability = null;
|
|
374
|
+
let pronounceInFlight = false;
|
|
334
375
|
setupRawMode();
|
|
335
376
|
function render() {
|
|
336
377
|
const word = words[currentIndex];
|
|
@@ -347,6 +388,7 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
347
388
|
console.log(picocolors.bold(picocolors.cyan(truncateLine(word.wordText, maxWidth))));
|
|
348
389
|
console.log(getPhoneticLine(word, question, loadingCurrent));
|
|
349
390
|
console.log(picocolors.yellow(`${currentIndex + 1}/${total}`));
|
|
391
|
+
console.log(getStatsLine(word, maxWidth));
|
|
350
392
|
console.log(picocolors.dim('─'.repeat(Math.min(maxWidth, 72))));
|
|
351
393
|
if (choiceMode) {
|
|
352
394
|
console.log(picocolors.green('Question'));
|
|
@@ -386,9 +428,10 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
386
428
|
const hintParts = choiceMode
|
|
387
429
|
? [
|
|
388
430
|
`${picocolors.cyan('[A/B/C/D]')} Choose`,
|
|
431
|
+
`${picocolors.cyan('[P]')} Pronounce`,
|
|
389
432
|
`${picocolors.cyan('[r]')} Toggle mode`,
|
|
390
433
|
`${picocolors.magenta('Mode:')} ${getModeLabel(currentMode)}`,
|
|
391
|
-
`${picocolors.red('[
|
|
434
|
+
`${picocolors.red('[Backspace]')} Delete`,
|
|
392
435
|
`${picocolors.cyan('[h/l]')} Prev/Next`,
|
|
393
436
|
`${picocolors.red('[q]')} Quit`,
|
|
394
437
|
]
|
|
@@ -397,9 +440,10 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
397
440
|
`${picocolors.yellow('[1]')} Forget`,
|
|
398
441
|
`${picocolors.yellow('[2]')} Hard`,
|
|
399
442
|
`${picocolors.yellow('[3]')} Mastered`,
|
|
443
|
+
`${picocolors.cyan('[P]')} Pronounce`,
|
|
400
444
|
`${picocolors.cyan('[r]')} Toggle mode`,
|
|
401
445
|
`${picocolors.magenta('Mode:')} ${getModeLabel(currentMode)}`,
|
|
402
|
-
`${picocolors.red('[
|
|
446
|
+
`${picocolors.red('[Backspace]')} Delete`,
|
|
403
447
|
`${picocolors.cyan('[h/l]')} Prev/Next`,
|
|
404
448
|
`${picocolors.red('[q]')} Quit`,
|
|
405
449
|
];
|
|
@@ -494,6 +538,53 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
494
538
|
render();
|
|
495
539
|
}
|
|
496
540
|
}
|
|
541
|
+
function triggerPronunciation() {
|
|
542
|
+
if (pronounceInFlight) {
|
|
543
|
+
notice = { type: 'warning', text: '发音进行中,请稍候。' };
|
|
544
|
+
render();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const wordText = words[currentIndex]?.wordText?.trim();
|
|
548
|
+
if (!wordText) {
|
|
549
|
+
notice = { type: 'warning', text: '当前词条为空,无法发音。' };
|
|
550
|
+
render();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
pronounceInFlight = true;
|
|
554
|
+
void (async () => {
|
|
555
|
+
try {
|
|
556
|
+
if (!pronounceCapability) {
|
|
557
|
+
pronounceCapability = await detectPronounceCapability();
|
|
558
|
+
}
|
|
559
|
+
if (pronounceCapability.status !== 'available') {
|
|
560
|
+
notice = { type: 'warning', text: getPronounceUnavailableMessage(pronounceCapability) };
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const speakResult = await speakWord(wordText);
|
|
564
|
+
if (speakResult.status === 'spoken') {
|
|
565
|
+
notice = { type: 'success', text: `已发音: ${wordText}` };
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (speakResult.status === 'skipped') {
|
|
569
|
+
notice = {
|
|
570
|
+
type: 'warning',
|
|
571
|
+
text: speakResult.reason === 'empty-word'
|
|
572
|
+
? '当前词条为空,无法发音。'
|
|
573
|
+
: '当前终端无可用 TTS 命令。',
|
|
574
|
+
};
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
notice = { type: 'error', text: getSpeakFailureMessage(speakResult) };
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
notice = { type: 'error', text: `发音失败: ${error?.message || 'Unknown error'}` };
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
pronounceInFlight = false;
|
|
584
|
+
render();
|
|
585
|
+
}
|
|
586
|
+
})();
|
|
587
|
+
}
|
|
497
588
|
process.stdin.on('keypress', async (_chunk, key) => {
|
|
498
589
|
if (pendingActionText) {
|
|
499
590
|
return;
|
|
@@ -538,7 +629,11 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
538
629
|
render();
|
|
539
630
|
return;
|
|
540
631
|
}
|
|
541
|
-
if (key.name === '
|
|
632
|
+
if (key.name === 'p') {
|
|
633
|
+
triggerPronunciation();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (key.name === 'backspace') {
|
|
542
637
|
confirmDelete = true;
|
|
543
638
|
notice = null;
|
|
544
639
|
render();
|
|
@@ -648,4 +743,3 @@ export async function review(initialMode = 'FLASHCARD_FORWARD') {
|
|
|
648
743
|
await ensureQuestionLoaded(words[currentIndex].id, currentMode, false);
|
|
649
744
|
render();
|
|
650
745
|
}
|
|
651
|
-
//# sourceMappingURL=review.js.map
|
package/dist/commands/status.js
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
2
5
|
import { login } from './commands/login.js';
|
|
3
6
|
import { logout } from './commands/logout.js';
|
|
4
7
|
import { status } from './commands/status.js';
|
|
@@ -9,6 +12,16 @@ import { getDefaultReviewMode } from './lib/config.js';
|
|
|
9
12
|
import { parseReviewModeArg } from './lib/review-mode.js';
|
|
10
13
|
const command = process.argv[2];
|
|
11
14
|
const commandArgs = process.argv.slice(3);
|
|
15
|
+
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
|
|
16
|
+
function getCliVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
19
|
+
return packageJson.version ?? 'unknown';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return 'unknown';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
12
25
|
function parseReviewMode(args) {
|
|
13
26
|
const modeFlagIndex = args.findIndex((arg) => arg === '--mode');
|
|
14
27
|
if (modeFlagIndex === -1) {
|
|
@@ -21,6 +34,10 @@ function parseReviewMode(args) {
|
|
|
21
34
|
return parseReviewModeArg(modeValue);
|
|
22
35
|
}
|
|
23
36
|
async function main() {
|
|
37
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
38
|
+
console.log(getCliVersion());
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
24
41
|
switch (command) {
|
|
25
42
|
case 'login':
|
|
26
43
|
await login();
|
|
@@ -51,6 +68,7 @@ async function main() {
|
|
|
51
68
|
${picocolors.cyan('vocabflow settings')} Show or update settings
|
|
52
69
|
${picocolors.cyan('vocabflow review')} Start a vocabulary review session
|
|
53
70
|
${picocolors.cyan('vocabflow review --mode revert')} Start in REVERT (A/B/C/D) mode
|
|
71
|
+
${picocolors.cyan('vocabflow --version')} Show current version
|
|
54
72
|
|
|
55
73
|
${picocolors.dim('Run `vocabflow <command> --help` for more options.')}
|
|
56
74
|
`);
|
|
@@ -61,4 +79,3 @@ main().catch((err) => {
|
|
|
61
79
|
console.error(picocolors.red(`Error: ${err.message}`));
|
|
62
80
|
process.exit(1);
|
|
63
81
|
});
|
|
64
|
-
//# sourceMappingURL=index.js.map
|
package/dist/lib/api-client.d.ts
CHANGED
package/dist/lib/api-client.js
CHANGED
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
|
@@ -4,9 +4,23 @@ const CALLBACK_PORT = 58421;
|
|
|
4
4
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
5
5
|
export async function startCallbackServer() {
|
|
6
6
|
return new Promise((resolve, reject) => {
|
|
7
|
+
let settled = false;
|
|
8
|
+
const settleSuccess = (result) => {
|
|
9
|
+
if (settled)
|
|
10
|
+
return;
|
|
11
|
+
settled = true;
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
server.close(() => resolve(result));
|
|
14
|
+
};
|
|
15
|
+
const settleError = (error) => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
server.close(() => reject(error));
|
|
21
|
+
};
|
|
7
22
|
const timer = setTimeout(() => {
|
|
8
|
-
|
|
9
|
-
reject(new Error('Authentication timed out. Please try again.'));
|
|
23
|
+
settleError(new Error('Authentication timed out. Please try again.'));
|
|
10
24
|
}, CALLBACK_TIMEOUT_MS);
|
|
11
25
|
const server = http.createServer((req, res) => {
|
|
12
26
|
// Handle CORS preflight
|
|
@@ -38,10 +52,9 @@ export async function startCallbackServer() {
|
|
|
38
52
|
<p>You can close this window.</p>
|
|
39
53
|
</body>
|
|
40
54
|
</html>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
resolve({ type: 'error', message: msg });
|
|
55
|
+
`, () => {
|
|
56
|
+
settleSuccess({ type: 'error', message: msg });
|
|
57
|
+
});
|
|
45
58
|
return;
|
|
46
59
|
}
|
|
47
60
|
if (token) {
|
|
@@ -57,10 +70,9 @@ export async function startCallbackServer() {
|
|
|
57
70
|
<p>You can close this window and return to the CLI.</p>
|
|
58
71
|
</body>
|
|
59
72
|
</html>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
resolve({ type: 'success', token });
|
|
73
|
+
`, () => {
|
|
74
|
+
settleSuccess({ type: 'success', token });
|
|
75
|
+
});
|
|
64
76
|
return;
|
|
65
77
|
}
|
|
66
78
|
// Unknown route
|
|
@@ -69,10 +81,10 @@ export async function startCallbackServer() {
|
|
|
69
81
|
});
|
|
70
82
|
server.on('error', (err) => {
|
|
71
83
|
if (err.code === 'EADDRINUSE') {
|
|
72
|
-
|
|
84
|
+
settleError(new Error(`Port ${CALLBACK_PORT} is already in use. Please close other vocab processes and try again.`));
|
|
73
85
|
}
|
|
74
86
|
else {
|
|
75
|
-
|
|
87
|
+
settleError(err);
|
|
76
88
|
}
|
|
77
89
|
});
|
|
78
90
|
server.listen(CALLBACK_PORT, () => {
|
|
@@ -80,4 +92,3 @@ export async function startCallbackServer() {
|
|
|
80
92
|
});
|
|
81
93
|
});
|
|
82
94
|
}
|
|
83
|
-
//# sourceMappingURL=oauth-callback.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type PronounceAvailability = 'available' | 'unavailable';
|
|
2
|
+
export type PronounceReason = 'command-not-found' | 'unsupported-platform' | 'probe-failed' | 'unavailable' | 'empty-word' | 'execution-failed' | 'timeout';
|
|
3
|
+
type SpeakerId = 'darwin-say' | 'linux-espeak-ng' | 'linux-espeak' | 'linux-spd-say' | 'win-powershell' | 'win-pwsh';
|
|
4
|
+
export interface PronounceCapability {
|
|
5
|
+
status: PronounceAvailability;
|
|
6
|
+
platform: NodeJS.Platform;
|
|
7
|
+
speaker?: SpeakerId;
|
|
8
|
+
command?: string;
|
|
9
|
+
reason?: Extract<PronounceReason, 'command-not-found' | 'unsupported-platform' | 'probe-failed'>;
|
|
10
|
+
detail?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SpeakWordResult {
|
|
13
|
+
status: 'spoken' | 'skipped' | 'failed';
|
|
14
|
+
reason?: PronounceReason;
|
|
15
|
+
detail?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function resetPronounceCapabilityCache(): void;
|
|
18
|
+
export declare function detectPronounceCapability(platform?: NodeJS.Platform): Promise<PronounceCapability>;
|
|
19
|
+
export declare function speakWord(word: string, platform?: NodeJS.Platform): Promise<SpeakWordResult>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const WINDOWS_SPEAK_SCRIPT = '$word = $args[0]; Add-Type -AssemblyName System.Speech; $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; $synth.Speak($word)';
|
|
3
|
+
const CANDIDATES_BY_PLATFORM = {
|
|
4
|
+
darwin: [
|
|
5
|
+
{
|
|
6
|
+
id: 'darwin-say',
|
|
7
|
+
command: 'say',
|
|
8
|
+
probeArgs: ['-v', '?'],
|
|
9
|
+
},
|
|
10
|
+
],
|
|
11
|
+
linux: [
|
|
12
|
+
{
|
|
13
|
+
id: 'linux-espeak-ng',
|
|
14
|
+
command: 'espeak-ng',
|
|
15
|
+
probeArgs: ['--version'],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'linux-espeak',
|
|
19
|
+
command: 'espeak',
|
|
20
|
+
probeArgs: ['--version'],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'linux-spd-say',
|
|
24
|
+
command: 'spd-say',
|
|
25
|
+
probeArgs: ['--version'],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
win32: [
|
|
29
|
+
{
|
|
30
|
+
id: 'win-powershell',
|
|
31
|
+
command: 'powershell',
|
|
32
|
+
probeArgs: ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'win-pwsh',
|
|
36
|
+
command: 'pwsh',
|
|
37
|
+
probeArgs: ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
const capabilityCache = new Map();
|
|
42
|
+
function getCandidates(platform) {
|
|
43
|
+
if (platform === 'darwin' || platform === 'linux' || platform === 'win32') {
|
|
44
|
+
return CANDIDATES_BY_PLATFORM[platform];
|
|
45
|
+
}
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
function buildSpeakArgs(speaker, word) {
|
|
49
|
+
switch (speaker) {
|
|
50
|
+
case 'darwin-say':
|
|
51
|
+
return [word];
|
|
52
|
+
case 'linux-espeak-ng':
|
|
53
|
+
case 'linux-espeak':
|
|
54
|
+
return ['-q', word];
|
|
55
|
+
case 'linux-spd-say':
|
|
56
|
+
return [word];
|
|
57
|
+
case 'win-powershell':
|
|
58
|
+
case 'win-pwsh':
|
|
59
|
+
return ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_SPEAK_SCRIPT, word];
|
|
60
|
+
default:
|
|
61
|
+
return [word];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function runCommand(command, args, timeoutMs) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
const finalize = (outcome) => {
|
|
69
|
+
if (settled) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
settled = true;
|
|
73
|
+
resolve(outcome);
|
|
74
|
+
};
|
|
75
|
+
let child;
|
|
76
|
+
try {
|
|
77
|
+
child = spawn(command, args, { stdio: 'ignore' });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
finalize({
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: 'spawn-error',
|
|
83
|
+
detail: error?.message ?? String(error),
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
if (settled) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
timedOut = true;
|
|
92
|
+
child.kill('SIGTERM');
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
if (!settled) {
|
|
95
|
+
child.kill('SIGKILL');
|
|
96
|
+
finalize({
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: 'timeout',
|
|
99
|
+
detail: 'process-timeout',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}, 150);
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
timeout.unref?.();
|
|
105
|
+
child.once('error', (error) => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
if (error?.code === 'ENOENT') {
|
|
108
|
+
finalize({
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: 'command-not-found',
|
|
111
|
+
detail: error?.message ?? 'command-not-found',
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
finalize({
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: 'spawn-error',
|
|
118
|
+
detail: error?.message ?? String(error),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
child.once('exit', (code) => {
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
if (timedOut) {
|
|
124
|
+
finalize({
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: 'timeout',
|
|
127
|
+
detail: 'process-timeout',
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (code === 0) {
|
|
132
|
+
finalize({ ok: true });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
finalize({
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: 'execution-failed',
|
|
138
|
+
detail: typeof code === 'number' ? `exit-${code}` : 'exit-unknown',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async function detectCapabilityForPlatform(platform) {
|
|
144
|
+
const candidates = getCandidates(platform);
|
|
145
|
+
if (candidates.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
status: 'unavailable',
|
|
148
|
+
platform,
|
|
149
|
+
reason: 'unsupported-platform',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
let sawProbeFailure = false;
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
const probe = await runCommand(candidate.command, candidate.probeArgs, 2_500);
|
|
155
|
+
if (probe.ok || probe.reason === 'execution-failed') {
|
|
156
|
+
return {
|
|
157
|
+
status: 'available',
|
|
158
|
+
platform,
|
|
159
|
+
speaker: candidate.id,
|
|
160
|
+
command: candidate.command,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (probe.reason === 'spawn-error' || probe.reason === 'timeout') {
|
|
164
|
+
sawProbeFailure = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (sawProbeFailure) {
|
|
168
|
+
return {
|
|
169
|
+
status: 'unavailable',
|
|
170
|
+
platform,
|
|
171
|
+
reason: 'probe-failed',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
status: 'unavailable',
|
|
176
|
+
platform,
|
|
177
|
+
reason: 'command-not-found',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export function resetPronounceCapabilityCache() {
|
|
181
|
+
capabilityCache.clear();
|
|
182
|
+
}
|
|
183
|
+
export async function detectPronounceCapability(platform = process.platform) {
|
|
184
|
+
const cached = capabilityCache.get(platform);
|
|
185
|
+
if (cached) {
|
|
186
|
+
return cached;
|
|
187
|
+
}
|
|
188
|
+
const task = detectCapabilityForPlatform(platform);
|
|
189
|
+
capabilityCache.set(platform, task);
|
|
190
|
+
return task;
|
|
191
|
+
}
|
|
192
|
+
export async function speakWord(word, platform = process.platform) {
|
|
193
|
+
const normalized = word.trim();
|
|
194
|
+
if (!normalized) {
|
|
195
|
+
return {
|
|
196
|
+
status: 'skipped',
|
|
197
|
+
reason: 'empty-word',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const capability = await detectPronounceCapability(platform);
|
|
202
|
+
if (capability.status !== 'available' || !capability.speaker || !capability.command) {
|
|
203
|
+
return {
|
|
204
|
+
status: 'skipped',
|
|
205
|
+
reason: capability.reason ?? 'unavailable',
|
|
206
|
+
detail: capability.detail,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const args = buildSpeakArgs(capability.speaker, normalized);
|
|
210
|
+
const outcome = await runCommand(capability.command, args, 12_000);
|
|
211
|
+
if (outcome.ok) {
|
|
212
|
+
return { status: 'spoken' };
|
|
213
|
+
}
|
|
214
|
+
if (outcome.reason === 'command-not-found') {
|
|
215
|
+
capabilityCache.delete(platform);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
status: 'failed',
|
|
219
|
+
reason: outcome.reason === 'timeout' ? 'timeout' : 'execution-failed',
|
|
220
|
+
detail: outcome.detail,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
status: 'failed',
|
|
226
|
+
reason: 'execution-failed',
|
|
227
|
+
detail: error?.message ?? String(error),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -2,4 +2,3 @@ import type { ReviewMode } from '../types/review.js';
|
|
|
2
2
|
export declare function parseReviewModeArg(raw: string): ReviewMode;
|
|
3
3
|
export declare function getModeLabel(mode: ReviewMode): 'FORWARD' | 'REVERT' | 'BACKWARD' | 'CHOICE';
|
|
4
4
|
export declare function getModeFlag(mode: ReviewMode): string;
|
|
5
|
-
//# sourceMappingURL=review-mode.d.ts.map
|
package/dist/lib/review-mode.js
CHANGED
package/dist/lib/tty.d.ts
CHANGED
package/dist/lib/tty.js
CHANGED
package/dist/types/review.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export interface TodayReviewVO {
|
|
|
10
10
|
reviewCount: number;
|
|
11
11
|
intervalDays: number;
|
|
12
12
|
easeFactor: number;
|
|
13
|
+
correctCount?: number;
|
|
14
|
+
accuracyRate?: string | null;
|
|
15
|
+
lastReviewTime?: string | null;
|
|
13
16
|
}
|
|
14
17
|
export interface PagedResponseTodayReviewVO {
|
|
15
18
|
datas?: TodayReviewVO[];
|
|
@@ -77,4 +80,3 @@ export interface ApiResponseReviewResponseDto {
|
|
|
77
80
|
data: ReviewResponseDto;
|
|
78
81
|
timestamp: number;
|
|
79
82
|
}
|
|
80
|
-
//# sourceMappingURL=review.d.ts.map
|
package/dist/types/review.js
CHANGED