@nbtca/prompt 1.4.1 → 1.5.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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +27 -58
  3. package/SECURITY.md +16 -45
  4. package/dist/app/app.js +53 -55
  5. package/dist/app/chrome.js +67 -50
  6. package/dist/app/fields/list-field.js +12 -25
  7. package/dist/app/fields/text-field.js +3 -8
  8. package/dist/app/frame.js +2 -21
  9. package/dist/app/keys.js +10 -2
  10. package/dist/app/views/docs-render.js +31 -24
  11. package/dist/app/views/docs.js +211 -67
  12. package/dist/app/views/events-render.js +19 -26
  13. package/dist/app/views/events.js +44 -31
  14. package/dist/app/views/home.js +33 -76
  15. package/dist/app/views/schedule-grid-cursor.js +9 -18
  16. package/dist/app/views/schedule-render.js +47 -71
  17. package/dist/app/views/schedule.js +158 -90
  18. package/dist/app/views/settings-render.js +8 -19
  19. package/dist/app/views/settings.js +93 -18
  20. package/dist/auth/cookie-transport.js +31 -32
  21. package/dist/auth/errors.js +3 -1
  22. package/dist/auth/nbt-auth.js +42 -25
  23. package/dist/auth/session-store.js +17 -9
  24. package/dist/config/data.js +10 -13
  25. package/dist/config/preferences.js +14 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/capabilities.js +6 -3
  28. package/dist/core/components/confirm.js +9 -8
  29. package/dist/core/components/menu.js +41 -16
  30. package/dist/core/components/messages.js +12 -4
  31. package/dist/core/components/painter.js +3 -1
  32. package/dist/core/components/spinner.js +17 -6
  33. package/dist/core/components/text-input.js +24 -18
  34. package/dist/core/icons.js +2 -2
  35. package/dist/core/logo.js +25 -21
  36. package/dist/core/motion.js +25 -19
  37. package/dist/core/text.js +182 -75
  38. package/dist/core/theme.js +0 -28
  39. package/dist/core/transitions.js +2 -2
  40. package/dist/core/ui.js +15 -30
  41. package/dist/core/vim-keys.js +9 -15
  42. package/dist/features/about.js +23 -0
  43. package/dist/features/calendar-heatmap.js +16 -40
  44. package/dist/features/calendar-query.js +1 -2
  45. package/dist/features/calendar.js +12 -185
  46. package/dist/features/docs.js +439 -320
  47. package/dist/features/schedule-render.js +65 -102
  48. package/dist/features/schedule-store.js +51 -9
  49. package/dist/features/schedule-view.js +46 -220
  50. package/dist/features/status.js +44 -59
  51. package/dist/features/student-timetable.js +73 -95
  52. package/dist/features/theme.js +6 -5
  53. package/dist/features/timetable-sanitize.js +40 -0
  54. package/dist/features/update.js +9 -37
  55. package/dist/i18n/index.js +87 -65
  56. package/dist/i18n/locales/en.json +1 -1
  57. package/dist/i18n/locales/zh.json +1 -1
  58. package/dist/index.js +85 -64
  59. package/dist/logo/ca-dotmatrix.txt +16 -18
  60. package/dist/main.js +7 -48
  61. package/package.json +30 -18
  62. package/bin/nbtca-welcome.js +0 -2
  63. package/dist/core/components/screen.js +0 -18
  64. package/dist/core/menu.js +0 -71
  65. package/dist/features/links.js +0 -39
  66. package/dist/features/schedule-query.js +0 -47
  67. package/dist/features/settings.js +0 -130
  68. package/dist/logo/ca-logo.png +0 -0
@@ -1,48 +1,27 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { createNbtTimetableClient, timetableToIcs, TimetableError, } from '@nbtca/nbtcal/timetable';
4
+ import { createNbtTimetableClient, findAcademicTerm, timetableToIcs, TimetableError, } from '@nbtca/nbtcal/timetable';
5
5
  import { runSecretInput, runTextInput } from '../core/components/text-input.js';
6
- import { runMenu } from '../core/components/menu.js';
7
- import { glyph } from '../core/theme.js';
8
6
  import { AuthError } from '../auth/errors.js';
9
- import { loginWithStudentPassword, restoreNbtSession } from '../auth/nbt-auth.js';
7
+ import { loginWithStudentPassword, restoreNbtSession, } from '../auth/nbt-auth.js';
10
8
  import { createSessionStore } from '../auth/session-store.js';
11
- import { clearScheduleCache } from './schedule-store.js';
9
+ import { clearScheduleCache, termKey } from './schedule-store.js';
12
10
  import { fmt, t } from '../i18n/index.js';
11
+ import { sanitizeAcademicTerm, sanitizeTimetable } from './timetable-sanitize.js';
13
12
  export const JWXT_ORIGIN = 'https://jwxt-443.webvpn.nbt.edu.cn';
14
13
  function flagValue(flags, prefix) {
15
14
  const flag = [...flags].find((value) => value.startsWith(prefix));
16
15
  return flag?.slice(prefix.length);
17
16
  }
18
- function semesterAlias(value) {
19
- if (value === '1')
20
- return '3';
21
- if (value === '2')
22
- return '12';
23
- if (value === '3')
24
- return '16';
25
- return value;
26
- }
27
17
  export function resolveTerm(catalog, selector) {
28
18
  if (catalog.length === 0)
29
19
  throw new Error('No academic terms are available.');
30
- if (!selector) {
31
- const current = catalog.filter((term) => term.current);
32
- if (current.length !== 1)
33
- throw new Error('The current academic term could not be determined.');
34
- return current[0];
35
- }
36
- const exact = catalog.find((term) => `${term.academicYear}:${term.semester}` === selector);
37
- if (exact)
38
- return exact;
39
- const shorthand = /^(\d{4})[-/:](\d+)$/.exec(selector);
40
- if (shorthand?.[1] && shorthand[2]) {
41
- const semester = semesterAlias(shorthand[2]);
42
- const matched = catalog.find((term) => term.academicYear === shorthand[1] && term.semester === semester);
43
- if (matched)
44
- return matched;
45
- }
20
+ const selected = findAcademicTerm(catalog, selector);
21
+ if (selected)
22
+ return selected;
23
+ if (!selector?.trim())
24
+ throw new Error('The current academic term could not be determined.');
46
25
  throw new Error('Unknown academic term. Run `nbtca schedule terms` first.');
47
26
  }
48
27
  export function relevantTerms(catalog) {
@@ -65,41 +44,66 @@ function displaySemesterLabel(term) {
65
44
  : term.semesterLabel;
66
45
  }
67
46
  export function isSessionExpired(error) {
68
- return (error instanceof AuthError && error.code === 'SESSION_EXPIRED') || (error instanceof TimetableError && error.code === 'SESSION_EXPIRED');
47
+ return ((error instanceof AuthError && error.code === 'SESSION_EXPIRED') ||
48
+ (error instanceof TimetableError && error.code === 'SESSION_EXPIRED'));
69
49
  }
70
50
  export function safeMessage(error) {
71
51
  const trans = t().timetable;
72
52
  if (error instanceof AuthError) {
73
53
  switch (error.code) {
74
- case 'INVALID_CREDENTIALS': return trans.invalidCredentials;
75
- case 'ACCOUNT_LOCKED': return trans.accountLocked;
76
- case 'ACCOUNT_INACTIVE': return trans.accountInactive;
77
- case 'INTERACTIVE_CHALLENGE': return trans.challenge;
78
- case 'SESSION_EXPIRED': return trans.sessionExpired;
79
- case 'TIMEOUT': return trans.timeout;
80
- case 'NETWORK': return trans.network;
81
- case 'UNTRUSTED_URL': return trans.untrustedUrl;
82
- case 'HTTP_ERROR': return trans.httpError;
83
- case 'LOGIN_PAGE_CHANGED': return trans.loginChanged;
84
- case 'UNEXPECTED_RESPONSE': return trans.unexpectedResponse;
85
- default: return trans.genericError;
54
+ case 'INVALID_CREDENTIALS':
55
+ return trans.invalidCredentials;
56
+ case 'ACCOUNT_LOCKED':
57
+ return trans.accountLocked;
58
+ case 'ACCOUNT_INACTIVE':
59
+ return trans.accountInactive;
60
+ case 'INTERACTIVE_CHALLENGE':
61
+ return trans.challenge;
62
+ case 'SESSION_EXPIRED':
63
+ return trans.sessionExpired;
64
+ case 'TIMEOUT':
65
+ return trans.timeout;
66
+ case 'NETWORK':
67
+ return trans.network;
68
+ case 'UNTRUSTED_URL':
69
+ return trans.untrustedUrl;
70
+ case 'HTTP_ERROR':
71
+ return trans.httpError;
72
+ case 'LOGIN_PAGE_CHANGED':
73
+ return trans.loginChanged;
74
+ case 'UNEXPECTED_RESPONSE':
75
+ return trans.unexpectedResponse;
76
+ default:
77
+ return trans.genericError;
86
78
  }
87
79
  }
88
80
  if (error instanceof TimetableError) {
89
81
  switch (error.code) {
90
- case 'MISSING_CALENDAR_DATES': return trans.missingDates;
91
- case 'MISSING_PERIOD_TIME': return trans.missingPeriod;
92
- case 'TERM_MISMATCH': return trans.termMismatch;
93
- case 'SESSION_EXPIRED': return trans.sessionExpired;
94
- default: return trans.invalidData;
82
+ case 'MISSING_CALENDAR_DATES':
83
+ return trans.missingDates;
84
+ case 'MISSING_PERIOD_TIME':
85
+ return trans.missingPeriod;
86
+ case 'TERM_MISMATCH':
87
+ return trans.termMismatch;
88
+ case 'SESSION_EXPIRED':
89
+ return trans.sessionExpired;
90
+ case 'HTTP_ERROR':
91
+ return trans.httpError;
92
+ case 'NETWORK_ERROR':
93
+ return trans.network;
94
+ case 'INVALID_TERM_CATALOG':
95
+ case 'INVALID_TIMETABLE':
96
+ return trans.invalidData;
95
97
  }
96
98
  }
97
- if (error instanceof Error && error.message === 'Unknown academic term. Run `nbtca schedule terms` first.') {
99
+ if (error instanceof Error &&
100
+ error.message === 'Unknown academic term. Run `nbtca schedule terms` first.') {
98
101
  return trans.unknownTerm;
99
102
  }
100
103
  if (error instanceof Error && error.message === 'No academic terms are available.')
101
104
  return trans.noTerms;
102
- if (error instanceof Error && error.message === 'The current academic term could not be determined.') {
105
+ if (error instanceof Error &&
106
+ error.message === 'The current academic term could not be determined.') {
103
107
  return trans.currentTermUnknown;
104
108
  }
105
109
  return trans.genericError;
@@ -183,19 +187,25 @@ export function writePrivateIcs(filePath, contents) {
183
187
  const temporaryPath = path.join(path.dirname(resolved), `.${path.basename(resolved)}.${process.pid}.${randomUUID()}.tmp`);
184
188
  try {
185
189
  fs.writeFileSync(temporaryPath, contents, {
186
- encoding: 'utf8', flag: 'wx', mode: 0o600,
190
+ encoding: 'utf8',
191
+ flag: 'wx',
192
+ mode: 0o600,
187
193
  });
188
194
  fs.renameSync(temporaryPath, resolved);
189
195
  try {
190
196
  fs.chmodSync(resolved, 0o600);
191
197
  }
192
- catch { /* Best effort on non-POSIX filesystems. */ }
198
+ catch {
199
+ /* Best effort on non-POSIX filesystems. */
200
+ }
193
201
  }
194
202
  finally {
195
203
  try {
196
204
  fs.unlinkSync(temporaryPath);
197
205
  }
198
- catch { /* Rename or the original error is authoritative. */ }
206
+ catch {
207
+ /* Rename or the original error is authoritative. */
208
+ }
199
209
  }
200
210
  }
201
211
  function clientFor(session) {
@@ -213,13 +223,13 @@ async function resolveWeekOneMonday(explicitValue, hasAuthoritativeDates, isInte
213
223
  placeholder: t().timetable.weekOneHint,
214
224
  allowEmpty: false,
215
225
  });
216
- return value || undefined;
226
+ return value === null || value === '' ? undefined : value;
217
227
  }
218
228
  export async function runStudentTimetableCommand(subcommandValue, options) {
219
229
  const subcommand = (subcommandValue ?? 'export').toLowerCase();
220
230
  const stdout = options.stdout ?? process.stdout;
221
231
  const stderr = options.stderr ?? process.stderr;
222
- const isInteractive = options.isInteractive ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
232
+ const isInteractive = options.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY);
223
233
  const store = options.store ?? createSessionStore();
224
234
  const oneShot = options.flags.has('--one-shot') || options.flags.has('--no-save');
225
235
  const trans = t().timetable;
@@ -233,7 +243,7 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
233
243
  : subcommand === 'logout'
234
244
  ? ['--plain']
235
245
  : [...commonFlags];
236
- const invalidFlag = [...options.flags].find((flag) => !allowedFlags.some((allowed) => (allowed.endsWith('=') ? flag.startsWith(allowed) : flag === allowed)));
246
+ const invalidFlag = [...options.flags].find((flag) => !allowedFlags.some((allowed) => allowed.endsWith('=') ? flag.startsWith(allowed) : flag === allowed));
237
247
  if (invalidFlag) {
238
248
  stderr.write(`${fmt(trans.invalidOption, { flag: invalidFlag })}\n`);
239
249
  return 1;
@@ -266,7 +276,7 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
266
276
  }
267
277
  return await withAuthenticatedSession(async (session) => {
268
278
  const client = clientFor(session);
269
- const catalog = await client.listTerms();
279
+ const catalog = (await client.listTerms()).map(sanitizeAcademicTerm);
270
280
  if (subcommand === 'terms') {
271
281
  stdout.write(`${trans.candidateTerms}\n`);
272
282
  for (const term of relevantTerms(catalog)) {
@@ -275,12 +285,14 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
275
285
  return 0;
276
286
  }
277
287
  const selected = resolveTerm(catalog, flagValue(options.flags, '--term='));
278
- const timetable = await client.fetchTerm(selected);
279
- const output = flagValue(options.flags, '--output=')
280
- || `timetable-${selected.academicYear}-${selected.semester}.ics`;
288
+ const timetable = sanitizeTimetable(await client.fetchTerm(selected));
289
+ const outputFlag = flagValue(options.flags, '--output=');
290
+ const output = outputFlag === undefined || outputFlag === ''
291
+ ? `timetable-${termKey(selected)}.ics`
292
+ : outputFlag;
281
293
  const weekOneMonday = await resolveWeekOneMonday(flagValue(options.flags, '--week-one='), timetable.calendarDays.length > 0, isInteractive);
282
294
  const ics = timetableToIcs(timetable, {
283
- weekOneMonday,
295
+ ...(weekOneMonday === undefined ? {} : { weekOneMonday }),
284
296
  calendarName: fmt(trans.calendarName, {
285
297
  year: selected.academicYearLabel,
286
298
  semester: displaySemesterLabel(selected),
@@ -310,37 +322,3 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
310
322
  return 1;
311
323
  }
312
324
  }
313
- export async function showStudentTimetableMenu() {
314
- while (true) {
315
- const trans = t();
316
- const footer = `${glyph.updown()} ${trans.menu.hintMove} ${glyph.enter()} ${trans.menu.hintOpen} q ${trans.menu.hintQuit}`;
317
- const action = await runMenu({
318
- title: trans.timetable.menuTitle,
319
- options: [
320
- { value: 'export', label: trans.timetable.actionExport },
321
- { value: 'terms', label: trans.timetable.actionTerms },
322
- { value: 'status', label: trans.timetable.actionStatus },
323
- { value: 'login', label: trans.timetable.actionLogin },
324
- { value: 'logout', label: trans.timetable.actionLogout },
325
- ],
326
- footer,
327
- });
328
- if (action === null)
329
- return;
330
- const flags = new Set();
331
- if (action === 'export') {
332
- const term = await runTextInput({
333
- message: trans.timetable.termPrompt,
334
- placeholder: trans.timetable.termPromptHint,
335
- });
336
- if (term === null)
337
- continue;
338
- if (term.trim())
339
- flags.add(`--term=${term.trim()}`);
340
- }
341
- await runStudentTimetableCommand(action, {
342
- flags,
343
- isInteractive: true,
344
- });
345
- }
346
- }
@@ -1,8 +1,6 @@
1
- /**
2
- * Theme CLI command handler (non-interactive)
3
- */
4
1
  import { applyColorModePreference, loadPreferences, resetPreferences, setColorMode, setIconMode, } from '../config/preferences.js';
5
2
  import { resetIconCache } from '../core/icons.js';
3
+ import { resetCapabilities } from '../core/capabilities.js';
6
4
  import { t } from '../i18n/index.js';
7
5
  const ICON_MODES = ['auto', 'ascii', 'unicode'];
8
6
  const COLOR_MODES = ['auto', 'on', 'off'];
@@ -21,25 +19,28 @@ export function runThemeCommand(args) {
21
19
  const saved = resetPreferences();
22
20
  resetIconCache();
23
21
  applyColorModePreference(false);
22
+ resetCapabilities();
24
23
  const message = saved ? trans.theme.reset : trans.theme.resetSessionOnly;
25
24
  return { ok: true, message };
26
25
  }
27
26
  if (scope === 'icon') {
28
- const mode = (value || '').toLowerCase();
27
+ const mode = (value?.toLowerCase() ?? '');
29
28
  if (!ICON_MODES.includes(mode)) {
30
29
  return { ok: false, message: `${trans.theme.invalidValue} auto, ascii, unicode` };
31
30
  }
32
31
  const saved = setIconMode(mode);
33
32
  resetIconCache();
33
+ resetCapabilities();
34
34
  return { ok: true, message: saved ? trans.theme.updated : trans.theme.updatedSessionOnly };
35
35
  }
36
36
  if (scope === 'color') {
37
- const mode = (value || '').toLowerCase();
37
+ const mode = (value?.toLowerCase() ?? '');
38
38
  if (!COLOR_MODES.includes(mode)) {
39
39
  return { ok: false, message: `${trans.theme.invalidValue} auto, on, off` };
40
40
  }
41
41
  const saved = setColorMode(mode);
42
42
  applyColorModePreference(false);
43
+ resetCapabilities();
43
44
  return { ok: true, message: saved ? trans.theme.updated : trans.theme.updatedSessionOnly };
44
45
  }
45
46
  return { ok: false, message: trans.theme.usage };
@@ -0,0 +1,40 @@
1
+ import { sanitizeTerminalLine } from '../core/text.js';
2
+ import { termKey } from './schedule-store.js';
3
+ export function sanitizeAcademicTerm(term) {
4
+ termKey(term);
5
+ return {
6
+ ...term,
7
+ academicYearLabel: sanitizeTerminalLine(term.academicYearLabel),
8
+ semesterLabel: sanitizeTerminalLine(term.semesterLabel),
9
+ };
10
+ }
11
+ function sanitizeUnresolvedItem(item) {
12
+ return {
13
+ ...item,
14
+ sourceFields: Object.fromEntries(Object.entries(item.sourceFields).map(([key, value]) => [key, sanitizeTerminalLine(value)])),
15
+ };
16
+ }
17
+ export function sanitizeTimetable(timetable) {
18
+ const untimedCourses = timetable.untimedCourses?.map((course) => ({
19
+ ...course,
20
+ courseName: sanitizeTerminalLine(course.courseName),
21
+ teacherNames: course.teacherNames.map(sanitizeTerminalLine),
22
+ campus: course.campus === null ? null : sanitizeTerminalLine(course.campus),
23
+ location: course.location === null ? null : sanitizeTerminalLine(course.location),
24
+ }));
25
+ return {
26
+ ...timetable,
27
+ meetings: timetable.meetings.map((meeting) => ({
28
+ ...meeting,
29
+ courseName: sanitizeTerminalLine(meeting.courseName),
30
+ teacherNames: meeting.teacherNames.map(sanitizeTerminalLine),
31
+ location: meeting.location === null ? null : sanitizeTerminalLine(meeting.location),
32
+ })),
33
+ ...(untimedCourses === undefined ? {} : { untimedCourses }),
34
+ unresolvedItems: timetable.unresolvedItems.map(sanitizeUnresolvedItem),
35
+ periods: timetable.periods.map((period) => ({
36
+ ...period,
37
+ label: period.label === null ? null : sanitizeTerminalLine(period.label),
38
+ })),
39
+ };
40
+ }
@@ -1,42 +1,30 @@
1
- /**
2
- * Version update checker
3
- * Non-blocking check against npm registry for newer versions.
4
- */
5
1
  import chalk from 'chalk';
6
2
  import { APP_INFO } from '../config/data.js';
7
3
  import { t, fmt } from '../i18n/index.js';
8
4
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/@nbtca/prompt/latest`;
9
- /**
10
- * Fetch latest version from npm registry.
11
- * Returns null on any failure (network, timeout, parse, or `signal` aborting
12
- * the request — e.g. the caller quit before this settled).
13
- */
14
- async function fetchLatestVersion(signal) {
5
+ async function fetchLatestVersion() {
15
6
  const controller = new AbortController();
16
- const timeout = setTimeout(() => controller.abort(), 3000);
17
- const onExternalAbort = () => controller.abort();
18
- signal?.addEventListener('abort', onExternalAbort);
7
+ const timeout = setTimeout(() => {
8
+ controller.abort();
9
+ }, 3000);
10
+ timeout.unref();
19
11
  try {
20
12
  const res = await fetch(NPM_REGISTRY_URL, {
21
13
  signal: controller.signal,
22
- headers: { 'Accept': 'application/json' },
14
+ headers: { Accept: 'application/json' },
23
15
  });
24
16
  if (!res.ok)
25
17
  return null;
26
18
  const data = (await res.json());
27
- return data.version ?? null;
19
+ return typeof data.version === 'string' ? data.version : null;
28
20
  }
29
21
  catch {
30
22
  return null;
31
23
  }
32
24
  finally {
33
25
  clearTimeout(timeout);
34
- signal?.removeEventListener('abort', onExternalAbort);
35
26
  }
36
27
  }
37
- /**
38
- * Compare semver strings. Returns true if remote > local.
39
- */
40
28
  function isNewer(local, remote) {
41
29
  const parse = (v) => v.split('.').map(Number);
42
30
  const l = parse(local);
@@ -49,22 +37,6 @@ function isNewer(local, remote) {
49
37
  }
50
38
  return false;
51
39
  }
52
- /**
53
- * Non-blocking update check for TUI startup.
54
- * Resolves to a notification string or null. Pass `signal` so a caller that
55
- * quits before this settles can cancel the in-flight request instead of
56
- * leaving it to hold the process open.
57
- */
58
- export async function checkForUpdate(signal) {
59
- const latest = await fetchLatestVersion(signal);
60
- if (!latest || !isNewer(APP_INFO.version, latest))
61
- return null;
62
- const trans = t();
63
- return `${fmt(trans.update.available, { latest, current: APP_INFO.version })} ${chalk.dim(trans.update.command)}`;
64
- }
65
- /**
66
- * Explicit update check command (nbtca update).
67
- */
68
40
  export async function runUpdateCheck() {
69
41
  const trans = t();
70
42
  const latest = await fetchLatestVersion();
@@ -73,10 +45,10 @@ export async function runUpdateCheck() {
73
45
  return;
74
46
  }
75
47
  if (isNewer(APP_INFO.version, latest)) {
76
- console.log(chalk.yellow(`${fmt(trans.update.available, { latest, current: APP_INFO.version })}`));
48
+ console.log(chalk.yellow(fmt(trans.update.available, { latest, current: APP_INFO.version })));
77
49
  console.log(chalk.dim(trans.update.command));
78
50
  }
79
51
  else {
80
- console.log(chalk.green(`${fmt(trans.update.upToDate, { version: APP_INFO.version })}`));
52
+ console.log(chalk.green(fmt(trans.update.upToDate, { version: APP_INFO.version })));
81
53
  }
82
54
  }
@@ -1,101 +1,127 @@
1
- /**
2
- * Internationalization (i18n) System
3
- * Multi-language support for the application
4
- */
5
1
  import fs from 'fs';
6
- import path from 'path';
2
+ import path, { dirname } from 'path';
7
3
  import { fileURLToPath } from 'url';
8
- import { dirname } from 'path';
9
4
  import { getConfigDir, getWritableConfigDir } from '../config/paths.js';
10
5
  const __filename = fileURLToPath(import.meta.url);
11
6
  const __dirname = dirname(__filename);
12
- /**
13
- * Language configuration
14
- */
15
- let currentLanguage = 'zh'; // Default to Chinese
16
- /**
17
- * Get language configuration file path (read, with legacy fallback)
18
- */
7
+ const unsafeTranslationKeys = new Set(['__proto__', 'constructor', 'prototype']);
8
+ function childPath(parent, key) {
9
+ return `${parent}.${key}`;
10
+ }
11
+ function assertTranslationNode(value, pathName) {
12
+ if (typeof value === 'string')
13
+ return;
14
+ assertTranslationObject(value, pathName);
15
+ }
16
+ function assertTranslationObject(value, pathName) {
17
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
18
+ throw new TypeError(`${pathName} must be a plain object`);
19
+ }
20
+ const prototype = Reflect.getPrototypeOf(value);
21
+ if (prototype !== Object.prototype && prototype !== null) {
22
+ throw new TypeError(`${pathName} must be a plain object`);
23
+ }
24
+ for (const [key, child] of Object.entries(value)) {
25
+ const currentPath = childPath(pathName, key);
26
+ if (unsafeTranslationKeys.has(key)) {
27
+ throw new TypeError(`${currentPath} uses an unsafe key`);
28
+ }
29
+ assertTranslationNode(child, currentPath);
30
+ }
31
+ }
32
+ function assertMatchingTranslationShape(candidate, reference, pathName) {
33
+ for (const key of Object.keys(candidate)) {
34
+ if (!Object.hasOwn(reference, key)) {
35
+ throw new TypeError(`${childPath(pathName, key)} is not in the reference translation`);
36
+ }
37
+ }
38
+ for (const key of Object.keys(reference)) {
39
+ const currentPath = childPath(pathName, key);
40
+ if (!Object.hasOwn(candidate, key)) {
41
+ throw new TypeError(`${currentPath} is missing`);
42
+ }
43
+ const candidateNode = candidate[key];
44
+ const referenceNode = reference[key];
45
+ if (candidateNode === undefined || referenceNode === undefined) {
46
+ throw new TypeError(`${currentPath} is missing`);
47
+ }
48
+ if (typeof candidateNode !== typeof referenceNode) {
49
+ throw new TypeError(`${currentPath} has a mismatched leaf type`);
50
+ }
51
+ if (typeof candidateNode !== 'string' && typeof referenceNode !== 'string') {
52
+ assertMatchingTranslationShape(candidateNode, referenceNode, currentPath);
53
+ }
54
+ }
55
+ }
56
+ export function validateTranslationShape(candidate, reference) {
57
+ assertTranslationObject(candidate, 'translations');
58
+ if (reference === undefined)
59
+ return;
60
+ assertTranslationObject(reference, 'reference');
61
+ assertMatchingTranslationShape(candidate, reference, 'translations');
62
+ }
63
+ function assertTranslations(candidate, reference) {
64
+ validateTranslationShape(candidate, reference);
65
+ }
66
+ let currentLanguage = 'zh';
19
67
  function getLanguageConfigPath() {
20
68
  return path.join(getConfigDir(), 'language.json');
21
69
  }
22
- /**
23
- * Get writable language configuration file path (XDG, creates dir)
24
- */
25
70
  function getWritableLanguageConfigPath() {
26
71
  return path.join(getWritableConfigDir(), 'language.json');
27
72
  }
28
- /**
29
- * Load language preference from config file
30
- */
31
73
  export function loadLanguagePreference() {
32
74
  try {
33
75
  const configPath = getLanguageConfigPath();
34
76
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
35
- if (config.language === 'zh' || config.language === 'en') {
36
- currentLanguage = config.language;
77
+ if (typeof config === 'object' && config !== null && 'language' in config) {
78
+ const language = config.language;
79
+ if (language === 'zh' || language === 'en') {
80
+ currentLanguage = language;
81
+ }
37
82
  }
38
83
  }
39
- catch {
40
- // If loading fails (file missing or invalid), use default (Chinese)
41
- }
84
+ catch { }
42
85
  return currentLanguage;
43
86
  }
44
- /**
45
- * Save language preference to config file
46
- */
47
87
  export function saveLanguagePreference(language) {
88
+ setLanguage(language);
48
89
  try {
49
90
  const configPath = getWritableLanguageConfigPath();
50
91
  fs.writeFileSync(configPath, JSON.stringify({ language }, null, 2));
51
- currentLanguage = language;
52
92
  return true;
53
93
  }
54
94
  catch {
55
95
  return false;
56
96
  }
57
97
  }
58
- /**
59
- * Get current language
60
- */
61
98
  export function getCurrentLanguage() {
62
99
  return currentLanguage;
63
100
  }
64
- /**
65
- * Set current language
66
- */
67
101
  export function setLanguage(language) {
68
102
  currentLanguage = language;
69
- return saveLanguagePreference(language);
70
103
  }
71
- /**
72
- * Load translation file
73
- */
74
- function loadTranslations(language) {
75
- try {
76
- const translationPath = path.join(__dirname, 'locales', `${language}.json`);
77
- const content = fs.readFileSync(translationPath, 'utf-8');
78
- return JSON.parse(content);
79
- }
80
- catch (err) {
81
- // Fallback to Chinese if loading fails
82
- const fallbackPath = path.join(__dirname, 'locales', 'zh.json');
83
- const content = fs.readFileSync(fallbackPath, 'utf-8');
84
- return JSON.parse(content);
85
- }
104
+ function parseTranslationFile(language) {
105
+ const translationPath = path.join(__dirname, 'locales', `${language}.json`);
106
+ const content = fs.readFileSync(translationPath, 'utf-8');
107
+ return JSON.parse(content);
108
+ }
109
+ function loadTranslations() {
110
+ const english = parseTranslationFile('en');
111
+ assertTranslations(english);
112
+ const chinese = parseTranslationFile('zh');
113
+ assertTranslations(chinese, english);
114
+ return { en: english, zh: chinese };
86
115
  }
87
- /**
88
- * Translation cache
89
- */
90
- let translationsCache = new Map();
91
- /**
92
- * Get translations for current language
93
- */
116
+ const translationsCache = new Map();
94
117
  export function t() {
95
- if (!translationsCache.has(currentLanguage)) {
96
- translationsCache.set(currentLanguage, loadTranslations(currentLanguage));
97
- }
98
- return translationsCache.get(currentLanguage);
118
+ const cached = translationsCache.get(currentLanguage);
119
+ if (cached !== undefined)
120
+ return cached;
121
+ const translations = loadTranslations();
122
+ translationsCache.set('en', translations.en);
123
+ translationsCache.set('zh', translations.zh);
124
+ return translations[currentLanguage];
99
125
  }
100
126
  export function fmt(template, vars) {
101
127
  return template.replace(/\{(\w+)\}/g, (_, key) => {
@@ -103,11 +129,7 @@ export function fmt(template, vars) {
103
129
  return val !== undefined ? String(val) : `{${key}}`;
104
130
  });
105
131
  }
106
- /**
107
- * Clear translation cache (useful when switching languages)
108
- */
109
132
  export function clearTranslationCache() {
110
133
  translationsCache.clear();
111
134
  }
112
- // Initialize language preference on module load
113
135
  loadLanguagePreference();
@@ -124,7 +124,7 @@
124
124
  "githubTokenHint": "Tip: set GITHUB_TOKEN for a higher rate limit.",
125
125
  "fetchDirFailed": "Failed to fetch directory: {error}",
126
126
  "fetchFileFailed": "Failed to fetch file: {error}",
127
- "searchPrompt": "Search documents by title:",
127
+ "searchPrompt": "Search documents:",
128
128
  "searchPlaceholder": "Enter keyword...",
129
129
  "searching": "Searching documents...",
130
130
  "searchResults": "results found",
@@ -124,7 +124,7 @@
124
124
  "githubTokenHint": "提示: 设置 GITHUB_TOKEN 环境变量可获得更高的速率限制。",
125
125
  "fetchDirFailed": "无法获取目录内容: {error}",
126
126
  "fetchFileFailed": "无法获取文件内容: {error}",
127
- "searchPrompt": "按标题搜索文档:",
127
+ "searchPrompt": "搜索文档:",
128
128
  "searchPlaceholder": "输入关键词...",
129
129
  "searching": "正在搜索文档...",
130
130
  "searchResults": "个结果",