@nbtca/prompt 1.5.9 → 1.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,8 +51,6 @@ npm run check
51
51
  `check` runs formatting, lint, full TypeScript validation, tests, build, package
52
52
  consumer checks and dependency audit.
53
53
 
54
- Project guides and release notes live in the [Wiki](https://github.com/nbtca/Prompt/wiki).
55
-
56
54
  ## License
57
55
 
58
56
  MIT
package/dist/app/app.js CHANGED
@@ -8,6 +8,8 @@ import { docsView } from './views/docs.js';
8
8
  import { eventsView } from './views/events.js';
9
9
  import { settingsView } from './views/settings.js';
10
10
  import { getAppTabs } from './tabs.js';
11
+ import { renderHelp } from './help.js';
12
+ import { t } from '../i18n/index.js';
11
13
  import { SPINNER_FRAME_MS } from '../core/components/spinner.js';
12
14
  export async function runApp() {
13
15
  if (!process.stdin.isTTY || !process.stdout.isTTY)
@@ -22,6 +24,7 @@ export async function runApp() {
22
24
  let keyFlushTimer;
23
25
  let clockTimer;
24
26
  let busyTimer;
27
+ let helpOpen = false;
25
28
  let painted;
26
29
  let lastBody = { length: 0, height: 0 };
27
30
  const viewIds = getAppTabs().map((tab) => tab.id);
@@ -77,11 +80,14 @@ export async function runApp() {
77
80
  const tabs = getAppTabs();
78
81
  const chrome = resolveChromeLayout(rows);
79
82
  const header = renderHeader(tabs, view, cols, chrome.headerLines, active?.contextPath?.());
80
- const body = active?.render(ctx) ?? [];
81
- const bodyScroll = active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
83
+ const activeTab = tabs.find((tab) => tab.id === view);
84
+ const body = helpOpen
85
+ ? renderHelp(activeTab?.title ?? '', active?.shortcuts?.() ?? [], tabs.length, cols)
86
+ : (active?.render(ctx) ?? []);
87
+ const bodyScroll = !helpOpen && active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
82
88
  const height = computeBodyRows(rows, chrome.headerLines, chrome.footerLines);
83
89
  lastBody = { length: body.length, height };
84
- const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines, active?.scrollsBody?.() === true ? scrollPercent() : undefined);
90
+ const footer = renderFooter(view, cols, tabs.length, helpOpen ? t().help.close : active?.footerHint?.(tabs.length, cols), chrome.footerLines, !helpOpen && active?.scrollsBody?.() === true ? scrollPercent() : undefined);
85
91
  const lines = composeFrameLines(header, body, footer, rows, cols, bodyScroll);
86
92
  const patch = diffFrame(painted?.cols === cols ? painted.lines : undefined, lines);
87
93
  painted = { cols, lines };
@@ -127,6 +133,19 @@ export async function runApp() {
127
133
  render();
128
134
  return;
129
135
  }
136
+ if (helpOpen) {
137
+ if (key === '?' || key === '\x1b') {
138
+ helpOpen = false;
139
+ render();
140
+ }
141
+ return;
142
+ }
143
+ if (key === '?') {
144
+ helpOpen = true;
145
+ scroll = 0;
146
+ render();
147
+ return;
148
+ }
130
149
  const g = routeGlobalKey(key, viewIds, view);
131
150
  if (g.quit) {
132
151
  quit();
@@ -112,7 +112,7 @@ export function passiveFooterHint(tabCount, cols = Number.POSITIVE_INFINITY) {
112
112
  const trans = t();
113
113
  const dot = pickIcon('·', '-');
114
114
  const compactTabs = tabCount > 1 ? `1-${tabCount}/Tab ${dot} ` : '';
115
- return fitFooterHint(cols, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit}`, `${compactTabs}Esc ${dot} q`, `Esc ${dot} q`, 'q');
115
+ return fitFooterHint(cols, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit} ${dot} ${trans.help.hint}`, `${digitTabHint(tabCount)}Esc ${dot} q ${trans.menu.hintQuit}`, `${compactTabs}Esc ${dot} q ${dot} ?`, `Esc ${dot} q ${dot} ?`, `Esc ${dot} q`, 'q');
116
116
  }
117
117
  function interactiveFooterHint(tabCount, cols) {
118
118
  const trans = t();
@@ -122,6 +122,7 @@ function interactiveFooterHint(tabCount, cols) {
122
122
  const localFull = `${trans.menu.hintMove} ${dot} ${trans.menu.hintOpen} ${dot} Esc ${dot} q ${trans.menu.hintQuit}`;
123
123
  const localCompact = `${trans.menu.hintMove} ${trans.menu.hintOpen} Esc q`;
124
124
  const candidates = [
125
+ `${fullTabs}${localFull} ${dot} ${trans.help.hint}`,
125
126
  `${fullTabs}${localFull}`,
126
127
  `${compactTabs}${localFull}`,
127
128
  localFull,
@@ -0,0 +1,44 @@
1
+ import { type, space, glyph } from '../core/theme.js';
2
+ import { pickIcon } from '../core/icons.js';
3
+ import { t } from '../i18n/index.js';
4
+ import { padEndV, visualWidth, wrapAnsiWithIndent } from '../core/text.js';
5
+ function row(shortcut, keyWidth, cols) {
6
+ const key = padEndV(type.label(shortcut.key), keyWidth);
7
+ const line = `${space.indent}${space.indent}${key} ${type.hint(shortcut.label)}`;
8
+ if (visualWidth(line) <= cols)
9
+ return [line];
10
+ return wrapAnsiWithIndent(`${type.label(shortcut.key)} ${type.hint(shortcut.label)}`, cols, space.indent + space.indent);
11
+ }
12
+ function group(title, shortcuts, cols) {
13
+ if (shortcuts.length === 0)
14
+ return [];
15
+ const keyWidth = shortcuts.reduce((width, item) => Math.max(width, visualWidth(item.key)), 0);
16
+ return [
17
+ ...wrapAnsiWithIndent(type.heading(title), cols, space.indent),
18
+ ...shortcuts.flatMap((shortcut) => row(shortcut, keyWidth, cols)),
19
+ '',
20
+ ];
21
+ }
22
+ export function globalShortcuts(tabCount) {
23
+ const trans = t();
24
+ const updown = glyph.updown();
25
+ return [
26
+ ...(tabCount > 1 ? [{ key: `1-${String(tabCount)}`, label: trans.help.tabs }] : []),
27
+ { key: 'Tab', label: trans.help.nextTab },
28
+ { key: `${updown} / j k`, label: trans.help.scroll },
29
+ { key: `PgUp/PgDn / ${pickIcon('␣', 'Space')}`, label: trans.help.page },
30
+ { key: 'Home/End / g G', label: trans.help.ends },
31
+ { key: pickIcon('⏎', 'Enter'), label: trans.help.open },
32
+ { key: 'Esc', label: trans.help.back },
33
+ { key: 'q', label: trans.help.quit },
34
+ ];
35
+ }
36
+ export function renderHelp(viewTitle, viewShortcuts, tabCount, cols) {
37
+ const trans = t();
38
+ return [
39
+ ...wrapAnsiWithIndent(type.heading(trans.help.title), cols, space.indent),
40
+ '',
41
+ ...group(trans.help.sectionGlobal, globalShortcuts(tabCount), cols),
42
+ ...group(viewTitle, viewShortcuts, cols),
43
+ ];
44
+ }
@@ -465,6 +465,17 @@ export const docsView = {
465
465
  return undefined;
466
466
  }
467
467
  },
468
+ shortcuts() {
469
+ const trans = t();
470
+ if (state.mode !== 'reader')
471
+ return [];
472
+ return [
473
+ ...((state.readerLinks?.length ?? 0) > 0
474
+ ? [{ key: 'f', label: trans.docs.readerLinksHint }]
475
+ : []),
476
+ { key: 'b', label: trans.docs.openBrowser },
477
+ ];
478
+ },
468
479
  scrollsBody() {
469
480
  return state.mode === 'reader' && state.readerLinksField === undefined;
470
481
  },
@@ -5,9 +5,9 @@ import { padEndV, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
5
  import { peekNextClassLine, peekTodayLines, peekWeekAheadInfo, peekUnresolvedCount, } from '../../features/schedule-view.js';
6
6
  import { loadCalendarOrThrow, peekCalendar, toDisplayEvent, renderEventBrief, } from '../../features/calendar.js';
7
7
  import { weekdayShortLabel } from '../../features/schedule-render.js';
8
- import { addLocalDays } from '../../core/calendar-day.js';
9
8
  import { passiveFooterHint } from '../chrome.js';
10
- import { campusWeekday } from '@nbtca/nbtcal/timetable';
9
+ import { campusDateTime, campusIsoDate } from '@nbtca/nbtcal/timetable';
10
+ import { isoDayDifference, localDayDifference, parseLocalDate } from '../../core/calendar-day.js';
11
11
  import { loadingLines } from '../../core/components/spinner.js';
12
12
  const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
13
13
  function wrappedIndentedLines(label, cols, style) {
@@ -20,10 +20,11 @@ function wrappedRenderedLines(line, cols) {
20
20
  const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
21
21
  return wrappedIndentedLines(content, cols, (value) => value);
22
22
  }
23
+ const DAY_MS = 86_400_000;
23
24
  const DAY_PROGRESS_WIDTH = 20;
24
25
  function renderDayProgress(now, cols) {
25
- const minutesElapsed = now.getHours() * 60 + now.getMinutes();
26
- const fraction = Math.min(1, Math.max(0, minutesElapsed / 1440));
26
+ const midnight = campusDateTime(campusIsoDate(now), '00:00');
27
+ const fraction = Math.min(1, Math.max(0, (now.getTime() - midnight.getTime()) / DAY_MS));
27
28
  const pct = Math.round(fraction * 100);
28
29
  const percentage = `${pct}%`;
29
30
  const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
@@ -149,8 +150,14 @@ function calendarSnapshot(cal, weekAheadInfo) {
149
150
  .map((event) => renderEventBrief(toDisplayEvent(event), now));
150
151
  if (!weekAheadInfo)
151
152
  return { eventLines };
152
- const weekEnd = addLocalDays(weekAheadInfo.weekStartDate, 7);
153
- const daySet = new Set(cal.inRange(weekAheadInfo.weekStartDate, weekEnd).map((event) => campusWeekday(event.start)));
153
+ const { weekStart } = weekAheadInfo;
154
+ const monday = campusDateTime(weekStart, '00:00');
155
+ const daySet = new Set(cal
156
+ .inRange(new Date(monday.getTime() - DAY_MS), new Date(monday.getTime() + 8 * DAY_MS))
157
+ .map((event) => 1 +
158
+ (event.isAllDay
159
+ ? localDayDifference(parseLocalDate(weekStart), event.start)
160
+ : isoDayDifference(weekStart, campusIsoDate(event.start)))));
154
161
  return {
155
162
  eventLines,
156
163
  weekAhead: {
@@ -1,11 +1,11 @@
1
- import { createTimetableSchedule, } from '@nbtca/nbtcal/timetable';
1
+ import { campusIsoDate, createTimetableSchedule, } from '@nbtca/nbtcal/timetable';
2
2
  import { c, type, space, glyph } from '../../core/theme.js';
3
3
  import { pickIcon } from '../../core/icons.js';
4
4
  import { t, fmt } from '../../i18n/index.js';
5
5
  import { renderListFieldWithContext } from '../fields/list-field.js';
6
6
  import { renderNextClassBanner, renderWeekGrid, renderUnresolvedItems, renderTodayTimeline, weekdayShortLabel, renderTermDensity, renderMeetingDetail, renderDayTimeline, renderDaySwitcher, } from '../../features/schedule-render.js';
7
7
  import { sanitizeTerminalLine, visualWidth, wrapAnsiWithIndent } from '../../core/text.js';
8
- import { localDayDifference, parseLocalDate, parseLocalMonday } from '../../core/calendar-day.js';
8
+ import { isoDayDifference } from '../../core/calendar-day.js';
9
9
  import { loadingLines } from '../../core/components/spinner.js';
10
10
  function heading(label) {
11
11
  return `${space.indent}${type.heading(label)}`;
@@ -90,7 +90,7 @@ function hubPreGridLines(state, now, cols) {
90
90
  lines.push(heading(trans.timetable.termNotStarted));
91
91
  lines.push(hint(fmt(trans.timetable.termStartsIn, {
92
92
  date: state.weekOne,
93
- days: String(daysBetween(now, new Date(`${state.weekOne}T00:00:00`))),
93
+ days: String(daysUntil(now, state.weekOne)),
94
94
  })));
95
95
  lines.push('');
96
96
  lines.push(heading(trans.timetable.termPreviewWeek));
@@ -160,7 +160,7 @@ const TERM_PROGRESS_WIDTH = 20;
160
160
  function renderTermProgressBar(w, cols) {
161
161
  if (!w.nextBreakStart)
162
162
  return null;
163
- const totalWeeks = Math.max(1, Math.round(localDayDifference(parseLocalMonday(w.weekOneMonday), parseLocalDate(w.nextBreakStart)) / 7));
163
+ const totalWeeks = Math.max(1, Math.round(isoDayDifference(w.weekOneMonday, w.nextBreakStart) / 7));
164
164
  const currentWeek = w.currentWeek;
165
165
  const labelText = fmt(t().timetable.weekLabel2, { week: `${currentWeek}/${totalWeeks}` });
166
166
  const label = type.hint(labelText);
@@ -177,8 +177,8 @@ function renderTermProgressBar(w, cols) {
177
177
  ? [`${indent}${bar} ${label}`]
178
178
  : [`${indent}${bar}`, ...hintLines(labelText, cols)];
179
179
  }
180
- function daysBetween(a, b) {
181
- return Math.max(0, localDayDifference(a, b));
180
+ function daysUntil(now, date) {
181
+ return Math.max(0, isoDayDifference(campusIsoDate(now), date));
182
182
  }
183
183
  function renderPublicBody(state, now, bodyRows, cols) {
184
184
  const trans = t();
@@ -202,7 +202,7 @@ function renderPublicBody(state, now, bodyRows, cols) {
202
202
  if (w.nextBreakStart && w.nextBreakTitle) {
203
203
  lines.push(...hintLines(fmt(trans.timetable.daysUntilBreak, {
204
204
  title: sanitizeTerminalLine(w.nextBreakTitle),
205
- days: String(daysBetween(now, parseLocalDate(w.nextBreakStart))),
205
+ days: String(daysUntil(now, w.nextBreakStart)),
206
206
  }), cols));
207
207
  }
208
208
  }
@@ -298,6 +298,11 @@ export const scheduleView = {
298
298
  isBusy() {
299
299
  return state.mode === 'loading';
300
300
  },
301
+ shortcuts() {
302
+ return state.mode === 'hub' && state.timetable
303
+ ? hubShortcuts(state.timetable).map(({ key, label }) => ({ key, label }))
304
+ : [];
305
+ },
301
306
  capturesInput() {
302
307
  return (state.mode === 'needsLoginId' ||
303
308
  state.mode === 'needsLoginPassword' ||
@@ -12,6 +12,8 @@ const WEBVPN_PATHS = new Set([
12
12
  '/vpn_key/update',
13
13
  ]);
14
14
  const AUTH_PATHS = new Set(['/authserver/login', '/authserver/checkNeedCaptcha.htl']);
15
+ // CAS parks accounts owing a profile here instead of issuing a ticket.
16
+ const PROFILE_GATE_PATH = '/authserver/improveInfo/improveUserInfo.do';
15
17
  const JWXT_EXACT_PATHS = new Set([
16
18
  '/sso/jziotlogin',
17
19
  '/jwglxt/ticketlogin',
@@ -92,6 +94,9 @@ export function assertAllowedCampusUrl(url) {
92
94
  }
93
95
  if (hostname === JWXT_HOST && isAllowedJwxtPath(url.pathname))
94
96
  return;
97
+ if (hostname === AUTH_HOST && url.pathname === PROFILE_GATE_PATH) {
98
+ throw new AuthError('PROFILE_INCOMPLETE', 'credentials', 'The campus requires this account to complete its profile.');
99
+ }
95
100
  throw new AuthError('UNTRUSTED_URL', 'session', 'The campus service URL is not allowed.');
96
101
  }
97
102
  function safeHeaders(headers) {
@@ -88,19 +88,21 @@ function classifyRejectedLogin(html) {
88
88
  if (/锁定|冻结|次数过多|稍后再试/.test(visibleError)) {
89
89
  return new AuthError('ACCOUNT_LOCKED', 'credentials', 'The campus account is temporarily locked.');
90
90
  }
91
- if (/激活|未启用/.test(visibleError)) {
92
- return new AuthError('ACCOUNT_INACTIVE', 'credentials', 'The campus account must be activated first.');
93
- }
94
91
  if (/验证码|滑块|captcha/i.test(visibleError)) {
95
92
  return new AuthError('INTERACTIVE_CHALLENGE', 'credentials', 'The campus login requires an interactive browser challenge.');
96
93
  }
97
- if (/用户名|账号|密码|credential|password/i.test(visibleError)) {
94
+ // Key `accountLogin_account_pwd_error`; localized it also tells first-time users
95
+ // to activate, so it must be classified before the activation branch below.
96
+ if (/用户名|密码|credential|password|pwd/i.test(visibleError)) {
98
97
  return new AuthError('INVALID_CREDENTIALS', 'credentials', 'The student id or password was rejected.');
99
98
  }
99
+ if (/激活|未启用/.test(visibleError)) {
100
+ return new AuthError('ACCOUNT_INACTIVE', 'credentials', 'The campus account must be activated first.');
101
+ }
100
102
  return new AuthError('UNEXPECTED_RESPONSE', 'credentials', 'Campus login could not be confirmed.');
101
103
  }
102
- async function readText(response, stage) {
103
- if (response.status < 200 || response.status >= 300) {
104
+ async function readText(response, stage, toleratedStatus) {
105
+ if ((response.status < 200 || response.status >= 300) && response.status !== toleratedStatus) {
104
106
  throw new AuthError('HTTP_ERROR', stage, 'The campus service returned an error.', {
105
107
  retryable: response.status >= 500,
106
108
  });
@@ -220,15 +222,21 @@ export async function loginWithStudentPassword(username, password, options = {})
220
222
  method: 'POST',
221
223
  headers: {
222
224
  Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
225
+ // No Accept-Language on purpose: unresolved, the campus renders the bare
226
+ // message key, which is the only unambiguous error id this login offers.
223
227
  'Content-Type': 'application/x-www-form-urlencoded',
224
228
  Referer: loginResponse.url,
225
229
  },
226
230
  body: body.toString(),
227
231
  ...signalOption(options.signal),
228
232
  }, 'credentials');
229
- const credentialHtml = await readText(credentialResponse, 'credentials');
233
+ // A rejected login is a 401 carrying the login page; classify it, don't report HTTP_ERROR.
234
+ const credentialHtml = await readText(credentialResponse, 'credentials', 401);
230
235
  if (hasLoginFingerprint(credentialHtml))
231
236
  throw classifyRejectedLogin(credentialHtml);
237
+ if (credentialResponse.status === 401) {
238
+ throw new AuthError('HTTP_ERROR', 'credentials', 'The campus service returned an error.');
239
+ }
232
240
  await verifyJwxtSession(cookies, options.signal);
233
241
  const authenticatedAt = (options.now ?? (() => new Date()))().toISOString();
234
242
  return createAuthenticatedSession(cookies, {
@@ -35,3 +35,6 @@ function localDayIndex(date) {
35
35
  export function localDayDifference(start, end) {
36
36
  return localDayIndex(end) - localDayIndex(start);
37
37
  }
38
+ export function isoDayDifference(start, end) {
39
+ return (Date.parse(end) - Date.parse(start)) / DAY_MS;
40
+ }
package/dist/core/logo.js CHANGED
@@ -5,7 +5,7 @@ import chalk from 'chalk';
5
5
  import { useUnicodeIcons } from './icons.js';
6
6
  import { APP_INFO } from '../config/data.js';
7
7
  import { typeReveal, materializeBraille } from './motion.js';
8
- import { brandGradient as brand } from './theme.js';
8
+ import { brandGradient as brand, c } from './theme.js';
9
9
  import { visualWidth } from './text.js';
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const TAGLINE = 'To be at the intersection of technology and liberal arts.';
@@ -69,7 +69,9 @@ export async function runStartup() {
69
69
  return;
70
70
  const color = !process.env['NO_COLOR'];
71
71
  process.stdout.write('\n');
72
- await materializeBraille(art, (s) => paint(s, color));
72
+ await materializeBraille(art, (s) => paint(s, color), {
73
+ paintProgress: (s) => (color ? c.brand(s) : s),
74
+ });
73
75
  await typeReveal([
74
76
  '',
75
77
  color ? brand(TAGLINE) : TAGLINE,
@@ -83,7 +83,8 @@ export async function materializeBraille(art, paint, opts = {}) {
83
83
  }
84
84
  shown++;
85
85
  }
86
- write(paint(renderFrame()) + '\n');
86
+ const painter = f === frameCount ? paint : (opts.paintProgress ?? paint);
87
+ write(painter(renderFrame()) + '\n');
87
88
  if (f < frameCount) {
88
89
  await sleep(frameMs);
89
90
  write(ansi.cursorUp(lines.length) + ansi.cursorToCol0 + ansi.eraseDown);
@@ -1,4 +1,4 @@
1
- import { createTimetableSchedule } from '@nbtca/nbtcal/timetable';
1
+ import { campusDateTime, campusIsoDate, createTimetableSchedule } from '@nbtca/nbtcal/timetable';
2
2
  import { countdownParts, isCountdownUrgent } from './calendar-query.js';
3
3
  import { c, type, space, glyph } from '../core/theme.js';
4
4
  import { pickIcon } from '../core/icons.js';
@@ -64,25 +64,6 @@ export function renderNextClassBanner(next, now, cols = Number.POSITIVE_INFINITY
64
64
  }
65
65
  return styleWhen(compactWhen);
66
66
  }
67
- export function renderTodayClasses(meetings, periods, now) {
68
- const trans = t();
69
- const sorted = [...meetings].sort((a, b) => a.startPeriod - b.startPeriod);
70
- if (sorted.length === 0)
71
- return `${space.indent}${type.hint(trans.timetable.noClassToday)}`;
72
- const dot = pickIcon('·', '-');
73
- const marker = pickIcon('▸', '>');
74
- const lines = sorted.map((m) => {
75
- const time = span(m, periods);
76
- const startStr = periods.find((p) => p.period === m.startPeriod)?.start ?? '00:00';
77
- const endStr = periods.find((p) => p.period === m.endPeriod)?.end ?? '23:59';
78
- const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
79
- const live = nowStr >= startStr && nowStr <= endStr;
80
- const head = live ? `${type.active(marker)} ` : ' ';
81
- const loc = m.location ? ` ${dot} ${type.hint(m.location)}` : '';
82
- return `${space.indent}${head}${type.hint(time)} ${live ? type.active(m.courseName) : type.body(m.courseName)}${loc}`;
83
- });
84
- return lines.join('\n');
85
- }
86
67
  export function weekdayShortLabel(wd) {
87
68
  const trans = t();
88
69
  const labels = [
@@ -101,7 +82,8 @@ function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cur
101
82
  const sorted = [...meetings].sort((a, b) => a.startPeriod - b.startPeriod);
102
83
  if (sorted.length === 0)
103
84
  return `${space.indent}${type.hint(trans.timetable.noClassToday)}`;
104
- const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
85
+ const today = campusIsoDate(now);
86
+ const minute = Math.floor(now.getTime() / 60_000) * 60_000;
105
87
  const dot = pickIcon('·', '-');
106
88
  const rule = pickIcon('─', '-');
107
89
  const midConnector = pickIcon('┼', '+');
@@ -110,8 +92,9 @@ function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cur
110
92
  const lines = sorted.map((m, i) => {
111
93
  const startStr = periods.find((p) => p.period === m.startPeriod)?.start ?? '00:00';
112
94
  const endStr = periods.find((p) => p.period === m.endPeriod)?.end ?? '23:59';
113
- const isLive = isToday && nowStr >= startStr && nowStr <= endStr;
114
- const isDone = isToday && nowStr > endStr;
95
+ const end = campusDateTime(today, endStr);
96
+ const isLive = isToday && campusDateTime(today, startStr).getTime() <= minute && minute <= end.getTime();
97
+ const isDone = isToday && minute > end.getTime();
115
98
  const isCursor = cursorPeriod !== undefined && m.startPeriod <= cursorPeriod && cursorPeriod <= m.endPeriod;
116
99
  const connector = i === 0 ? topConnector : midConnector;
117
100
  const marker = isLive ? type.active(pickIcon('▶', '>')) : ' ';
@@ -124,9 +107,6 @@ function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cur
124
107
  compactStatusText = statusText;
125
108
  }
126
109
  else if (isLive) {
127
- const end = new Date(now);
128
- const [eh, em] = endStr.split(':').map((x) => Number.parseInt(x, 10));
129
- end.setHours(eh !== undefined && Number.isFinite(eh) ? eh : 0, em !== undefined && Number.isFinite(em) ? em : 0, 0, 0);
130
110
  const remaining = countdownParts(end, now);
131
111
  const mins = remaining.days * 1440 + remaining.hours * 60 + remaining.minutes;
132
112
  statusText = `${trans.timetable.classLive} ${dot} ${fmt(trans.timetable.minutesRemaining, { minutes: String(mins) })}`;
@@ -1,9 +1,9 @@
1
- import { createTimetableSchedule } from '@nbtca/nbtcal/timetable';
2
- import { addLocalDays, parseLocalMonday } from '../core/calendar-day.js';
1
+ import { campusDateTime, campusIsoDate, createTimetableSchedule, } from '@nbtca/nbtcal/timetable';
3
2
  import { renderNextClassBanner, renderTodayTimeline } from './schedule-render.js';
4
3
  import { loadCurrentPointer, loadTimetableCache } from './schedule-store.js';
5
4
  import { sanitizeTimetable } from './timetable-sanitize.js';
6
5
  const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
6
+ const WEEK_MS = 7 * 86_400_000;
7
7
  function loadCachedTimetable() {
8
8
  const pointer = loadCurrentPointer();
9
9
  if (!pointer)
@@ -69,8 +69,8 @@ export function peekWeekAheadInfo(now = new Date()) {
69
69
  if (week < 1)
70
70
  return null;
71
71
  const classDays = WEEKDAYS.map((weekday) => schedule.meetingsOnDay(week, weekday).length > 0);
72
- const weekStartDate = addLocalDays(parseLocalMonday(cached.weekOneMonday), (week - 1) * 7);
73
- return { weekStartDate, classDays };
72
+ const weekStart = campusDateTime(cached.weekOneMonday, '00:00').getTime() + (week - 1) * WEEK_MS;
73
+ return { weekStart: campusIsoDate(new Date(weekStart)), classDays };
74
74
  }
75
75
  catch {
76
76
  return null;
@@ -59,6 +59,8 @@ export function safeMessage(error) {
59
59
  return trans.accountInactive;
60
60
  case 'INTERACTIVE_CHALLENGE':
61
61
  return trans.challenge;
62
+ case 'PROFILE_INCOMPLETE':
63
+ return trans.profileIncomplete;
62
64
  case 'SESSION_EXPIRED':
63
65
  return trans.sessionExpired;
64
66
  case 'TIMEOUT':
@@ -12,6 +12,21 @@
12
12
  "moreAbove": "{count} more above",
13
13
  "moreBelow": "{count} more below"
14
14
  },
15
+ "help": {
16
+ "title": "Keys",
17
+ "sectionGlobal": "Anywhere",
18
+ "sectionView": "Here",
19
+ "tabs": "Switch tab",
20
+ "nextTab": "Next tab",
21
+ "scroll": "Scroll a line",
22
+ "page": "Scroll a page",
23
+ "ends": "Jump to top or bottom",
24
+ "open": "Open",
25
+ "back": "Back",
26
+ "quit": "Quit",
27
+ "close": "Esc or ? to close",
28
+ "hint": "? keys"
29
+ },
15
30
  "menu": {
16
31
  "events": "Events",
17
32
  "eventsDesc": "",
@@ -218,6 +233,7 @@
218
233
  "sessionExpired": "The login session expired. Please sign in again.",
219
234
  "timeout": "The school service timed out.",
220
235
  "network": "Could not connect to the school service.",
236
+ "profileIncomplete": "The school wants this account's profile completed first; finish it in a browser, then try again.",
221
237
  "untrustedUrl": "The school returned an unapproved redirect; the request stopped safely.",
222
238
  "httpError": "The school login service returned an error status.",
223
239
  "loginChanged": "The school login page changed; credentials were not submitted.",
@@ -12,6 +12,21 @@
12
12
  "moreAbove": "上方还有 {count} 项",
13
13
  "moreBelow": "下方还有 {count} 项"
14
14
  },
15
+ "help": {
16
+ "title": "快捷键",
17
+ "sectionGlobal": "全局",
18
+ "sectionView": "当前页",
19
+ "tabs": "切换标签页",
20
+ "nextTab": "下一个标签页",
21
+ "scroll": "滚动一行",
22
+ "page": "翻页",
23
+ "ends": "跳到开头或结尾",
24
+ "open": "打开",
25
+ "back": "返回",
26
+ "quit": "退出",
27
+ "close": "Esc 或 ? 关闭",
28
+ "hint": "? 快捷键"
29
+ },
15
30
  "menu": {
16
31
  "events": "活动",
17
32
  "eventsDesc": "",
@@ -218,6 +233,7 @@
218
233
  "sessionExpired": "登录状态已过期,请重新登录。",
219
234
  "timeout": "学校服务响应超时。",
220
235
  "network": "无法连接学校服务。",
236
+ "profileIncomplete": "学校要求先补全个人资料;请在浏览器登录统一身份认证完成后再试。",
221
237
  "untrustedUrl": "学校返回了未授权的登录跳转,已安全中止。",
222
238
  "httpError": "学校登录服务返回了错误状态。",
223
239
  "loginChanged": "学校登录页结构已经变化,当前版本未提交凭据。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/prompt",
3
- "version": "1.5.9",
3
+ "version": "1.5.11",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -46,8 +46,8 @@
46
46
  "interactive"
47
47
  ],
48
48
  "dependencies": {
49
- "@nbtca/docs": "^0.3.0",
50
- "@nbtca/nbtcal": "^0.4.0",
49
+ "@nbtca/docs": "^0.3.1",
50
+ "@nbtca/nbtcal": "^0.4.1",
51
51
  "chalk": "^5.6.2",
52
52
  "cheerio": "1.0.0",
53
53
  "fetch-cookie": "^3.2.0",
@@ -68,7 +68,7 @@
68
68
  "typescript": "^5.9.3",
69
69
  "typescript-eslint": "^8.67.0",
70
70
  "vite": "^6.4.3",
71
- "vitest": "^3.2.7"
71
+ "vitest": "^4.1.11"
72
72
  },
73
73
  "engines": {
74
74
  "node": ">=20.12.0"