@nbtca/prompt 1.4.1 → 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/LICENSE +1 -1
- 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 -67
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +33 -76
- 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 -90
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +93 -18
- 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 +10 -13
- 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 +25 -21
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -75
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -30
- 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 +439 -320
- package/dist/features/schedule-render.js +65 -102
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -220
- package/dist/features/status.js +44 -59
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -37
- package/dist/i18n/index.js +87 -65
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +85 -64
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +30 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -71
- package/dist/features/links.js +0 -39
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -130
- 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;
|
|
@@ -139,21 +155,15 @@ async function afterAuthenticated(ctx, s) {
|
|
|
139
155
|
}
|
|
140
156
|
catch (err) {
|
|
141
157
|
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
158
|
createSessionStore().clear();
|
|
159
|
+
await releaseSession(s);
|
|
148
160
|
if (!hadCache)
|
|
149
161
|
goToLoginId(t().timetable.expiredRelogin);
|
|
150
162
|
}
|
|
151
|
-
else
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// best-effort contract refreshFromNetwork documents below).
|
|
156
|
-
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
163
|
+
else {
|
|
164
|
+
await releaseSession(s);
|
|
165
|
+
if (!hadCache)
|
|
166
|
+
state = { mode: 'error', errorMessage: safeMessage(err) };
|
|
157
167
|
}
|
|
158
168
|
ctx.rerender();
|
|
159
169
|
}
|
|
@@ -164,17 +174,23 @@ async function fetchAndShowHub(ctx, term, key, weekOne) {
|
|
|
164
174
|
state = { mode: 'loading', statusMessage: t().calendar.loading };
|
|
165
175
|
ctx.rerender();
|
|
166
176
|
try {
|
|
167
|
-
const timetable = await client.fetchTerm(term);
|
|
177
|
+
const timetable = sanitizeTimetable(await client.fetchTerm(term));
|
|
178
|
+
const schedule = createTimetableSchedule(timetable, { weekOneMonday: weekOne });
|
|
168
179
|
saveTimetableCache(key, timetable);
|
|
169
180
|
saveCurrentPointer(key, weekOne);
|
|
170
181
|
state = {
|
|
171
|
-
mode: 'hub',
|
|
172
|
-
|
|
182
|
+
mode: 'hub',
|
|
183
|
+
key,
|
|
184
|
+
term,
|
|
185
|
+
weekOne,
|
|
186
|
+
timetable,
|
|
187
|
+
gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), timetable.periods),
|
|
173
188
|
};
|
|
174
189
|
}
|
|
175
190
|
catch (err) {
|
|
176
191
|
if (isSessionExpired(err)) {
|
|
177
192
|
createSessionStore().clear();
|
|
193
|
+
await releaseSession();
|
|
178
194
|
goToLoginId(t().timetable.expiredRelogin);
|
|
179
195
|
}
|
|
180
196
|
else {
|
|
@@ -203,7 +219,6 @@ async function refreshFromNetwork(ctx) {
|
|
|
203
219
|
}
|
|
204
220
|
await goToPublic(ctx);
|
|
205
221
|
}
|
|
206
|
-
// best-effort: a cached hub already showed, keep it as-is on refresh failure.
|
|
207
222
|
}
|
|
208
223
|
}
|
|
209
224
|
export const scheduleView = {
|
|
@@ -211,11 +226,15 @@ export const scheduleView = {
|
|
|
211
226
|
title: t().timetable.menuEntry,
|
|
212
227
|
async load(ctx) {
|
|
213
228
|
const ptr = loadCurrentPointer();
|
|
214
|
-
const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
|
|
215
|
-
if (ptr &&
|
|
229
|
+
const cached = readCachedTimetable(ptr ? loadTimetableCache(ptr.termKey) : null);
|
|
230
|
+
if (ptr && cached) {
|
|
231
|
+
const schedule = createTimetableSchedule(cached, { weekOneMonday: ptr.weekOneMonday });
|
|
216
232
|
state = {
|
|
217
|
-
mode: 'hub',
|
|
218
|
-
|
|
233
|
+
mode: 'hub',
|
|
234
|
+
key: ptr.termKey,
|
|
235
|
+
weekOne: ptr.weekOneMonday,
|
|
236
|
+
timetable: cached,
|
|
237
|
+
gridCursor: defaultGridCursor(schedule.weekdayAt(new Date()), cached.periods),
|
|
219
238
|
};
|
|
220
239
|
}
|
|
221
240
|
else {
|
|
@@ -224,26 +243,33 @@ export const scheduleView = {
|
|
|
224
243
|
ctx.rerender();
|
|
225
244
|
await refreshFromNetwork(ctx);
|
|
226
245
|
},
|
|
246
|
+
async dispose() {
|
|
247
|
+
await releaseSession();
|
|
248
|
+
},
|
|
227
249
|
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
250
|
state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
|
|
232
251
|
return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
|
|
233
252
|
},
|
|
234
253
|
capturesInput() {
|
|
235
|
-
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';
|
|
236
260
|
},
|
|
237
261
|
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
238
|
-
const capturing = state.mode === 'needsLoginId' ||
|
|
262
|
+
const capturing = state.mode === 'needsLoginId' ||
|
|
263
|
+
state.mode === 'needsLoginPassword' ||
|
|
264
|
+
state.mode === 'needsWeekOne';
|
|
239
265
|
if (capturing)
|
|
240
266
|
return captureFooterHint(cols);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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';
|
|
247
273
|
return passive ? passiveFooterHint(tabCount, cols) : undefined;
|
|
248
274
|
},
|
|
249
275
|
handleBack(ctx) {
|
|
@@ -256,16 +282,16 @@ export const scheduleView = {
|
|
|
256
282
|
return true;
|
|
257
283
|
}
|
|
258
284
|
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
285
|
if (state.detailFrom === 'week') {
|
|
262
286
|
state = { ...state, mode: 'week' };
|
|
263
287
|
return true;
|
|
264
288
|
}
|
|
265
289
|
return returnToHub();
|
|
266
290
|
}
|
|
267
|
-
if (state.mode === 'week' ||
|
|
268
|
-
|
|
291
|
+
if (state.mode === 'week' ||
|
|
292
|
+
state.mode === 'unresolved' ||
|
|
293
|
+
state.mode === 'termPicker' ||
|
|
294
|
+
state.mode === 'termDensity') {
|
|
269
295
|
return returnToHub();
|
|
270
296
|
}
|
|
271
297
|
return false;
|
|
@@ -288,7 +314,11 @@ export const scheduleView = {
|
|
|
288
314
|
pendingId = result.submitted;
|
|
289
315
|
state = {
|
|
290
316
|
mode: 'needsLoginPassword',
|
|
291
|
-
passwordField: new TextField({
|
|
317
|
+
passwordField: new TextField({
|
|
318
|
+
message: t().timetable.password,
|
|
319
|
+
placeholder: t().timetable.passwordHint,
|
|
320
|
+
secret: true,
|
|
321
|
+
}),
|
|
292
322
|
};
|
|
293
323
|
}
|
|
294
324
|
return;
|
|
@@ -306,8 +336,16 @@ export const scheduleView = {
|
|
|
306
336
|
ctx.rerender();
|
|
307
337
|
void loginWithStudentPassword(pendingId, password)
|
|
308
338
|
.then(async (s) => {
|
|
309
|
-
|
|
310
|
-
|
|
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
|
+
}
|
|
311
349
|
})
|
|
312
350
|
.catch((err) => {
|
|
313
351
|
goToLoginId(safeMessage(err));
|
|
@@ -324,7 +362,13 @@ export const scheduleView = {
|
|
|
324
362
|
}
|
|
325
363
|
if (result?.submitted !== undefined) {
|
|
326
364
|
const trimmed = result.submitted.trim();
|
|
327
|
-
|
|
365
|
+
let valid = true;
|
|
366
|
+
try {
|
|
367
|
+
parseLocalMonday(trimmed);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
valid = false;
|
|
371
|
+
}
|
|
328
372
|
const targetKey = state.key;
|
|
329
373
|
const targetTerm = state.term;
|
|
330
374
|
if (!valid || !targetKey || !targetTerm) {
|
|
@@ -344,15 +388,22 @@ export const scheduleView = {
|
|
|
344
388
|
if (!tt || !hubKey || !hubWeekOne)
|
|
345
389
|
return;
|
|
346
390
|
{
|
|
347
|
-
const
|
|
348
|
-
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));
|
|
349
395
|
const nav = handleGridKey(key, cursor, tt, week);
|
|
350
396
|
if (nav.kind === 'moveCursor') {
|
|
351
397
|
state = { ...state, gridCursor: nav.cursor };
|
|
352
398
|
return;
|
|
353
399
|
}
|
|
354
400
|
if (nav.kind === 'openDetail') {
|
|
355
|
-
state = {
|
|
401
|
+
state = {
|
|
402
|
+
...state,
|
|
403
|
+
mode: 'meetingDetail',
|
|
404
|
+
detailMeeting: nav.meeting,
|
|
405
|
+
detailFrom: 'hub',
|
|
406
|
+
};
|
|
356
407
|
return;
|
|
357
408
|
}
|
|
358
409
|
}
|
|
@@ -375,19 +426,26 @@ export const scheduleView = {
|
|
|
375
426
|
const options = relevantTerms(catalog).map((tm) => ({
|
|
376
427
|
value: `${tm.academicYear}:${tm.semester}`,
|
|
377
428
|
label: tm.academicYearLabel,
|
|
378
|
-
|
|
429
|
+
...(tm.current ? { hint: t().common.current } : {}),
|
|
379
430
|
}));
|
|
380
|
-
options.push({ value: '__back__', label: t().common.back
|
|
431
|
+
options.push({ value: '__back__', label: t().common.back });
|
|
381
432
|
state = {
|
|
382
433
|
...state,
|
|
383
434
|
mode: 'termPicker',
|
|
384
|
-
termField: new ListField({
|
|
435
|
+
termField: new ListField({
|
|
436
|
+
title: t().timetable.hubSwitchTerm,
|
|
437
|
+
options,
|
|
438
|
+
maxVisible: computeMaxVisible(ctx.bodyRows),
|
|
439
|
+
}),
|
|
385
440
|
};
|
|
386
441
|
return;
|
|
387
442
|
}
|
|
388
443
|
if (shortcut.key === 'e') {
|
|
389
444
|
try {
|
|
390
|
-
const ics = timetableToIcs(tt, {
|
|
445
|
+
const ics = timetableToIcs(tt, {
|
|
446
|
+
weekOneMonday: hubWeekOne,
|
|
447
|
+
calendarName: `NBT ${state.term?.academicYearLabel ?? ''}`,
|
|
448
|
+
});
|
|
391
449
|
const out = `timetable-${hubKey}.ics`;
|
|
392
450
|
writePrivateIcs(out, ics);
|
|
393
451
|
state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
|
|
@@ -400,9 +458,7 @@ export const scheduleView = {
|
|
|
400
458
|
if (shortcut.key === 'x') {
|
|
401
459
|
createSessionStore().clear();
|
|
402
460
|
clearScheduleCache();
|
|
403
|
-
void
|
|
404
|
-
session = null;
|
|
405
|
-
client = null;
|
|
461
|
+
void releaseSession();
|
|
406
462
|
void goToPublic(ctx);
|
|
407
463
|
}
|
|
408
464
|
return;
|
|
@@ -414,15 +470,22 @@ export const scheduleView = {
|
|
|
414
470
|
returnToHub();
|
|
415
471
|
return;
|
|
416
472
|
}
|
|
417
|
-
const
|
|
418
|
-
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));
|
|
419
477
|
const nav = handleGridKey(key, cursor, tt, week);
|
|
420
478
|
if (nav.kind === 'moveCursor') {
|
|
421
479
|
state = { ...state, gridCursor: nav.cursor };
|
|
422
480
|
return;
|
|
423
481
|
}
|
|
424
482
|
if (nav.kind === 'openDetail') {
|
|
425
|
-
state = {
|
|
483
|
+
state = {
|
|
484
|
+
...state,
|
|
485
|
+
mode: 'meetingDetail',
|
|
486
|
+
detailMeeting: nav.meeting,
|
|
487
|
+
detailFrom: 'week',
|
|
488
|
+
};
|
|
426
489
|
return;
|
|
427
490
|
}
|
|
428
491
|
returnToHub();
|
|
@@ -458,14 +521,19 @@ export const scheduleView = {
|
|
|
458
521
|
mode: 'needsWeekOne',
|
|
459
522
|
key: newTermKey,
|
|
460
523
|
term,
|
|
461
|
-
weekOneField: new TextField({
|
|
524
|
+
weekOneField: new TextField({
|
|
525
|
+
message: t().timetable.weekOne,
|
|
526
|
+
placeholder: t().timetable.weekOneHint,
|
|
527
|
+
}),
|
|
462
528
|
};
|
|
463
529
|
return;
|
|
464
530
|
}
|
|
465
531
|
void fetchAndShowHub(ctx, term, newTermKey, weekOne);
|
|
466
532
|
return;
|
|
467
533
|
}
|
|
468
|
-
|
|
534
|
+
case 'loading':
|
|
535
|
+
case 'authenticating':
|
|
536
|
+
case 'error':
|
|
469
537
|
return;
|
|
470
538
|
}
|
|
471
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 [];
|