@nbtca/prompt 1.5.10 → 1.5.12

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
@@ -19,6 +19,8 @@ function renderHubBody(state, now, bodyRows, cols) {
19
19
  const rows = Number.isFinite(bodyRows)
20
20
  ? Math.max(0, Math.floor(bodyRows))
21
21
  : Number.POSITIVE_INFINITY;
22
+ if (state.stale)
23
+ lines.push(...wrappedIndentedLines(trans.calendar.stale, cols, type.hint), '');
22
24
  const banner = renderCountdownBanner(state.nextEvent, now, cols);
23
25
  if (banner)
24
26
  lines.push(...banner.split('\n'), '');
@@ -5,11 +5,12 @@ import { renderEvents } from './events-render.js';
5
5
  import { setVimKeysActive } from '../../core/vim-keys.js';
6
6
  import { pickIcon } from '../../core/icons.js';
7
7
  import { t } from '../../i18n/index.js';
8
- import { loadCalendarOrThrow, toDisplayEvent, exportEventIcs } from '../../features/calendar.js';
9
- import { weekRange, monthRange, filterEvents } from '../../features/calendar-query.js';
8
+ import { exportEventIcs, loadCalendarOrCache, toDisplayEvent, yearHeatmap, } from '../../features/calendar.js';
9
+ import { currentEvents, filterEvents, monthRange, weekRange, } from '../../features/calendar-query.js';
10
10
  import { addLocalDays } from '../../core/calendar-day.js';
11
11
  let state = { mode: 'loading' };
12
12
  let calendar = null;
13
+ let stale = false;
13
14
  let currentList = [];
14
15
  function backLabel() {
15
16
  return t().common.back;
@@ -54,19 +55,15 @@ function showList(title, events, ctx) {
54
55
  }
55
56
  const RECENT_ACTIVITY_FETCH_CAP = 15;
56
57
  function goToHub() {
57
- const upcoming = calendar ? calendar.upcoming({ days: 30 }) : [];
58
- const nextEvent = upcoming[0];
58
+ const now = new Date();
59
+ const upcoming = calendar ? currentEvents(calendar, now) : [];
60
+ const nextEvent = upcoming.find((event) => event.start >= now);
59
61
  state = {
60
62
  mode: 'hub',
61
63
  hubField: buildHubField(),
64
+ ...(stale ? { stale } : {}),
62
65
  ...(nextEvent === undefined ? {} : { nextEvent: toDisplayEvent(nextEvent) }),
63
- heatmapBuckets: calendar
64
- ? calendar.heatmap({
65
- start: addLocalDays(new Date(), -365),
66
- end: new Date(),
67
- bucket: 'day',
68
- })
69
- : [],
66
+ heatmapBuckets: calendar ? yearHeatmap(calendar, now) : [],
70
67
  recentEvents: upcoming.slice(0, RECENT_ACTIVITY_FETCH_CAP).map(toDisplayEvent),
71
68
  };
72
69
  }
@@ -98,10 +95,11 @@ export const eventsView = {
98
95
  state = { mode: 'loading' };
99
96
  ctx.rerender();
100
97
  try {
101
- const loadedCalendar = await loadCalendarOrThrow(ctx.signal);
98
+ const loaded = await loadCalendarOrCache(ctx.signal);
102
99
  if (ctx.signal?.aborted)
103
100
  return;
104
- calendar = loadedCalendar;
101
+ calendar = loaded.calendar;
102
+ stale = loaded.stale;
105
103
  goToHub();
106
104
  }
107
105
  catch {
@@ -153,7 +151,7 @@ export const eventsView = {
153
151
  return;
154
152
  const now = new Date();
155
153
  if (result.selected === 'upcoming') {
156
- showList(t().menu.events, calendar.upcoming({ days: 30 }), ctx);
154
+ showList(t().menu.events, currentEvents(calendar, now), ctx);
157
155
  return;
158
156
  }
159
157
  if (result.selected === 'week') {
@@ -5,9 +5,10 @@ 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
+ import { currentEvents } from '../../features/calendar-query.js';
11
12
  import { loadingLines } from '../../core/components/spinner.js';
12
13
  const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
13
14
  function wrappedIndentedLines(label, cols, style) {
@@ -20,10 +21,11 @@ function wrappedRenderedLines(line, cols) {
20
21
  const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
21
22
  return wrappedIndentedLines(content, cols, (value) => value);
22
23
  }
24
+ const DAY_MS = 86_400_000;
23
25
  const DAY_PROGRESS_WIDTH = 20;
24
26
  function renderDayProgress(now, cols) {
25
- const minutesElapsed = now.getHours() * 60 + now.getMinutes();
26
- const fraction = Math.min(1, Math.max(0, minutesElapsed / 1440));
27
+ const midnight = campusDateTime(campusIsoDate(now), '00:00');
28
+ const fraction = Math.min(1, Math.max(0, (now.getTime() - midnight.getTime()) / DAY_MS));
27
29
  const pct = Math.round(fraction * 100);
28
30
  const percentage = `${pct}%`;
29
31
  const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
@@ -143,14 +145,19 @@ export function renderHome(data, now, bodyRows = 100, cols = 80) {
143
145
  const HOME_EVENT_FETCH_CAP = 15;
144
146
  function calendarSnapshot(cal, weekAheadInfo) {
145
147
  const now = new Date();
146
- const eventLines = cal
147
- .upcoming({ days: 30 })
148
+ const eventLines = currentEvents(cal, now)
148
149
  .slice(0, HOME_EVENT_FETCH_CAP)
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
  }
@@ -513,8 +513,8 @@ export const scheduleView = {
513
513
  writePrivateIcs(out, ics);
514
514
  state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
515
515
  }
516
- catch {
517
- state = { ...state, statusMessage: t().timetable.genericError };
516
+ catch (error) {
517
+ state = { ...state, statusMessage: safeMessage(error) };
518
518
  }
519
519
  return;
520
520
  }
@@ -108,36 +108,9 @@ function safeHeaders(headers) {
108
108
  }
109
109
  return Object.fromEntries(result.entries());
110
110
  }
111
- function abortSignal(signal, timeoutMs) {
112
- const controller = new AbortController();
113
- let didTimeout = false;
114
- const onAbort = () => {
115
- controller.abort(signal?.reason);
116
- };
117
- signal?.addEventListener('abort', onAbort, { once: true });
118
- if (signal?.aborted)
119
- onAbort();
120
- const timer = setTimeout(() => {
121
- didTimeout = true;
122
- controller.abort();
123
- }, timeoutMs);
124
- timer.unref();
125
- return {
126
- signal: controller.signal,
127
- cleanup() {
128
- clearTimeout(timer);
129
- signal?.removeEventListener('abort', onAbort);
130
- },
131
- timedOut: () => didTimeout,
132
- };
133
- }
134
- function safeFetchError(error, stage, didTimeout) {
111
+ function safeFetchError(error, stage) {
135
112
  if (error instanceof AuthError)
136
113
  return error;
137
- if (didTimeout)
138
- return new AuthError('TIMEOUT', stage, 'The campus service request timed out.', {
139
- retryable: true,
140
- });
141
114
  if (typeof error === 'object' && error !== null && Reflect.get(error, 'name') === 'AbortError') {
142
115
  return new DOMException('The campus service request was aborted.', 'AbortError');
143
116
  }
@@ -169,20 +142,24 @@ export function createCampusCookieSession(options = {}) {
169
142
  if (init.method && init.method !== 'GET' && init.method !== 'POST') {
170
143
  throw new AuthError('UNTRUSTED_URL', stage, 'Only read and login requests are allowed.');
171
144
  }
172
- const controlled = abortSignal(init.signal, timeoutMs);
145
+ const timeout = new AbortController();
146
+ setTimeout(() => {
147
+ timeout.abort(new AuthError('TIMEOUT', stage, 'The campus service request timed out.', {
148
+ retryable: true,
149
+ }));
150
+ }, timeoutMs).unref();
151
+ // Keep the timer running after the headers: the caller still has to read the body.
152
+ const signal = init.signal ? AbortSignal.any([init.signal, timeout.signal]) : timeout.signal;
173
153
  try {
174
154
  return await cookieFetch(url, {
175
155
  ...init,
176
156
  headers: safeHeaders(init.headers),
177
- signal: controlled.signal,
157
+ signal,
178
158
  maxRedirect: 8,
179
159
  });
180
160
  }
181
161
  catch (error) {
182
- throw safeFetchError(error, stage, controlled.timedOut());
183
- }
184
- finally {
185
- controlled.cleanup();
162
+ throw safeFetchError(error, stage);
186
163
  }
187
164
  }
188
165
  async function timetableTransport(url, init) {
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { main } from './main.js';
3
- import { fetchEvents, fetchHeatmapBuckets, renderEventsTable, serializeEvents, } from './features/calendar.js';
3
+ import { loadCalendarOrCache, renderEventsTable, serializeEvents, toDisplayEvent, yearHeatmap, } from './features/calendar.js';
4
4
  import { renderHeatmap } from './features/calendar-heatmap.js';
5
+ import { currentEvents, dayRange, monthRange, weekRange } from './features/calendar-query.js';
5
6
  import { checkServices, countServiceHealth, hasServiceFailures, renderServiceStatusTable, serializeServiceStatus, } from './features/status.js';
6
7
  import { pickIcon } from './core/icons.js';
7
8
  import { applyColorModePreference } from './config/preferences.js';
@@ -261,45 +262,34 @@ async function runEventsCommand(flags) {
261
262
  console.error(chalk.red(t().cli.invalidNext));
262
263
  process.exit(1);
263
264
  }
265
+ const { calendar, stale } = await loadCalendarOrCache();
266
+ if (stale)
267
+ console.error(chalk.yellow(t().calendar.stale));
268
+ const now = new Date();
264
269
  if (flags.has('--heatmap')) {
265
- const buckets = await fetchHeatmapBuckets();
270
+ const buckets = yearHeatmap(calendar, now);
266
271
  if (flags.has('--json')) {
267
272
  process.stdout.write(JSON.stringify(buckets, null, 2) + '\n');
268
273
  }
269
274
  else {
270
275
  const useColor = !flags.has('--plain') && isTty(process.stdout.isTTY);
271
- console.log(renderHeatmap(buckets, new Date(), { color: useColor }));
276
+ const cols = terminalWidth();
277
+ console.log(renderHeatmap(buckets, now, { color: useColor, ...(cols === undefined ? {} : { cols }) }));
272
278
  }
273
279
  return;
274
280
  }
275
- const { weekRange, monthRange } = await import('./features/calendar-query.js');
276
- const { fetchInRange } = await import('./features/calendar.js');
277
- const now0 = new Date();
278
- let events;
279
- if (flags.has('--week')) {
280
- const r = weekRange(now0);
281
- events = await fetchInRange(r.start, r.end);
282
- }
283
- else if (flags.has('--month')) {
284
- const r = monthRange(now0);
285
- events = await fetchInRange(r.start, r.end);
286
- }
287
- else {
288
- events = await fetchEvents();
289
- }
281
+ const range = flags.has('--today')
282
+ ? dayRange(now)
283
+ : flags.has('--week')
284
+ ? weekRange(now)
285
+ : flags.has('--month')
286
+ ? monthRange(now)
287
+ : undefined;
288
+ let events = (range ? calendar.inRange(range.start, range.end) : currentEvents(calendar, now)).map(toDisplayEvent);
290
289
  if (searchFlag) {
291
290
  const q = searchFlag.slice('--search='.length).toLowerCase();
292
291
  events = events.filter((e) => `${e.title} ${e.location}`.toLowerCase().includes(q));
293
292
  }
294
- if (flags.has('--today')) {
295
- const now = new Date();
296
- events = events.filter((e) => {
297
- const d = e.startDate;
298
- return (d.getFullYear() === now.getFullYear() &&
299
- d.getMonth() === now.getMonth() &&
300
- d.getDate() === now.getDate());
301
- });
302
- }
303
293
  if (next !== undefined)
304
294
  events = events.slice(0, next);
305
295
  if (flags.has('--json')) {
@@ -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
+ }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { pickIcon } from '../core/icons.js';
3
3
  import { space, type } from '../core/theme.js';
4
4
  import { t, getCurrentLanguage } from '../i18n/index.js';
5
+ import { visualWidth } from '../core/text.js';
5
6
  function parseBucketDate(date) {
6
7
  const parts = date.split('-').map(Number);
7
8
  const y = parts[0] ?? 0;
@@ -77,7 +78,7 @@ export function renderHeatmap(buckets, today, options) {
77
78
  timeZone: 'UTC',
78
79
  });
79
80
  const cellsWidth = numCols * cellWidth;
80
- const monthChars = new Array(cellsWidth).fill(' ');
81
+ let monthLine = '';
81
82
  let prevMonth = -1;
82
83
  for (let col = 0; col < numCols; col++) {
83
84
  let labelDate = null;
@@ -93,14 +94,15 @@ export function renderHeatmap(buckets, today, options) {
93
94
  const month = labelDate.getUTCMonth();
94
95
  if (month !== prevMonth) {
95
96
  prevMonth = month;
96
- const label = monthFmt.format(labelDate); // e.g. "Jun"
97
+ const label = monthFmt.format(labelDate);
97
98
  const start = col * cellWidth;
98
- for (let i = 0; i < label.length && start + i < cellsWidth; i++) {
99
- monthChars[start + i] = label[i] ?? ' ';
99
+ const used = visualWidth(monthLine);
100
+ if (start >= used + (used > 0 ? 1 : 0) && start + visualWidth(label) <= cellsWidth) {
101
+ monthLine += ' '.repeat(start - used) + label;
100
102
  }
101
103
  }
102
104
  }
103
- const monthLabelLine = space.indent + weekdayLabel + monthChars.join('');
105
+ const monthLabelLine = space.indent + weekdayLabel + monthLine;
104
106
  const weekdayNames = [
105
107
  trans.timetable.weekdayMon.slice(0, 2),
106
108
  ' ',
@@ -1,3 +1,4 @@
1
+ const UPCOMING_DAYS = 30;
1
2
  export function weekRange(now) {
2
3
  const start = new Date(now);
3
4
  start.setHours(0, 0, 0, 0);
@@ -7,6 +8,24 @@ export function weekRange(now) {
7
8
  end.setDate(end.getDate() + 7);
8
9
  return { start, end };
9
10
  }
11
+ export function dayRange(now) {
12
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
13
+ const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
14
+ return { start, end };
15
+ }
16
+ export function currentEvents(calendar, now) {
17
+ const { start } = dayRange(now);
18
+ const end = new Date(now.getTime() + UPCOMING_DAYS * 86_400_000);
19
+ return calendar.inRange(start, end).filter((event) => {
20
+ if (event.start >= now)
21
+ return true;
22
+ const eventEnd = event.end ??
23
+ (event.isAllDay
24
+ ? new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate() + 1)
25
+ : event.start);
26
+ return eventEnd > now;
27
+ });
28
+ }
10
29
  export function monthRange(now) {
11
30
  const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
12
31
  const end = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
@@ -76,16 +76,19 @@ export function toDisplayEvent(e) {
76
76
  uid: e.uid,
77
77
  };
78
78
  }
79
- export async function fetchEvents() {
80
- return (await loadCalendarOrThrow()).upcoming({ days: 30 }).map(toDisplayEvent);
81
- }
82
- export async function fetchInRange(start, end) {
83
- return (await loadCalendarOrThrow()).inRange(start, end).map(toDisplayEvent);
79
+ export async function loadCalendarOrCache(signal) {
80
+ try {
81
+ return { calendar: await loadCalendarOrThrow(signal), stale: false };
82
+ }
83
+ catch (err) {
84
+ const cached = peekCalendar();
85
+ if (!cached)
86
+ throw err;
87
+ return { calendar: cached, stale: true };
88
+ }
84
89
  }
85
- export async function fetchHeatmapBuckets() {
86
- const now = new Date();
87
- const start = addLocalDays(now, -365);
88
- return (await loadCalendarOrThrow()).heatmap({ start, end: now, bucket: 'day' });
90
+ export function yearHeatmap(calendar, now) {
91
+ return calendar.heatmap({ start: addLocalDays(now, -365), end: now, bucket: 'day' });
89
92
  }
90
93
  export function serializeEvents(events) {
91
94
  return events.map((event) => ({
@@ -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;
@@ -6,7 +6,9 @@ import { runSecretInput, runTextInput } from '../core/components/text-input.js';
6
6
  import { AuthError } from '../auth/errors.js';
7
7
  import { loginWithStudentPassword, restoreNbtSession, } from '../auth/nbt-auth.js';
8
8
  import { createSessionStore } from '../auth/session-store.js';
9
+ import { isoDayDifference, parseLocalMonday } from '../core/calendar-day.js';
9
10
  import { clearScheduleCache, termKey } from './schedule-store.js';
11
+ import { sanitizeTerminalLine } from '../core/text.js';
10
12
  import { fmt, t } from '../i18n/index.js';
11
13
  import { sanitizeAcademicTerm, sanitizeTimetable } from './timetable-sanitize.js';
12
14
  export const JWXT_ORIGIN = 'https://jwxt-443.webvpn.nbt.edu.cn';
@@ -43,12 +45,48 @@ function displaySemesterLabel(term) {
43
45
  ? fmt(t().timetable.semesterNumber, { number: term.semesterLabel })
44
46
  : term.semesterLabel;
45
47
  }
48
+ class WeekOneError extends Error {
49
+ reason;
50
+ constructor(reason) {
51
+ super(`--week-one is ${reason}.`);
52
+ this.reason = reason;
53
+ }
54
+ }
55
+ export function assertWeekOne(weekOneMonday, calendarDays = []) {
56
+ try {
57
+ parseLocalMonday(weekOneMonday);
58
+ }
59
+ catch {
60
+ throw new WeekOneError('invalid');
61
+ }
62
+ if (calendarDays.some((day) => isoDayDifference(weekOneMonday, day.date) !== (day.week - 1) * 7 + day.weekday - 1)) {
63
+ throw new WeekOneError('conflict');
64
+ }
65
+ }
66
+ class IcsWriteError extends Error {
67
+ file;
68
+ reason;
69
+ constructor(file, reason) {
70
+ super(`Could not write ${file}.`);
71
+ this.file = file;
72
+ this.reason = reason;
73
+ }
74
+ }
46
75
  export function isSessionExpired(error) {
47
76
  return ((error instanceof AuthError && error.code === 'SESSION_EXPIRED') ||
48
77
  (error instanceof TimetableError && error.code === 'SESSION_EXPIRED'));
49
78
  }
50
79
  export function safeMessage(error) {
51
80
  const trans = t().timetable;
81
+ if (error instanceof IcsWriteError) {
82
+ return fmt(trans.writeFailed, {
83
+ file: sanitizeTerminalLine(error.file),
84
+ reason: sanitizeTerminalLine(error.reason),
85
+ });
86
+ }
87
+ if (error instanceof WeekOneError) {
88
+ return error.reason === 'invalid' ? trans.invalidWeekOne : trans.weekOneConflict;
89
+ }
52
90
  if (error instanceof AuthError) {
53
91
  switch (error.code) {
54
92
  case 'INVALID_CREDENTIALS':
@@ -110,25 +148,28 @@ export function safeMessage(error) {
110
148
  }
111
149
  return trans.genericError;
112
150
  }
151
+ class PromptCancelledError extends Error {
152
+ }
153
+ function answered(value) {
154
+ if (value === null)
155
+ throw new PromptCancelledError();
156
+ return value;
157
+ }
113
158
  async function interactiveLogin(isInteractive) {
114
159
  const trans = t().timetable;
115
160
  if (!isInteractive) {
116
161
  throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Interactive login requires a terminal.');
117
162
  }
118
- const username = await runTextInput({
163
+ const username = answered(await runTextInput({
119
164
  message: trans.studentId,
120
165
  placeholder: trans.studentIdHint,
121
166
  allowEmpty: false,
122
- });
123
- if (!username)
124
- throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Student id is required.');
125
- const password = await runSecretInput({
167
+ }));
168
+ const password = answered(await runSecretInput({
126
169
  message: trans.password,
127
170
  placeholder: trans.passwordHint,
128
171
  allowEmpty: false,
129
- });
130
- if (!password)
131
- throw new AuthError('INVALID_CREDENTIALS', 'credentials', 'Password is required.');
172
+ }));
132
173
  return loginWithStudentPassword(username, password);
133
174
  }
134
175
  export async function withAuthenticatedSession(operation, options) {
@@ -200,6 +241,11 @@ export function writePrivateIcs(filePath, contents) {
200
241
  /* Best effort on non-POSIX filesystems. */
201
242
  }
202
243
  }
244
+ catch (error) {
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ // Node appends the syscall and the temporary path; keep only "CODE: description".
247
+ throw new IcsWriteError(resolved, /^(.+?), \w+ '/.exec(message)?.[1] ?? message);
248
+ }
203
249
  finally {
204
250
  try {
205
251
  fs.unlinkSync(temporaryPath);
@@ -219,12 +265,13 @@ async function resolveWeekOneMonday(explicitValue, hasAuthoritativeDates, isInte
219
265
  return explicitValue;
220
266
  if (!isInteractive)
221
267
  return undefined;
222
- const value = await runTextInput({
268
+ const value = answered(await runTextInput({
223
269
  message: t().timetable.weekOne,
224
270
  placeholder: t().timetable.weekOneHint,
225
271
  allowEmpty: false,
226
- });
227
- return value === null || value === '' ? undefined : value;
272
+ })).trim();
273
+ assertWeekOne(value);
274
+ return value;
228
275
  }
229
276
  export async function runStudentTimetableCommand(subcommandValue, options) {
230
277
  const subcommand = (subcommandValue ?? 'export').toLowerCase();
@@ -249,7 +296,10 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
249
296
  stderr.write(`${fmt(trans.invalidOption, { flag: invalidFlag })}\n`);
250
297
  return 1;
251
298
  }
299
+ const weekOneFlag = flagValue(options.flags, '--week-one=');
252
300
  try {
301
+ if (weekOneFlag !== undefined)
302
+ assertWeekOne(weekOneFlag);
253
303
  if (subcommand === 'logout') {
254
304
  store.clear();
255
305
  clearScheduleCache();
@@ -291,7 +341,9 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
291
341
  const output = outputFlag === undefined || outputFlag === ''
292
342
  ? `timetable-${termKey(selected)}.ics`
293
343
  : outputFlag;
294
- const weekOneMonday = await resolveWeekOneMonday(flagValue(options.flags, '--week-one='), timetable.calendarDays.length > 0, isInteractive);
344
+ const weekOneMonday = await resolveWeekOneMonday(weekOneFlag, timetable.calendarDays.length > 0, isInteractive);
345
+ if (weekOneMonday !== undefined)
346
+ assertWeekOne(weekOneMonday, timetable.calendarDays);
295
347
  const ics = timetableToIcs(timetable, {
296
348
  ...(weekOneMonday === undefined ? {} : { weekOneMonday }),
297
349
  calendarName: fmt(trans.calendarName, {
@@ -315,6 +367,8 @@ export async function runStudentTimetableCommand(subcommandValue, options) {
315
367
  }, { oneShot, isInteractive, store, stderr });
316
368
  }
317
369
  catch (error) {
370
+ if (error instanceof PromptCancelledError)
371
+ return 130;
318
372
  if (!isInteractive && error instanceof AuthError && error.code === 'INVALID_CREDENTIALS') {
319
373
  stderr.write(`${trans.noSession}\n`);
320
374
  return 2;
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "menu": {
31
31
  "events": "Events",
32
- "eventsDesc": "",
32
+ "eventsDesc": "Upcoming activities",
33
33
  "docs": "Docs",
34
34
  "docsDesc": "Knowledge base",
35
35
  "status": "Status",
@@ -239,6 +239,8 @@
239
239
  "loginChanged": "The school login page changed; credentials were not submitted.",
240
240
  "unexpectedResponse": "A valid JWXT session could not be confirmed after login.",
241
241
  "missingDates": "JWXT returned no calendar dates. Provide --week-one=YYYY-MM-DD.",
242
+ "invalidWeekOne": "The first teaching week must start on a Monday, written as YYYY-MM-DD.",
243
+ "weekOneConflict": "--week-one does not match the dates JWXT provided; drop the option to use JWXT's dates.",
242
244
  "missingPeriod": "JWXT returned no usable period times, so export cannot continue safely.",
243
245
  "termMismatch": "JWXT returned a different academic term. Try again.",
244
246
  "invalidData": "The timetable response format is not currently recognized.",
@@ -246,6 +248,7 @@
246
248
  "noTerms": "JWXT returned no available terms.",
247
249
  "currentTermUnknown": "JWXT did not identify one current term. Choose one with --term=year:code.",
248
250
  "genericError": "The timetable operation failed.",
251
+ "writeFailed": "Could not write {file}: {reason}",
249
252
  "hubToday": "Today",
250
253
  "hubWeek": "This week",
251
254
  "hubSwitchTerm": "Switch term",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "menu": {
31
31
  "events": "活动",
32
- "eventsDesc": "",
32
+ "eventsDesc": "近期活动安排",
33
33
  "docs": "文档",
34
34
  "docsDesc": "知识库",
35
35
  "status": "状态",
@@ -239,6 +239,8 @@
239
239
  "loginChanged": "学校登录页结构已经变化,当前版本未提交凭据。",
240
240
  "unexpectedResponse": "登录后未能确认有效的教务会话。",
241
241
  "missingDates": "教务响应没有可用日期,请用 --week-one=YYYY-MM-DD 指定第一周周一。",
242
+ "invalidWeekOne": "第一教学周的起始日期必须是周一,并写成 YYYY-MM-DD 格式。",
243
+ "weekOneConflict": "--week-one 与教务提供的日期对不上;去掉这个选项即可直接使用教务日期。",
242
244
  "missingPeriod": "教务响应缺少课程节次时间,无法安全导出。",
243
245
  "termMismatch": "教务系统返回了不同学期,请重试。",
244
246
  "invalidData": "课表数据格式暂时无法识别。",
@@ -246,6 +248,7 @@
246
248
  "noTerms": "教务系统没有返回可用学期。",
247
249
  "currentTermUnknown": "教务没有标出唯一的当前学期;请用 --term=学年:代码 指定。",
248
250
  "genericError": "课表操作失败。",
251
+ "writeFailed": "无法写入 {file}:{reason}",
249
252
  "hubToday": "今日",
250
253
  "hubWeek": "本周",
251
254
  "hubSwitchTerm": "切换学期",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/prompt",
3
- "version": "1.5.10",
3
+ "version": "1.5.12",
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",