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.
Files changed (100) hide show
  1. package/dist/commands/login.d.ts +0 -1
  2. package/dist/commands/login.js +0 -1
  3. package/dist/commands/logout.d.ts +0 -1
  4. package/dist/commands/logout.js +0 -1
  5. package/dist/commands/review.d.ts +3 -2
  6. package/dist/commands/review.js +98 -4
  7. package/dist/commands/settings.d.ts +0 -1
  8. package/dist/commands/settings.js +0 -1
  9. package/dist/commands/status.d.ts +0 -1
  10. package/dist/commands/status.js +0 -1
  11. package/dist/index.d.ts +0 -1
  12. package/dist/index.js +18 -1
  13. package/dist/lib/api-client.d.ts +0 -1
  14. package/dist/lib/api-client.js +0 -1
  15. package/dist/lib/config.d.ts +0 -1
  16. package/dist/lib/config.js +0 -1
  17. package/dist/lib/oauth-callback.d.ts +0 -1
  18. package/dist/lib/oauth-callback.js +24 -13
  19. package/dist/lib/pronounce.d.ts +20 -0
  20. package/dist/lib/pronounce.js +230 -0
  21. package/dist/lib/review-mode.d.ts +0 -1
  22. package/dist/lib/review-mode.js +0 -1
  23. package/dist/lib/tty.d.ts +0 -1
  24. package/dist/lib/tty.js +0 -1
  25. package/dist/types/review.d.ts +3 -1
  26. package/dist/types/review.js +0 -1
  27. package/package.json +6 -2
  28. package/dist/__tests__/api-client.test.d.ts +0 -2
  29. package/dist/__tests__/api-client.test.d.ts.map +0 -1
  30. package/dist/__tests__/api-client.test.js +0 -108
  31. package/dist/__tests__/api-client.test.js.map +0 -1
  32. package/dist/__tests__/config.test.d.ts +0 -2
  33. package/dist/__tests__/config.test.d.ts.map +0 -1
  34. package/dist/__tests__/config.test.js +0 -65
  35. package/dist/__tests__/config.test.js.map +0 -1
  36. package/dist/__tests__/oauth-callback.test.d.ts +0 -2
  37. package/dist/__tests__/oauth-callback.test.d.ts.map +0 -1
  38. package/dist/__tests__/oauth-callback.test.js +0 -46
  39. package/dist/__tests__/oauth-callback.test.js.map +0 -1
  40. package/dist/__tests__/review-mode.test.d.ts +0 -2
  41. package/dist/__tests__/review-mode.test.d.ts.map +0 -1
  42. package/dist/__tests__/review-mode.test.js +0 -152
  43. package/dist/__tests__/review-mode.test.js.map +0 -1
  44. package/dist/__tests__/review.test.d.ts +0 -2
  45. package/dist/__tests__/review.test.d.ts.map +0 -1
  46. package/dist/__tests__/review.test.js +0 -37
  47. package/dist/__tests__/review.test.js.map +0 -1
  48. package/dist/__tests__/session.test.d.ts +0 -2
  49. package/dist/__tests__/session.test.d.ts.map +0 -1
  50. package/dist/__tests__/session.test.js +0 -41
  51. package/dist/__tests__/session.test.js.map +0 -1
  52. package/dist/__tests__/settings.test.d.ts +0 -2
  53. package/dist/__tests__/settings.test.d.ts.map +0 -1
  54. package/dist/__tests__/settings.test.js +0 -26
  55. package/dist/__tests__/settings.test.js.map +0 -1
  56. package/dist/commands/login.d.ts.map +0 -1
  57. package/dist/commands/login.js.map +0 -1
  58. package/dist/commands/logout.d.ts.map +0 -1
  59. package/dist/commands/logout.js.map +0 -1
  60. package/dist/commands/review.d.ts.map +0 -1
  61. package/dist/commands/review.js.map +0 -1
  62. package/dist/commands/settings.d.ts.map +0 -1
  63. package/dist/commands/settings.js.map +0 -1
  64. package/dist/commands/status.d.ts.map +0 -1
  65. package/dist/commands/status.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/lib/api-client.d.ts.map +0 -1
  69. package/dist/lib/api-client.js.map +0 -1
  70. package/dist/lib/config.d.ts.map +0 -1
  71. package/dist/lib/config.js.map +0 -1
  72. package/dist/lib/oauth-callback.d.ts.map +0 -1
  73. package/dist/lib/oauth-callback.js.map +0 -1
  74. package/dist/lib/review-mode.d.ts.map +0 -1
  75. package/dist/lib/review-mode.js.map +0 -1
  76. package/dist/lib/tty.d.ts.map +0 -1
  77. package/dist/lib/tty.js.map +0 -1
  78. package/dist/types/review.d.ts.map +0 -1
  79. package/dist/types/review.js.map +0 -1
  80. package/src/__tests__/api-client.test.ts +0 -126
  81. package/src/__tests__/config.test.ts +0 -74
  82. package/src/__tests__/oauth-callback.test.ts +0 -54
  83. package/src/__tests__/review-mode.test.ts +0 -179
  84. package/src/__tests__/review.test.ts +0 -41
  85. package/src/__tests__/session.test.ts +0 -53
  86. package/src/__tests__/settings.test.ts +0 -32
  87. package/src/commands/login.ts +0 -60
  88. package/src/commands/logout.ts +0 -31
  89. package/src/commands/review.ts +0 -772
  90. package/src/commands/settings.ts +0 -48
  91. package/src/commands/status.ts +0 -32
  92. package/src/index.ts +0 -70
  93. package/src/lib/api-client.ts +0 -127
  94. package/src/lib/config.ts +0 -66
  95. package/src/lib/oauth-callback.ts +0 -102
  96. package/src/lib/review-mode.ts +0 -48
  97. package/src/lib/tty.ts +0 -64
  98. package/src/types/review.ts +0 -90
  99. package/tsconfig.json +0 -10
  100. package/vitest.config.ts +0 -24
@@ -1,2 +1 @@
1
1
  export declare function login(): Promise<void>;
2
- //# sourceMappingURL=login.d.ts.map
@@ -48,4 +48,3 @@ export async function login() {
48
48
  process.exit(1);
49
49
  }
50
50
  }
51
- //# sourceMappingURL=login.js.map
@@ -1,2 +1 @@
1
1
  export declare function logout(): Promise<void>;
2
- //# sourceMappingURL=logout.d.ts.map
@@ -25,4 +25,3 @@ export async function logout() {
25
25
  });
26
26
  });
27
27
  }
28
- //# sourceMappingURL=logout.js.map
@@ -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
@@ -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('[d]')} Delete`,
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('[d]')} Delete`,
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 === 'd') {
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
@@ -1,2 +1 @@
1
1
  export declare function settings(args: string[]): Promise<void>;
2
- //# sourceMappingURL=settings.d.ts.map
@@ -38,4 +38,3 @@ export async function settings(args) {
38
38
  }
39
39
  throw new Error('Invalid settings command. Use: vocabflow settings --help');
40
40
  }
41
- //# sourceMappingURL=settings.js.map
@@ -1,2 +1 @@
1
1
  export declare function status(): Promise<void>;
2
- //# sourceMappingURL=status.d.ts.map
@@ -26,4 +26,3 @@ export async function status() {
26
26
  process.exit(1);
27
27
  }
28
28
  }
29
- //# sourceMappingURL=status.js.map
package/dist/index.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=index.d.ts.map
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
@@ -52,4 +52,3 @@ export declare const reviewApi: {
52
52
  timestamp?: number;
53
53
  }>;
54
54
  };
55
- //# sourceMappingURL=api-client.d.ts.map
@@ -90,4 +90,3 @@ export const reviewApi = {
90
90
  */
91
91
  deleteWord: (wordId) => cliApi.delete(`api/words/${wordId}`),
92
92
  };
93
- //# sourceMappingURL=api-client.js.map
@@ -21,4 +21,3 @@ export declare function isTokenExpired(): boolean;
21
21
  export declare function getDefaultReviewMode(): ReviewMode;
22
22
  export declare function setDefaultReviewMode(mode: ReviewMode): void;
23
23
  export { config };
24
- //# sourceMappingURL=config.d.ts.map
@@ -45,4 +45,3 @@ export function setDefaultReviewMode(mode) {
45
45
  config.set('defaultReviewMode', mode);
46
46
  }
47
47
  export { config };
48
- //# sourceMappingURL=config.js.map
@@ -8,4 +8,3 @@ export interface CallbackError {
8
8
  }
9
9
  export type CallbackResult = CallbackSuccess | CallbackError;
10
10
  export declare function startCallbackServer(): Promise<CallbackResult>;
11
- //# sourceMappingURL=oauth-callback.d.ts.map
@@ -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
- server.close();
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
- clearTimeout(timer);
43
- server.close();
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
- clearTimeout(timer);
62
- server.close();
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
- reject(new Error(`Port ${CALLBACK_PORT} is already in use. Please close other vocab processes and try again.`));
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
- reject(err);
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
@@ -38,4 +38,3 @@ export function getModeFlag(mode) {
38
38
  }
39
39
  return 'forward';
40
40
  }
41
- //# sourceMappingURL=review-mode.js.map
package/dist/lib/tty.d.ts CHANGED
@@ -25,4 +25,3 @@ export declare function moveCursorTop(): void;
25
25
  * Clear screen from cursor position to bottom
26
26
  */
27
27
  export declare function clearScreen(): void;
28
- //# sourceMappingURL=tty.d.ts.map
package/dist/lib/tty.js CHANGED
@@ -56,4 +56,3 @@ export function clearScreen() {
56
56
  // ANSI full-screen clear avoids stale lines in some TTY implementations.
57
57
  process.stdout.write('\u001B[2J');
58
58
  }
59
- //# sourceMappingURL=tty.js.map
@@ -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
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=review.js.map