@nbtca/prompt 1.4.2 → 1.5.1

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 (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  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 +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  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/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. 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;
@@ -109,11 +113,10 @@ async function interactiveLogin(isInteractive) {
109
113
  if (!isInteractive) {
110
114
  throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Interactive login requires a terminal.');
111
115
  }
112
- const username = await runSecretInput({
116
+ const username = await runTextInput({
113
117
  message: trans.studentId,
114
118
  placeholder: trans.studentIdHint,
115
119
  allowEmpty: false,
116
- mask: '•',
117
120
  });
118
121
  if (!username)
119
122
  throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Student id is required.');
@@ -183,19 +186,25 @@ export function writePrivateIcs(filePath, contents) {
183
186
  const temporaryPath = path.join(path.dirname(resolved), `.${path.basename(resolved)}.${process.pid}.${randomUUID()}.tmp`);
184
187
  try {
185
188
  fs.writeFileSync(temporaryPath, contents, {
186
- encoding: 'utf8', flag: 'wx', mode: 0o600,
189
+ encoding: 'utf8',
190
+ flag: 'wx',
191
+ mode: 0o600,
187
192
  });
188
193
  fs.renameSync(temporaryPath, resolved);
189
194
  try {
190
195
  fs.chmodSync(resolved, 0o600);
191
196
  }
192
- catch { /* Best effort on non-POSIX filesystems. */ }
197
+ catch {
198
+ /* Best effort on non-POSIX filesystems. */
199
+ }
193
200
  }
194
201
  finally {
195
202
  try {
196
203
  fs.unlinkSync(temporaryPath);
197
204
  }
198
- catch { /* Rename or the original error is authoritative. */ }
205
+ catch {
206
+ /* Rename or the original error is authoritative. */
207
+ }
199
208
  }
200
209
  }
201
210
  function clientFor(session) {
@@ -213,13 +222,13 @@ async function resolveWeekOneMonday(explicitValue, hasAuthoritativeDates, isInte
213
222
  placeholder: t().timetable.weekOneHint,
214
223
  allowEmpty: false,
215
224
  });
216
- return value || undefined;
225
+ return value === null || value === '' ? undefined : value;
217
226
  }
218
227
  export async function runStudentTimetableCommand(subcommandValue, options) {
219
228
  const subcommand = (subcommandValue ?? 'export').toLowerCase();
220
229
  const stdout = options.stdout ?? process.stdout;
221
230
  const stderr = options.stderr ?? process.stderr;
222
- const isInteractive = options.isInteractive ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
231
+ const isInteractive = options.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY);
223
232
  const store = options.store ?? createSessionStore();
224
233
  const oneShot = options.flags.has('--one-shot') || options.flags.has('--no-save');
225
234
  const trans = t().timetable;
@@ -233,7 +242,7 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
233
242
  : subcommand === 'logout'
234
243
  ? ['--plain']
235
244
  : [...commonFlags];
236
- const invalidFlag = [...options.flags].find((flag) => !allowedFlags.some((allowed) => (allowed.endsWith('=') ? flag.startsWith(allowed) : flag === allowed)));
245
+ const invalidFlag = [...options.flags].find((flag) => !allowedFlags.some((allowed) => allowed.endsWith('=') ? flag.startsWith(allowed) : flag === allowed));
237
246
  if (invalidFlag) {
238
247
  stderr.write(`${fmt(trans.invalidOption, { flag: invalidFlag })}\n`);
239
248
  return 1;
@@ -266,7 +275,7 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
266
275
  }
267
276
  return await withAuthenticatedSession(async (session) => {
268
277
  const client = clientFor(session);
269
- const catalog = await client.listTerms();
278
+ const catalog = (await client.listTerms()).map(sanitizeAcademicTerm);
270
279
  if (subcommand === 'terms') {
271
280
  stdout.write(`${trans.candidateTerms}\n`);
272
281
  for (const term of relevantTerms(catalog)) {
@@ -275,12 +284,14 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
275
284
  return 0;
276
285
  }
277
286
  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`;
287
+ const timetable = sanitizeTimetable(await client.fetchTerm(selected));
288
+ const outputFlag = flagValue(options.flags, '--output=');
289
+ const output = outputFlag === undefined || outputFlag === ''
290
+ ? `timetable-${termKey(selected)}.ics`
291
+ : outputFlag;
281
292
  const weekOneMonday = await resolveWeekOneMonday(flagValue(options.flags, '--week-one='), timetable.calendarDays.length > 0, isInteractive);
282
293
  const ics = timetableToIcs(timetable, {
283
- weekOneMonday,
294
+ ...(weekOneMonday === undefined ? {} : { weekOneMonday }),
284
295
  calendarName: fmt(trans.calendarName, {
285
296
  year: selected.academicYearLabel,
286
297
  semester: displaySemesterLabel(selected),
@@ -310,37 +321,3 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
310
321
  return 1;
311
322
  }
312
323
  }
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'];
@@ -8,7 +9,7 @@ function formatThemeSummary() {
8
9
  const prefs = loadPreferences();
9
10
  return `${trans.theme.iconMode}: ${prefs.iconMode}, ${trans.theme.colorMode}: ${prefs.colorMode}`;
10
11
  }
11
- export function runThemeCommand(args) {
12
+ export function runThemeCommand(args, options = {}) {
12
13
  const trans = t();
13
14
  const [scope, value] = args;
14
15
  if (!scope) {
@@ -17,26 +18,29 @@ export function runThemeCommand(args) {
17
18
  if (scope === 'reset') {
18
19
  const saved = resetPreferences();
19
20
  resetIconCache();
20
- applyColorModePreference(false);
21
+ applyColorModePreference(options.forcePlain === true);
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
- applyColorModePreference(false);
42
+ applyColorModePreference(options.forcePlain === true);
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,31 +37,19 @@ 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();
61
43
  if (!latest) {
62
- console.log(chalk.yellow(trans.update.checkFailed));
63
- return;
44
+ console.error(chalk.yellow(trans.update.checkFailed));
45
+ return false;
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
  }
54
+ return true;
72
55
  }
@@ -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) => {
@@ -46,6 +46,7 @@
46
46
  "loading": "Loading event calendar...",
47
47
  "noEvents": "No upcoming events",
48
48
  "error": "Failed to load event calendar",
49
+ "stale": "offline, showing last known",
49
50
  "errorHint": "Please try again later or visit https://nbtca.space",
50
51
  "eventsFound": "events found",
51
52
  "dateTime": "Date & Time",
@@ -113,7 +114,6 @@
113
114
  "opening": "Opening browser...",
114
115
  "browserOpened": "Knowledge base opened in browser",
115
116
  "browserError": "Failed to open browser",
116
- "browserErrorHint": "Please visit manually: https://docs.nbtca.space",
117
117
  "retry": "Retry?",
118
118
  "tocTitle": "Table of Contents",
119
119
  "tableHint": "diagrams and tables render better in browser",
@@ -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",
@@ -142,12 +142,14 @@
142
142
  "repair": "Repair Service",
143
143
  "opening": "Opening...",
144
144
  "opened": "Opened in browser",
145
- "error": "Failed to open browser"
145
+ "error": "Failed to open browser",
146
+ "openManually": "Open manually: {url}"
146
147
  },
147
148
  "status": {
148
149
  "checking": "Checking service status...",
149
150
  "summaryOk": "All services are healthy",
150
151
  "summaryFail": "Some services are unavailable",
152
+ "summaryIntranet": "Public services are healthy; intranet availability depends on your network",
151
153
  "service": "Service",
152
154
  "health": "Health",
153
155
  "code": "Code",
@@ -361,6 +363,9 @@
361
363
  "invalidFlagHint": "Run `nbtca --help` to see command usage.",
362
364
  "invalidLang": "Invalid language. Use `zh` or `en`.",
363
365
  "invalidNext": "Invalid --next value. Use --next=<number> (>= 1).",
366
+ "eventsHeatmapConflict": "--heatmap cannot be combined with --today, --week, --month, --next, or --search.",
367
+ "eventsRangeConflict": "Choose only one event range: --today, --week, or --month.",
368
+ "unexpectedArguments": "Unexpected arguments for {command}.",
364
369
  "requiresTty": "Interactive mode requires a TTY terminal.",
365
370
  "requiresTtyHint": "Use `nbtca --help` for command mode."
366
371
  }