@nbtca/prompt 1.4.2 → 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 (67) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +53 -55
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +2 -21
  8. package/dist/app/keys.js +10 -2
  9. package/dist/app/views/docs-render.js +31 -24
  10. package/dist/app/views/docs.js +211 -60
  11. package/dist/app/views/events-render.js +19 -26
  12. package/dist/app/views/events.js +44 -31
  13. package/dist/app/views/home.js +28 -30
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +47 -71
  16. package/dist/app/views/schedule.js +158 -81
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/config/data.js +9 -11
  24. package/dist/config/preferences.js +14 -7
  25. package/dist/core/calendar-day.js +37 -0
  26. package/dist/core/capabilities.js +6 -3
  27. package/dist/core/components/confirm.js +9 -8
  28. package/dist/core/components/menu.js +41 -16
  29. package/dist/core/components/messages.js +12 -4
  30. package/dist/core/components/painter.js +3 -1
  31. package/dist/core/components/spinner.js +17 -6
  32. package/dist/core/components/text-input.js +24 -18
  33. package/dist/core/icons.js +2 -2
  34. package/dist/core/logo.js +23 -5
  35. package/dist/core/motion.js +25 -19
  36. package/dist/core/text.js +182 -69
  37. package/dist/core/theme.js +0 -28
  38. package/dist/core/transitions.js +2 -2
  39. package/dist/core/ui.js +15 -13
  40. package/dist/core/vim-keys.js +9 -15
  41. package/dist/features/about.js +23 -0
  42. package/dist/features/calendar-heatmap.js +16 -40
  43. package/dist/features/calendar-query.js +1 -2
  44. package/dist/features/calendar.js +12 -185
  45. package/dist/features/docs.js +436 -275
  46. package/dist/features/schedule-render.js +65 -101
  47. package/dist/features/schedule-store.js +51 -9
  48. package/dist/features/schedule-view.js +46 -213
  49. package/dist/features/status.js +44 -56
  50. package/dist/features/student-timetable.js +73 -95
  51. package/dist/features/theme.js +6 -2
  52. package/dist/features/timetable-sanitize.js +40 -0
  53. package/dist/features/update.js +9 -27
  54. package/dist/i18n/index.js +83 -19
  55. package/dist/i18n/locales/en.json +1 -1
  56. package/dist/i18n/locales/zh.json +1 -1
  57. package/dist/index.js +83 -58
  58. package/dist/logo/ca-dotmatrix.txt +16 -18
  59. package/dist/main.js +7 -48
  60. package/package.json +27 -18
  61. package/bin/nbtca-welcome.js +0 -2
  62. package/dist/core/components/screen.js +0 -18
  63. package/dist/core/menu.js +0 -68
  64. package/dist/features/links.js +0 -36
  65. package/dist/features/schedule-query.js +0 -47
  66. package/dist/features/settings.js +0 -127
  67. 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,5 +1,6 @@
1
1
  import { applyColorModePreference, loadPreferences, resetPreferences, setColorMode, setIconMode, } from '../config/preferences.js';
2
2
  import { resetIconCache } from '../core/icons.js';
3
+ import { resetCapabilities } from '../core/capabilities.js';
3
4
  import { t } from '../i18n/index.js';
4
5
  const ICON_MODES = ['auto', 'ascii', 'unicode'];
5
6
  const COLOR_MODES = ['auto', 'on', 'off'];
@@ -18,25 +19,28 @@ export function runThemeCommand(args) {
18
19
  const saved = resetPreferences();
19
20
  resetIconCache();
20
21
  applyColorModePreference(false);
22
+ resetCapabilities();
21
23
  const message = saved ? trans.theme.reset : trans.theme.resetSessionOnly;
22
24
  return { ok: true, message };
23
25
  }
24
26
  if (scope === 'icon') {
25
- const mode = (value || '').toLowerCase();
27
+ const mode = (value?.toLowerCase() ?? '');
26
28
  if (!ICON_MODES.includes(mode)) {
27
29
  return { ok: false, message: `${trans.theme.invalidValue} auto, ascii, unicode` };
28
30
  }
29
31
  const saved = setIconMode(mode);
30
32
  resetIconCache();
33
+ resetCapabilities();
31
34
  return { ok: true, message: saved ? trans.theme.updated : trans.theme.updatedSessionOnly };
32
35
  }
33
36
  if (scope === 'color') {
34
- const mode = (value || '').toLowerCase();
37
+ const mode = (value?.toLowerCase() ?? '');
35
38
  if (!COLOR_MODES.includes(mode)) {
36
39
  return { ok: false, message: `${trans.theme.invalidValue} auto, on, off` };
37
40
  }
38
41
  const saved = setColorMode(mode);
39
42
  applyColorModePreference(false);
43
+ resetCapabilities();
40
44
  return { ok: true, message: saved ? trans.theme.updated : trans.theme.updatedSessionOnly };
41
45
  }
42
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
+ }
@@ -2,32 +2,27 @@ import chalk from 'chalk';
2
2
  import { APP_INFO } from '../config/data.js';
3
3
  import { t, fmt } from '../i18n/index.js';
4
4
  const NPM_REGISTRY_URL = `https://registry.npmjs.org/@nbtca/prompt/latest`;
5
- /**
6
- * Fetch latest version from npm registry.
7
- * Returns null on any failure (network, timeout, parse, or `signal` aborting
8
- * the request — e.g. the caller quit before this settled).
9
- */
10
- async function fetchLatestVersion(signal) {
5
+ async function fetchLatestVersion() {
11
6
  const controller = new AbortController();
12
- const timeout = setTimeout(() => controller.abort(), 3000);
13
- const onExternalAbort = () => controller.abort();
14
- signal?.addEventListener('abort', onExternalAbort);
7
+ const timeout = setTimeout(() => {
8
+ controller.abort();
9
+ }, 3000);
10
+ timeout.unref();
15
11
  try {
16
12
  const res = await fetch(NPM_REGISTRY_URL, {
17
13
  signal: controller.signal,
18
- headers: { 'Accept': 'application/json' },
14
+ headers: { Accept: 'application/json' },
19
15
  });
20
16
  if (!res.ok)
21
17
  return null;
22
18
  const data = (await res.json());
23
- return data.version ?? null;
19
+ return typeof data.version === 'string' ? data.version : null;
24
20
  }
25
21
  catch {
26
22
  return null;
27
23
  }
28
24
  finally {
29
25
  clearTimeout(timeout);
30
- signal?.removeEventListener('abort', onExternalAbort);
31
26
  }
32
27
  }
33
28
  function isNewer(local, remote) {
@@ -42,19 +37,6 @@ function isNewer(local, remote) {
42
37
  }
43
38
  return false;
44
39
  }
45
- /**
46
- * Non-blocking update check for TUI startup.
47
- * Resolves to a notification string or null. Pass `signal` so a caller that
48
- * quits before this settles can cancel the in-flight request instead of
49
- * leaving it to hold the process open.
50
- */
51
- export async function checkForUpdate(signal) {
52
- const latest = await fetchLatestVersion(signal);
53
- if (!latest || !isNewer(APP_INFO.version, latest))
54
- return null;
55
- const trans = t();
56
- return `${fmt(trans.update.available, { latest, current: APP_INFO.version })} ${chalk.dim(trans.update.command)}`;
57
- }
58
40
  export async function runUpdateCheck() {
59
41
  const trans = t();
60
42
  const latest = await fetchLatestVersion();
@@ -63,10 +45,10 @@ export async function runUpdateCheck() {
63
45
  return;
64
46
  }
65
47
  if (isNewer(APP_INFO.version, latest)) {
66
- 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 })));
67
49
  console.log(chalk.dim(trans.update.command));
68
50
  }
69
51
  else {
70
- 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 })));
71
53
  }
72
54
  }
@@ -1,10 +1,68 @@
1
1
  import fs from 'fs';
2
- import path from 'path';
2
+ import path, { dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { dirname } from 'path';
5
4
  import { getConfigDir, getWritableConfigDir } from '../config/paths.js';
6
5
  const __filename = fileURLToPath(import.meta.url);
7
6
  const __dirname = dirname(__filename);
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
+ }
8
66
  let currentLanguage = 'zh';
9
67
  function getLanguageConfigPath() {
10
68
  return path.join(getConfigDir(), 'language.json');
@@ -16,8 +74,11 @@ export function loadLanguagePreference() {
16
74
  try {
17
75
  const configPath = getLanguageConfigPath();
18
76
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
19
- if (config.language === 'zh' || config.language === 'en') {
20
- 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
+ }
21
82
  }
22
83
  }
23
84
  catch { }
@@ -40,24 +101,27 @@ export function getCurrentLanguage() {
40
101
  export function setLanguage(language) {
41
102
  currentLanguage = language;
42
103
  }
43
- function loadTranslations(language) {
44
- try {
45
- const translationPath = path.join(__dirname, 'locales', `${language}.json`);
46
- const content = fs.readFileSync(translationPath, 'utf-8');
47
- return JSON.parse(content);
48
- }
49
- catch {
50
- const fallbackPath = path.join(__dirname, 'locales', 'zh.json');
51
- const content = fs.readFileSync(fallbackPath, 'utf-8');
52
- return JSON.parse(content);
53
- }
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 };
54
115
  }
55
116
  const translationsCache = new Map();
56
117
  export function t() {
57
- if (!translationsCache.has(currentLanguage)) {
58
- translationsCache.set(currentLanguage, loadTranslations(currentLanguage));
59
- }
60
- 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];
61
125
  }
62
126
  export function fmt(template, vars) {
63
127
  return template.replace(/\{(\w+)\}/g, (_, key) => {
@@ -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": "个结果",