@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,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { createNbtTimetableClient, timetableToIcs, } from '@nbtca/nbtcal/timetable';
|
|
2
|
+
import { createNbtTimetableClient, createTimetableSchedule, timetableToIcs, } from '@nbtca/nbtcal/timetable';
|
|
3
3
|
import { captureFooterHint, passiveFooterHint } from '../chrome.js';
|
|
4
4
|
import { ListField, computeMaxVisible } from '../fields/list-field.js';
|
|
5
5
|
import { TextField } from '../fields/text-field.js';
|
|
@@ -8,35 +8,72 @@ import { defaultGridCursor, handleGridKey } from './schedule-grid-cursor.js';
|
|
|
8
8
|
import { setVimKeysActive } from '../../core/vim-keys.js';
|
|
9
9
|
import { t } from '../../i18n/index.js';
|
|
10
10
|
import { AuthError } from '../../auth/errors.js';
|
|
11
|
-
import { loginWithStudentPassword, restoreNbtSession } from '../../auth/nbt-auth.js';
|
|
11
|
+
import { loginWithStudentPassword, restoreNbtSession, } from '../../auth/nbt-auth.js';
|
|
12
12
|
import { createSessionStore } from '../../auth/session-store.js';
|
|
13
13
|
import { resolveTerm, relevantTerms, writePrivateIcs, isSessionExpired, JWXT_ORIGIN, safeMessage, } from '../../features/student-timetable.js';
|
|
14
14
|
import { termKey, loadWeekOne, saveWeekOne, saveTimetableCache, saveCurrentPointer, loadCurrentPointer, loadTimetableCache, clearScheduleCache, } from '../../features/schedule-store.js';
|
|
15
15
|
import { loadCalendarOrThrow, toDisplayEvent } from '../../features/calendar.js';
|
|
16
16
|
import { currentAcademicWindow, inferWeekOneMonday, isAcademicBreakEvent, } from '@nbtca/nbtcal';
|
|
17
|
-
import {
|
|
17
|
+
import { sanitizeAcademicTerm, sanitizeTimetable } from '../../features/timetable-sanitize.js';
|
|
18
|
+
import { addLocalDays, parseLocalMonday } from '../../core/calendar-day.js';
|
|
18
19
|
let state = { mode: 'loading' };
|
|
19
20
|
let session = null;
|
|
20
21
|
let client = null;
|
|
21
22
|
let catalog = [];
|
|
22
23
|
let pendingId = '';
|
|
24
|
+
let lifecycleGeneration = 0;
|
|
25
|
+
const closingSessions = new WeakMap();
|
|
26
|
+
function isLifecycleActive(ctx, generation) {
|
|
27
|
+
return generation === lifecycleGeneration && ctx.signal?.aborted !== true;
|
|
28
|
+
}
|
|
29
|
+
async function closeSession(target) {
|
|
30
|
+
let closing = closingSessions.get(target);
|
|
31
|
+
if (!closing) {
|
|
32
|
+
closing = Promise.resolve()
|
|
33
|
+
.then(() => target.close())
|
|
34
|
+
.catch(() => undefined);
|
|
35
|
+
closingSessions.set(target, closing);
|
|
36
|
+
}
|
|
37
|
+
await closing;
|
|
38
|
+
}
|
|
39
|
+
async function releaseSession(target = session) {
|
|
40
|
+
if (!target)
|
|
41
|
+
return;
|
|
42
|
+
if (session === target) {
|
|
43
|
+
session = null;
|
|
44
|
+
client = null;
|
|
45
|
+
}
|
|
46
|
+
await closeSession(target);
|
|
47
|
+
}
|
|
23
48
|
function isTimetableLike(value) {
|
|
24
|
-
return !!value &&
|
|
25
|
-
|
|
26
|
-
|
|
49
|
+
return (!!value &&
|
|
50
|
+
typeof value === 'object' &&
|
|
51
|
+
Array.isArray(value.meetings) &&
|
|
52
|
+
Array.isArray(value.periods));
|
|
53
|
+
}
|
|
54
|
+
function readCachedTimetable(value) {
|
|
55
|
+
if (!isTimetableLike(value))
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
return sanitizeTimetable(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
27
63
|
}
|
|
28
64
|
function returnToHub() {
|
|
29
65
|
const tt = state.timetable;
|
|
30
66
|
const backKey = state.key;
|
|
31
67
|
const backWeekOne = state.weekOne;
|
|
32
68
|
if (tt && backKey && backWeekOne) {
|
|
33
|
-
|
|
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.
|
|
69
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: backWeekOne });
|
|
37
70
|
state = {
|
|
38
|
-
mode: 'hub',
|
|
39
|
-
|
|
71
|
+
mode: 'hub',
|
|
72
|
+
key: backKey,
|
|
73
|
+
...(state.term ? { term: state.term } : {}),
|
|
74
|
+
weekOne: backWeekOne,
|
|
75
|
+
timetable: tt,
|
|
76
|
+
gridCursor: state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(new Date()), tt.periods),
|
|
40
77
|
};
|
|
41
78
|
return true;
|
|
42
79
|
}
|
|
@@ -47,8 +84,11 @@ function goToLoginId(errorMessage) {
|
|
|
47
84
|
setVimKeysActive(false);
|
|
48
85
|
state = {
|
|
49
86
|
mode: 'needsLoginId',
|
|
50
|
-
errorMessage,
|
|
51
|
-
idField: new TextField({
|
|
87
|
+
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
88
|
+
idField: new TextField({
|
|
89
|
+
message: t().timetable.studentId,
|
|
90
|
+
placeholder: t().timetable.studentIdHint,
|
|
91
|
+
}),
|
|
52
92
|
};
|
|
53
93
|
}
|
|
54
94
|
function buildPublicField() {
|
|
@@ -59,67 +99,74 @@ function buildPublicField() {
|
|
|
59
99
|
footer: trans.menu.hintMove,
|
|
60
100
|
});
|
|
61
101
|
}
|
|
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
102
|
const PUBLIC_UPCOMING_FETCH_CAP = 15;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
async function goToPublic(ctx) {
|
|
103
|
+
async function goToPublic(ctx, generation = lifecycleGeneration) {
|
|
104
|
+
if (!isLifecycleActive(ctx, generation))
|
|
105
|
+
return;
|
|
70
106
|
setVimKeysActive(true);
|
|
71
107
|
state = { mode: 'public', publicField: buildPublicField() };
|
|
72
108
|
ctx.rerender();
|
|
73
109
|
try {
|
|
74
|
-
const cal = await loadCalendarOrThrow();
|
|
110
|
+
const cal = await loadCalendarOrThrow(ctx.signal);
|
|
111
|
+
if (!isLifecycleActive(ctx, generation))
|
|
112
|
+
return;
|
|
75
113
|
const now = new Date();
|
|
76
|
-
const windowEvents = cal.inRange(
|
|
114
|
+
const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
|
|
77
115
|
const publicWindow = currentAcademicWindow(windowEvents, now);
|
|
78
|
-
const publicUpcoming = cal
|
|
116
|
+
const publicUpcoming = cal
|
|
117
|
+
.upcoming({ days: 30 })
|
|
79
118
|
.filter((e) => !isAcademicBreakEvent(e))
|
|
80
119
|
.slice(0, PUBLIC_UPCOMING_FETCH_CAP)
|
|
81
120
|
.map(toDisplayEvent);
|
|
82
121
|
state = { ...state, publicWindow, publicUpcoming };
|
|
83
122
|
}
|
|
84
123
|
catch {
|
|
124
|
+
if (!isLifecycleActive(ctx, generation))
|
|
125
|
+
return;
|
|
85
126
|
state = { ...state, publicWindow: null };
|
|
86
127
|
}
|
|
87
|
-
ctx
|
|
128
|
+
if (isLifecycleActive(ctx, generation))
|
|
129
|
+
ctx.rerender();
|
|
88
130
|
}
|
|
89
|
-
|
|
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() {
|
|
131
|
+
async function tryInferWeekOne(ctx, generation) {
|
|
93
132
|
try {
|
|
94
|
-
const cal = await loadCalendarOrThrow();
|
|
133
|
+
const cal = await loadCalendarOrThrow(ctx.signal);
|
|
134
|
+
if (!isLifecycleActive(ctx, generation))
|
|
135
|
+
return null;
|
|
95
136
|
const now = new Date();
|
|
96
|
-
|
|
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));
|
|
137
|
+
const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
|
|
102
138
|
return inferWeekOneMonday(events, now);
|
|
103
139
|
}
|
|
104
140
|
catch {
|
|
105
141
|
return null;
|
|
106
142
|
}
|
|
107
143
|
}
|
|
108
|
-
async function afterAuthenticated(ctx, s) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
144
|
+
async function afterAuthenticated(ctx, s, generation) {
|
|
145
|
+
if (!isLifecycleActive(ctx, generation)) {
|
|
146
|
+
await closeSession(s);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
113
149
|
const hadCache = state.mode === 'hub';
|
|
150
|
+
if (session && session !== s)
|
|
151
|
+
await releaseSession(session);
|
|
152
|
+
if (!isLifecycleActive(ctx, generation)) {
|
|
153
|
+
await closeSession(s);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
114
156
|
session = s;
|
|
115
157
|
client = createNbtTimetableClient(s.timetableTransport, { baseUrl: JWXT_ORIGIN });
|
|
116
158
|
try {
|
|
117
|
-
|
|
159
|
+
const nextCatalog = (await client.listTerms(ctx.signal === undefined ? {} : { signal: ctx.signal })).map(sanitizeAcademicTerm);
|
|
160
|
+
if (!isLifecycleActive(ctx, generation))
|
|
161
|
+
return;
|
|
162
|
+
catalog = nextCatalog;
|
|
118
163
|
const term = resolveTerm(catalog);
|
|
119
164
|
const key = termKey(term);
|
|
120
165
|
let weekOne = loadWeekOne(key);
|
|
121
166
|
if (!weekOne) {
|
|
122
|
-
weekOne = await tryInferWeekOne();
|
|
167
|
+
weekOne = await tryInferWeekOne(ctx, generation);
|
|
168
|
+
if (!isLifecycleActive(ctx, generation))
|
|
169
|
+
return;
|
|
123
170
|
if (weekOne)
|
|
124
171
|
saveWeekOne(key, weekOne);
|
|
125
172
|
}
|
|
@@ -130,111 +177,156 @@ async function afterAuthenticated(ctx, s) {
|
|
|
130
177
|
key,
|
|
131
178
|
term,
|
|
132
179
|
errorMessage: t().timetable.weekOneAutoFailed,
|
|
133
|
-
weekOneField: new TextField({
|
|
180
|
+
weekOneField: new TextField({
|
|
181
|
+
message: t().timetable.weekOne,
|
|
182
|
+
placeholder: t().timetable.weekOneHint,
|
|
183
|
+
}),
|
|
134
184
|
};
|
|
135
185
|
ctx.rerender();
|
|
136
186
|
return;
|
|
137
187
|
}
|
|
138
|
-
await fetchAndShowHub(ctx, term, key, weekOne);
|
|
188
|
+
await fetchAndShowHub(ctx, term, key, weekOne, generation);
|
|
139
189
|
}
|
|
140
190
|
catch (err) {
|
|
191
|
+
if (!isLifecycleActive(ctx, generation))
|
|
192
|
+
return;
|
|
141
193
|
if (isSessionExpired(err)) {
|
|
142
194
|
createSessionStore().clear();
|
|
195
|
+
await releaseSession(s);
|
|
143
196
|
if (!hadCache)
|
|
144
197
|
goToLoginId(t().timetable.expiredRelogin);
|
|
145
198
|
}
|
|
146
|
-
else
|
|
147
|
-
|
|
199
|
+
else {
|
|
200
|
+
await releaseSession(s);
|
|
201
|
+
if (!hadCache)
|
|
202
|
+
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
148
203
|
}
|
|
149
204
|
ctx.rerender();
|
|
150
205
|
}
|
|
151
206
|
}
|
|
152
|
-
async function fetchAndShowHub(ctx, term, key, weekOne) {
|
|
153
|
-
if (!client)
|
|
207
|
+
async function fetchAndShowHub(ctx, term, key, weekOne, generation = lifecycleGeneration) {
|
|
208
|
+
if (!client || !isLifecycleActive(ctx, generation))
|
|
154
209
|
return;
|
|
155
210
|
state = { mode: 'loading', statusMessage: t().calendar.loading };
|
|
156
211
|
ctx.rerender();
|
|
157
212
|
try {
|
|
158
|
-
const timetable = await client.fetchTerm(term);
|
|
213
|
+
const timetable = sanitizeTimetable(await client.fetchTerm(term, ctx.signal === undefined ? {} : { signal: ctx.signal }));
|
|
214
|
+
if (!isLifecycleActive(ctx, generation))
|
|
215
|
+
return;
|
|
216
|
+
const schedule = createTimetableSchedule(timetable, { weekOneMonday: weekOne });
|
|
159
217
|
saveTimetableCache(key, timetable);
|
|
160
218
|
saveCurrentPointer(key, weekOne);
|
|
161
219
|
state = {
|
|
162
|
-
mode: 'hub',
|
|
163
|
-
|
|
220
|
+
mode: 'hub',
|
|
221
|
+
key,
|
|
222
|
+
term,
|
|
223
|
+
weekOne,
|
|
224
|
+
timetable,
|
|
225
|
+
gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), timetable.periods),
|
|
164
226
|
};
|
|
165
227
|
}
|
|
166
228
|
catch (err) {
|
|
229
|
+
if (!isLifecycleActive(ctx, generation))
|
|
230
|
+
return;
|
|
167
231
|
if (isSessionExpired(err)) {
|
|
168
232
|
createSessionStore().clear();
|
|
233
|
+
await releaseSession();
|
|
169
234
|
goToLoginId(t().timetable.expiredRelogin);
|
|
170
235
|
}
|
|
171
236
|
else {
|
|
172
237
|
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
173
238
|
}
|
|
174
239
|
}
|
|
175
|
-
ctx
|
|
240
|
+
if (isLifecycleActive(ctx, generation))
|
|
241
|
+
ctx.rerender();
|
|
176
242
|
}
|
|
177
|
-
async function refreshFromNetwork(ctx) {
|
|
243
|
+
async function refreshFromNetwork(ctx, generation) {
|
|
244
|
+
if (!isLifecycleActive(ctx, generation))
|
|
245
|
+
return;
|
|
178
246
|
const hadCache = state.mode === 'hub';
|
|
179
247
|
try {
|
|
180
248
|
const store = createSessionStore();
|
|
181
249
|
const persisted = store.load();
|
|
182
250
|
if (!persisted) {
|
|
183
251
|
if (!hadCache)
|
|
184
|
-
await goToPublic(ctx);
|
|
252
|
+
await goToPublic(ctx, generation);
|
|
185
253
|
return;
|
|
186
254
|
}
|
|
187
255
|
const restored = await restoreNbtSession(persisted);
|
|
188
|
-
|
|
256
|
+
if (!isLifecycleActive(ctx, generation)) {
|
|
257
|
+
await closeSession(restored);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
await afterAuthenticated(ctx, restored, generation);
|
|
189
261
|
}
|
|
190
262
|
catch (err) {
|
|
263
|
+
if (!isLifecycleActive(ctx, generation))
|
|
264
|
+
return;
|
|
191
265
|
if (!hadCache) {
|
|
192
266
|
if (err instanceof AuthError && isSessionExpired(err)) {
|
|
193
267
|
createSessionStore().clear();
|
|
194
268
|
}
|
|
195
|
-
await goToPublic(ctx);
|
|
269
|
+
await goToPublic(ctx, generation);
|
|
196
270
|
}
|
|
197
|
-
// best-effort: a cached hub already showed, keep it as-is on refresh failure.
|
|
198
271
|
}
|
|
199
272
|
}
|
|
200
273
|
export const scheduleView = {
|
|
201
274
|
id: 'schedule',
|
|
202
275
|
title: t().timetable.menuEntry,
|
|
203
276
|
async load(ctx) {
|
|
277
|
+
const generation = ++lifecycleGeneration;
|
|
278
|
+
if (!isLifecycleActive(ctx, generation))
|
|
279
|
+
return;
|
|
204
280
|
const ptr = loadCurrentPointer();
|
|
205
|
-
const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
|
|
206
|
-
if (ptr &&
|
|
281
|
+
const cached = readCachedTimetable(ptr ? loadTimetableCache(ptr.termKey) : null);
|
|
282
|
+
if (ptr && cached) {
|
|
283
|
+
const schedule = createTimetableSchedule(cached, { weekOneMonday: ptr.weekOneMonday });
|
|
207
284
|
state = {
|
|
208
|
-
mode: 'hub',
|
|
209
|
-
|
|
285
|
+
mode: 'hub',
|
|
286
|
+
key: ptr.termKey,
|
|
287
|
+
weekOne: ptr.weekOneMonday,
|
|
288
|
+
timetable: cached,
|
|
289
|
+
gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), cached.periods),
|
|
210
290
|
};
|
|
211
291
|
}
|
|
212
292
|
else {
|
|
213
293
|
state = { mode: 'loading' };
|
|
214
294
|
}
|
|
215
295
|
ctx.rerender();
|
|
216
|
-
await refreshFromNetwork(ctx);
|
|
296
|
+
await refreshFromNetwork(ctx, generation);
|
|
297
|
+
},
|
|
298
|
+
async dispose() {
|
|
299
|
+
lifecycleGeneration += 1;
|
|
300
|
+
setVimKeysActive(true);
|
|
301
|
+
await releaseSession();
|
|
217
302
|
},
|
|
218
303
|
render(ctx) {
|
|
219
|
-
// Sync every visible field's scroll window to the *current* terminal
|
|
220
|
-
// size on every frame (not just construction time) — this is what
|
|
221
|
-
// keeps a long list correctly windowed across a live resize.
|
|
222
304
|
state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
|
|
223
305
|
return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
|
|
224
306
|
},
|
|
307
|
+
isBusy() {
|
|
308
|
+
return state.mode === 'loading';
|
|
309
|
+
},
|
|
225
310
|
capturesInput() {
|
|
226
|
-
return state.mode === 'needsLoginId' ||
|
|
311
|
+
return (state.mode === 'needsLoginId' ||
|
|
312
|
+
state.mode === 'needsLoginPassword' ||
|
|
313
|
+
state.mode === 'needsWeekOne');
|
|
314
|
+
},
|
|
315
|
+
capturesPageKeys() {
|
|
316
|
+
return state.mode === 'public' || state.mode === 'termPicker';
|
|
227
317
|
},
|
|
228
318
|
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
229
|
-
const capturing = state.mode === 'needsLoginId' ||
|
|
319
|
+
const capturing = state.mode === 'needsLoginId' ||
|
|
320
|
+
state.mode === 'needsLoginPassword' ||
|
|
321
|
+
state.mode === 'needsWeekOne';
|
|
230
322
|
if (capturing)
|
|
231
323
|
return captureFooterHint(cols);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
324
|
+
const passive = state.mode === 'loading' ||
|
|
325
|
+
state.mode === 'authenticating' ||
|
|
326
|
+
state.mode === 'error' ||
|
|
327
|
+
state.mode === 'meetingDetail' ||
|
|
328
|
+
state.mode === 'unresolved' ||
|
|
329
|
+
state.mode === 'termDensity';
|
|
238
330
|
return passive ? passiveFooterHint(tabCount, cols) : undefined;
|
|
239
331
|
},
|
|
240
332
|
handleBack(ctx) {
|
|
@@ -247,16 +339,16 @@ export const scheduleView = {
|
|
|
247
339
|
return true;
|
|
248
340
|
}
|
|
249
341
|
if (state.mode === 'meetingDetail') {
|
|
250
|
-
// Esc respects where the detail card was opened from -- from the
|
|
251
|
-
// standalone 'week' mode, it steps back there, not all the way to hub.
|
|
252
342
|
if (state.detailFrom === 'week') {
|
|
253
343
|
state = { ...state, mode: 'week' };
|
|
254
344
|
return true;
|
|
255
345
|
}
|
|
256
346
|
return returnToHub();
|
|
257
347
|
}
|
|
258
|
-
if (state.mode === 'week' ||
|
|
259
|
-
|
|
348
|
+
if (state.mode === 'week' ||
|
|
349
|
+
state.mode === 'unresolved' ||
|
|
350
|
+
state.mode === 'termPicker' ||
|
|
351
|
+
state.mode === 'termDensity') {
|
|
260
352
|
return returnToHub();
|
|
261
353
|
}
|
|
262
354
|
return false;
|
|
@@ -279,7 +371,11 @@ export const scheduleView = {
|
|
|
279
371
|
pendingId = result.submitted;
|
|
280
372
|
state = {
|
|
281
373
|
mode: 'needsLoginPassword',
|
|
282
|
-
passwordField: new TextField({
|
|
374
|
+
passwordField: new TextField({
|
|
375
|
+
message: t().timetable.password,
|
|
376
|
+
placeholder: t().timetable.passwordHint,
|
|
377
|
+
secret: true,
|
|
378
|
+
}),
|
|
283
379
|
};
|
|
284
380
|
}
|
|
285
381
|
return;
|
|
@@ -292,15 +388,33 @@ export const scheduleView = {
|
|
|
292
388
|
}
|
|
293
389
|
if (result?.submitted !== undefined) {
|
|
294
390
|
const password = result.submitted;
|
|
391
|
+
const generation = lifecycleGeneration;
|
|
295
392
|
setVimKeysActive(true);
|
|
296
393
|
state = { mode: 'authenticating', statusMessage: t().timetable.loginWillSave };
|
|
297
394
|
ctx.rerender();
|
|
298
|
-
void loginWithStudentPassword(pendingId, password)
|
|
395
|
+
void loginWithStudentPassword(pendingId, password, ctx.signal === undefined ? {} : { signal: ctx.signal })
|
|
299
396
|
.then(async (s) => {
|
|
300
|
-
|
|
301
|
-
|
|
397
|
+
try {
|
|
398
|
+
if (!isLifecycleActive(ctx, generation)) {
|
|
399
|
+
await closeSession(s);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const snapshot = await s.snapshot();
|
|
403
|
+
if (!isLifecycleActive(ctx, generation)) {
|
|
404
|
+
await closeSession(s);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
createSessionStore().save(snapshot);
|
|
408
|
+
await afterAuthenticated(ctx, s, generation);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
await closeSession(s);
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
302
414
|
})
|
|
303
415
|
.catch((err) => {
|
|
416
|
+
if (!isLifecycleActive(ctx, generation))
|
|
417
|
+
return;
|
|
304
418
|
goToLoginId(safeMessage(err));
|
|
305
419
|
ctx.rerender();
|
|
306
420
|
});
|
|
@@ -315,7 +429,13 @@ export const scheduleView = {
|
|
|
315
429
|
}
|
|
316
430
|
if (result?.submitted !== undefined) {
|
|
317
431
|
const trimmed = result.submitted.trim();
|
|
318
|
-
|
|
432
|
+
let valid = true;
|
|
433
|
+
try {
|
|
434
|
+
parseLocalMonday(trimmed);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
valid = false;
|
|
438
|
+
}
|
|
319
439
|
const targetKey = state.key;
|
|
320
440
|
const targetTerm = state.term;
|
|
321
441
|
if (!valid || !targetKey || !targetTerm) {
|
|
@@ -324,7 +444,7 @@ export const scheduleView = {
|
|
|
324
444
|
}
|
|
325
445
|
saveWeekOne(targetKey, trimmed);
|
|
326
446
|
setVimKeysActive(true);
|
|
327
|
-
void fetchAndShowHub(ctx, targetTerm, targetKey, trimmed);
|
|
447
|
+
void fetchAndShowHub(ctx, targetTerm, targetKey, trimmed, lifecycleGeneration);
|
|
328
448
|
}
|
|
329
449
|
return;
|
|
330
450
|
}
|
|
@@ -335,15 +455,22 @@ export const scheduleView = {
|
|
|
335
455
|
if (!tt || !hubKey || !hubWeekOne)
|
|
336
456
|
return;
|
|
337
457
|
{
|
|
338
|
-
const
|
|
339
|
-
const
|
|
458
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: hubWeekOne });
|
|
459
|
+
const now = new Date();
|
|
460
|
+
const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
|
|
461
|
+
const week = Math.max(1, schedule.weekAt(now));
|
|
340
462
|
const nav = handleGridKey(key, cursor, tt, week);
|
|
341
463
|
if (nav.kind === 'moveCursor') {
|
|
342
464
|
state = { ...state, gridCursor: nav.cursor };
|
|
343
465
|
return;
|
|
344
466
|
}
|
|
345
467
|
if (nav.kind === 'openDetail') {
|
|
346
|
-
state = {
|
|
468
|
+
state = {
|
|
469
|
+
...state,
|
|
470
|
+
mode: 'meetingDetail',
|
|
471
|
+
detailMeeting: nav.meeting,
|
|
472
|
+
detailFrom: 'hub',
|
|
473
|
+
};
|
|
347
474
|
return;
|
|
348
475
|
}
|
|
349
476
|
}
|
|
@@ -366,19 +493,26 @@ export const scheduleView = {
|
|
|
366
493
|
const options = relevantTerms(catalog).map((tm) => ({
|
|
367
494
|
value: `${tm.academicYear}:${tm.semester}`,
|
|
368
495
|
label: tm.academicYearLabel,
|
|
369
|
-
|
|
496
|
+
...(tm.current ? { hint: t().common.current } : {}),
|
|
370
497
|
}));
|
|
371
|
-
options.push({ value: '__back__', label: t().common.back
|
|
498
|
+
options.push({ value: '__back__', label: t().common.back });
|
|
372
499
|
state = {
|
|
373
500
|
...state,
|
|
374
501
|
mode: 'termPicker',
|
|
375
|
-
termField: new ListField({
|
|
502
|
+
termField: new ListField({
|
|
503
|
+
title: t().timetable.hubSwitchTerm,
|
|
504
|
+
options,
|
|
505
|
+
maxVisible: computeMaxVisible(ctx.bodyRows),
|
|
506
|
+
}),
|
|
376
507
|
};
|
|
377
508
|
return;
|
|
378
509
|
}
|
|
379
510
|
if (shortcut.key === 'e') {
|
|
380
511
|
try {
|
|
381
|
-
const ics = timetableToIcs(tt, {
|
|
512
|
+
const ics = timetableToIcs(tt, {
|
|
513
|
+
weekOneMonday: hubWeekOne,
|
|
514
|
+
calendarName: `NBT ${state.term?.academicYearLabel ?? ''}`,
|
|
515
|
+
});
|
|
382
516
|
const out = `timetable-${hubKey}.ics`;
|
|
383
517
|
writePrivateIcs(out, ics);
|
|
384
518
|
state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
|
|
@@ -389,12 +523,11 @@ export const scheduleView = {
|
|
|
389
523
|
return;
|
|
390
524
|
}
|
|
391
525
|
if (shortcut.key === 'x') {
|
|
526
|
+
const generation = ++lifecycleGeneration;
|
|
392
527
|
createSessionStore().clear();
|
|
393
528
|
clearScheduleCache();
|
|
394
|
-
void
|
|
395
|
-
|
|
396
|
-
client = null;
|
|
397
|
-
void goToPublic(ctx);
|
|
529
|
+
void releaseSession();
|
|
530
|
+
void goToPublic(ctx, generation);
|
|
398
531
|
}
|
|
399
532
|
return;
|
|
400
533
|
}
|
|
@@ -405,15 +538,22 @@ export const scheduleView = {
|
|
|
405
538
|
returnToHub();
|
|
406
539
|
return;
|
|
407
540
|
}
|
|
408
|
-
const
|
|
409
|
-
const
|
|
541
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: weekOne });
|
|
542
|
+
const now = new Date();
|
|
543
|
+
const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
|
|
544
|
+
const week = Math.max(1, schedule.weekAt(now));
|
|
410
545
|
const nav = handleGridKey(key, cursor, tt, week);
|
|
411
546
|
if (nav.kind === 'moveCursor') {
|
|
412
547
|
state = { ...state, gridCursor: nav.cursor };
|
|
413
548
|
return;
|
|
414
549
|
}
|
|
415
550
|
if (nav.kind === 'openDetail') {
|
|
416
|
-
state = {
|
|
551
|
+
state = {
|
|
552
|
+
...state,
|
|
553
|
+
mode: 'meetingDetail',
|
|
554
|
+
detailMeeting: nav.meeting,
|
|
555
|
+
detailFrom: 'week',
|
|
556
|
+
};
|
|
417
557
|
return;
|
|
418
558
|
}
|
|
419
559
|
returnToHub();
|
|
@@ -449,14 +589,19 @@ export const scheduleView = {
|
|
|
449
589
|
mode: 'needsWeekOne',
|
|
450
590
|
key: newTermKey,
|
|
451
591
|
term,
|
|
452
|
-
weekOneField: new TextField({
|
|
592
|
+
weekOneField: new TextField({
|
|
593
|
+
message: t().timetable.weekOne,
|
|
594
|
+
placeholder: t().timetable.weekOneHint,
|
|
595
|
+
}),
|
|
453
596
|
};
|
|
454
597
|
return;
|
|
455
598
|
}
|
|
456
|
-
void fetchAndShowHub(ctx, term, newTermKey, weekOne);
|
|
599
|
+
void fetchAndShowHub(ctx, term, newTermKey, weekOne, lifecycleGeneration);
|
|
457
600
|
return;
|
|
458
601
|
}
|
|
459
|
-
|
|
602
|
+
case 'loading':
|
|
603
|
+
case 'authenticating':
|
|
604
|
+
case 'error':
|
|
460
605
|
return;
|
|
461
606
|
}
|
|
462
607
|
},
|
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
import { space, type } from '../../core/theme.js';
|
|
2
|
-
import {
|
|
2
|
+
import { wrapAnsiWithIndent } from '../../core/text.js';
|
|
3
3
|
import { renderListFieldWithContext } from '../fields/list-field.js';
|
|
4
4
|
function wrappedIndentedLines(label, cols, style) {
|
|
5
|
-
|
|
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}`);
|
|
5
|
+
return wrapAnsiWithIndent(style(label), cols, space.indent);
|
|
15
6
|
}
|
|
16
7
|
export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols = Number.POSITIVE_INFINITY) {
|
|
17
8
|
switch (state.mode) {
|
|
18
9
|
case 'menu': {
|
|
19
10
|
const context = [
|
|
20
|
-
...(state.statusMessage
|
|
11
|
+
...(state.statusMessage
|
|
12
|
+
? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), '']
|
|
13
|
+
: []),
|
|
21
14
|
];
|
|
22
15
|
return state.menuField
|
|
23
16
|
? renderListFieldWithContext(context, state.menuField, bodyRows, cols)
|
|
@@ -29,7 +22,7 @@ export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols
|
|
|
29
22
|
return state.subField?.render(bodyRows, cols) ?? [];
|
|
30
23
|
case 'about': {
|
|
31
24
|
const context = [
|
|
32
|
-
...(state.aboutLines ?? []).flatMap((line) =>
|
|
25
|
+
...(state.aboutLines ?? []).flatMap((line) => line ? wrappedIndentedLines(line, cols, (value) => value) : ['']),
|
|
33
26
|
'',
|
|
34
27
|
];
|
|
35
28
|
if (!state.backField)
|
|
@@ -39,13 +32,9 @@ export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols
|
|
|
39
32
|
return [...context, ...fieldLines];
|
|
40
33
|
}
|
|
41
34
|
const rows = Math.max(0, Math.floor(bodyRows));
|
|
42
|
-
const visibleField = fieldLines.length <= rows
|
|
43
|
-
? fieldLines
|
|
44
|
-
: state.backField.render(rows, cols);
|
|
35
|
+
const visibleField = fieldLines.length <= rows ? fieldLines : state.backField.render(rows, cols);
|
|
45
36
|
const content = context.at(-1) === '' ? context.slice(0, -1) : context;
|
|
46
|
-
return content.length > 0
|
|
47
|
-
? [...visibleField, '', ...content]
|
|
48
|
-
: visibleField;
|
|
37
|
+
return content.length > 0 ? [...visibleField, '', ...content] : visibleField;
|
|
49
38
|
}
|
|
50
39
|
default:
|
|
51
40
|
return [];
|