@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.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +167 -64
- 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 +16 -23
- package/dist/app/keys.js +110 -2
- package/dist/app/views/docs-render.js +34 -26
- package/dist/app/views/docs.js +280 -68
- package/dist/app/views/events-render.js +21 -27
- package/dist/app/views/events.js +57 -33
- package/dist/app/views/home.js +67 -47
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +51 -74
- package/dist/app/views/schedule.js +246 -101
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- 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/cli.js +570 -0
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +21 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/canvas.js +1 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +64 -38
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +34 -7
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +186 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +156 -19
- 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-store.js +27 -0
- package/dist/features/calendar.js +66 -190
- package/dist/features/docs-client.js +225 -0
- package/dist/features/docs.js +615 -298
- package/dist/features/links.js +44 -29
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +117 -60
- package/dist/features/student-timetable.js +74 -97
- package/dist/features/theme.js +9 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +12 -29
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +8 -3
- package/dist/i18n/locales/zh.json +8 -3
- package/dist/index.js +6 -474
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +28 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- 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;
|
|
@@ -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
|
|
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',
|
|
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 {
|
|
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 {
|
|
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 ?? (
|
|
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) =>
|
|
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
|
|
280
|
-
|
|
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
|
-
}
|
package/dist/features/theme.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/dist/features/update.js
CHANGED
|
@@ -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(() =>
|
|
13
|
-
|
|
14
|
-
|
|
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: {
|
|
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
|
|
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.
|
|
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(
|
|
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(
|
|
52
|
+
console.log(chalk.green(fmt(trans.update.upToDate, { version: APP_INFO.version })));
|
|
71
53
|
}
|
|
54
|
+
return true;
|
|
72
55
|
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
}
|