@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,472 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createNbtTimetableClient, timetableToIcs, } from '@nbtca/nbtcal/timetable';
|
|
3
|
+
import { captureFooterHint, passiveFooterHint } from '../chrome.js';
|
|
4
|
+
import { ListField, computeMaxVisible } from '../fields/list-field.js';
|
|
5
|
+
import { TextField } from '../fields/text-field.js';
|
|
6
|
+
import { renderSchedule, hubShortcuts } from './schedule-render.js';
|
|
7
|
+
import { defaultGridCursor, handleGridKey } from './schedule-grid-cursor.js';
|
|
8
|
+
import { setVimKeysActive } from '../../core/vim-keys.js';
|
|
9
|
+
import { t } from '../../i18n/index.js';
|
|
10
|
+
import { AuthError } from '../../auth/errors.js';
|
|
11
|
+
import { loginWithStudentPassword, restoreNbtSession } from '../../auth/nbt-auth.js';
|
|
12
|
+
import { createSessionStore } from '../../auth/session-store.js';
|
|
13
|
+
import { resolveTerm, relevantTerms, writePrivateIcs, isSessionExpired, JWXT_ORIGIN, safeMessage, } from '../../features/student-timetable.js';
|
|
14
|
+
import { termKey, loadWeekOne, saveWeekOne, saveTimetableCache, saveCurrentPointer, loadCurrentPointer, loadTimetableCache, clearScheduleCache, } from '../../features/schedule-store.js';
|
|
15
|
+
import { loadCalendarOrThrow, toDisplayEvent } from '../../features/calendar.js';
|
|
16
|
+
import { currentAcademicWindow, inferWeekOneMonday, isAcademicBreakEvent, } from '@nbtca/nbtcal';
|
|
17
|
+
import { currentWeekNumber, campusWeekday } from '../../features/schedule-query.js';
|
|
18
|
+
let state = { mode: 'loading' };
|
|
19
|
+
let session = null;
|
|
20
|
+
let client = null;
|
|
21
|
+
let catalog = [];
|
|
22
|
+
let pendingId = '';
|
|
23
|
+
function isTimetableLike(value) {
|
|
24
|
+
return !!value && typeof value === 'object'
|
|
25
|
+
&& Array.isArray(value.meetings)
|
|
26
|
+
&& Array.isArray(value.periods);
|
|
27
|
+
}
|
|
28
|
+
function returnToHub() {
|
|
29
|
+
const tt = state.timetable;
|
|
30
|
+
const backKey = state.key;
|
|
31
|
+
const backWeekOne = state.weekOne;
|
|
32
|
+
if (tt && backKey && backWeekOne) {
|
|
33
|
+
// Carries over the existing cursor rather than resetting it -- closing a
|
|
34
|
+
// meeting's detail card (or backing out of term density/unresolved)
|
|
35
|
+
// should leave the student's grid navigation exactly where it was, not
|
|
36
|
+
// silently jump back to today.
|
|
37
|
+
state = {
|
|
38
|
+
mode: 'hub', key: backKey, term: state.term, weekOne: backWeekOne, timetable: tt,
|
|
39
|
+
gridCursor: state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods),
|
|
40
|
+
};
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function goToLoginId(errorMessage) {
|
|
46
|
+
pendingId = '';
|
|
47
|
+
setVimKeysActive(false);
|
|
48
|
+
state = {
|
|
49
|
+
mode: 'needsLoginId',
|
|
50
|
+
errorMessage,
|
|
51
|
+
idField: new TextField({ message: t().timetable.studentId, placeholder: t().timetable.studentIdHint }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function buildPublicField() {
|
|
55
|
+
const trans = t();
|
|
56
|
+
return new ListField({
|
|
57
|
+
title: trans.timetable.menuEntry,
|
|
58
|
+
options: [{ value: 'login', label: trans.timetable.publicLoginAction }],
|
|
59
|
+
footer: trans.menu.hintMove,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// A glance-panel ceiling, not "browse everything" (matches the same-purpose
|
|
63
|
+
// constants in events.ts/home.ts) — renderPublicBody trims further based on
|
|
64
|
+
// the real ctx.bodyRows.
|
|
65
|
+
const PUBLIC_UPCOMING_FETCH_CAP = 15;
|
|
66
|
+
/** The default, no-login Schedule view: public term/week status sourced from
|
|
67
|
+
* the same public calendar feed Events already uses. Login is now something
|
|
68
|
+
* the student opts into from here, not a gate they hit immediately. */
|
|
69
|
+
async function goToPublic(ctx) {
|
|
70
|
+
setVimKeysActive(true);
|
|
71
|
+
state = { mode: 'public', publicField: buildPublicField() };
|
|
72
|
+
ctx.rerender();
|
|
73
|
+
try {
|
|
74
|
+
const cal = await loadCalendarOrThrow();
|
|
75
|
+
const now = new Date();
|
|
76
|
+
const windowEvents = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
|
|
77
|
+
const publicWindow = currentAcademicWindow(windowEvents, now);
|
|
78
|
+
const publicUpcoming = cal.upcoming({ days: 30 })
|
|
79
|
+
.filter((e) => !isAcademicBreakEvent(e))
|
|
80
|
+
.slice(0, PUBLIC_UPCOMING_FETCH_CAP)
|
|
81
|
+
.map(toDisplayEvent);
|
|
82
|
+
state = { ...state, publicWindow, publicUpcoming };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
state = { ...state, publicWindow: null };
|
|
86
|
+
}
|
|
87
|
+
ctx.rerender();
|
|
88
|
+
}
|
|
89
|
+
/** Best-effort: try to auto-fill "week one Monday" from the same public
|
|
90
|
+
* calendar feed before falling back to the manual prompt. Never throws —
|
|
91
|
+
* any failure here just means the student sees today's existing prompt. */
|
|
92
|
+
async function tryInferWeekOne() {
|
|
93
|
+
try {
|
|
94
|
+
const cal = await loadCalendarOrThrow();
|
|
95
|
+
const now = new Date();
|
|
96
|
+
// Symmetric window (matches goToPublic's): inferWeekOneMonday can look
|
|
97
|
+
// forward to an *upcoming* semester-start marker while on break (e.g. a
|
|
98
|
+
// student logging in mid-summer, months before the next term's own
|
|
99
|
+
// start date) — a narrow forward window would silently miss exactly the
|
|
100
|
+
// event this is meant to find.
|
|
101
|
+
const events = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
|
|
102
|
+
return inferWeekOneMonday(events, now);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function afterAuthenticated(ctx, s) {
|
|
109
|
+
// Captured before any state mutation below, so it reflects whether a
|
|
110
|
+
// cached hub was already on screen when this call started (e.g. a
|
|
111
|
+
// background session restore on launch) as opposed to a fresh login
|
|
112
|
+
// (where state is 'authenticating', so hadCache is false).
|
|
113
|
+
const hadCache = state.mode === 'hub';
|
|
114
|
+
session = s;
|
|
115
|
+
client = createNbtTimetableClient(s.timetableTransport, { baseUrl: JWXT_ORIGIN });
|
|
116
|
+
try {
|
|
117
|
+
catalog = await client.listTerms();
|
|
118
|
+
const term = resolveTerm(catalog);
|
|
119
|
+
const key = termKey(term);
|
|
120
|
+
let weekOne = loadWeekOne(key);
|
|
121
|
+
if (!weekOne) {
|
|
122
|
+
weekOne = await tryInferWeekOne();
|
|
123
|
+
if (weekOne)
|
|
124
|
+
saveWeekOne(key, weekOne);
|
|
125
|
+
}
|
|
126
|
+
if (!weekOne) {
|
|
127
|
+
setVimKeysActive(false);
|
|
128
|
+
state = {
|
|
129
|
+
mode: 'needsWeekOne',
|
|
130
|
+
key,
|
|
131
|
+
term,
|
|
132
|
+
errorMessage: t().timetable.weekOneAutoFailed,
|
|
133
|
+
weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
|
|
134
|
+
};
|
|
135
|
+
ctx.rerender();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await fetchAndShowHub(ctx, term, key, weekOne);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (isSessionExpired(err)) {
|
|
142
|
+
// A dead session must be cleared and routed back to the login
|
|
143
|
+
// field — leaving it as a bare error message here was a dead end:
|
|
144
|
+
// the stale session would keep failing the same way on every
|
|
145
|
+
// future launch/tab-switch, and there was no way back into the
|
|
146
|
+
// login form short of quitting the app.
|
|
147
|
+
createSessionStore().clear();
|
|
148
|
+
if (!hadCache)
|
|
149
|
+
goToLoginId(t().timetable.expiredRelogin);
|
|
150
|
+
}
|
|
151
|
+
else if (!hadCache) {
|
|
152
|
+
// Only replace the screen with an error if there was nothing
|
|
153
|
+
// useful showing already — a background refresh failure must not
|
|
154
|
+
// blow away a working cached timetable (matches the same
|
|
155
|
+
// best-effort contract refreshFromNetwork documents below).
|
|
156
|
+
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
157
|
+
}
|
|
158
|
+
ctx.rerender();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function fetchAndShowHub(ctx, term, key, weekOne) {
|
|
162
|
+
if (!client)
|
|
163
|
+
return;
|
|
164
|
+
state = { mode: 'loading', statusMessage: t().calendar.loading };
|
|
165
|
+
ctx.rerender();
|
|
166
|
+
try {
|
|
167
|
+
const timetable = await client.fetchTerm(term);
|
|
168
|
+
saveTimetableCache(key, timetable);
|
|
169
|
+
saveCurrentPointer(key, weekOne);
|
|
170
|
+
state = {
|
|
171
|
+
mode: 'hub', key, term, weekOne, timetable,
|
|
172
|
+
gridCursor: defaultGridCursor(campusWeekday(new Date()), timetable.periods),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
if (isSessionExpired(err)) {
|
|
177
|
+
createSessionStore().clear();
|
|
178
|
+
goToLoginId(t().timetable.expiredRelogin);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
ctx.rerender();
|
|
185
|
+
}
|
|
186
|
+
async function refreshFromNetwork(ctx) {
|
|
187
|
+
const hadCache = state.mode === 'hub';
|
|
188
|
+
try {
|
|
189
|
+
const store = createSessionStore();
|
|
190
|
+
const persisted = store.load();
|
|
191
|
+
if (!persisted) {
|
|
192
|
+
if (!hadCache)
|
|
193
|
+
await goToPublic(ctx);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const restored = await restoreNbtSession(persisted);
|
|
197
|
+
await afterAuthenticated(ctx, restored);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (!hadCache) {
|
|
201
|
+
if (err instanceof AuthError && isSessionExpired(err)) {
|
|
202
|
+
createSessionStore().clear();
|
|
203
|
+
}
|
|
204
|
+
await goToPublic(ctx);
|
|
205
|
+
}
|
|
206
|
+
// best-effort: a cached hub already showed, keep it as-is on refresh failure.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
export const scheduleView = {
|
|
210
|
+
id: 'schedule',
|
|
211
|
+
title: t().timetable.menuEntry,
|
|
212
|
+
async load(ctx) {
|
|
213
|
+
const ptr = loadCurrentPointer();
|
|
214
|
+
const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
|
|
215
|
+
if (ptr && isTimetableLike(cached)) {
|
|
216
|
+
state = {
|
|
217
|
+
mode: 'hub', key: ptr.termKey, weekOne: ptr.weekOneMonday, timetable: cached,
|
|
218
|
+
gridCursor: defaultGridCursor(campusWeekday(new Date()), cached.periods),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
state = { mode: 'loading' };
|
|
223
|
+
}
|
|
224
|
+
ctx.rerender();
|
|
225
|
+
await refreshFromNetwork(ctx);
|
|
226
|
+
},
|
|
227
|
+
render(ctx) {
|
|
228
|
+
// Sync every visible field's scroll window to the *current* terminal
|
|
229
|
+
// size on every frame (not just construction time) — this is what
|
|
230
|
+
// keeps a long list correctly windowed across a live resize.
|
|
231
|
+
state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
|
|
232
|
+
return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
|
|
233
|
+
},
|
|
234
|
+
capturesInput() {
|
|
235
|
+
return state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
|
|
236
|
+
},
|
|
237
|
+
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
238
|
+
const capturing = state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
|
|
239
|
+
if (capturing)
|
|
240
|
+
return captureFooterHint(cols);
|
|
241
|
+
// These three are pure "read this, any key returns to the hub" drill-
|
|
242
|
+
// downs (see handleKey below) — no field to move a cursor within or
|
|
243
|
+
// open an item from, so the generic "move · open" hint would promise
|
|
244
|
+
// keys that don't do that here.
|
|
245
|
+
const passive = state.mode === 'loading' || state.mode === 'authenticating' || state.mode === 'error'
|
|
246
|
+
|| state.mode === 'meetingDetail' || state.mode === 'unresolved' || state.mode === 'termDensity';
|
|
247
|
+
return passive ? passiveFooterHint(tabCount, cols) : undefined;
|
|
248
|
+
},
|
|
249
|
+
handleBack(ctx) {
|
|
250
|
+
if (state.mode === 'needsLoginId') {
|
|
251
|
+
void goToPublic(ctx);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne') {
|
|
255
|
+
goToLoginId();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
if (state.mode === 'meetingDetail') {
|
|
259
|
+
// Esc respects where the detail card was opened from -- from the
|
|
260
|
+
// standalone 'week' mode, it steps back there, not all the way to hub.
|
|
261
|
+
if (state.detailFrom === 'week') {
|
|
262
|
+
state = { ...state, mode: 'week' };
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return returnToHub();
|
|
266
|
+
}
|
|
267
|
+
if (state.mode === 'week' || state.mode === 'unresolved' || state.mode === 'termPicker'
|
|
268
|
+
|| state.mode === 'termDensity') {
|
|
269
|
+
return returnToHub();
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
},
|
|
273
|
+
handleKey(key, ctx) {
|
|
274
|
+
switch (state.mode) {
|
|
275
|
+
case 'public': {
|
|
276
|
+
const result = state.publicField?.handleKey(key);
|
|
277
|
+
if (result?.selected === 'login')
|
|
278
|
+
goToLoginId();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case 'needsLoginId': {
|
|
282
|
+
const result = state.idField?.handleKey(key);
|
|
283
|
+
if (result?.cancelled) {
|
|
284
|
+
void goToPublic(ctx);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (result?.submitted !== undefined) {
|
|
288
|
+
pendingId = result.submitted;
|
|
289
|
+
state = {
|
|
290
|
+
mode: 'needsLoginPassword',
|
|
291
|
+
passwordField: new TextField({ message: t().timetable.password, placeholder: t().timetable.passwordHint, secret: true }),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
case 'needsLoginPassword': {
|
|
297
|
+
const result = state.passwordField?.handleKey(key);
|
|
298
|
+
if (result?.cancelled) {
|
|
299
|
+
goToLoginId();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (result?.submitted !== undefined) {
|
|
303
|
+
const password = result.submitted;
|
|
304
|
+
setVimKeysActive(true);
|
|
305
|
+
state = { mode: 'authenticating', statusMessage: t().timetable.loginWillSave };
|
|
306
|
+
ctx.rerender();
|
|
307
|
+
void loginWithStudentPassword(pendingId, password)
|
|
308
|
+
.then(async (s) => {
|
|
309
|
+
createSessionStore().save(await s.snapshot());
|
|
310
|
+
await afterAuthenticated(ctx, s);
|
|
311
|
+
})
|
|
312
|
+
.catch((err) => {
|
|
313
|
+
goToLoginId(safeMessage(err));
|
|
314
|
+
ctx.rerender();
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
case 'needsWeekOne': {
|
|
320
|
+
const result = state.weekOneField?.handleKey(key);
|
|
321
|
+
if (result?.cancelled) {
|
|
322
|
+
goToLoginId();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (result?.submitted !== undefined) {
|
|
326
|
+
const trimmed = result.submitted.trim();
|
|
327
|
+
const valid = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) && !Number.isNaN(new Date(`${trimmed}T00:00:00`).getTime());
|
|
328
|
+
const targetKey = state.key;
|
|
329
|
+
const targetTerm = state.term;
|
|
330
|
+
if (!valid || !targetKey || !targetTerm) {
|
|
331
|
+
state = { ...state, errorMessage: t().timetable.weekOneHint };
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
saveWeekOne(targetKey, trimmed);
|
|
335
|
+
setVimKeysActive(true);
|
|
336
|
+
void fetchAndShowHub(ctx, targetTerm, targetKey, trimmed);
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
case 'hub': {
|
|
341
|
+
const tt = state.timetable;
|
|
342
|
+
const hubKey = state.key;
|
|
343
|
+
const hubWeekOne = state.weekOne;
|
|
344
|
+
if (!tt || !hubKey || !hubWeekOne)
|
|
345
|
+
return;
|
|
346
|
+
{
|
|
347
|
+
const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
|
|
348
|
+
const week = Math.max(1, currentWeekNumber(hubWeekOne, new Date()));
|
|
349
|
+
const nav = handleGridKey(key, cursor, tt, week);
|
|
350
|
+
if (nav.kind === 'moveCursor') {
|
|
351
|
+
state = { ...state, gridCursor: nav.cursor };
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (nav.kind === 'openDetail') {
|
|
355
|
+
state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'hub' };
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const shortcut = hubShortcuts(tt).find((sc) => sc.key === key);
|
|
360
|
+
if (!shortcut)
|
|
361
|
+
return;
|
|
362
|
+
if (shortcut.key === 'w') {
|
|
363
|
+
state = { ...state, mode: 'week' };
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (shortcut.key === 't') {
|
|
367
|
+
state = { ...state, mode: 'termDensity' };
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (shortcut.key === 'u') {
|
|
371
|
+
state = { ...state, mode: 'unresolved' };
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (shortcut.key === 's') {
|
|
375
|
+
const options = relevantTerms(catalog).map((tm) => ({
|
|
376
|
+
value: `${tm.academicYear}:${tm.semester}`,
|
|
377
|
+
label: tm.academicYearLabel,
|
|
378
|
+
hint: tm.current ? t().common.current : undefined,
|
|
379
|
+
}));
|
|
380
|
+
options.push({ value: '__back__', label: t().common.back, hint: undefined });
|
|
381
|
+
state = {
|
|
382
|
+
...state,
|
|
383
|
+
mode: 'termPicker',
|
|
384
|
+
termField: new ListField({ title: t().timetable.hubSwitchTerm, options, maxVisible: computeMaxVisible(ctx.bodyRows) }),
|
|
385
|
+
};
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (shortcut.key === 'e') {
|
|
389
|
+
try {
|
|
390
|
+
const ics = timetableToIcs(tt, { weekOneMonday: hubWeekOne, calendarName: `NBT ${state.term?.academicYearLabel ?? ''}` });
|
|
391
|
+
const out = `timetable-${hubKey}.ics`;
|
|
392
|
+
writePrivateIcs(out, ics);
|
|
393
|
+
state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
state = { ...state, statusMessage: t().timetable.genericError };
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (shortcut.key === 'x') {
|
|
401
|
+
createSessionStore().clear();
|
|
402
|
+
clearScheduleCache();
|
|
403
|
+
void session?.close();
|
|
404
|
+
session = null;
|
|
405
|
+
client = null;
|
|
406
|
+
void goToPublic(ctx);
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
case 'week': {
|
|
411
|
+
const tt = state.timetable;
|
|
412
|
+
const weekOne = state.weekOne;
|
|
413
|
+
if (!tt || !weekOne) {
|
|
414
|
+
returnToHub();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
|
|
418
|
+
const week = Math.max(1, currentWeekNumber(weekOne, new Date()));
|
|
419
|
+
const nav = handleGridKey(key, cursor, tt, week);
|
|
420
|
+
if (nav.kind === 'moveCursor') {
|
|
421
|
+
state = { ...state, gridCursor: nav.cursor };
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (nav.kind === 'openDetail') {
|
|
425
|
+
state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'week' };
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
returnToHub();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
case 'meetingDetail': {
|
|
432
|
+
if (state.detailFrom === 'week') {
|
|
433
|
+
state = { ...state, mode: 'week' };
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
returnToHub();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
case 'unresolved':
|
|
440
|
+
case 'termDensity': {
|
|
441
|
+
returnToHub();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
case 'termPicker': {
|
|
445
|
+
const result = state.termField?.handleKey(key);
|
|
446
|
+
if (!result?.selected)
|
|
447
|
+
return;
|
|
448
|
+
if (result.selected === '__back__') {
|
|
449
|
+
returnToHub();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const term = resolveTerm(catalog, result.selected);
|
|
453
|
+
const newTermKey = termKey(term);
|
|
454
|
+
const weekOne = loadWeekOne(newTermKey);
|
|
455
|
+
if (!weekOne) {
|
|
456
|
+
setVimKeysActive(false);
|
|
457
|
+
state = {
|
|
458
|
+
mode: 'needsWeekOne',
|
|
459
|
+
key: newTermKey,
|
|
460
|
+
term,
|
|
461
|
+
weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
|
|
462
|
+
};
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
void fetchAndShowHub(ctx, term, newTermKey, weekOne);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
default:
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { space, type } from '../../core/theme.js';
|
|
2
|
+
import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
|
|
3
|
+
import { renderListFieldWithContext } from '../fields/list-field.js';
|
|
4
|
+
function wrappedIndentedLines(label, cols, style) {
|
|
5
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
6
|
+
const styled = style(label);
|
|
7
|
+
const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
|
|
8
|
+
const indent = preferredIndent
|
|
9
|
+
&& visualWidth(styled) > width - visualWidth(preferredIndent)
|
|
10
|
+
&& visualWidth(styled) <= width
|
|
11
|
+
? ''
|
|
12
|
+
: preferredIndent;
|
|
13
|
+
const contentWidth = Math.max(1, width - visualWidth(indent));
|
|
14
|
+
return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
|
|
15
|
+
}
|
|
16
|
+
export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols = Number.POSITIVE_INFINITY) {
|
|
17
|
+
switch (state.mode) {
|
|
18
|
+
case 'menu': {
|
|
19
|
+
const context = [
|
|
20
|
+
...(state.statusMessage ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []),
|
|
21
|
+
];
|
|
22
|
+
return state.menuField
|
|
23
|
+
? renderListFieldWithContext(context, state.menuField, bodyRows, cols)
|
|
24
|
+
: context;
|
|
25
|
+
}
|
|
26
|
+
case 'language':
|
|
27
|
+
case 'icon':
|
|
28
|
+
case 'color':
|
|
29
|
+
return state.subField?.render(bodyRows, cols) ?? [];
|
|
30
|
+
case 'about': {
|
|
31
|
+
const context = [
|
|
32
|
+
...(state.aboutLines ?? []).flatMap((line) => (line ? wrappedIndentedLines(line, cols, (value) => value) : [''])),
|
|
33
|
+
'',
|
|
34
|
+
];
|
|
35
|
+
if (!state.backField)
|
|
36
|
+
return context;
|
|
37
|
+
const fieldLines = state.backField.render(Number.POSITIVE_INFINITY, cols);
|
|
38
|
+
if (!Number.isFinite(bodyRows) || context.length + fieldLines.length <= bodyRows) {
|
|
39
|
+
return [...context, ...fieldLines];
|
|
40
|
+
}
|
|
41
|
+
const rows = Math.max(0, Math.floor(bodyRows));
|
|
42
|
+
const visibleField = fieldLines.length <= rows
|
|
43
|
+
? fieldLines
|
|
44
|
+
: state.backField.render(rows, cols);
|
|
45
|
+
const content = context.at(-1) === '' ? context.slice(0, -1) : context;
|
|
46
|
+
return content.length > 0
|
|
47
|
+
? [...visibleField, '', ...content]
|
|
48
|
+
: visibleField;
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { ListField } from '../fields/list-field.js';
|
|
2
|
+
import { renderSettings } from './settings-render.js';
|
|
3
|
+
import { applyColorModePreference, loadPreferences, resetPreferences, setColorMode, setIconMode, } from '../../config/preferences.js';
|
|
4
|
+
import { resetIconCache, pickIcon } from '../../core/icons.js';
|
|
5
|
+
import { APP_INFO, URLS } from '../../config/data.js';
|
|
6
|
+
import { t, getCurrentLanguage, setLanguage, clearTranslationCache } from '../../i18n/index.js';
|
|
7
|
+
import { padEndV } from '../../core/text.js';
|
|
8
|
+
let state = { mode: 'menu' };
|
|
9
|
+
function buildMenuField(statusMessage) {
|
|
10
|
+
const trans = t();
|
|
11
|
+
const prefs = loadPreferences();
|
|
12
|
+
const currentLang = getCurrentLanguage();
|
|
13
|
+
const options = [
|
|
14
|
+
{ value: 'language', label: trans.language.selectLanguage, hint: currentLang === 'zh' ? trans.language.zh : trans.language.en },
|
|
15
|
+
{ value: 'icon', label: trans.theme.iconMode, hint: prefs.iconMode },
|
|
16
|
+
{ value: 'color', label: trans.theme.colorMode, hint: prefs.colorMode },
|
|
17
|
+
{ value: 'reset', label: trans.theme.resetLabel },
|
|
18
|
+
{ value: 'about', label: trans.about.title },
|
|
19
|
+
];
|
|
20
|
+
return { mode: 'menu', statusMessage, menuField: new ListField({ title: trans.theme.chooseAction, options }) };
|
|
21
|
+
}
|
|
22
|
+
function goToMenu(statusMessage) {
|
|
23
|
+
state = buildMenuField(statusMessage);
|
|
24
|
+
}
|
|
25
|
+
export const settingsView = {
|
|
26
|
+
id: 'settings',
|
|
27
|
+
title: t().menu.settings,
|
|
28
|
+
async load(_ctx) {
|
|
29
|
+
goToMenu();
|
|
30
|
+
},
|
|
31
|
+
render(ctx) {
|
|
32
|
+
return renderSettings(state, ctx.bodyRows, ctx.size.cols);
|
|
33
|
+
},
|
|
34
|
+
capturesInput() {
|
|
35
|
+
return false;
|
|
36
|
+
},
|
|
37
|
+
handleBack() {
|
|
38
|
+
if (state.mode !== 'menu') {
|
|
39
|
+
goToMenu();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
},
|
|
44
|
+
handleKey(key, _ctx) {
|
|
45
|
+
const trans = t();
|
|
46
|
+
switch (state.mode) {
|
|
47
|
+
case 'menu': {
|
|
48
|
+
const result = state.menuField?.handleKey(key);
|
|
49
|
+
if (!result?.selected)
|
|
50
|
+
return;
|
|
51
|
+
if (result.selected === 'language') {
|
|
52
|
+
const currentLang = getCurrentLanguage();
|
|
53
|
+
const options = [
|
|
54
|
+
{ value: 'zh', label: trans.language.zh, hint: currentLang === 'zh' ? trans.common.current : undefined },
|
|
55
|
+
{ value: 'en', label: trans.language.en, hint: currentLang === 'en' ? trans.common.current : undefined },
|
|
56
|
+
];
|
|
57
|
+
state = { mode: 'language', subField: new ListField({ title: trans.language.selectLanguage, options, initialIndex: currentLang === 'en' ? 1 : 0 }) };
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (result.selected === 'icon') {
|
|
61
|
+
const prefs = loadPreferences();
|
|
62
|
+
const options = [
|
|
63
|
+
{ value: 'auto', label: trans.theme.modeAuto, hint: prefs.iconMode === 'auto' ? trans.common.current : undefined },
|
|
64
|
+
{ value: 'ascii', label: trans.theme.modeAscii, hint: prefs.iconMode === 'ascii' ? trans.common.current : undefined },
|
|
65
|
+
{ value: 'unicode', label: trans.theme.modeUnicode, hint: prefs.iconMode === 'unicode' ? trans.common.current : undefined },
|
|
66
|
+
];
|
|
67
|
+
const idx = Math.max(0, options.findIndex((o) => o.value === prefs.iconMode));
|
|
68
|
+
state = { mode: 'icon', subField: new ListField({ title: trans.theme.chooseIconMode, options, initialIndex: idx }) };
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (result.selected === 'color') {
|
|
72
|
+
const prefs = loadPreferences();
|
|
73
|
+
const options = [
|
|
74
|
+
{ value: 'auto', label: trans.theme.modeAuto, hint: prefs.colorMode === 'auto' ? trans.common.current : undefined },
|
|
75
|
+
{ value: 'on', label: trans.theme.modeOn, hint: prefs.colorMode === 'on' ? trans.common.current : undefined },
|
|
76
|
+
{ value: 'off', label: trans.theme.modeOff, hint: prefs.colorMode === 'off' ? trans.common.current : undefined },
|
|
77
|
+
];
|
|
78
|
+
const idx = Math.max(0, options.findIndex((o) => o.value === prefs.colorMode));
|
|
79
|
+
state = { mode: 'color', subField: new ListField({ title: trans.theme.chooseColorMode, options, initialIndex: idx }) };
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (result.selected === 'reset') {
|
|
83
|
+
const saved = resetPreferences();
|
|
84
|
+
resetIconCache();
|
|
85
|
+
applyColorModePreference(false);
|
|
86
|
+
goToMenu(saved ? trans.theme.reset : trans.theme.resetSessionOnly);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (result.selected === 'about') {
|
|
90
|
+
const pad = 12;
|
|
91
|
+
const row = (label, value) => `${padEndV(label, pad)}${value}`;
|
|
92
|
+
state = {
|
|
93
|
+
mode: 'about',
|
|
94
|
+
aboutLines: [
|
|
95
|
+
row(trans.about.project, APP_INFO.name),
|
|
96
|
+
row(trans.about.version, `v${APP_INFO.version}`),
|
|
97
|
+
row(trans.about.description, trans.about.descriptionText),
|
|
98
|
+
'',
|
|
99
|
+
row(trans.about.github, APP_INFO.repository),
|
|
100
|
+
row(trans.about.website, URLS.homepage),
|
|
101
|
+
row(trans.about.email, URLS.email),
|
|
102
|
+
'',
|
|
103
|
+
row(trans.about.license, `MIT ${pickIcon('·', '-')} ${trans.about.author}: m1ngsama`),
|
|
104
|
+
],
|
|
105
|
+
backField: new ListField({ title: trans.about.title, options: [{ value: '__back__', label: trans.common.back }] }),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
case 'language': {
|
|
111
|
+
const result = state.subField?.handleKey(key);
|
|
112
|
+
if (!result?.selected)
|
|
113
|
+
return;
|
|
114
|
+
const currentLang = getCurrentLanguage();
|
|
115
|
+
if (result.selected !== currentLang) {
|
|
116
|
+
const saved = setLanguage(result.selected);
|
|
117
|
+
clearTranslationCache();
|
|
118
|
+
goToMenu(saved ? t().language.changed : t().language.changedSessionOnly);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
goToMenu();
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
case 'icon': {
|
|
126
|
+
const result = state.subField?.handleKey(key);
|
|
127
|
+
if (!result?.selected)
|
|
128
|
+
return;
|
|
129
|
+
const saved = setIconMode(result.selected);
|
|
130
|
+
resetIconCache();
|
|
131
|
+
goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case 'color': {
|
|
135
|
+
const result = state.subField?.handleKey(key);
|
|
136
|
+
if (!result?.selected)
|
|
137
|
+
return;
|
|
138
|
+
const saved = setColorMode(result.selected);
|
|
139
|
+
applyColorModePreference(false);
|
|
140
|
+
goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
case 'about': {
|
|
144
|
+
const result = state.backField?.handleKey(key);
|
|
145
|
+
if (result?.selected === '__back__')
|
|
146
|
+
goToMenu();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|