@nbtca/prompt 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +53 -55
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +2 -21
- package/dist/app/keys.js +10 -2
- package/dist/app/views/docs-render.js +31 -24
- package/dist/app/views/docs.js +211 -60
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +28 -30
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +47 -71
- package/dist/app/views/schedule.js +158 -81
- 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/config/data.js +9 -11
- package/dist/config/preferences.js +14 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +41 -16
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +17 -6
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -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 +9 -15
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar.js +12 -185
- package/dist/features/docs.js +436 -275
- 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 +44 -56
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -2
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -27
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +83 -58
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +27 -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/links.js +0 -36
- 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,60 @@ 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
|
+
async function releaseSession(target = session) {
|
|
25
|
+
if (!target)
|
|
26
|
+
return;
|
|
27
|
+
if (session === target) {
|
|
28
|
+
session = null;
|
|
29
|
+
client = null;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
await target.close();
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}
|
|
23
36
|
function isTimetableLike(value) {
|
|
24
|
-
return !!value &&
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
return (!!value &&
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
Array.isArray(value.meetings) &&
|
|
40
|
+
Array.isArray(value.periods));
|
|
41
|
+
}
|
|
42
|
+
function readCachedTimetable(value) {
|
|
43
|
+
if (!isTimetableLike(value))
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
return sanitizeTimetable(value);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
27
51
|
}
|
|
28
52
|
function returnToHub() {
|
|
29
53
|
const tt = state.timetable;
|
|
30
54
|
const backKey = state.key;
|
|
31
55
|
const backWeekOne = state.weekOne;
|
|
32
56
|
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.
|
|
57
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: backWeekOne });
|
|
37
58
|
state = {
|
|
38
|
-
mode: 'hub',
|
|
39
|
-
|
|
59
|
+
mode: 'hub',
|
|
60
|
+
key: backKey,
|
|
61
|
+
...(state.term ? { term: state.term } : {}),
|
|
62
|
+
weekOne: backWeekOne,
|
|
63
|
+
timetable: tt,
|
|
64
|
+
gridCursor: state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(new Date()), tt.periods),
|
|
40
65
|
};
|
|
41
66
|
return true;
|
|
42
67
|
}
|
|
@@ -47,8 +72,11 @@ function goToLoginId(errorMessage) {
|
|
|
47
72
|
setVimKeysActive(false);
|
|
48
73
|
state = {
|
|
49
74
|
mode: 'needsLoginId',
|
|
50
|
-
errorMessage,
|
|
51
|
-
idField: new TextField({
|
|
75
|
+
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
76
|
+
idField: new TextField({
|
|
77
|
+
message: t().timetable.studentId,
|
|
78
|
+
placeholder: t().timetable.studentIdHint,
|
|
79
|
+
}),
|
|
52
80
|
};
|
|
53
81
|
}
|
|
54
82
|
function buildPublicField() {
|
|
@@ -59,13 +87,7 @@ function buildPublicField() {
|
|
|
59
87
|
footer: trans.menu.hintMove,
|
|
60
88
|
});
|
|
61
89
|
}
|
|
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
90
|
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
91
|
async function goToPublic(ctx) {
|
|
70
92
|
setVimKeysActive(true);
|
|
71
93
|
state = { mode: 'public', publicField: buildPublicField() };
|
|
@@ -73,9 +95,10 @@ async function goToPublic(ctx) {
|
|
|
73
95
|
try {
|
|
74
96
|
const cal = await loadCalendarOrThrow();
|
|
75
97
|
const now = new Date();
|
|
76
|
-
const windowEvents = cal.inRange(
|
|
98
|
+
const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
|
|
77
99
|
const publicWindow = currentAcademicWindow(windowEvents, now);
|
|
78
|
-
const publicUpcoming = cal
|
|
100
|
+
const publicUpcoming = cal
|
|
101
|
+
.upcoming({ days: 30 })
|
|
79
102
|
.filter((e) => !isAcademicBreakEvent(e))
|
|
80
103
|
.slice(0, PUBLIC_UPCOMING_FETCH_CAP)
|
|
81
104
|
.map(toDisplayEvent);
|
|
@@ -86,19 +109,11 @@ async function goToPublic(ctx) {
|
|
|
86
109
|
}
|
|
87
110
|
ctx.rerender();
|
|
88
111
|
}
|
|
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
112
|
async function tryInferWeekOne() {
|
|
93
113
|
try {
|
|
94
114
|
const cal = await loadCalendarOrThrow();
|
|
95
115
|
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));
|
|
116
|
+
const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
|
|
102
117
|
return inferWeekOneMonday(events, now);
|
|
103
118
|
}
|
|
104
119
|
catch {
|
|
@@ -106,15 +121,13 @@ async function tryInferWeekOne() {
|
|
|
106
121
|
}
|
|
107
122
|
}
|
|
108
123
|
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
124
|
const hadCache = state.mode === 'hub';
|
|
125
|
+
if (session && session !== s)
|
|
126
|
+
await releaseSession(session);
|
|
114
127
|
session = s;
|
|
115
128
|
client = createNbtTimetableClient(s.timetableTransport, { baseUrl: JWXT_ORIGIN });
|
|
116
129
|
try {
|
|
117
|
-
catalog = await client.listTerms();
|
|
130
|
+
catalog = (await client.listTerms()).map(sanitizeAcademicTerm);
|
|
118
131
|
const term = resolveTerm(catalog);
|
|
119
132
|
const key = termKey(term);
|
|
120
133
|
let weekOne = loadWeekOne(key);
|
|
@@ -130,7 +143,10 @@ async function afterAuthenticated(ctx, s) {
|
|
|
130
143
|
key,
|
|
131
144
|
term,
|
|
132
145
|
errorMessage: t().timetable.weekOneAutoFailed,
|
|
133
|
-
weekOneField: new TextField({
|
|
146
|
+
weekOneField: new TextField({
|
|
147
|
+
message: t().timetable.weekOne,
|
|
148
|
+
placeholder: t().timetable.weekOneHint,
|
|
149
|
+
}),
|
|
134
150
|
};
|
|
135
151
|
ctx.rerender();
|
|
136
152
|
return;
|
|
@@ -140,11 +156,14 @@ async function afterAuthenticated(ctx, s) {
|
|
|
140
156
|
catch (err) {
|
|
141
157
|
if (isSessionExpired(err)) {
|
|
142
158
|
createSessionStore().clear();
|
|
159
|
+
await releaseSession(s);
|
|
143
160
|
if (!hadCache)
|
|
144
161
|
goToLoginId(t().timetable.expiredRelogin);
|
|
145
162
|
}
|
|
146
|
-
else
|
|
147
|
-
|
|
163
|
+
else {
|
|
164
|
+
await releaseSession(s);
|
|
165
|
+
if (!hadCache)
|
|
166
|
+
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
148
167
|
}
|
|
149
168
|
ctx.rerender();
|
|
150
169
|
}
|
|
@@ -155,17 +174,23 @@ async function fetchAndShowHub(ctx, term, key, weekOne) {
|
|
|
155
174
|
state = { mode: 'loading', statusMessage: t().calendar.loading };
|
|
156
175
|
ctx.rerender();
|
|
157
176
|
try {
|
|
158
|
-
const timetable = await client.fetchTerm(term);
|
|
177
|
+
const timetable = sanitizeTimetable(await client.fetchTerm(term));
|
|
178
|
+
const schedule = createTimetableSchedule(timetable, { weekOneMonday: weekOne });
|
|
159
179
|
saveTimetableCache(key, timetable);
|
|
160
180
|
saveCurrentPointer(key, weekOne);
|
|
161
181
|
state = {
|
|
162
|
-
mode: 'hub',
|
|
163
|
-
|
|
182
|
+
mode: 'hub',
|
|
183
|
+
key,
|
|
184
|
+
term,
|
|
185
|
+
weekOne,
|
|
186
|
+
timetable,
|
|
187
|
+
gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), timetable.periods),
|
|
164
188
|
};
|
|
165
189
|
}
|
|
166
190
|
catch (err) {
|
|
167
191
|
if (isSessionExpired(err)) {
|
|
168
192
|
createSessionStore().clear();
|
|
193
|
+
await releaseSession();
|
|
169
194
|
goToLoginId(t().timetable.expiredRelogin);
|
|
170
195
|
}
|
|
171
196
|
else {
|
|
@@ -194,7 +219,6 @@ async function refreshFromNetwork(ctx) {
|
|
|
194
219
|
}
|
|
195
220
|
await goToPublic(ctx);
|
|
196
221
|
}
|
|
197
|
-
// best-effort: a cached hub already showed, keep it as-is on refresh failure.
|
|
198
222
|
}
|
|
199
223
|
}
|
|
200
224
|
export const scheduleView = {
|
|
@@ -202,11 +226,15 @@ export const scheduleView = {
|
|
|
202
226
|
title: t().timetable.menuEntry,
|
|
203
227
|
async load(ctx) {
|
|
204
228
|
const ptr = loadCurrentPointer();
|
|
205
|
-
const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
|
|
206
|
-
if (ptr &&
|
|
229
|
+
const cached = readCachedTimetable(ptr ? loadTimetableCache(ptr.termKey) : null);
|
|
230
|
+
if (ptr && cached) {
|
|
231
|
+
const schedule = createTimetableSchedule(cached, { weekOneMonday: ptr.weekOneMonday });
|
|
207
232
|
state = {
|
|
208
|
-
mode: 'hub',
|
|
209
|
-
|
|
233
|
+
mode: 'hub',
|
|
234
|
+
key: ptr.termKey,
|
|
235
|
+
weekOne: ptr.weekOneMonday,
|
|
236
|
+
timetable: cached,
|
|
237
|
+
gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), cached.periods),
|
|
210
238
|
};
|
|
211
239
|
}
|
|
212
240
|
else {
|
|
@@ -215,26 +243,33 @@ export const scheduleView = {
|
|
|
215
243
|
ctx.rerender();
|
|
216
244
|
await refreshFromNetwork(ctx);
|
|
217
245
|
},
|
|
246
|
+
async dispose() {
|
|
247
|
+
await releaseSession();
|
|
248
|
+
},
|
|
218
249
|
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
250
|
state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
|
|
223
251
|
return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
|
|
224
252
|
},
|
|
225
253
|
capturesInput() {
|
|
226
|
-
return state.mode === 'needsLoginId' ||
|
|
254
|
+
return (state.mode === 'needsLoginId' ||
|
|
255
|
+
state.mode === 'needsLoginPassword' ||
|
|
256
|
+
state.mode === 'needsWeekOne');
|
|
257
|
+
},
|
|
258
|
+
capturesPageKeys() {
|
|
259
|
+
return state.mode === 'public' || state.mode === 'termPicker';
|
|
227
260
|
},
|
|
228
261
|
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
229
|
-
const capturing = state.mode === 'needsLoginId' ||
|
|
262
|
+
const capturing = state.mode === 'needsLoginId' ||
|
|
263
|
+
state.mode === 'needsLoginPassword' ||
|
|
264
|
+
state.mode === 'needsWeekOne';
|
|
230
265
|
if (capturing)
|
|
231
266
|
return captureFooterHint(cols);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
267
|
+
const passive = state.mode === 'loading' ||
|
|
268
|
+
state.mode === 'authenticating' ||
|
|
269
|
+
state.mode === 'error' ||
|
|
270
|
+
state.mode === 'meetingDetail' ||
|
|
271
|
+
state.mode === 'unresolved' ||
|
|
272
|
+
state.mode === 'termDensity';
|
|
238
273
|
return passive ? passiveFooterHint(tabCount, cols) : undefined;
|
|
239
274
|
},
|
|
240
275
|
handleBack(ctx) {
|
|
@@ -247,16 +282,16 @@ export const scheduleView = {
|
|
|
247
282
|
return true;
|
|
248
283
|
}
|
|
249
284
|
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
285
|
if (state.detailFrom === 'week') {
|
|
253
286
|
state = { ...state, mode: 'week' };
|
|
254
287
|
return true;
|
|
255
288
|
}
|
|
256
289
|
return returnToHub();
|
|
257
290
|
}
|
|
258
|
-
if (state.mode === 'week' ||
|
|
259
|
-
|
|
291
|
+
if (state.mode === 'week' ||
|
|
292
|
+
state.mode === 'unresolved' ||
|
|
293
|
+
state.mode === 'termPicker' ||
|
|
294
|
+
state.mode === 'termDensity') {
|
|
260
295
|
return returnToHub();
|
|
261
296
|
}
|
|
262
297
|
return false;
|
|
@@ -279,7 +314,11 @@ export const scheduleView = {
|
|
|
279
314
|
pendingId = result.submitted;
|
|
280
315
|
state = {
|
|
281
316
|
mode: 'needsLoginPassword',
|
|
282
|
-
passwordField: new TextField({
|
|
317
|
+
passwordField: new TextField({
|
|
318
|
+
message: t().timetable.password,
|
|
319
|
+
placeholder: t().timetable.passwordHint,
|
|
320
|
+
secret: true,
|
|
321
|
+
}),
|
|
283
322
|
};
|
|
284
323
|
}
|
|
285
324
|
return;
|
|
@@ -297,8 +336,16 @@ export const scheduleView = {
|
|
|
297
336
|
ctx.rerender();
|
|
298
337
|
void loginWithStudentPassword(pendingId, password)
|
|
299
338
|
.then(async (s) => {
|
|
300
|
-
|
|
301
|
-
|
|
339
|
+
let handedOff = false;
|
|
340
|
+
try {
|
|
341
|
+
createSessionStore().save(await s.snapshot());
|
|
342
|
+
handedOff = true;
|
|
343
|
+
await afterAuthenticated(ctx, s);
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
if (!handedOff)
|
|
347
|
+
await releaseSession(s);
|
|
348
|
+
}
|
|
302
349
|
})
|
|
303
350
|
.catch((err) => {
|
|
304
351
|
goToLoginId(safeMessage(err));
|
|
@@ -315,7 +362,13 @@ export const scheduleView = {
|
|
|
315
362
|
}
|
|
316
363
|
if (result?.submitted !== undefined) {
|
|
317
364
|
const trimmed = result.submitted.trim();
|
|
318
|
-
|
|
365
|
+
let valid = true;
|
|
366
|
+
try {
|
|
367
|
+
parseLocalMonday(trimmed);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
valid = false;
|
|
371
|
+
}
|
|
319
372
|
const targetKey = state.key;
|
|
320
373
|
const targetTerm = state.term;
|
|
321
374
|
if (!valid || !targetKey || !targetTerm) {
|
|
@@ -335,15 +388,22 @@ export const scheduleView = {
|
|
|
335
388
|
if (!tt || !hubKey || !hubWeekOne)
|
|
336
389
|
return;
|
|
337
390
|
{
|
|
338
|
-
const
|
|
339
|
-
const
|
|
391
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: hubWeekOne });
|
|
392
|
+
const now = new Date();
|
|
393
|
+
const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
|
|
394
|
+
const week = Math.max(1, schedule.weekAt(now));
|
|
340
395
|
const nav = handleGridKey(key, cursor, tt, week);
|
|
341
396
|
if (nav.kind === 'moveCursor') {
|
|
342
397
|
state = { ...state, gridCursor: nav.cursor };
|
|
343
398
|
return;
|
|
344
399
|
}
|
|
345
400
|
if (nav.kind === 'openDetail') {
|
|
346
|
-
state = {
|
|
401
|
+
state = {
|
|
402
|
+
...state,
|
|
403
|
+
mode: 'meetingDetail',
|
|
404
|
+
detailMeeting: nav.meeting,
|
|
405
|
+
detailFrom: 'hub',
|
|
406
|
+
};
|
|
347
407
|
return;
|
|
348
408
|
}
|
|
349
409
|
}
|
|
@@ -366,19 +426,26 @@ export const scheduleView = {
|
|
|
366
426
|
const options = relevantTerms(catalog).map((tm) => ({
|
|
367
427
|
value: `${tm.academicYear}:${tm.semester}`,
|
|
368
428
|
label: tm.academicYearLabel,
|
|
369
|
-
|
|
429
|
+
...(tm.current ? { hint: t().common.current } : {}),
|
|
370
430
|
}));
|
|
371
|
-
options.push({ value: '__back__', label: t().common.back
|
|
431
|
+
options.push({ value: '__back__', label: t().common.back });
|
|
372
432
|
state = {
|
|
373
433
|
...state,
|
|
374
434
|
mode: 'termPicker',
|
|
375
|
-
termField: new ListField({
|
|
435
|
+
termField: new ListField({
|
|
436
|
+
title: t().timetable.hubSwitchTerm,
|
|
437
|
+
options,
|
|
438
|
+
maxVisible: computeMaxVisible(ctx.bodyRows),
|
|
439
|
+
}),
|
|
376
440
|
};
|
|
377
441
|
return;
|
|
378
442
|
}
|
|
379
443
|
if (shortcut.key === 'e') {
|
|
380
444
|
try {
|
|
381
|
-
const ics = timetableToIcs(tt, {
|
|
445
|
+
const ics = timetableToIcs(tt, {
|
|
446
|
+
weekOneMonday: hubWeekOne,
|
|
447
|
+
calendarName: `NBT ${state.term?.academicYearLabel ?? ''}`,
|
|
448
|
+
});
|
|
382
449
|
const out = `timetable-${hubKey}.ics`;
|
|
383
450
|
writePrivateIcs(out, ics);
|
|
384
451
|
state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
|
|
@@ -391,9 +458,7 @@ export const scheduleView = {
|
|
|
391
458
|
if (shortcut.key === 'x') {
|
|
392
459
|
createSessionStore().clear();
|
|
393
460
|
clearScheduleCache();
|
|
394
|
-
void
|
|
395
|
-
session = null;
|
|
396
|
-
client = null;
|
|
461
|
+
void releaseSession();
|
|
397
462
|
void goToPublic(ctx);
|
|
398
463
|
}
|
|
399
464
|
return;
|
|
@@ -405,15 +470,22 @@ export const scheduleView = {
|
|
|
405
470
|
returnToHub();
|
|
406
471
|
return;
|
|
407
472
|
}
|
|
408
|
-
const
|
|
409
|
-
const
|
|
473
|
+
const schedule = createTimetableSchedule(tt, { weekOneMonday: weekOne });
|
|
474
|
+
const now = new Date();
|
|
475
|
+
const cursor = state.gridCursor ?? defaultGridCursor(schedule.weekdayAt(now), tt.periods);
|
|
476
|
+
const week = Math.max(1, schedule.weekAt(now));
|
|
410
477
|
const nav = handleGridKey(key, cursor, tt, week);
|
|
411
478
|
if (nav.kind === 'moveCursor') {
|
|
412
479
|
state = { ...state, gridCursor: nav.cursor };
|
|
413
480
|
return;
|
|
414
481
|
}
|
|
415
482
|
if (nav.kind === 'openDetail') {
|
|
416
|
-
state = {
|
|
483
|
+
state = {
|
|
484
|
+
...state,
|
|
485
|
+
mode: 'meetingDetail',
|
|
486
|
+
detailMeeting: nav.meeting,
|
|
487
|
+
detailFrom: 'week',
|
|
488
|
+
};
|
|
417
489
|
return;
|
|
418
490
|
}
|
|
419
491
|
returnToHub();
|
|
@@ -449,14 +521,19 @@ export const scheduleView = {
|
|
|
449
521
|
mode: 'needsWeekOne',
|
|
450
522
|
key: newTermKey,
|
|
451
523
|
term,
|
|
452
|
-
weekOneField: new TextField({
|
|
524
|
+
weekOneField: new TextField({
|
|
525
|
+
message: t().timetable.weekOne,
|
|
526
|
+
placeholder: t().timetable.weekOneHint,
|
|
527
|
+
}),
|
|
453
528
|
};
|
|
454
529
|
return;
|
|
455
530
|
}
|
|
456
531
|
void fetchAndShowHub(ctx, term, newTermKey, weekOne);
|
|
457
532
|
return;
|
|
458
533
|
}
|
|
459
|
-
|
|
534
|
+
case 'loading':
|
|
535
|
+
case 'authenticating':
|
|
536
|
+
case 'error':
|
|
460
537
|
return;
|
|
461
538
|
}
|
|
462
539
|
},
|
|
@@ -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 [];
|