@nbtca/prompt 1.3.2 → 1.4.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 +44 -0
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +457 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +236 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +472 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +40 -15
- package/dist/core/menu.js +24 -6
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +127 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +5 -29
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +258 -55
- package/dist/features/links.js +7 -4
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +574 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +260 -0
- package/dist/features/settings.js +41 -30
- package/dist/features/status.js +37 -13
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/update.js +16 -8
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +59 -5
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +10 -7
|
@@ -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
|
+
}
|
package/dist/features/update.js
CHANGED
|
@@ -8,17 +8,19 @@ import { t, fmt } from '../i18n/index.js';
|
|
|
8
8
|
const NPM_REGISTRY_URL = `https://registry.npmjs.org/@nbtca/prompt/latest`;
|
|
9
9
|
/**
|
|
10
10
|
* Fetch latest version from npm registry.
|
|
11
|
-
* Returns null on any failure (network, timeout, parse
|
|
11
|
+
* Returns null on any failure (network, timeout, parse, or `signal` aborting
|
|
12
|
+
* the request — e.g. the caller quit before this settled).
|
|
12
13
|
*/
|
|
13
|
-
async function fetchLatestVersion() {
|
|
14
|
+
async function fetchLatestVersion(signal) {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
17
|
+
const onExternalAbort = () => controller.abort();
|
|
18
|
+
signal?.addEventListener('abort', onExternalAbort);
|
|
14
19
|
try {
|
|
15
|
-
const controller = new AbortController();
|
|
16
|
-
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
17
20
|
const res = await fetch(NPM_REGISTRY_URL, {
|
|
18
21
|
signal: controller.signal,
|
|
19
22
|
headers: { 'Accept': 'application/json' },
|
|
20
23
|
});
|
|
21
|
-
clearTimeout(timeout);
|
|
22
24
|
if (!res.ok)
|
|
23
25
|
return null;
|
|
24
26
|
const data = (await res.json());
|
|
@@ -27,6 +29,10 @@ async function fetchLatestVersion() {
|
|
|
27
29
|
catch {
|
|
28
30
|
return null;
|
|
29
31
|
}
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
signal?.removeEventListener('abort', onExternalAbort);
|
|
35
|
+
}
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
32
38
|
* Compare semver strings. Returns true if remote > local.
|
|
@@ -45,10 +51,12 @@ function isNewer(local, remote) {
|
|
|
45
51
|
}
|
|
46
52
|
/**
|
|
47
53
|
* Non-blocking update check for TUI startup.
|
|
48
|
-
* Resolves to a notification string or null.
|
|
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.
|
|
49
57
|
*/
|
|
50
|
-
export async function checkForUpdate() {
|
|
51
|
-
const latest = await fetchLatestVersion();
|
|
58
|
+
export async function checkForUpdate(signal) {
|
|
59
|
+
const latest = await fetchLatestVersion(signal);
|
|
52
60
|
if (!latest || !isNewer(APP_INFO.version, latest))
|
|
53
61
|
return null;
|
|
54
62
|
const trans = t();
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"error": "Error",
|
|
9
9
|
"success": "Success",
|
|
10
10
|
"goodbye": "Goodbye!",
|
|
11
|
-
"current": "current"
|
|
11
|
+
"current": "current",
|
|
12
|
+
"moreAbove": "{count} more above",
|
|
13
|
+
"moreBelow": "{count} more below"
|
|
12
14
|
},
|
|
13
15
|
"menu": {
|
|
14
16
|
"events": "Events",
|
|
@@ -17,11 +19,16 @@
|
|
|
17
19
|
"docsDesc": "Knowledge base",
|
|
18
20
|
"status": "Status",
|
|
19
21
|
"statusDesc": "Online status",
|
|
22
|
+
"timetable": "My Timetable",
|
|
23
|
+
"timetableDesc": "Sign in to JWXT, choose a term and export ICS",
|
|
20
24
|
"links": "Links",
|
|
21
25
|
"linksDesc": "website · GitHub · roadmap",
|
|
22
26
|
"settings": "Settings",
|
|
23
27
|
"settingsDesc": "language · theme · about",
|
|
24
|
-
"chooseAction": "nbtca"
|
|
28
|
+
"chooseAction": "nbtca",
|
|
29
|
+
"hintMove": "move",
|
|
30
|
+
"hintOpen": "open",
|
|
31
|
+
"hintQuit": "quit"
|
|
25
32
|
},
|
|
26
33
|
"about": {
|
|
27
34
|
"title": "About",
|
|
@@ -58,18 +65,34 @@
|
|
|
58
65
|
"pastEvents": "Past Events",
|
|
59
66
|
"pastEventsDesc": "Recent activity history",
|
|
60
67
|
"noPastEvents": "No past events in the last 30 days",
|
|
61
|
-
"viewPastDetail": "Select an event for details:"
|
|
68
|
+
"viewPastDetail": "Select an event for details:",
|
|
69
|
+
"next": "Next",
|
|
70
|
+
"recentActivity": "Recent",
|
|
71
|
+
"inPrefix": "in",
|
|
72
|
+
"startingNow": "starting now",
|
|
73
|
+
"thisWeek": "This Week",
|
|
74
|
+
"thisMonth": "This Month",
|
|
75
|
+
"search": "Search",
|
|
76
|
+
"searchPrompt": "Search events",
|
|
77
|
+
"searchPlaceholder": "keyword…",
|
|
78
|
+
"searchNoResults": "No matching events",
|
|
79
|
+
"exportIcs": "Export .ics",
|
|
80
|
+
"exportSuccess": "Saved",
|
|
81
|
+
"exportError": "Could not write the .ics file",
|
|
82
|
+
"recurringLabel": "recurring"
|
|
62
83
|
},
|
|
63
84
|
"docs": {
|
|
64
85
|
"loading": "Loading documentation list...",
|
|
65
86
|
"loadingDir": "Loading directory",
|
|
66
|
-
"
|
|
87
|
+
"categoryAbout": "About NBTCA",
|
|
88
|
+
"categoryGuide": "Guide",
|
|
67
89
|
"categoryRepairLogs": "Repair Logs",
|
|
68
90
|
"categoryEvents": "Event Docs",
|
|
69
|
-
"
|
|
91
|
+
"categoryConcepts": "Reference",
|
|
70
92
|
"categoryRepair": "Repair",
|
|
71
93
|
"categoryArchived": "Archived",
|
|
72
94
|
"categoryReadme": "Project README",
|
|
95
|
+
"overviewLabel": "Overview",
|
|
73
96
|
"chooseCategory": "Docs",
|
|
74
97
|
"refreshCache": "Refresh cache",
|
|
75
98
|
"cacheCleared": "Documentation cache cleared",
|
|
@@ -106,7 +129,10 @@
|
|
|
106
129
|
"searching": "Searching documents...",
|
|
107
130
|
"searchResults": "results found",
|
|
108
131
|
"searchNoResults": "No documents match your search",
|
|
109
|
-
"loadingFile": "Loading"
|
|
132
|
+
"loadingFile": "Loading",
|
|
133
|
+
"readerLinksTitle": "Jump to",
|
|
134
|
+
"readerLinksHint": "links",
|
|
135
|
+
"readerNoLinks": "This page has no links to other docs"
|
|
110
136
|
},
|
|
111
137
|
"links": {
|
|
112
138
|
"choose": "Links",
|
|
@@ -150,6 +176,114 @@
|
|
|
150
176
|
"watchJsonConflict": "--watch cannot be used with --json.",
|
|
151
177
|
"intervalNeedsWatch": "--interval requires --watch."
|
|
152
178
|
},
|
|
179
|
+
"timetable": {
|
|
180
|
+
"menuTitle": "My Timetable",
|
|
181
|
+
"actionExport": "Export ICS",
|
|
182
|
+
"actionTerms": "List terms",
|
|
183
|
+
"actionLogin": "Sign in or refresh session",
|
|
184
|
+
"actionLogout": "Sign out",
|
|
185
|
+
"actionStatus": "Login status",
|
|
186
|
+
"termPrompt": "Term code",
|
|
187
|
+
"termPromptHint": "Leave empty for current, for example 2026:3",
|
|
188
|
+
"unknownCommand": "Unknown timetable command. Use login, logout, status, terms, or export.",
|
|
189
|
+
"loggedOut": "Local login session cleared.",
|
|
190
|
+
"savedStatus": "A login session is saved{account}.",
|
|
191
|
+
"noSavedStatus": "No login session is saved.",
|
|
192
|
+
"loginSaved": "Login verified and session saved.",
|
|
193
|
+
"loginOneShot": "Login verified; no session was saved.",
|
|
194
|
+
"loginWillSave": "A local bearer session will be saved after success; use --one-shot to avoid this.",
|
|
195
|
+
"sessionRefreshFailed": "The operation completed, but the saved session expiry could not be refreshed.",
|
|
196
|
+
"candidateTerms": "Candidate JWXT terms (not every term necessarily has a personal timetable):",
|
|
197
|
+
"semesterNumber": "Semester {number}",
|
|
198
|
+
"expiredRelogin": "The saved login session expired. Please sign in again.",
|
|
199
|
+
"studentId": "Student id",
|
|
200
|
+
"studentIdHint": "Sent only to the school authentication service",
|
|
201
|
+
"password": "Password",
|
|
202
|
+
"passwordHint": "Input is masked and never saved",
|
|
203
|
+
"weekOne": "First teaching Monday (YYYY-MM-DD)",
|
|
204
|
+
"weekOneHint": "JWXT returned no start date; confirm it against the official calendar",
|
|
205
|
+
"exported": "Exported {count} timetable meetings to {file}",
|
|
206
|
+
"warnings": "{count} record(s) could not be fully resolved; verify the imported calendar.",
|
|
207
|
+
"unresolvedPractice": "{count} practice record(s) lacked a confirmed weekday/period and were not added to the ICS.",
|
|
208
|
+
"calendarName": "{year} {semester} Timetable",
|
|
209
|
+
"invalidOption": "Option {flag} is not valid for this timetable command.",
|
|
210
|
+
"invalidArguments": "Too many timetable arguments. Use login, logout, status, terms, or export.",
|
|
211
|
+
"noSession": "No usable login session. Run `nbtca schedule login` in an interactive terminal.",
|
|
212
|
+
"invalidCredentials": "The student id or password was rejected.",
|
|
213
|
+
"accountLocked": "The account is temporarily locked. Try later or use the official school page.",
|
|
214
|
+
"accountInactive": "The account is inactive. Activate it on the official school page first.",
|
|
215
|
+
"challenge": "The school requires a slider or browser challenge; CLI login stopped safely.",
|
|
216
|
+
"sessionExpired": "The login session expired. Please sign in again.",
|
|
217
|
+
"timeout": "The school service timed out.",
|
|
218
|
+
"network": "Could not connect to the school service.",
|
|
219
|
+
"untrustedUrl": "The school returned an unapproved redirect; the request stopped safely.",
|
|
220
|
+
"httpError": "The school login service returned an error status.",
|
|
221
|
+
"loginChanged": "The school login page changed; credentials were not submitted.",
|
|
222
|
+
"unexpectedResponse": "A valid JWXT session could not be confirmed after login.",
|
|
223
|
+
"missingDates": "JWXT returned no calendar dates. Provide --week-one=YYYY-MM-DD.",
|
|
224
|
+
"missingPeriod": "JWXT returned no usable period times, so export cannot continue safely.",
|
|
225
|
+
"termMismatch": "JWXT returned a different academic term. Try again.",
|
|
226
|
+
"invalidData": "The timetable response format is not currently recognized.",
|
|
227
|
+
"unknownTerm": "Unknown term. Run `nbtca schedule terms` first.",
|
|
228
|
+
"noTerms": "JWXT returned no available terms.",
|
|
229
|
+
"currentTermUnknown": "JWXT did not identify one current term. Choose one with --term=year:code.",
|
|
230
|
+
"genericError": "The timetable operation failed.",
|
|
231
|
+
"hubToday": "Today",
|
|
232
|
+
"hubWeek": "This week",
|
|
233
|
+
"hubSwitchTerm": "Switch term",
|
|
234
|
+
"hubExport": "Export .ics",
|
|
235
|
+
"hubLogout": "Log out",
|
|
236
|
+
"hubUnresolved": "Needs attention",
|
|
237
|
+
"unresolvedTitle": "Needs attention",
|
|
238
|
+
"unresolvedEmpty": "Nothing needs attention",
|
|
239
|
+
"unresolvedUnknownItem": "Unnamed item",
|
|
240
|
+
"nextClass": "Next",
|
|
241
|
+
"noClassToday": "No classes today",
|
|
242
|
+
"noNextClass": "No upcoming classes",
|
|
243
|
+
"nowLabel": "now",
|
|
244
|
+
"weekLabel": "Week",
|
|
245
|
+
"promptWeekOne": "First-week Monday (YYYY-MM-DD)",
|
|
246
|
+
"menuEntry": "Schedule",
|
|
247
|
+
"semester1": "Term 1",
|
|
248
|
+
"semester2": "Term 2",
|
|
249
|
+
"weekLabel2": "Week {week}",
|
|
250
|
+
"academicYearSuffix": "{year}",
|
|
251
|
+
"onBreak": "On break · {title}",
|
|
252
|
+
"publicUnavailable": "Academic calendar not available yet",
|
|
253
|
+
"daysUntilBreak": "{days} days until {title}",
|
|
254
|
+
"publicLoginAction": "Log in to see my timetable",
|
|
255
|
+
"publicLoginHint": "Log in to export your timetable (.ics)",
|
|
256
|
+
"weekOneAutoFailed": "Couldn't infer the term's first week automatically — please confirm it once",
|
|
257
|
+
"weekdayMon": "Mon",
|
|
258
|
+
"weekdayTue": "Tue",
|
|
259
|
+
"weekdayWed": "Wed",
|
|
260
|
+
"weekdayThu": "Thu",
|
|
261
|
+
"weekdayFri": "Fri",
|
|
262
|
+
"weekdaySat": "Sat",
|
|
263
|
+
"weekdaySun": "Sun",
|
|
264
|
+
"todayHeading": "Today · {weekday} · Week {week}",
|
|
265
|
+
"classDone": "Done",
|
|
266
|
+
"classLive": "In progress",
|
|
267
|
+
"minutesRemaining": "{minutes}m left",
|
|
268
|
+
"timelineEnd": "—",
|
|
269
|
+
"termNotStarted": "Term hasn't started yet",
|
|
270
|
+
"termStartsIn": "Classes begin {date} · {days} days to go",
|
|
271
|
+
"hubTermDensity": "Term density",
|
|
272
|
+
"termDensityTitle": "Term density",
|
|
273
|
+
"termDensityThisWeek": "This week",
|
|
274
|
+
"weekOverviewTitle": "Week overview",
|
|
275
|
+
"weekAheadClasses": "Classes",
|
|
276
|
+
"weekAheadBusy": "Busy",
|
|
277
|
+
"weekAheadFree": "Free",
|
|
278
|
+
"weekAheadNone": "N/A",
|
|
279
|
+
"termPreviewWeek": "Week 1 preview",
|
|
280
|
+
"hubFullGrid": "Full grid",
|
|
281
|
+
"detailTime": "Time",
|
|
282
|
+
"detailLocation": "Location",
|
|
283
|
+
"detailTeacher": "Teacher",
|
|
284
|
+
"detailWeeks": "Weeks",
|
|
285
|
+
"teacherSeparator": ", "
|
|
286
|
+
},
|
|
153
287
|
"theme": {
|
|
154
288
|
"current": "Current theme settings",
|
|
155
289
|
"chooseAction": "Settings",
|
|
@@ -197,11 +331,15 @@
|
|
|
197
331
|
"cmdTheme": "View or set theme",
|
|
198
332
|
"cmdLang": "Set language",
|
|
199
333
|
"cmdUpdate": "Check for updates",
|
|
334
|
+
"cmdSchedule": "Personal timetable login, term lookup and ICS export",
|
|
200
335
|
"flagVersion": "Show version",
|
|
201
336
|
"flagHelp": "Show help",
|
|
202
337
|
"flagOpen": "Open in browser (URL commands)",
|
|
203
338
|
"flagJson": "JSON output (events, status)",
|
|
204
339
|
"flagToday": "Today only (events)",
|
|
340
|
+
"flagWeek": "Events this week",
|
|
341
|
+
"flagMonth": "Events this month",
|
|
342
|
+
"flagSearch": "Filter events by keyword",
|
|
205
343
|
"flagNext": "Limit to next N (events)",
|
|
206
344
|
"flagWatch": "Live refresh (status)",
|
|
207
345
|
"flagInterval": "Refresh interval (status --watch)",
|
|
@@ -210,6 +348,11 @@
|
|
|
210
348
|
"flagHeatmap": "Activity heatmap (events)",
|
|
211
349
|
"flagPlain": "Disable colors",
|
|
212
350
|
"flagNoLogo": "Skip logo",
|
|
351
|
+
"flagOneShot": "Do not read or save a login session",
|
|
352
|
+
"flagNoSave": "Alias for --one-shot",
|
|
353
|
+
"flagTerm": "Select a term (for example 2026:3)",
|
|
354
|
+
"flagOutput": "ICS output path",
|
|
355
|
+
"flagWeekOne": "First teaching Monday when JWXT has no dates",
|
|
213
356
|
"unknownCommand": "Unknown command: {command}",
|
|
214
357
|
"unknownCommandHint": "Run `nbtca --help` to see available commands.",
|
|
215
358
|
"unknownFlag": "Unknown flag: {flag}",
|