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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ Or try it without installing: `npx @nbtca/prompt`.
unreachable.
- **Settings**: language, icons, and colors.

The first load needs a network connection. Cached events and school calendars
remain available without an age limit; a failed refresh shows a cached-data notice.
Docs are available offline after reading them or loading them through search,
subject to the local cache's 4 MiB size limit.

## Commands

```text
Expand All @@ -48,6 +53,10 @@ nbtca update
`nbtca --help` lists every command and flag. Add `--plain` for output without
color.

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.

Prompt never stores your password. A saved campus session gets user-only file
permissions. See [SECURITY.md](SECURITY.md).

Expand Down
42 changes: 21 additions & 21 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.19",
"version": "1.5.20",
"type": "module",
"main": "dist/index.js",
"exports": {
Expand Down Expand Up @@ -46,7 +46,7 @@
"interactive"
],
"dependencies": {
"@nbtca/docs": "^0.3.4",
"@nbtca/docs": "^0.3.5",
"@nbtca/nbtcal": "^0.4.4",
"chalk": "^5.6.2",
"marked": "^15.0.12"
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ node -e '
const statuses = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
if (
statuses.length !== 8 ||
statuses.some((item) => item.ok || item.error !== "Request timed out")
statuses.some((item) => item.ok || !item.timedOut || item.error !== "Request timed out")
) process.exit(1);
' "$ignored_status_file"
if [[ -s "$cli_test_root/status-ignored-signal.stderr" ]]; then
Expand Down
2 changes: 1 addition & 1 deletion src/app/chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('renderHeader', () => {
describe('offlineNotice', () => {
it('leads with a warning icon and leaves a blank line before the content', () => {
const lines = offlineNotice(80);
expect(lines.map(stripAnsi)).toEqual([' ! Offline, showing cached data', '']);
expect(lines.map(stripAnsi)).toEqual([' ! Refresh failed; showing cache', '']);
});

it('keeps its indent on one line at forty columns in both languages', () => {
Expand Down
17 changes: 16 additions & 1 deletion src/app/views/schedule-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import chalk from 'chalk';
import { renderSchedule, hubShortcuts, type ScheduleViewState } from './schedule-render.js';
import { ListField } from '../fields/list-field.js';
import { TextField } from '../fields/text-field.js';
import { setLanguage } from '../../i18n/index.js';
import { setLanguage, t } from '../../i18n/index.js';
import { resetIconCache } from '../../core/icons.js';
import { campusDateTime } from '@nbtca/nbtcal/timetable';
import { stripAnsi, visualWidth } from '../../core/text.js';
Expand Down Expand Up @@ -930,6 +930,21 @@ describe('hubShortcuts', () => {
});

describe('renderSchedule — public mode', () => {
it('marks a cached school calendar when its refresh fails', () => {
const out = stripAnsi(
renderSchedule(
{
mode: 'public',
publicStale: true,
publicWindow: { status: 'onBreak', breakTitle: '暑假' },
},
new Date(),
).join('\n'),
);
expect(out).toContain(t().common.offline);
expect(out).toContain('暑假');
});

it.each([
['en', 'Academic calendar not available yet', 'Log in to export your timetable (.ics)'],
['zh', '校历数据暂不可用', '登录后可导出课表 (.ics)'],
Expand Down
4 changes: 3 additions & 1 deletion src/app/views/schedule-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from '../../core/text.js';
import { isoDayDifference } from '../../core/calendar-day.js';
import { loadingLines } from '../../core/components/spinner.js';
import { offlineNotice } from '../chrome.js';

export type ScheduleMode =
| 'loading'
Expand Down Expand Up @@ -68,6 +69,7 @@ export interface ScheduleViewState {
timetable?: Timetable;
publicField?: ListField;
publicWindow?: AcademicWindow | OnBreak | null;
publicStale?: boolean;
gridCursor?: GridCursor;
detailMeeting?: TimetableMeeting;
detailFrom?: 'hub' | 'week';
Expand Down Expand Up @@ -447,7 +449,7 @@ function renderPublicBody(
cols: number,
): string[] {
const trans = t();
const lines: string[] = [];
const lines: string[] = state.publicStale ? offlineNotice(cols) : [];
const w = state.publicWindow;

if (w === undefined) {
Expand Down
15 changes: 9 additions & 6 deletions src/app/views/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,15 @@ vi.mock('../../features/calendar.js', async (importOriginal) => {
const actual = await importOriginal<typeof CalendarModule>();
return {
...actual,
loadSchoolCalendar: vi.fn().mockResolvedValue({
upcoming: calendarUpcoming,
past: vi.fn().mockReturnValue([]),
next: vi.fn().mockReturnValue([]),
inRange: calendarInRange,
heatmap: vi.fn().mockReturnValue([]),
loadSchoolCalendarOrCache: vi.fn().mockResolvedValue({
calendar: {
upcoming: calendarUpcoming,
past: vi.fn().mockReturnValue([]),
next: vi.fn().mockReturnValue([]),
inRange: calendarInRange,
heatmap: vi.fn().mockReturnValue([]),
},
stale: false,
}),
};
});
Expand Down
8 changes: 4 additions & 4 deletions src/app/views/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
loadTimetableCache,
clearScheduleCache,
} from '../../features/schedule-store.js';
import { loadSchoolCalendar } from '../../features/calendar.js';
import { loadSchoolCalendarOrCache } from '../../features/calendar.js';
import {
currentAcademicWindow,
inferWeekOneMonday,
Expand Down Expand Up @@ -175,12 +175,12 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr
state = { mode: 'public', publicField: buildPublicField() };
ctx.rerender();
try {
const cal = await loadSchoolCalendar(ctx.signal);
const { calendar: cal, stale } = await loadSchoolCalendarOrCache(ctx.signal);
if (!isLifecycleActive(ctx, generation)) return;
const now = new Date();
const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
const publicWindow: AcademicWindow | OnBreak | null = currentAcademicWindow(windowEvents, now);
state = { ...state, publicWindow };
state = { ...state, publicWindow, publicStale: stale };
} catch {
if (!isLifecycleActive(ctx, generation)) return;
state = { ...state, publicWindow: null };
Expand All @@ -190,7 +190,7 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr

async function tryInferWeekOne(ctx: AppContext, generation: number): Promise<string | null> {
try {
const cal = await loadSchoolCalendar(ctx.signal);
const { calendar: cal } = await loadSchoolCalendarOrCache(ctx.signal);
if (!isLifecycleActive(ctx, generation)) return null;
const now = new Date();
const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400));
Expand Down
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { renderHeatmap } from './features/calendar-heatmap.js';
import { currentEvents, dayRange, monthRange, weekRange } from './features/calendar-query.js';
import {
checkServices,
DEFAULT_STATUS_TIMEOUT_MS,
countServiceHealth,
hasServiceFailures,
renderServiceStatusTable,
Expand Down Expand Up @@ -360,7 +361,7 @@ async function runStatusCommand(flags: Set<string>): Promise<boolean> {
: 10;
const timeoutMs = timeoutFlag
? parseAsciiDecimalInteger(timeoutFlag.slice('--timeout='.length))
: 6000;
: DEFAULT_STATUS_TIMEOUT_MS;
const retries = retriesFlag
? parseAsciiDecimalInteger(retriesFlag.slice('--retries='.length))
: 1;
Expand Down Expand Up @@ -441,7 +442,7 @@ async function runStatusCommand(flags: Set<string>): Promise<boolean> {
console.log(chalk.bold(`${trans.status.watchUpdated}: ${new Date().toLocaleString()}`));
console.log(
chalk.dim(
`${trans.status.up}: ${health.up} | ${trans.status.down}: ${health.down} | ${trans.status.watchHint}`,
`${trans.status.up}: ${health.up} | ${trans.status.down}: ${health.down} | ${trans.status.timeout}: ${health.timedOut} | ${trans.status.watchHint}`,
),
);
console.log();
Expand Down
14 changes: 9 additions & 5 deletions src/features/calendar-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,15 @@ describe('loadCalendarOrThrow', () => {
});
});

describe('loadSchoolCalendar', () => {
describe('loadSchoolCalendarOrCache', () => {
it('fetches the school feed into its own cache', async () => {
const { loadSchoolCalendar, loadCalendarOrThrow } = await freshCalendar();
const { loadSchoolCalendarOrCache, loadCalendarOrThrow } = await freshCalendar();
loadFeedCache.mockReturnValue(null);
const school = { status: 'modified', text: feed('school'), validators: {} };
fetchFeedConditional.mockResolvedValueOnce(school);
expect(uids(await loadSchoolCalendar())).toEqual(['school']);
const loaded = await loadSchoolCalendarOrCache();
expect(uids(loaded.calendar)).toEqual(['school']);
expect(loaded.stale).toBe(false);
expect(fetchFeedConditional.mock.calls[0]?.[0]).toBe('https://ical.nbtca.space/school.ics');
expect(saveFeedCache).toHaveBeenCalledWith('school-feed', school);
fetchFeedConditional.mockResolvedValueOnce({
Expand All @@ -176,10 +178,12 @@ describe('loadSchoolCalendar', () => {
});

it('falls back to the cached school feed when the network fails', async () => {
const { loadSchoolCalendar } = await freshCalendar();
const { loadSchoolCalendarOrCache } = await freshCalendar();
fetchFeedConditional.mockRejectedValue(new Error('offline'));
loadFeedCache.mockReturnValue(cached);
expect(uids(await loadSchoolCalendar())).toEqual(['cached']);
const loaded = await loadSchoolCalendarOrCache();
expect(uids(loaded.calendar)).toEqual(['cached']);
expect(loaded.stale).toBe(true);
expect(loadFeedCache).toHaveBeenCalledWith('school-feed');
});
});
12 changes: 6 additions & 6 deletions src/features/calendar-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,12 @@ describe('calendar-store', () => {
});
});

it('refuses a feed older than the max age', () => {
it('retains old feeds and validators for offline use and revalidation', () => {
withDir((dir) => {
saveFeedCache('calendar-feed', { text: 'stale', validators: {} }, dir);
const longAgo = new Date(Date.now() - 60 * 60 * 1000);
saveFeedCache('calendar-feed', { text: 'stale', validators }, dir);
const longAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo);
expect(loadFeedCache('calendar-feed', dir, 30 * 60 * 1000)).toBeNull();
expect(loadFeedCache('calendar-feed', dir, 2 * 60 * 60 * 1000)?.text).toBe('stale');
expect(loadFeedCache('calendar-feed', dir)).toEqual({ text: 'stale', validators });
});
});

Expand All @@ -71,7 +70,8 @@ describe('calendar-store', () => {
const longAgo = new Date(Date.now() - 60 * 60 * 1000);
utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo);
touchFeedCache('calendar-feed', { etag: '"v2"' }, dir);
expect(loadFeedCache('calendar-feed', dir, 30 * 60 * 1000)).toEqual({
expect(statSync(join(dir, 'calendar-feed.ics')).mtimeMs).toBeGreaterThan(longAgo.getTime());
expect(loadFeedCache('calendar-feed', dir)).toEqual({
text: 'same',
validators: { etag: '"v2"' },
});
Expand Down
4 changes: 1 addition & 3 deletions src/features/calendar-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import path from 'path';
import type { FeedValidators } from '@nbtca/nbtcal';
import { getStateDir, getWritableStateDir } from '../config/paths.js';

const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const HEADER_VALUE = /^[\x21-\x7e][\x20-\x7e]*$/;

export interface FeedCache {
Expand Down Expand Up @@ -54,12 +53,11 @@ export function touchFeedCache(name: string, validators: FeedValidators, dir?: s
}
}

export function loadFeedCache(name: string, dir?: string, maxAgeMs = MAX_AGE_MS): FeedCache | null {
export function loadFeedCache(name: string, dir?: string): FeedCache | null {
try {
const source = dir ?? getStateDir();
const validators = readValidators(name, source);
const file = path.join(source, `${name}.ics`);
if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs) return null;
return { text: fs.readFileSync(file, 'utf8'), validators };
} catch {
return null;
Expand Down
Loading
Loading