Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -43,6 +46,9 @@ subject to the local cache's 4 MiB size limit.
```text
nbtca events [--today|--week|--month] [--search=<q>] [--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]
Expand All @@ -57,6 +63,19 @@ Service checks wait up to 15 seconds by default. Use `--timeout=<ms>` 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).

Expand All @@ -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
9 changes: 7 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nbtca/prompt",
"version": "1.5.20",
"version": "1.5.21",
"type": "module",
"main": "dist/index.js",
"exports": {
Expand Down Expand Up @@ -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"
},
Expand Down
10 changes: 10 additions & 0 deletions scripts/test-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
8 changes: 4 additions & 4 deletions src/app/views/schedule-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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}]`);
}
});

Expand Down Expand Up @@ -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']);
});
});

Expand Down
5 changes: 5 additions & 0 deletions src/app/views/schedule-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { offlineNotice } from '../chrome.js';
export type ScheduleMode =
| 'loading'
| 'public'
| 'records'
| 'needsLoginId'
| 'needsLoginPassword'
| 'authenticating'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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), ''] : []),
Expand Down
180 changes: 180 additions & 0 deletions src/app/views/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcademicTerm[]>((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);
});
});
});
Loading
Loading