@nbtca/prompt 1.3.2 → 1.4.2

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 (69) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -1
  3. package/SECURITY.md +47 -0
  4. package/dist/app/app.js +202 -0
  5. package/dist/app/chrome.js +104 -0
  6. package/dist/app/fields/list-field.js +174 -0
  7. package/dist/app/fields/text-field.js +38 -0
  8. package/dist/app/frame.js +48 -0
  9. package/dist/app/keys.js +20 -0
  10. package/dist/app/tabs.js +11 -0
  11. package/dist/app/view.js +1 -0
  12. package/dist/app/views/docs-render.js +82 -0
  13. package/dist/app/views/docs.js +450 -0
  14. package/dist/app/views/events-render.js +111 -0
  15. package/dist/app/views/events.js +228 -0
  16. package/dist/app/views/home.js +195 -0
  17. package/dist/app/views/schedule-grid-cursor.js +52 -0
  18. package/dist/app/views/schedule-render.js +317 -0
  19. package/dist/app/views/schedule.js +463 -0
  20. package/dist/app/views/settings-render.js +53 -0
  21. package/dist/app/views/settings.js +153 -0
  22. package/dist/auth/cookie-transport.js +222 -0
  23. package/dist/auth/errors.js +18 -0
  24. package/dist/auth/nbt-auth.js +239 -0
  25. package/dist/auth/session-store.js +118 -0
  26. package/dist/config/data.js +1 -2
  27. package/dist/config/paths.js +22 -2
  28. package/dist/core/canvas.js +23 -0
  29. package/dist/core/capabilities.js +42 -0
  30. package/dist/core/components/confirm.js +75 -0
  31. package/dist/core/components/input-session.js +24 -0
  32. package/dist/core/components/menu.js +122 -0
  33. package/dist/core/components/messages.js +16 -0
  34. package/dist/core/components/note.js +18 -0
  35. package/dist/core/components/painter.js +26 -0
  36. package/dist/core/components/screen.js +18 -0
  37. package/dist/core/components/spinner.js +47 -0
  38. package/dist/core/components/text-input.js +98 -0
  39. package/dist/core/logo.js +33 -22
  40. package/dist/core/menu.js +24 -9
  41. package/dist/core/motion.js +86 -0
  42. package/dist/core/text.js +121 -5
  43. package/dist/core/theme.js +61 -0
  44. package/dist/core/transitions.js +19 -0
  45. package/dist/core/ui.js +4 -45
  46. package/dist/features/calendar-heatmap.js +29 -27
  47. package/dist/features/calendar-query.js +50 -0
  48. package/dist/features/calendar.js +192 -98
  49. package/dist/features/docs.js +222 -61
  50. package/dist/features/links.js +7 -7
  51. package/dist/features/schedule-query.js +47 -0
  52. package/dist/features/schedule-render.js +573 -0
  53. package/dist/features/schedule-store.js +73 -0
  54. package/dist/features/schedule-view.js +253 -0
  55. package/dist/features/settings.js +43 -35
  56. package/dist/features/status.js +37 -16
  57. package/dist/features/student-timetable.js +346 -0
  58. package/dist/features/theme.js +0 -3
  59. package/dist/features/update.js +16 -18
  60. package/dist/i18n/index.js +5 -47
  61. package/dist/i18n/locales/en.json +149 -6
  62. package/dist/i18n/locales/zh.json +149 -6
  63. package/dist/index.js +61 -11
  64. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  65. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  66. package/dist/logo/ca-dotmatrix.txt +18 -16
  67. package/dist/logo/ca-logo.png +0 -0
  68. package/dist/main.js +33 -13
  69. package/package.json +18 -12
@@ -0,0 +1,346 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createNbtTimetableClient, timetableToIcs, TimetableError, } from '@nbtca/nbtcal/timetable';
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
+ import { AuthError } from '../auth/errors.js';
9
+ import { loginWithStudentPassword, restoreNbtSession } from '../auth/nbt-auth.js';
10
+ import { createSessionStore } from '../auth/session-store.js';
11
+ import { clearScheduleCache } from './schedule-store.js';
12
+ import { fmt, t } from '../i18n/index.js';
13
+ export const JWXT_ORIGIN = 'https://jwxt-443.webvpn.nbt.edu.cn';
14
+ function flagValue(flags, prefix) {
15
+ const flag = [...flags].find((value) => value.startsWith(prefix));
16
+ return flag?.slice(prefix.length);
17
+ }
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
+ export function resolveTerm(catalog, selector) {
28
+ if (catalog.length === 0)
29
+ 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
+ }
46
+ throw new Error('Unknown academic term. Run `nbtca schedule terms` first.');
47
+ }
48
+ export function relevantTerms(catalog) {
49
+ const selected = catalog.find((term) => term.current);
50
+ const currentYear = Number.parseInt(selected?.academicYear ?? '', 10);
51
+ if (!Number.isInteger(currentYear))
52
+ return [...catalog].slice(0, 15);
53
+ return catalog.filter((term) => {
54
+ const year = Number.parseInt(term.academicYear, 10);
55
+ return Number.isInteger(year) && year <= currentYear && year >= currentYear - 4;
56
+ });
57
+ }
58
+ function displaySemesterLabel(term) {
59
+ const aliases = { '3': '1', '12': '2', '16': '3' };
60
+ const aliased = aliases[term.semester];
61
+ if (aliased)
62
+ return fmt(t().timetable.semesterNumber, { number: aliased });
63
+ return /^\d+$/.test(term.semesterLabel)
64
+ ? fmt(t().timetable.semesterNumber, { number: term.semesterLabel })
65
+ : term.semesterLabel;
66
+ }
67
+ export function isSessionExpired(error) {
68
+ return (error instanceof AuthError && error.code === 'SESSION_EXPIRED') || (error instanceof TimetableError && error.code === 'SESSION_EXPIRED');
69
+ }
70
+ export function safeMessage(error) {
71
+ const trans = t().timetable;
72
+ if (error instanceof AuthError) {
73
+ 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;
86
+ }
87
+ }
88
+ if (error instanceof TimetableError) {
89
+ 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;
95
+ }
96
+ }
97
+ if (error instanceof Error && error.message === 'Unknown academic term. Run `nbtca schedule terms` first.') {
98
+ return trans.unknownTerm;
99
+ }
100
+ if (error instanceof Error && error.message === 'No academic terms are available.')
101
+ return trans.noTerms;
102
+ if (error instanceof Error && error.message === 'The current academic term could not be determined.') {
103
+ return trans.currentTermUnknown;
104
+ }
105
+ return trans.genericError;
106
+ }
107
+ async function interactiveLogin(isInteractive) {
108
+ const trans = t().timetable;
109
+ if (!isInteractive) {
110
+ throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Interactive login requires a terminal.');
111
+ }
112
+ const username = await runSecretInput({
113
+ message: trans.studentId,
114
+ placeholder: trans.studentIdHint,
115
+ allowEmpty: false,
116
+ mask: '•',
117
+ });
118
+ if (!username)
119
+ throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Student id is required.');
120
+ const password = await runSecretInput({
121
+ message: trans.password,
122
+ placeholder: trans.passwordHint,
123
+ allowEmpty: false,
124
+ });
125
+ if (!password)
126
+ throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Password is required.');
127
+ return loginWithStudentPassword(username, password);
128
+ }
129
+ export async function withAuthenticatedSession(operation, options) {
130
+ let persisted = options.oneShot ? null : options.store.load();
131
+ for (let attempt = 0; attempt < 2; attempt += 1) {
132
+ let session = null;
133
+ const wasRestored = persisted !== null;
134
+ let savedBeforeOperation = false;
135
+ try {
136
+ if (!wasRestored && !options.oneShot) {
137
+ options.stderr.write(`${t().timetable.loginWillSave}\n`);
138
+ }
139
+ session = persisted
140
+ ? await (options.restoreSession ?? restoreNbtSession)(persisted)
141
+ : await (options.login ?? interactiveLogin)(options.isInteractive);
142
+ if (!options.oneShot && !wasRestored) {
143
+ options.store.save(await session.snapshot());
144
+ savedBeforeOperation = true;
145
+ }
146
+ const result = await operation(session);
147
+ if (!options.oneShot) {
148
+ try {
149
+ options.store.save(await session.snapshot());
150
+ }
151
+ catch {
152
+ // A new session was already saved before the operation, or a restored
153
+ // session still has its previous atomic file. Do not turn a completed
154
+ // export into a reported failure just because its TTL could not refresh.
155
+ if (!savedBeforeOperation && !wasRestored)
156
+ throw new Error('Session persistence failed.');
157
+ options.stderr.write(`${t().timetable.sessionRefreshFailed}\n`);
158
+ }
159
+ }
160
+ return result;
161
+ }
162
+ catch (error) {
163
+ if (isSessionExpired(error)) {
164
+ if (!options.oneShot)
165
+ options.store.clear();
166
+ persisted = null;
167
+ if (!wasRestored || !options.isInteractive || attempt > 0)
168
+ throw error;
169
+ options.stderr.write(`${t().timetable.expiredRelogin}\n`);
170
+ }
171
+ else {
172
+ throw error;
173
+ }
174
+ }
175
+ finally {
176
+ await session?.close();
177
+ }
178
+ }
179
+ throw new AuthError('SESSION_EXPIRED', 'session', 'The campus login session has expired.');
180
+ }
181
+ export function writePrivateIcs(filePath, contents) {
182
+ const resolved = path.resolve(filePath);
183
+ const temporaryPath = path.join(path.dirname(resolved), `.${path.basename(resolved)}.${process.pid}.${randomUUID()}.tmp`);
184
+ try {
185
+ fs.writeFileSync(temporaryPath, contents, {
186
+ encoding: 'utf8', flag: 'wx', mode: 0o600,
187
+ });
188
+ fs.renameSync(temporaryPath, resolved);
189
+ try {
190
+ fs.chmodSync(resolved, 0o600);
191
+ }
192
+ catch { /* Best effort on non-POSIX filesystems. */ }
193
+ }
194
+ finally {
195
+ try {
196
+ fs.unlinkSync(temporaryPath);
197
+ }
198
+ catch { /* Rename or the original error is authoritative. */ }
199
+ }
200
+ }
201
+ function clientFor(session) {
202
+ return createNbtTimetableClient(session.timetableTransport, { baseUrl: JWXT_ORIGIN });
203
+ }
204
+ async function resolveWeekOneMonday(explicitValue, hasAuthoritativeDates, isInteractive) {
205
+ if (hasAuthoritativeDates)
206
+ return explicitValue;
207
+ if (explicitValue)
208
+ return explicitValue;
209
+ if (!isInteractive)
210
+ return undefined;
211
+ const value = await runTextInput({
212
+ message: t().timetable.weekOne,
213
+ placeholder: t().timetable.weekOneHint,
214
+ allowEmpty: false,
215
+ });
216
+ return value || undefined;
217
+ }
218
+ export async function runStudentTimetableCommand(subcommandValue, options) {
219
+ const subcommand = (subcommandValue ?? 'export').toLowerCase();
220
+ const stdout = options.stdout ?? process.stdout;
221
+ const stderr = options.stderr ?? process.stderr;
222
+ const isInteractive = options.isInteractive ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
223
+ const store = options.store ?? createSessionStore();
224
+ const oneShot = options.flags.has('--one-shot') || options.flags.has('--no-save');
225
+ const trans = t().timetable;
226
+ if (!['login', 'logout', 'status', 'terms', 'export'].includes(subcommand)) {
227
+ stderr.write(`${trans.unknownCommand}\n`);
228
+ return 1;
229
+ }
230
+ const commonFlags = new Set(['--plain', '--one-shot', '--no-save']);
231
+ const allowedFlags = subcommand === 'export'
232
+ ? [...commonFlags, '--term=', '--output=', '--week-one=']
233
+ : subcommand === 'logout'
234
+ ? ['--plain']
235
+ : [...commonFlags];
236
+ const invalidFlag = [...options.flags].find((flag) => !allowedFlags.some((allowed) => (allowed.endsWith('=') ? flag.startsWith(allowed) : flag === allowed)));
237
+ if (invalidFlag) {
238
+ stderr.write(`${fmt(trans.invalidOption, { flag: invalidFlag })}\n`);
239
+ return 1;
240
+ }
241
+ try {
242
+ if (subcommand === 'logout') {
243
+ store.clear();
244
+ clearScheduleCache();
245
+ stdout.write(`${trans.loggedOut}\n`);
246
+ return 0;
247
+ }
248
+ if (subcommand === 'status') {
249
+ const persisted = oneShot ? null : store.load();
250
+ stdout.write(persisted
251
+ ? `${fmt(trans.savedStatus, { account: persisted.accountHint ? ` (${persisted.accountHint})` : '' })}\n`
252
+ : `${trans.noSavedStatus}\n`);
253
+ return persisted ? 0 : 1;
254
+ }
255
+ if (subcommand === 'login') {
256
+ const session = await interactiveLogin(isInteractive);
257
+ try {
258
+ if (!oneShot)
259
+ store.save(await session.snapshot());
260
+ stdout.write(`${oneShot ? trans.loginOneShot : trans.loginSaved}\n`);
261
+ }
262
+ finally {
263
+ await session.close();
264
+ }
265
+ return 0;
266
+ }
267
+ return await withAuthenticatedSession(async (session) => {
268
+ const client = clientFor(session);
269
+ const catalog = await client.listTerms();
270
+ if (subcommand === 'terms') {
271
+ stdout.write(`${trans.candidateTerms}\n`);
272
+ for (const term of relevantTerms(catalog)) {
273
+ stdout.write(`${term.current ? '*' : ' '} ${term.academicYear}:${term.semester} ${term.academicYearLabel} ${displaySemesterLabel(term)}\n`);
274
+ }
275
+ return 0;
276
+ }
277
+ 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`;
281
+ const weekOneMonday = await resolveWeekOneMonday(flagValue(options.flags, '--week-one='), timetable.calendarDays.length > 0, isInteractive);
282
+ const ics = timetableToIcs(timetable, {
283
+ weekOneMonday,
284
+ calendarName: fmt(trans.calendarName, {
285
+ year: selected.academicYearLabel,
286
+ semester: displaySemesterLabel(selected),
287
+ }),
288
+ });
289
+ writePrivateIcs(output, ics);
290
+ stdout.write(`${fmt(trans.exported, {
291
+ count: timetable.meetings.length,
292
+ file: path.resolve(output),
293
+ })}\n`);
294
+ const actionableWarnings = timetable.warnings.filter((warning) => warning.code !== 'CALENDAR_DATES_UNAVAILABLE' || !weekOneMonday);
295
+ if (actionableWarnings.length > 0) {
296
+ stderr.write(`${fmt(trans.warnings, { count: actionableWarnings.length })}\n`);
297
+ }
298
+ if (timetable.unresolvedItems.length > 0) {
299
+ stderr.write(`${fmt(trans.unresolvedPractice, { count: timetable.unresolvedItems.length })}\n`);
300
+ }
301
+ return 0;
302
+ }, { oneShot, isInteractive, store, stderr });
303
+ }
304
+ catch (error) {
305
+ if (!isInteractive && error instanceof AuthError && error.code === 'INVALID_CREDENTIALS') {
306
+ stderr.write(`${trans.noSession}\n`);
307
+ return 2;
308
+ }
309
+ stderr.write(`${safeMessage(error)}\n`);
310
+ return 1;
311
+ }
312
+ }
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,6 +1,3 @@
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';
6
3
  import { t } from '../i18n/index.js';
@@ -1,24 +1,22 @@
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
5
  /**
10
6
  * Fetch latest version from npm registry.
11
- * Returns null on any failure (network, timeout, parse).
7
+ * Returns null on any failure (network, timeout, parse, or `signal` aborting
8
+ * the request — e.g. the caller quit before this settled).
12
9
  */
13
- async function fetchLatestVersion() {
10
+ async function fetchLatestVersion(signal) {
11
+ const controller = new AbortController();
12
+ const timeout = setTimeout(() => controller.abort(), 3000);
13
+ const onExternalAbort = () => controller.abort();
14
+ signal?.addEventListener('abort', onExternalAbort);
14
15
  try {
15
- const controller = new AbortController();
16
- const timeout = setTimeout(() => controller.abort(), 3000);
17
16
  const res = await fetch(NPM_REGISTRY_URL, {
18
17
  signal: controller.signal,
19
18
  headers: { 'Accept': 'application/json' },
20
19
  });
21
- clearTimeout(timeout);
22
20
  if (!res.ok)
23
21
  return null;
24
22
  const data = (await res.json());
@@ -27,10 +25,11 @@ async function fetchLatestVersion() {
27
25
  catch {
28
26
  return null;
29
27
  }
28
+ finally {
29
+ clearTimeout(timeout);
30
+ signal?.removeEventListener('abort', onExternalAbort);
31
+ }
30
32
  }
31
- /**
32
- * Compare semver strings. Returns true if remote > local.
33
- */
34
33
  function isNewer(local, remote) {
35
34
  const parse = (v) => v.split('.').map(Number);
36
35
  const l = parse(local);
@@ -45,18 +44,17 @@ function isNewer(local, remote) {
45
44
  }
46
45
  /**
47
46
  * Non-blocking update check for TUI startup.
48
- * Resolves to a notification string or null.
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.
49
50
  */
50
- export async function checkForUpdate() {
51
- const latest = await fetchLatestVersion();
51
+ export async function checkForUpdate(signal) {
52
+ const latest = await fetchLatestVersion(signal);
52
53
  if (!latest || !isNewer(APP_INFO.version, latest))
53
54
  return null;
54
55
  const trans = t();
55
56
  return `${fmt(trans.update.available, { latest, current: APP_INFO.version })} ${chalk.dim(trans.update.command)}`;
56
57
  }
57
- /**
58
- * Explicit update check command (nbtca update).
59
- */
60
58
  export async function runUpdateCheck() {
61
59
  const trans = t();
62
60
  const latest = await fetchLatestVersion();
@@ -1,7 +1,3 @@
1
- /**
2
- * Internationalization (i18n) System
3
- * Multi-language support for the application
4
- */
5
1
  import fs from 'fs';
6
2
  import path from 'path';
7
3
  import { fileURLToPath } from 'url';
@@ -9,25 +5,13 @@ import { dirname } from 'path';
9
5
  import { getConfigDir, getWritableConfigDir } from '../config/paths.js';
10
6
  const __filename = fileURLToPath(import.meta.url);
11
7
  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
- */
8
+ let currentLanguage = 'zh';
19
9
  function getLanguageConfigPath() {
20
10
  return path.join(getConfigDir(), 'language.json');
21
11
  }
22
- /**
23
- * Get writable language configuration file path (XDG, creates dir)
24
- */
25
12
  function getWritableLanguageConfigPath() {
26
13
  return path.join(getWritableConfigDir(), 'language.json');
27
14
  }
28
- /**
29
- * Load language preference from config file
30
- */
31
15
  export function loadLanguagePreference() {
32
16
  try {
33
17
  const configPath = getLanguageConfigPath();
@@ -36,61 +20,39 @@ export function loadLanguagePreference() {
36
20
  currentLanguage = config.language;
37
21
  }
38
22
  }
39
- catch {
40
- // If loading fails (file missing or invalid), use default (Chinese)
41
- }
23
+ catch { }
42
24
  return currentLanguage;
43
25
  }
44
- /**
45
- * Save language preference to config file
46
- */
47
26
  export function saveLanguagePreference(language) {
27
+ setLanguage(language);
48
28
  try {
49
29
  const configPath = getWritableLanguageConfigPath();
50
30
  fs.writeFileSync(configPath, JSON.stringify({ language }, null, 2));
51
- currentLanguage = language;
52
31
  return true;
53
32
  }
54
33
  catch {
55
34
  return false;
56
35
  }
57
36
  }
58
- /**
59
- * Get current language
60
- */
61
37
  export function getCurrentLanguage() {
62
38
  return currentLanguage;
63
39
  }
64
- /**
65
- * Set current language
66
- */
67
40
  export function setLanguage(language) {
68
41
  currentLanguage = language;
69
- return saveLanguagePreference(language);
70
42
  }
71
- /**
72
- * Load translation file
73
- */
74
43
  function loadTranslations(language) {
75
44
  try {
76
45
  const translationPath = path.join(__dirname, 'locales', `${language}.json`);
77
46
  const content = fs.readFileSync(translationPath, 'utf-8');
78
47
  return JSON.parse(content);
79
48
  }
80
- catch (err) {
81
- // Fallback to Chinese if loading fails
49
+ catch {
82
50
  const fallbackPath = path.join(__dirname, 'locales', 'zh.json');
83
51
  const content = fs.readFileSync(fallbackPath, 'utf-8');
84
52
  return JSON.parse(content);
85
53
  }
86
54
  }
87
- /**
88
- * Translation cache
89
- */
90
- let translationsCache = new Map();
91
- /**
92
- * Get translations for current language
93
- */
55
+ const translationsCache = new Map();
94
56
  export function t() {
95
57
  if (!translationsCache.has(currentLanguage)) {
96
58
  translationsCache.set(currentLanguage, loadTranslations(currentLanguage));
@@ -103,11 +65,7 @@ export function fmt(template, vars) {
103
65
  return val !== undefined ? String(val) : `{${key}}`;
104
66
  });
105
67
  }
106
- /**
107
- * Clear translation cache (useful when switching languages)
108
- */
109
68
  export function clearTranslationCache() {
110
69
  translationsCache.clear();
111
70
  }
112
- // Initialize language preference on module load
113
71
  loadLanguagePreference();