From 7b4a5e439b6871ce05486558c903be40499e5dcb Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 19 Oct 2026 11:45:26 +0800 Subject: [PATCH] feat: add personal exam and grade queries --- README.md | 30 ++- SECURITY.md | 9 +- package-lock.json | 12 +- package.json | 4 +- scripts/test-cli.sh | 10 + src/app/views/schedule-render.test.ts | 8 +- src/app/views/schedule-render.ts | 5 + src/app/views/schedule.test.ts | 180 +++++++++++++ src/app/views/schedule.ts | 157 ++++++++++- src/app/views/student-records.test.ts | 187 +++++++++++++ src/app/views/student-records.ts | 158 +++++++++++ src/auth/cookie-transport.test.ts | 20 ++ src/auth/cookie-transport.ts | 17 +- src/cli.ts | 17 ++ src/config/campus.ts | 3 + src/features/student-records-client.test.ts | 42 +++ src/features/student-records-client.ts | 30 +++ src/features/student-records-render.ts | 49 ++++ src/features/student-records.test.ts | 283 ++++++++++++++++++++ src/features/student-records.ts | 78 ++++++ src/features/student-timetable.ts | 27 +- src/i18n/index.ts | 1 + src/i18n/locales/en.json | 19 ++ src/i18n/locales/zh.json | 19 ++ 24 files changed, 1332 insertions(+), 33 deletions(-) create mode 100644 src/app/views/student-records.test.ts create mode 100644 src/app/views/student-records.ts create mode 100644 src/config/campus.ts create mode 100644 src/features/student-records-client.test.ts create mode 100644 src/features/student-records-client.ts create mode 100644 src/features/student-records-render.ts create mode 100644 src/features/student-records.test.ts create mode 100644 src/features/student-records.ts diff --git a/README.md b/README.md index 4c44be8..4481d57 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # NBTCA Prompt -Club events, the school calendar, your campus timetable, and the NBTCA knowledge -base in one terminal app. What you have loaded stays readable offline. +Club events, the school calendar, your campus timetable, exams, grades, and the +NBTCA knowledge base in one terminal app. Cached public content and timetables +stay readable offline. Exam and grade queries require a connection. [![npm version](https://img.shields.io/npm/v/@nbtca/prompt)](https://www.npmjs.com/package/@nbtca/prompt) [![License](https://img.shields.io/npm/l/@nbtca/prompt)](LICENSE) @@ -27,6 +28,8 @@ Or try it without installing: `npx @nbtca/prompt`. - **Home**: today's classes and the next club events. - **Schedule**: the current term, week, and next break. Log in to the campus system to see your timetable and export it to any calendar app as `.ics`. + Press `a` for personal exam arrangements or `g` for grades. Both are also + available from the public Schedule menu before login. - **Events**: a countdown to the next event, plus week and month views, search, history, and a one-year activity heatmap. - **Docs**: read and search the knowledge base. It works even when GitHub is @@ -43,6 +46,9 @@ subject to the local cache's 4 MiB size limit. ```text nbtca events [--today|--week|--month] [--search=] [--json] nbtca schedule login|terms|export|logout +nbtca exams [--term=2025-2] [--json] [--one-shot] +nbtca exams --output=exams.ics [--term=2025-2] +nbtca grades [--term=2025-2] [--json] [--one-shot] nbtca status [--watch] nbtca docs nbtca website|github|roadmap|repair [--open] @@ -57,6 +63,19 @@ Service checks wait up to 15 seconds by default. Use `--timeout=` to change the limit. A timeout is shown separately from an offline result and appears as `timedOut: true` in JSON. Failed public checks still return a nonzero exit code. +Exam and grade queries default to the current term. Use `--term` to select +another term, or press `s` in the interactive results. Press `r` to refresh +or `x` to log out. +Exam times remain unavailable until the school publishes them. Grades retain +the school's original numeric or textual result; Prompt does not calculate an +overall GPA. Records stay in memory and are not cached. `--json` writes them to +standard output, so keep redirected output private. + +Use `nbtca exams --output=exams.ics` to export a private calendar for a calendar +app. Export requires a published time for every exam; it fails before writing if +any time is unavailable. Authentication stays in Prompt. The `nbtcal/academic` +module owns academic queries, parsing, calendar conversion and ICS serialization. + Prompt never stores your password. A saved campus session gets user-only file permissions. See [SECURITY.md](SECURITY.md). @@ -67,6 +86,13 @@ npm ci npm run check ``` +For unpublished changes shared with `nbtcal`, build and pack that repository, +then extract the candidate package into `node_modules/@nbtca/nbtcal` after +`npm ci`. Set `NBTCA_NBTCAL_TARBALL` to the tarball's absolute path when running +`npm run check` so the isolated package check uses the same candidate. `npm ci` +restores the published dependency. Publish `nbtcal` before updating Prompt's +registry dependency to the new API version. + ## License MIT diff --git a/SECURITY.md b/SECURITY.md index 53a0f10..44e6676 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,14 @@ to the user's state directory with `0700` directory and `0600` file permissions on POSIX systems. Use `nbtca schedule logout` to remove it, or `--one-shot` to avoid reading or writing a session. +Exam and grade queries use allowlisted, read-only academic endpoints. Prompt +discards unrelated identity fields and does not cache these records. JSON output +contains personal academic information; redirected output and terminal scrollback +may retain it. + Exported calendars may reveal a person's routine and location. Keep session and ICS files private. Report vulnerabilities through GitHub Security Advisories. Never include -credentials, cookies, raw campus responses or personal calendars in public -issues. +credentials, cookies, raw campus responses, exam arrangements, grades or personal +calendars in public issues. diff --git a/package-lock.json b/package-lock.json index 222f902..c2701ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@nbtca/prompt", - "version": "1.5.20", + "version": "1.5.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nbtca/prompt", - "version": "1.5.20", + "version": "1.5.21", "license": "MIT", "dependencies": { "@nbtca/docs": "^0.3.5", - "@nbtca/nbtcal": "^0.4.4", + "@nbtca/nbtcal": "^0.4.5", "chalk": "^5.6.2", "marked": "^15.0.12" }, @@ -779,9 +779,9 @@ } }, "node_modules/@nbtca/nbtcal": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@nbtca/nbtcal/-/nbtcal-0.4.4.tgz", - "integrity": "sha512-brD5OgI4i4B3/+DiwZP+zwfDcKBGNTwL/VadrEeW/akYinKK/v7pW5jTrxjSDQ88kWbpOxn3lS7yslS9bxPOww==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@nbtca/nbtcal/-/nbtcal-0.4.5.tgz", + "integrity": "sha512-uxPIEW0fvKF3hc5qvL05CRaAjBj0TvC/oGylQN3qKHwd+YwTeUgsuSBqy5Jbf/OAZ/5W4xia2AfKF95aRkNMlg==", "license": "MIT", "dependencies": { "ical.js": "^2.2.1" diff --git a/package.json b/package.json index 636b2d7..9829525 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nbtca/prompt", - "version": "1.5.20", + "version": "1.5.21", "type": "module", "main": "dist/index.js", "exports": { @@ -47,7 +47,7 @@ ], "dependencies": { "@nbtca/docs": "^0.3.5", - "@nbtca/nbtcal": "^0.4.4", + "@nbtca/nbtcal": "^0.4.5", "chalk": "^5.6.2", "marked": "^15.0.12" }, diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh index 20911f6..8315245 100644 --- a/scripts/test-cli.sh +++ b/scripts/test-cli.sh @@ -185,6 +185,16 @@ if [[ "$intranet_output" != *"Public services are healthy; intranet availability echo "intranet-only failure returned the wrong summary" >&2 exit 1 fi +for records_command in exams grades; do + assert_fails_with "$records_command-no-session" 'No usable login session' node dist/index.js "$records_command" --json + assert_fails_with "$records_command-extra" 'Unexpected arguments' node dist/index.js "$records_command" extra + assert_fails_with "$records_command-flag" 'Unknown flag' node dist/index.js "$records_command" --export + assert_fails_with "$records_command-empty-term" 'Unknown term' node dist/index.js "$records_command" --term= +done + +assert_fails_with exams-export-json 'Use a nonempty --output path' node dist/index.js exams --output=exams.ics --json +assert_fails_with grades-export 'not valid for this command' node dist/index.js grades --output=grades.ics + assert_fails_with non-tty 'Interactive mode requires a TTY terminal' node dist/index.js echo "CLI contract tests passed." diff --git a/src/app/views/schedule-render.test.ts b/src/app/views/schedule-render.test.ts index 115505f..3b080d9 100644 --- a/src/app/views/schedule-render.test.ts +++ b/src/app/views/schedule-render.test.ts @@ -544,7 +544,7 @@ describe('renderSchedule', () => { expect(text).toContain('Log out'); }); - it('keeps shortcut labels inside the frame in a 40x12 terminal', () => { + it('keeps shortcut keys inside the frame in a 40x12 terminal', () => { const lines = renderSchedule( { mode: 'hub', key: '2026-3', weekOne: '2026-09-07', timetable }, campusDateTime('2026-09-07', '07:00'), @@ -556,8 +556,8 @@ describe('renderSchedule', () => { const shortcutLines = lines.filter((line) => /\[(?:w|t|s|e|x)\]/.test(stripAnsi(line))); expect(lines.length).toBeLessThanOrEqual(7); expect(shortcutLines.every((line) => visualWidth(line) <= 37)).toBe(true); - for (const label of ['Full grid', 'Term density', 'Switch term', 'Export .ics', 'Log out']) { - expect(text).toContain(label); + for (const key of ['w', 't', 's', 'e', 'a', 'g', 'u', 'x']) { + expect(text).toContain(`[${key}]`); } }); @@ -925,7 +925,7 @@ describe('hubShortcuts', () => { it('always includes the full-grid, term-density, switch-term, export, and logout shortcuts', () => { const shortcuts = hubShortcuts({ ...baseTimetable, unresolvedItems: [] }); - expect(shortcuts.map((s) => s.key)).toEqual(['w', 't', 's', 'e', 'x']); + expect(shortcuts.map((s) => s.key)).toEqual(['w', 't', 's', 'e', 'a', 'g', 'x']); }); }); diff --git a/src/app/views/schedule-render.ts b/src/app/views/schedule-render.ts index 4665249..90c3ff4 100644 --- a/src/app/views/schedule-render.ts +++ b/src/app/views/schedule-render.ts @@ -41,6 +41,7 @@ import { offlineNotice } from '../chrome.js'; export type ScheduleMode = | 'loading' | 'public' + | 'records' | 'needsLoginId' | 'needsLoginPassword' | 'authenticating' @@ -113,6 +114,8 @@ export function hubShortcuts(tt: Timetable): HubShortcut[] { { key: 't', label: trans.timetable.hubTermDensity }, { key: 's', label: trans.timetable.hubSwitchTerm }, { key: 'e', label: trans.timetable.hubExport }, + { key: 'a', label: trans.records.exams }, + { key: 'g', label: trans.records.grades }, ]; if (tt.unresolvedItems.length > 0) { shortcuts.push({ @@ -512,6 +515,8 @@ export function renderSchedule( return loadingLines(trans.common.loading, cols); case 'public': return renderPublicBody(state, now, bodyRows, cols); + case 'records': + return []; case 'needsLoginId': return [ ...(state.errorMessage ? [...hintLines(state.errorMessage, cols), ''] : []), diff --git a/src/app/views/schedule.test.ts b/src/app/views/schedule.test.ts index 856b610..fabaaa8 100644 --- a/src/app/views/schedule.test.ts +++ b/src/app/views/schedule.test.ts @@ -840,3 +840,183 @@ describe('scheduleView lifecycle', () => { expect(rerender).toHaveBeenCalledTimes(rendersBeforeAbort); }); }); + +describe('scheduleView student records', () => { + const term: AcademicTerm = { + academicYear: '2026', + semester: '3', + academicYearLabel: '2026-2027', + semesterLabel: '1', + current: true, + }; + const recordsTransport = vi.fn(); + + beforeEach(async () => { + await scheduleView.dispose(); + vi.mocked(loadCurrentPointer).mockReturnValue(null); + vi.mocked(loadTimetableCache).mockReturnValue(null); + sessionStoreLoad.mockReturnValue(null); + loadWeekOneMock.mockReturnValue(null); + listTerms.mockResolvedValue([term]); + recordsTransport.mockReset().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + items: [ + { + xnm: '2026', + xqm: '3', + kcmc: 'Synthetic Algorithms', + kssj: '未放开不可查', + cj: '良好', + }, + ], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ), + ); + loginWithStudentPasswordMock.mockImplementation(() => + Promise.resolve({ + timetableTransport: recordsTransport, + snapshot: vi.fn().mockResolvedValue({}), + close: sessionClose, + }), + ); + }); + + afterEach(async () => { + await scheduleView.dispose(); + }); + + function login(ctx: AppContext) { + scheduleView.handleKey('SYNTHETIC-ID', ctx); + scheduleView.handleKey('\r', ctx); + scheduleView.handleKey('synthetic-password', ctx); + scheduleView.handleKey('\r', ctx); + } + + it.each(['exams', 'grades'] as const)( + 'opens %s after login without fetching a timetable or asking for a first-week date', + async (kind) => { + const ctx = fakeCtx(); + await scheduleView.load(ctx); + scheduleView.handleKey('\x1b[B', ctx); + if (kind === 'grades') scheduleView.handleKey('\x1b[B', ctx); + scheduleView.handleKey('\r', ctx); + expect(scheduleView.capturesInput()).toBe(true); + login(ctx); + await vi.waitFor(() => { + expect(recordsTransport).toHaveBeenCalledOnce(); + expect(scheduleView.isBusy()).toBe(false); + }); + expect(fetchTerm).not.toHaveBeenCalled(); + expect(saveWeekOneMock).not.toHaveBeenCalled(); + const rendered = stripAnsi(scheduleView.render(ctx).join('\n')); + expect(rendered).toContain('Synthetic Algorithms'); + expect(rendered).toContain(t().records[kind]); + expect(rendered).not.toContain(t().timetable.weekOneAutoFailed); + expect(scheduleView.scrollsBody()).toBe(true); + expect(scheduleView.handleBack(ctx)).toBe(true); + expect(scheduleView.render(ctx).join('\n')).not.toContain('Synthetic Algorithms'); + }, + ); + + it('returns from the first-week prompt to records without asking for another login', async () => { + sessionStoreLoad.mockReturnValue({ jar: { cookies: [] } }); + restoreNbtSessionMock.mockResolvedValue({ + timetableTransport: recordsTransport, + snapshot: vi.fn(), + close: sessionClose, + }); + const ctx = fakeCtx(); + await scheduleView.load(ctx); + expect(stripAnsi(scheduleView.render(ctx).join('')).replace(/\s/g, '')).toContain( + t().timetable.weekOneAutoFailed.replace(/\s/g, ''), + ); + expect(scheduleView.handleBack(ctx)).toBe(true); + expect(scheduleView.capturesInput()).toBe(false); + expect(scheduleView.render(ctx).join('\n')).toContain(t().timetable.publicLoginAction); + scheduleView.handleKey('\x1b[B', ctx); + scheduleView.handleKey('\r', ctx); + await vi.waitFor(() => { + expect(recordsTransport).toHaveBeenCalledOnce(); + expect(scheduleView.isBusy()).toBe(false); + }); + expect(loginWithStudentPasswordMock).not.toHaveBeenCalled(); + expect(fetchTerm).not.toHaveBeenCalled(); + }); + + it('keeps records open when a cached timetable refresh finishes later', async () => { + vi.mocked(loadCurrentPointer).mockReturnValue({ + termKey: '2026-3', + weekOneMonday: '2026-09-07', + }); + vi.mocked(loadTimetableCache).mockReturnValue({ + term, + meetings: [], + periods: [], + calendarDays: [], + warnings: [], + unresolvedItems: [], + fetchedAt: new Date(), + }); + sessionStoreLoad.mockReturnValue({ jar: { cookies: [] } }); + restoreNbtSessionMock.mockResolvedValue({ + timetableTransport: recordsTransport, + snapshot: vi.fn(), + close: sessionClose, + }); + let finishRefresh: (terms: AcademicTerm[]) => void = () => undefined; + listTerms.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = resolve; + }), + ); + const ctx = fakeCtx(); + const loading = scheduleView.load(ctx); + await vi.waitFor(() => { + expect(listTerms).toHaveBeenCalledOnce(); + }); + scheduleView.handleKey('g', ctx); + await vi.waitFor(() => { + expect(recordsTransport).toHaveBeenCalledOnce(); + expect(scheduleView.isBusy()).toBe(false); + }); + finishRefresh([term]); + await loading; + expect(scheduleView.render(ctx).join('\n')).toContain('Synthetic Algorithms'); + expect(fetchTerm).not.toHaveBeenCalled(); + expect(saveTimetableCacheMock).not.toHaveBeenCalled(); + }); + + it('returns to login after expiry and resumes the selected query', async () => { + recordsTransport.mockResolvedValueOnce(new Response(null, { status: 401 })); + const ctx = fakeCtx(); + await scheduleView.load(ctx); + scheduleView.handleKey('\x1b[B', ctx); + scheduleView.handleKey('\r', ctx); + login(ctx); + await vi.waitFor(() => { + expect(sessionStoreClear).toHaveBeenCalled(); + expect(scheduleView.capturesInput()).toBe(true); + }); + expect(scheduleView.render(ctx).join('\n')).not.toContain('Synthetic Algorithms'); + login(ctx); + await vi.waitFor(() => { + expect(recordsTransport).toHaveBeenCalledTimes(2); + expect(scheduleView.isBusy()).toBe(false); + }); + expect(scheduleView.render(ctx).join('\n')).toContain('Synthetic Algorithms'); + expect(fetchTerm).not.toHaveBeenCalled(); + scheduleView.handleKey('x', ctx); + expect(scheduleView.render(ctx).join('\n')).not.toContain('Synthetic Algorithms'); + expect(sessionStoreClear).toHaveBeenCalledTimes(2); + await vi.waitFor(() => { + expect(sessionClose).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index 9aefb9a..956689e 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -51,12 +51,16 @@ import { } from '@nbtca/nbtcal'; import { sanitizeAcademicTerm, sanitizeTimetable } from '../../features/timetable-sanitize.js'; import { addLocalDays, parseLocalMonday } from '../../core/calendar-day.js'; +import { StudentRecordsPanel } from './student-records.js'; +import type { StudentRecordsKind } from '../../features/student-records-client.js'; let state: ScheduleViewState = { mode: 'loading' }; let session: AuthenticatedNbtSession | null = null; let client: NbtTimetableClient | null = null; let catalog: AcademicTerm[] = []; let pendingId = ''; +let pendingRecordsKind: StudentRecordsKind | null = null; +let recordsPanel: StudentRecordsPanel | null = null; let lifecycleGeneration = 0; const closingSessions = new WeakMap>(); @@ -109,6 +113,7 @@ async function releaseSession(target: AuthenticatedNbtSession | null = session): if (session === target) { session = null; client = null; + catalog = []; } await closeSession(target); } @@ -165,7 +170,11 @@ function goToLoginId(errorMessage?: string): void { function buildPublicField(): ListField { return new ListField({ - options: [{ value: 'login', label: t().timetable.publicLoginAction }], + options: [ + { value: 'login', label: t().timetable.publicLoginAction }, + { value: 'exams', label: t().records.exams }, + { value: 'grades', label: t().records.grades }, + ], }); } @@ -223,6 +232,12 @@ async function afterAuthenticated( ).map(sanitizeAcademicTerm); if (!isLifecycleActive(ctx, generation)) return; catalog = nextCatalog; + if (pendingRecordsKind) { + const kind = pendingRecordsKind; + pendingRecordsKind = null; + await openStudentRecords(ctx, kind, generation); + return; + } const term = resolveTerm(catalog); const key = termKey(term); let weekOne = loadWeekOne(key); @@ -327,12 +342,100 @@ async function refreshFromNetwork(ctx: AppContext, generation: number): Promise< } } +async function openStudentRecords( + ctx: AppContext, + kind: StudentRecordsKind, + generation = ++lifecycleGeneration, +): Promise { + if (!isLifecycleActive(ctx, generation)) return; + recordsPanel?.dispose(); + recordsPanel = null; + pendingRecordsKind = kind; + try { + if (!session) { + const saved = createSessionStore().load(); + if (!saved) { + goToLoginId(); + ctx.rerender(); + return; + } + state = { ...state, mode: 'loading' }; + ctx.rerender(); + const restored = await restoreNbtSession(saved); + await afterAuthenticated(ctx, restored, generation); + return; + } + if (catalog.length === 0) { + state = { ...state, mode: 'loading' }; + ctx.rerender(); + const nextCatalog = ( + await createNbtTimetableClient(session.timetableTransport, { + baseUrl: JWXT_ORIGIN, + }).listTerms(ctx.signal === undefined ? {} : { signal: ctx.signal }) + ).map(sanitizeAcademicTerm); + if (!isLifecycleActive(ctx, generation)) return; + catalog = nextCatalog; + } + const previousTerm = state.term ?? state.timetable?.term; + const selected = + catalog.find( + (term) => + term.academicYear === previousTerm?.academicYear && + term.semester === previousTerm.semester, + ) ?? resolveTerm(catalog); + pendingRecordsKind = null; + const panel: StudentRecordsPanel = new StudentRecordsPanel( + kind, + selected, + relevantTerms(catalog), + session.timetableTransport, + () => isLifecycleActive(ctx, generation) && recordsPanel === panel, + () => { + pendingRecordsKind = kind; + createSessionStore().clear(); + recordsPanel?.dispose(); + recordsPanel = null; + void releaseSession(); + goToLoginId(t().timetable.expiredRelogin); + ctx.rerender(); + }, + ); + recordsPanel = panel; + state = { ...state, mode: 'records' }; + await panel.load(ctx); + } catch (error) { + if (!isLifecycleActive(ctx, generation)) return; + if (isSessionExpired(error)) { + createSessionStore().clear(); + await releaseSession(); + goToLoginId(t().timetable.expiredRelogin); + } else { + state = { ...state, mode: 'error', errorMessage: safeMessage(error) }; + } + ctx.rerender(); + } +} + +function logout(ctx: AppContext): void { + const generation = ++lifecycleGeneration; + pendingRecordsKind = null; + recordsPanel?.dispose(); + recordsPanel = null; + createSessionStore().clear(); + clearScheduleCache(); + void releaseSession(); + void goToPublic(ctx, generation); +} + export const scheduleView = { id: 'schedule', title: t().timetable.menuEntry, async load(ctx: AppContext): Promise { const generation = ++lifecycleGeneration; + pendingRecordsKind = null; + recordsPanel?.dispose(); + recordsPanel = null; if (!isLifecycleActive(ctx, generation)) return; const ptr = loadCurrentPointer(); const cached = readCachedTimetable(ptr ? loadTimetableCache(ptr.termKey) : null); @@ -354,20 +457,30 @@ export const scheduleView = { async dispose(): Promise { lifecycleGeneration += 1; + pendingRecordsKind = null; + recordsPanel?.dispose(); + recordsPanel = null; setVimKeysActive(true); await releaseSession(); }, render(ctx: AppContext): string[] { + if (state.mode === 'records' && recordsPanel) return recordsPanel.render(ctx); state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows)); return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols); }, isBusy(): boolean { + if (state.mode === 'records') return recordsPanel?.isBusy() ?? false; return state.mode === 'loading'; }, + scrollsBody(): boolean { + return state.mode === 'records' && !recordsPanel?.capturesPageKeys(); + }, + shortcuts(): readonly { key: string; label: string }[] { + if (state.mode === 'records') return recordsPanel?.shortcuts() ?? []; if ((state.mode !== 'hub' && state.mode !== 'week') || !state.timetable) return []; const trans = t(); const keys = gridKeys(); @@ -389,10 +502,12 @@ export const scheduleView = { }, capturesPageKeys(): boolean { + if (state.mode === 'records') return recordsPanel?.capturesPageKeys() ?? false; return state.mode === 'public' || state.mode === 'termPicker'; }, footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string | undefined { + if (state.mode === 'records') return passiveFooterHint(tabCount, cols); const capturing = state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || @@ -412,11 +527,23 @@ export const scheduleView = { }, handleBack(ctx: AppContext): boolean { + if (state.mode === 'records') { + if (recordsPanel?.handleBack()) return true; + recordsPanel?.dispose(); + recordsPanel = null; + if (!returnToHub()) void goToPublic(ctx); + return true; + } if (state.mode === 'needsLoginId') { + pendingRecordsKind = null; void goToPublic(ctx); return true; } - if (state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne') { + if (state.mode === 'needsWeekOne') { + void goToPublic(ctx); + return true; + } + if (state.mode === 'needsLoginPassword') { goToLoginId(); return true; } @@ -442,9 +569,19 @@ export const scheduleView = { switch (state.mode) { case 'public': { const result = state.publicField?.handleKey(key); - if (result?.selected === 'login') goToLoginId(); + if (result?.selected === 'exams' || result?.selected === 'grades') { + void openStudentRecords(ctx, result.selected); + } else if (result?.selected === 'login') { + pendingRecordsKind = null; + if (session) void afterAuthenticated(ctx, session, lifecycleGeneration); + else goToLoginId(); + } return; } + case 'records': + if (key === 'x' && !recordsPanel?.capturesPageKeys()) logout(ctx); + else recordsPanel?.handleKey(key, ctx); + return; case 'needsLoginId': { const result = state.idField?.handleKey(key); if (result?.cancelled) { @@ -510,7 +647,7 @@ export const scheduleView = { case 'needsWeekOne': { const result = state.weekOneField?.handleKey(key); if (result?.cancelled) { - goToLoginId(); + void goToPublic(ctx); return; } if (result?.submitted !== undefined) { @@ -561,6 +698,10 @@ export const scheduleView = { const shortcut = hubShortcuts(tt).find((sc) => sc.key === key); if (!shortcut) return; + if (shortcut.key === 'a' || shortcut.key === 'g') { + void openStudentRecords(ctx, shortcut.key === 'a' ? 'exams' : 'grades'); + return; + } if (shortcut.key === 'w') { state = { ...state, mode: 'week' }; return; @@ -617,13 +758,7 @@ export const scheduleView = { } return; } - if (shortcut.key === 'x') { - const generation = ++lifecycleGeneration; - createSessionStore().clear(); - clearScheduleCache(); - void releaseSession(); - void goToPublic(ctx, generation); - } + if (shortcut.key === 'x') logout(ctx); return; } case 'week': { diff --git a/src/app/views/student-records.test.ts b/src/app/views/student-records.test.ts new file mode 100644 index 0000000..d2cbbe8 --- /dev/null +++ b/src/app/views/student-records.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AcademicTerm, AuthenticatedTransport } from '@nbtca/nbtcal/timetable'; +import { StudentRecordsPanel } from './student-records.js'; +import { renderStudentRecords } from '../../features/student-records-render.js'; +import { stripAnsi, visualWidth } from '../../core/text.js'; +import { setLanguage, t } from '../../i18n/index.js'; +import type { AppContext } from '../view.js'; + +const terms: [AcademicTerm, AcademicTerm] = [ + { + academicYear: '2026', + semester: '3', + academicYearLabel: '2026-2027', + semesterLabel: '1', + current: true, + }, + { + academicYear: '2025', + semester: '12', + academicYearLabel: '2025-2026', + semesterLabel: '2', + current: false, + }, +]; +const term = terms[0]; + +function ctx() { + return { + size: { rows: 24, cols: 80 }, + bodyRows: 19, + resetScroll: vi.fn(), + rerender: vi.fn(), + quit: vi.fn(), + runClassic: vi.fn(), + } satisfies AppContext; +} + +afterEach(() => { + setLanguage('en'); +}); + +describe('student records panel', () => { + it('queries another term without a timetable or first-week date', async () => { + const transport = vi.fn((_url, init) => { + const body = new URLSearchParams(init.body as string); + return Promise.resolve( + new Response( + JSON.stringify({ + items: [{ xnm: body.get('xnm'), xqm: body.get('xqm'), kcmc: 'Algorithms', cj: '良好' }], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ); + }); + const panel = new StudentRecordsPanel('grades', term, terms, transport, () => true, vi.fn()); + const context = ctx(); + await panel.load(context); + expect(panel.render(context).join('\n')).toContain('Algorithms'); + panel.handleKey('s', context); + expect(panel.capturesPageKeys()).toBe(true); + panel.handleKey('\x1b[B', context); + panel.handleKey('\r', context); + await vi.waitFor(() => { + expect(panel.isBusy()).toBe(false); + }); + expect(new URLSearchParams(transport.mock.calls[1]?.[1].body as string).get('xnm')).toBe( + '2025', + ); + expect(panel.render(context).join('\n')).toContain('2025-2026'); + expect(panel.capturesPageKeys()).toBe(false); + }); + + it('clears the previous term before a failed refresh', async () => { + const transport = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [{ xnm: '2026', xqm: '3', kcmc: 'Algorithms', cj: '90' }], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ) + .mockResolvedValueOnce(new Response('private marker', { status: 500 })); + const panel = new StudentRecordsPanel('grades', term, terms, transport, () => true, vi.fn()); + const context = ctx(); + await panel.load(context); + expect(panel.render(context).join('\n')).toContain('Algorithms'); + await panel.load(context); + const out = panel.render(context).join('\n'); + expect(out).not.toMatch(/Algorithms|private marker/); + expect(out).toContain(t().timetable.httpError); + }); + + it('aborts private queries when the panel is disposed', async () => { + let signal: AbortSignal | null | undefined; + const transport: AuthenticatedTransport = (_url, init) => { + signal = init.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + }; + const panel = new StudentRecordsPanel('exams', term, terms, transport, () => true, vi.fn()); + const context = ctx(); + const request = panel.load(context); + panel.dispose(); + await request; + expect(signal?.aborted).toBe(true); + expect(context.rerender).toHaveBeenCalledOnce(); + }); + + it('requests login again after the session expires', async () => { + const expired = vi.fn(); + const panel = new StudentRecordsPanel( + 'exams', + term, + terms, + () => Promise.resolve(new Response(null, { status: 401 })), + () => true, + expired, + ); + await panel.load(ctx()); + expect(expired).toHaveBeenCalledOnce(); + }); + + it('returns from the term picker before leaving the results', () => { + const panel = new StudentRecordsPanel( + 'exams', + term, + terms, + () => Promise.resolve(new Response()), + () => true, + vi.fn(), + ); + panel.handleKey('s', ctx()); + expect(panel.handleBack()).toBe(true); + expect(panel.handleBack()).toBe(false); + }); +}); + +describe('student records rendering', () => { + it.each(['en', 'zh'] as const)('keeps %s records readable at twenty columns', (language) => { + setLanguage(language); + const lines = renderStudentRecords( + { + kind: 'grades', + term, + fetchedAt: new Date(), + items: [ + { + courseCode: 'COURSE-A', + courseName: 'Algorithms and data structures', + grade: '0', + credits: 2.5, + gradePoint: 0, + courseNature: null, + examNature: null, + remark: null, + voided: true, + }, + ], + }, + term, + 20, + ); + expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); + const text = lines.map(stripAnsi).join('').replace(/\s/g, ''); + expect(text).toContain('Algorithmsanddatastructures'); + expect(text).not.toContain(t().records.notPublished.replace(/\s/g, '')); + }); + + it.each(['exams', 'grades'] as const)( + 'distinguishes unpublished %s from a failed request', + (kind) => { + const lines = renderStudentRecords({ kind, term, fetchedAt: new Date(), items: [] }, term); + expect(lines.join('\n')).toContain( + kind === 'exams' ? t().records.noExams : t().records.noGrades, + ); + }, + ); +}); diff --git a/src/app/views/student-records.ts b/src/app/views/student-records.ts new file mode 100644 index 0000000..4c5ff74 --- /dev/null +++ b/src/app/views/student-records.ts @@ -0,0 +1,158 @@ +import type { AcademicTerm, AuthenticatedTransport } from '@nbtca/nbtcal/timetable'; +import { JWXT_ORIGIN } from '../../config/campus.js'; +import { loadingLines } from '../../core/components/spinner.js'; +import { wrapAnsiWithIndent } from '../../core/text.js'; +import { + isSessionExpired, + safeMessage, + displaySemesterLabel, +} from '../../features/student-timetable.js'; +import { + createStudentRecordsClient, + type StudentRecords, + type StudentRecordsKind, +} from '../../features/student-records-client.js'; +import { renderStudentRecords } from '../../features/student-records-render.js'; +import { t } from '../../i18n/index.js'; +import { ListField, computeMaxVisible, renderListFieldWithContext } from '../fields/list-field.js'; +import type { AppContext } from '../view.js'; + +export class StudentRecordsPanel { + private data: StudentRecords | undefined; + private error: string | undefined; + private termsField: ListField | undefined; + private controller: AbortController | undefined; + private pending = false; + + constructor( + readonly kind: StudentRecordsKind, + private term: AcademicTerm, + private readonly catalog: readonly AcademicTerm[], + private readonly transport: AuthenticatedTransport, + private readonly isActive: () => boolean, + private readonly onSessionExpired: () => void, + ) {} + + async load(ctx: AppContext): Promise { + this.controller?.abort(); + const controller = new AbortController(); + this.controller = controller; + const signal = ctx.signal + ? AbortSignal.any([ctx.signal, controller.signal]) + : controller.signal; + this.pending = true; + this.error = undefined; + this.data = undefined; + this.termsField = undefined; + ctx.resetScroll(); + ctx.rerender(); + try { + const data = await createStudentRecordsClient(this.transport, JWXT_ORIGIN).fetchRecords( + this.kind, + this.term, + signal, + ); + if (signal.aborted || !this.isActive()) return; + this.data = data; + } catch (error) { + if (signal.aborted || !this.isActive()) return; + if (isSessionExpired(error)) this.onSessionExpired(); + else this.error = safeMessage(error); + } finally { + if (!signal.aborted && this.isActive()) { + this.pending = false; + ctx.rerender(); + } + } + } + + dispose(): void { + this.controller?.abort(); + this.data = undefined; + this.termsField = undefined; + } + + isBusy(): boolean { + return this.pending; + } + + capturesPageKeys(): boolean { + return this.termsField !== undefined; + } + + handleBack(): boolean { + if (!this.termsField) return false; + this.termsField = undefined; + return true; + } + + shortcuts(): readonly { key: string; label: string }[] { + return this.pending || this.termsField + ? [] + : [ + { key: 's', label: t().records.switchTerm }, + { key: 'r', label: t().records.refresh }, + { key: 'x', label: t().timetable.hubLogout }, + ]; + } + + render(ctx: AppContext): string[] { + if (this.termsField) { + this.termsField.setMaxVisible(computeMaxVisible(ctx.bodyRows)); + return renderListFieldWithContext([], this.termsField, ctx.bodyRows, ctx.size.cols); + } + if (this.pending) return loadingLines(t().common.loading, ctx.size.cols); + const lines = this.data + ? renderStudentRecords(this.data, this.term, ctx.size.cols) + : wrapAnsiWithIndent(this.error ?? t().timetable.genericError, ctx.size.cols, ' '); + return [ + ...lines, + '', + ...wrapAnsiWithIndent( + `[s] ${t().records.switchTerm} · [r] ${t().records.refresh} · [x] ${t().timetable.hubLogout}`, + ctx.size.cols, + ' ', + ), + ]; + } + + handleKey(key: string, ctx: AppContext): void { + if (this.termsField) { + const result = this.termsField.handleKey(key); + if (result.cancelled || result.selected === '__back__') { + this.termsField = undefined; + } else if (result.selected) { + const term = this.catalog.find( + (candidate) => `${candidate.academicYear}:${candidate.semester}` === result.selected, + ); + if (term) { + this.term = term; + void this.load(ctx); + } + } + return; + } + if (this.pending) return; + if (key === 'r') void this.load(ctx); + if (key === 's') { + this.termsField = new ListField({ + title: t().records.switchTerm, + options: [ + ...this.catalog.map((term) => ({ + value: `${term.academicYear}:${term.semester}`, + label: `${term.academicYearLabel} · ${displaySemesterLabel(term)}`, + })), + { value: '__back__', label: t().common.back }, + ], + initialIndex: Math.max( + 0, + this.catalog.findIndex( + (term) => + term.academicYear === this.term.academicYear && term.semester === this.term.semester, + ), + ), + }); + ctx.resetScroll(); + } + } +} diff --git a/src/auth/cookie-transport.test.ts b/src/auth/cookie-transport.test.ts index cc5248d..bae4960 100644 --- a/src/auth/cookie-transport.test.ts +++ b/src/auth/cookie-transport.test.ts @@ -22,6 +22,26 @@ function expectAuthError(run: () => unknown, code: AuthErrorCode): void { } describe('campus URL policy', () => { + it('allows academic queries but rejects mutation actions and unrelated endpoints', () => { + for (const path of [ + '/jwglxt/kwgl/kscx_cxXsksxxIndex.html?doType=query&gnmkdm=N358105', + '/jwglxt/cjcx/cjcx_cxXsgrcj.html?doType=query&gnmkdm=N305005', + '/jwglxt/cjcx/cjcx_cxDgXscj.html?gnmkdm=N305005', + ]) + expect(() => { + assertAllowedCampusUrl(new URL(path, 'https://jwxt-443.webvpn.nbt.edu.cn')); + }).not.toThrow(); + for (const path of [ + '/jwglxt/kwgl/kscx_cxXsksxxIndex.html?doType=save', + '/jwglxt/cjcx/cjcx_cxXsgrcj.html?doType=delete', + '/jwglxt/cjcx/cjcx_cxXsgrcj.html?doType=query&doType=delete', + '/jwglxt/cjcx/cjcx_bcXscj.html', + ]) + expectAuthError(() => { + assertAllowedCampusUrl(new URL(path, 'https://jwxt-443.webvpn.nbt.edu.cn')); + }, 'UNTRUSTED_URL'); + }); + it('allows only exact HTTPS campus hosts and routes', () => { expect(() => { assertAllowedCampusUrl( diff --git a/src/auth/cookie-transport.ts b/src/auth/cookie-transport.ts index d6193fc..527f38e 100644 --- a/src/auth/cookie-transport.ts +++ b/src/auth/cookie-transport.ts @@ -1,5 +1,6 @@ import { createCookieJar, type CookieJar, type SerializedCookieJar } from './cookie-jar.js'; import { AuthError, SessionExpiredError, type AuthStage } from './errors.js'; +import { JWXT_ORIGIN, STUDENT_RECORD_ROUTES } from '../config/campus.js'; const MAX_REDIRECTS = 8; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); @@ -13,7 +14,7 @@ const BODY_HEADERS = [ const WEBVPN_HOST = 'webvpn.nbt.edu.cn'; const AUTH_HOST = 'authserver-443.webvpn.nbt.edu.cn'; -export const JWXT_HOST = 'jwxt-443.webvpn.nbt.edu.cn'; +export const JWXT_HOST = new URL(JWXT_ORIGIN).hostname; const WEBVPN_PATHS = new Set([ '/', @@ -35,6 +36,7 @@ const JWXT_EXACT_PATHS = new Set([ '/jwglxt/kbcx/xskbcx_cxXskbcxIndex.html', '/jwglxt/kbcx/xskbcx_cxXsgrkb.html', '/jwglxt/kbcx/xskbcx_cxRjc.html', + ...Object.values(STUDENT_RECORD_ROUTES).flatMap((route) => [route.index, route.query]), ]); function isAllowedJwxtPath(pathname: string): boolean { @@ -134,7 +136,18 @@ export function assertAllowedCampusUrl(url: URL): void { if (service) assertAllowedCasService(service); return; } - if (hostname === JWXT_HOST && isAllowedJwxtPath(url.pathname)) return; + if (hostname === JWXT_HOST && isAllowedJwxtPath(url.pathname)) { + const academicRoute = Object.values(STUDENT_RECORD_ROUTES).some( + (route) => route.index === url.pathname || route.query === url.pathname, + ); + if (academicRoute && url.searchParams.getAll('doType').some((action) => action !== 'query')) + throw new AuthError( + 'UNTRUSTED_URL', + 'session', + 'Only academic query operations are allowed.', + ); + return; + } if (hostname === AUTH_HOST && url.pathname === PROFILE_GATE_PATH) { throw new AuthError( 'PROFILE_INCOMPLETE', diff --git a/src/cli.ts b/src/cli.ts index cb11bad..ebd408a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,6 +27,7 @@ import { clearScreen, handleGracefulExit } from './core/ui.js'; import { APP_INFO, URLS } from './config/data.js'; import { runUpdateCheck } from './features/update.js'; import { runStudentTimetableCommand } from './features/student-timetable.js'; +import { runStudentRecordsCommand } from './features/student-records.js'; import { showAbout } from './features/about.js'; import { openUrlInBrowser } from './features/links.js'; @@ -151,6 +152,12 @@ function getAllowedFlagsFor(command?: string): Set { if (command === 'lang' || command === 'language') return allowed; if (command === 'theme') return allowed; + if (command === 'exams' || command === 'grades') { + allowed.add('--json'); + allowed.add('--one-shot'); + allowed.add('--no-save'); + return allowed; + } if (command === 'schedule' || command === 'timetable') { allowed.add('--one-shot'); allowed.add('--no-save'); @@ -187,6 +194,8 @@ function getAllowedFlagsFor(command?: string): Set { function getAllowedFlagPrefixesFor(command?: string): string[] { if (!command) return []; + if (command === 'exams') return ['--term=', '--output=']; + if (command === 'grades') return ['--term=']; if (command === 'schedule' || command === 'timetable') { return ['--term=', '--output=', '--week-one=']; } @@ -245,6 +254,8 @@ function printHelp(): void { console.log(` status ${trans.menu.statusDesc}`); console.log(' schedule '); console.log(` ${c.cmdSchedule}`); + console.log(` exams ${trans.records.exams}`); + console.log(` grades ${trans.records.grades}`); console.log(` website ${c.cmdWebsite}`); console.log(` github ${c.cmdGithub}`); console.log(` roadmap ${c.cmdRoadmap}`); @@ -522,6 +533,12 @@ async function runCommandMode(argv: string[]): Promise { validateFlags(command, flags); + if (command === 'exams' || command === 'grades') { + rejectUnexpectedArguments(command, args); + process.exitCode = await runStudentRecordsCommand(command, { flags }); + return; + } + if (!command) { if (!hasInteractiveTerminal()) { const cliTrans = t().cli; diff --git a/src/config/campus.ts b/src/config/campus.ts new file mode 100644 index 0000000..6bd8c0c --- /dev/null +++ b/src/config/campus.ts @@ -0,0 +1,3 @@ +export const JWXT_ORIGIN = 'https://jwxt-443.webvpn.nbt.edu.cn'; + +export { STUDENT_RECORD_ROUTES } from '@nbtca/nbtcal/academic'; diff --git a/src/features/student-records-client.test.ts b/src/features/student-records-client.test.ts new file mode 100644 index 0000000..35fdfb4 --- /dev/null +++ b/src/features/student-records-client.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { createStudentRecordsClient } from './student-records-client.js'; +import { JWXT_ORIGIN } from '../config/campus.js'; + +describe('student records terminal boundary', () => { + it.each(['exams', 'grades'] as const)( + 'sanitizes %s strings before JSON or interactive display', + async (kind) => { + const client = createStudentRecordsClient( + () => + Promise.resolve( + new Response( + JSON.stringify({ + items: [ + { + xnm: '2026', + xqm: '3', + kcmc: 'Algorithms\u001b[2J\nforged', + cj: '\u001b]52;c;secret\u0007Pass', + kssj: '2026-12-10(09:00-11:00)', + cdmc: '\u001b]52;c;secret\u0007A101', + xm: 'Synthetic Student', + xh: 'SYNTHETIC-ID', + }, + ], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ), + JWXT_ORIGIN, + ); + const data = await client.fetchRecords(kind, { academicYear: '2026', semester: '3' }); + expect(data.items[0]?.courseName).toBe('Algorithms forged'); + expect(JSON.stringify(data)).not.toMatch(/secret|\\u001b|SYNTHETIC-ID|Synthetic Student/); + if (data.kind === 'exams') + expect(data.items[0]?.start).toEqual(new Date('2026-12-10T01:00:00Z')); + else expect(data.items[0]?.grade).toBe('Pass'); + }, + ); +}); diff --git a/src/features/student-records-client.ts b/src/features/student-records-client.ts new file mode 100644 index 0000000..5a5093b --- /dev/null +++ b/src/features/student-records-client.ts @@ -0,0 +1,30 @@ +import { createNbtStudentRecordsClient, type StudentRecords } from '@nbtca/nbtcal/academic'; +import type { AcademicTermRef, AuthenticatedTransport } from '@nbtca/nbtcal/timetable'; +import { sanitizeTerminalLine } from '../core/text.js'; + +export type { StudentRecords, StudentRecordsKind } from '@nbtca/nbtcal/academic'; + +function sanitizeRecord(record: T): T { + return Object.fromEntries( + Object.entries(record).map(([key, value]: [string, unknown]) => [ + key, + typeof value === 'string' ? sanitizeTerminalLine(value) : value, + ]), + ) as T; +} + +export function createStudentRecordsClient(transport: AuthenticatedTransport, baseUrl: string) { + const client = createNbtStudentRecordsClient(transport, { baseUrl }); + return { + async fetchRecords( + kind: StudentRecords['kind'], + term: AcademicTermRef, + signal?: AbortSignal, + ): Promise { + const records = await client.fetchRecords(kind, term, signal ? { signal } : {}); + return records.kind === 'exams' + ? { ...records, items: records.items.map(sanitizeRecord) } + : { ...records, items: records.items.map(sanitizeRecord) }; + }, + }; +} diff --git a/src/features/student-records-render.ts b/src/features/student-records-render.ts new file mode 100644 index 0000000..79f5361 --- /dev/null +++ b/src/features/student-records-render.ts @@ -0,0 +1,49 @@ +import type { AcademicTerm } from '@nbtca/nbtcal/timetable'; +import { sanitizeTerminalLine, wrapAnsiWithIndent } from '../core/text.js'; +import { fmt, t } from '../i18n/index.js'; +import { displaySemesterLabel } from './student-timetable.js'; +import type { StudentRecords } from './student-records-client.js'; + +export function renderStudentRecords( + data: StudentRecords, + term: AcademicTerm, + cols = Number.POSITIVE_INFINITY, +): string[] { + const trans = t().records; + const lines: string[] = []; + const add = (value: string) => { + lines.push(...wrapAnsiWithIndent(sanitizeTerminalLine(value), cols, ' ')); + }; + add(`${trans[data.kind]} · ${term.academicYearLabel} · ${displaySemesterLabel(term)}`); + lines.push(''); + if (data.items.length === 0) { + add(data.kind === 'exams' ? trans.noExams : trans.noGrades); + return lines; + } + if (data.kind === 'exams') { + for (const exam of data.items) { + add(exam.courseName + (exam.courseCode ? ` (${exam.courseCode})` : '')); + add(exam.timeText ?? trans.timeUnavailable); + if (exam.examName) add(exam.examName); + if (exam.location || exam.campus) + add([exam.campus, exam.location].filter(Boolean).join(' · ')); + if (exam.seat) add(fmt(trans.seat, { value: exam.seat })); + if (exam.assessment) add(exam.assessment); + lines.push(''); + } + } else { + for (const grade of data.items) { + add(grade.courseName + (grade.courseCode ? ` (${grade.courseCode})` : '')); + add(fmt(trans.gradeValue, { value: grade.grade ?? trans.notPublished })); + if (grade.credits !== null) add(fmt(trans.credits, { value: grade.credits })); + if (grade.gradePoint !== null) add(fmt(trans.gradePoint, { value: grade.gradePoint })); + if (grade.examNature || grade.courseNature) + add([grade.examNature, grade.courseNature].filter(Boolean).join(' · ')); + if (grade.voided === true) add(trans.voided); + if (grade.remark) add(grade.remark); + lines.push(''); + } + } + add(trans.memoryOnly); + return lines; +} diff --git a/src/features/student-records.test.ts b/src/features/student-records.test.ts new file mode 100644 index 0000000..29e6325 --- /dev/null +++ b/src/features/student-records.test.ts @@ -0,0 +1,283 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as TimetableModule from '@nbtca/nbtcal/timetable'; +import type { AcademicTerm } from '@nbtca/nbtcal/timetable'; +import type { PersistedNbtSession, SessionStore } from '../auth/session-store.js'; +import { runStudentRecordsCommand } from './student-records.js'; +import { t } from '../i18n/index.js'; + +const { restore, listTerms, transport, close, input } = vi.hoisted(() => ({ + restore: vi.fn(), + listTerms: vi.fn(), + transport: vi.fn(), + close: vi.fn(), + input: vi.fn(), +})); +vi.mock('../auth/nbt-auth.js', () => ({ + restoreNbtSession: restore, + loginWithStudentPassword: vi.fn(), +})); +vi.mock('../core/components/text-input.js', () => ({ + runTextInput: input, + runSecretInput: vi.fn(), +})); +vi.mock('@nbtca/nbtcal/timetable', async (original) => ({ + ...(await original()), + createNbtTimetableClient: () => ({ listTerms }), +})); + +const term: AcademicTerm = { + academicYear: '2026', + semester: '3', + academicYearLabel: '2026-2027', + semesterLabel: '1', + current: true, +}; +const snapshot: PersistedNbtSession = { + version: 1, + provider: 'nbt-webvpn', + jar: { version: 1, cookies: [] }, + authenticatedAt: '2026-10-01T00:00:00Z', + validatedAt: '2026-10-01T00:00:00Z', + expiresAt: '2026-10-02T00:00:00Z', +}; +function output() { + let value = ''; + return { + write(chunk: string | Uint8Array) { + value += String(chunk); + return true; + }, + text: () => value, + }; +} +function store() { + return { + filePath: '/unused', + load: vi.fn(() => snapshot), + save: vi.fn(), + clear: vi.fn(), + } satisfies SessionStore; +} + +beforeEach(() => { + vi.resetAllMocks(); + listTerms.mockResolvedValue([term]); + restore.mockResolvedValue({ + timetableTransport: transport, + snapshot: () => Promise.resolve(snapshot), + close, + }); + transport.mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + items: [ + { + xnm: '2026', + xqm: '3', + kcmc: 'Algorithms', + cj: '良好', + xh: 'SYNTHETIC-ID', + xm: 'Synthetic Student', + }, + ], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ), + ); +}); + +describe('student records commands', () => { + it.each(['exams', 'grades'] as const)( + 'reuses the session for %s JSON without persisting records', + async (kind) => { + const stdout = output(); + const stderr = output(); + const sessionStore = store(); + const code = await runStudentRecordsCommand(kind, { + flags: new Set(['--json']), + isInteractive: false, + stdout, + stderr, + store: sessionStore, + }); + expect(code).toBe(0); + const parsed = JSON.parse(stdout.text()) as { kind: string; items: unknown[] }; + expect(parsed.kind).toBe(kind); + expect(parsed.items).toHaveLength(1); + expect(stdout.text()).not.toMatch(/SYNTHETIC-ID|Synthetic Student/); + expect(sessionStore.save).toHaveBeenCalledExactlyOnceWith(snapshot); + expect(close).toHaveBeenCalledOnce(); + expect(stderr.text()).toBe(''); + }, + ); + + it('requires a saved session in a noninteractive terminal', async () => { + const stderr = output(); + const sessionStore = store(); + vi.mocked(sessionStore.load).mockReturnValue(null); + expect( + await runStudentRecordsCommand('grades', { + flags: new Set(), + isInteractive: false, + stdout: output(), + stderr, + store: sessionStore, + }), + ).toBe(2); + expect(stderr.text()).toContain(t().timetable.noSession); + expect(transport).not.toHaveBeenCalled(); + }); + + it('clears an expired session without emitting raw campus data', async () => { + transport.mockResolvedValue(new Response('private marker', { status: 401 })); + const sessionStore = store(); + const stdout = output(); + const stderr = output(); + expect( + await runStudentRecordsCommand('exams', { + flags: new Set(), + isInteractive: false, + stdout, + stderr, + store: sessionStore, + }), + ).toBe(1); + expect(sessionStore.clear).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(stdout.text()).toBe(''); + expect(stderr.text()).not.toContain('private marker'); + }); + + it('rejects an empty term before reading the saved session', async () => { + const sessionStore = store(); + expect( + await runStudentRecordsCommand('grades', { + flags: new Set(['--term=']), + isInteractive: false, + stdout: output(), + stderr: output(), + store: sessionStore, + }), + ).toBe(1); + expect(sessionStore.load).not.toHaveBeenCalled(); + }); + + it('does not silently use the current term for an unknown selector', async () => { + const stderr = output(); + expect( + await runStudentRecordsCommand('exams', { + flags: new Set(['--term=2020-1']), + isInteractive: false, + stdout: output(), + stderr, + store: store(), + }), + ).toBe(1); + expect(stderr.text()).toContain(t().timetable.unknownTerm); + expect(transport).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + }); + + it.each(['--one-shot', '--no-save'])( + 'cancels %s without reading or writing a session', + async (flag) => { + input.mockResolvedValue(null); + const sessionStore = store(); + const stdout = output(); + const stderr = output(); + expect( + await runStudentRecordsCommand('grades', { + flags: new Set([flag]), + isInteractive: true, + stdout, + stderr, + store: sessionStore, + }), + ).toBe(130); + expect(sessionStore.load).not.toHaveBeenCalled(); + expect(sessionStore.save).not.toHaveBeenCalled(); + expect(sessionStore.clear).not.toHaveBeenCalled(); + expect(stdout.text() + stderr.text()).toBe(''); + }, + ); +}); + +describe('exam calendar output', () => { + it('writes a private ICS using the nbtcal exporter and owner-only permissions', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'nbtca-exam-export-')); + try { + const file = path.join(directory, 'exams.ics'); + transport.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [{ xnm: '2026', xqm: '3', kcmc: 'Algorithms', kssj: '2026-12-10(09:00-11:00)' }], + currentPage: 1, + totalPage: 1, + totalResult: 1, + }), + ), + ); + expect( + await runStudentRecordsCommand('exams', { + flags: new Set([`--output=${file}`]), + isInteractive: false, + stdout: output(), + stderr: output(), + store: store(), + }), + ).toBe(0); + const ics = fs.readFileSync(file, 'utf8'); + expect(ics).toContain('CLASS:PRIVATE'); + expect(ics).toContain('DTSTART:20261210T010000Z'); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('does not create or overwrite an export when an exam time is unpublished', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'nbtca-exam-export-')); + try { + const file = path.join(directory, 'exams.ics'); + fs.writeFileSync(file, 'existing calendar'); + const stderr = output(); + expect( + await runStudentRecordsCommand('exams', { + flags: new Set([`--output=${file}`]), + isInteractive: false, + stdout: output(), + stderr, + store: store(), + }), + ).toBe(1); + expect(stderr.text()).toContain(t().records.exportNeedsTime); + expect(fs.readFileSync(file, 'utf8')).toBe('existing calendar'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it.each([['--output='], ['--output=exams.ics', '--json']])( + 'rejects ambiguous export options before authentication', + async (...flags) => { + const sessionStore = store(); + expect( + await runStudentRecordsCommand('exams', { + flags: new Set(flags), + isInteractive: false, + stdout: output(), + stderr: output(), + store: sessionStore, + }), + ).toBe(1); + expect(sessionStore.load).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/features/student-records.ts b/src/features/student-records.ts new file mode 100644 index 0000000..a620bac --- /dev/null +++ b/src/features/student-records.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import { studentExamsToIcs } from '@nbtca/nbtcal/academic'; +import { createNbtTimetableClient } from '@nbtca/nbtcal/timetable'; +import { AuthError } from '../auth/errors.js'; +import { createSessionStore } from '../auth/session-store.js'; +import { JWXT_ORIGIN } from '../config/campus.js'; +import { + resolveTerm, + PromptCancelledError, + safeMessage, + withAuthenticatedSession, + writePrivateIcs, + type StudentTimetableCommandOptions, +} from './student-timetable.js'; +import { sanitizeAcademicTerm } from './timetable-sanitize.js'; +import { createStudentRecordsClient, type StudentRecordsKind } from './student-records-client.js'; +import { renderStudentRecords } from './student-records-render.js'; +import { fmt, t } from '../i18n/index.js'; + +export async function runStudentRecordsCommand( + kind: StudentRecordsKind, + options: StudentTimetableCommandOptions, +): Promise { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const isInteractive = options.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY); + const outputFlag = [...options.flags].find((flag) => flag.startsWith('--output=')); + const output = outputFlag?.slice('--output='.length); + if (output !== undefined && (kind !== 'exams' || !output.trim() || options.flags.has('--json'))) { + stderr.write(`${t().records.invalidExportOptions}\n`); + return 1; + } + const termFlag = [...options.flags].find((flag) => flag.startsWith('--term=')); + if (termFlag === '--term=') { + stderr.write(`${t().timetable.unknownTerm}\n`); + return 1; + } + try { + return await withAuthenticatedSession( + async (session) => { + const client = createNbtTimetableClient(session.timetableTransport, { + baseUrl: JWXT_ORIGIN, + }); + const terms = (await client.listTerms()).map(sanitizeAcademicTerm); + const term = resolveTerm(terms, termFlag?.slice('--term='.length)); + const records = await createStudentRecordsClient( + session.timetableTransport, + JWXT_ORIGIN, + ).fetchRecords(kind, term); + if (output !== undefined && records.kind === 'exams') { + writePrivateIcs(output, studentExamsToIcs(records)); + stdout.write(`${fmt(t().records.exported, { file: path.resolve(output) })}\n`); + return 0; + } + stdout.write( + options.flags.has('--json') + ? `${JSON.stringify(records, null, 2)}\n` + : `${renderStudentRecords(records, term).join('\n')}\n`, + ); + return 0; + }, + { + oneShot: options.flags.has('--one-shot') || options.flags.has('--no-save'), + isInteractive, + store: options.store ?? createSessionStore(), + stderr, + }, + ); + } catch (error) { + if (error instanceof PromptCancelledError) return 130; + if (!isInteractive && error instanceof AuthError && error.code === 'INVALID_CREDENTIALS') { + stderr.write(`${t().timetable.noSession}\n`); + return 2; + } + stderr.write(`${safeMessage(error)}\n`); + return 1; + } +} diff --git a/src/features/student-timetable.ts b/src/features/student-timetable.ts index 76ccd72..d243354 100644 --- a/src/features/student-timetable.ts +++ b/src/features/student-timetable.ts @@ -1,3 +1,4 @@ +import { StudentRecordsError } from '@nbtca/nbtcal/academic'; import fs from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -22,8 +23,9 @@ import { clearScheduleCache, termKey } from './schedule-store.js'; import { sanitizeTerminalLine } from '../core/text.js'; import { fmt, t } from '../i18n/index.js'; import { sanitizeAcademicTerm, sanitizeTimetable } from './timetable-sanitize.js'; +import { JWXT_ORIGIN } from '../config/campus.js'; -export const JWXT_ORIGIN = 'https://jwxt-443.webvpn.nbt.edu.cn'; +export { JWXT_ORIGIN }; type StudentTimetableSubcommand = 'login' | 'logout' | 'status' | 'terms' | 'export'; @@ -58,7 +60,7 @@ export function relevantTerms(catalog: readonly AcademicTerm[]): AcademicTerm[] }); } -function displaySemesterLabel(term: AcademicTerm): string { +export function displaySemesterLabel(term: AcademicTerm): string { const aliases: Readonly> = { '3': '1', '12': '2', '16': '3' }; const aliased = aliases[term.semester]; if (aliased) return fmt(t().timetable.semesterNumber, { number: aliased }); @@ -103,7 +105,8 @@ class IcsWriteError extends Error { export function isSessionExpired(error: unknown): boolean { return ( (error instanceof AuthError && error.code === 'SESSION_EXPIRED') || - (error instanceof TimetableError && error.code === 'SESSION_EXPIRED') + (error instanceof TimetableError && error.code === 'SESSION_EXPIRED') || + (error instanceof StudentRecordsError && error.code === 'SESSION_EXPIRED') ); } @@ -148,6 +151,22 @@ export function safeMessage(error: unknown): string { return trans.genericError; } } + if (error instanceof StudentRecordsError) { + switch (error.code) { + case 'SESSION_EXPIRED': + return trans.sessionExpired; + case 'HTTP_ERROR': + return trans.httpError; + case 'NETWORK_ERROR': + return trans.network; + case 'INVALID_RECORDS': + return trans.unexpectedResponse; + case 'TERM_MISMATCH': + return trans.termMismatch; + case 'UNPUBLISHED_EXAM_TIME': + return t().records.exportNeedsTime; + } + } if (error instanceof TimetableError) { switch (error.code) { case 'MISSING_CALENDAR_DATES': @@ -184,7 +203,7 @@ export function safeMessage(error: unknown): string { return trans.genericError; } -class PromptCancelledError extends Error {} +export class PromptCancelledError extends Error {} function answered(value: string | null): string { if (value === null) throw new PromptCancelledError(); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index e0b8180..490d86f 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -223,6 +223,7 @@ export interface Translations { watchJsonConflict: string; intervalNeedsWatch: string; }; + records: typeof englishTranslationSchema.records; timetable: { menuTitle: string; actionExport: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7af8c82..e22d7cd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -212,6 +212,25 @@ "watchJsonConflict": "--watch cannot be used with --json.", "intervalNeedsWatch": "--interval requires --watch." }, + "records": { + "exams": "Exam schedule", + "grades": "Grades", + "noExams": "No exam arrangements have been published for this term.", + "noGrades": "No grades have been published for this term.", + "timeUnavailable": "Time not published", + "notPublished": "Not published", + "seat": "Seat: {value}", + "gradeValue": "Grade: {value}", + "credits": "Credits: {value}", + "gradePoint": "Grade points: {value}", + "voided": "The school has marked this grade as void.", + "memoryOnly": "Exam and grade query results are not cached.", + "refresh": "Refresh", + "switchTerm": "Switch term", + "exportNeedsTime": "Some exam times are unpublished. A complete exam calendar cannot be exported yet.", + "invalidExportOptions": "Use a nonempty --output path for exams without --json.", + "exported": "Exported the exam calendar to {file}." + }, "timetable": { "menuTitle": "My Timetable", "actionExport": "Export ICS", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index fabcb09..6b436cd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -212,6 +212,25 @@ "watchJsonConflict": "--watch 不能和 --json 同时使用。", "intervalNeedsWatch": "--interval 需要配合 --watch 使用。" }, + "records": { + "exams": "个人考试安排", + "grades": "成绩查询", + "noExams": "这个学期尚无已公布的个人考试安排。", + "noGrades": "这个学期尚无已公布的成绩。", + "timeUnavailable": "考试时间尚未公布", + "notPublished": "尚未公布", + "seat": "座位号:{value}", + "gradeValue": "成绩:{value}", + "credits": "学分:{value}", + "gradePoint": "绩点:{value}", + "voided": "教务系统已将这条成绩标记为作废。", + "memoryOnly": "考试和成绩查询结果不会写入本地缓存。", + "refresh": "刷新查询", + "switchTerm": "切换学期", + "exportNeedsTime": "部分考试尚未公布时间,暂时无法导出完整的考试日历。", + "invalidExportOptions": "请使用非空的 --output 路径导出考试日历,并且不要同时使用 --json。", + "exported": "考试日历已导出至 {file}。" + }, "timetable": { "menuTitle": "我的课表", "actionExport": "导出 ICS",