@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.
- package/LICENSE +1 -1
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +53 -55
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +2 -21
- package/dist/app/keys.js +10 -2
- package/dist/app/views/docs-render.js +31 -24
- package/dist/app/views/docs.js +211 -67
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +33 -76
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +47 -71
- package/dist/app/views/schedule.js +158 -90
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +93 -18
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/config/data.js +10 -13
- package/dist/config/preferences.js +14 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +41 -16
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +17 -6
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +25 -21
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -75
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -30
- package/dist/core/vim-keys.js +9 -15
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar.js +12 -185
- package/dist/features/docs.js +439 -320
- package/dist/features/schedule-render.js +65 -102
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -220
- package/dist/features/status.js +44 -59
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -37
- package/dist/i18n/index.js +87 -65
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +85 -64
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +30 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -71
- package/dist/features/links.js +0 -39
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -130
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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') ||
|
|
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':
|
|
75
|
-
|
|
76
|
-
case '
|
|
77
|
-
|
|
78
|
-
case '
|
|
79
|
-
|
|
80
|
-
case '
|
|
81
|
-
|
|
82
|
-
case '
|
|
83
|
-
|
|
84
|
-
case '
|
|
85
|
-
|
|
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':
|
|
91
|
-
|
|
92
|
-
case '
|
|
93
|
-
|
|
94
|
-
|
|
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 &&
|
|
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 &&
|
|
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',
|
|
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 {
|
|
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 {
|
|
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 ?? (
|
|
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) =>
|
|
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
|
|
280
|
-
|
|
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
|
-
}
|
package/dist/features/theme.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/dist/features/update.js
CHANGED
|
@@ -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(() =>
|
|
17
|
-
|
|
18
|
-
|
|
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: {
|
|
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
|
|
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(
|
|
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(
|
|
52
|
+
console.log(chalk.green(fmt(trans.update.upToDate, { version: APP_INFO.version })));
|
|
81
53
|
}
|
|
82
54
|
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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": "个结果",
|