@nbtca/prompt 1.4.2 → 1.5.1

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.
Files changed (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -1,36 +1,51 @@
1
- import open from 'open';
2
1
  import chalk from 'chalk';
3
- import { runMenu, menuFooter } from '../core/components/menu.js';
4
- import { createSpinner } from '../core/ui.js';
5
- import { URLS } from '../config/data.js';
6
- import { t } from '../i18n/index.js';
7
- import { enterScreen, breadcrumb } from '../core/transitions.js';
8
- async function openUrl(url) {
9
- const trans = t();
10
- const s = createSpinner(trans.links.opening);
2
+ import open from 'open';
3
+ import { sanitizeTerminalLine } from '../core/text.js';
4
+ import { fmt, t } from '../i18n/index.js';
5
+ const BROWSER_LAUNCH_SETTLE_MS = 1000;
6
+ function settleBrowserLauncher(child) {
7
+ return new Promise((resolve) => {
8
+ let settled = false;
9
+ const timer = setTimeout(() => {
10
+ finish(true);
11
+ }, BROWSER_LAUNCH_SETTLE_MS);
12
+ function finish(success) {
13
+ if (settled)
14
+ return;
15
+ settled = true;
16
+ child.off('close', onClose);
17
+ child.off('error', onError);
18
+ clearTimeout(timer);
19
+ resolve(success);
20
+ }
21
+ function onClose(code, signal) {
22
+ finish(code === 0 && signal === null);
23
+ }
24
+ function onError() {
25
+ finish(false);
26
+ }
27
+ child.once('close', onClose);
28
+ child.once('error', onError);
29
+ if (child.exitCode !== null || child.signalCode !== null) {
30
+ finish(child.exitCode === 0 && child.signalCode === null);
31
+ return;
32
+ }
33
+ });
34
+ }
35
+ export async function launchBrowserUrl(url) {
11
36
  try {
12
- await open(url);
13
- s.stop(trans.links.opened);
37
+ return await settleBrowserLauncher(await open(url));
14
38
  }
15
39
  catch {
16
- s.error(trans.links.error);
17
- console.log(chalk.dim(` ${url}`));
40
+ return false;
18
41
  }
19
42
  }
20
- export async function showLinksMenu() {
21
- const trans = t();
22
- await enterScreen(breadcrumb(trans.menu.links));
23
- const selected = await runMenu({
24
- title: trans.links.choose,
25
- options: [
26
- { value: URLS.homepage, label: trans.links.website },
27
- { value: URLS.github, label: trans.links.github },
28
- { value: URLS.roadmap, label: trans.links.roadmap },
29
- { value: URLS.repair, label: trans.links.repair },
30
- ],
31
- footer: menuFooter(),
32
- });
33
- if (selected === null)
34
- return;
35
- await openUrl(selected);
43
+ export async function openUrlInBrowser(url) {
44
+ const safeUrl = sanitizeTerminalLine(url);
45
+ if (await launchBrowserUrl(safeUrl))
46
+ return true;
47
+ const trans = t().links;
48
+ console.error(chalk.red(trans.error));
49
+ console.error(chalk.dim(fmt(trans.openManually, { url: safeUrl })));
50
+ return false;
36
51
  }
@@ -1,8 +1,9 @@
1
+ import { createTimetableSchedule } from '@nbtca/nbtcal/timetable';
1
2
  import { countdownParts, isCountdownUrgent } from './calendar-query.js';
2
- import { meetingsInWeek, campusWeekday } from './schedule-query.js';
3
3
  import { c, type, space, glyph } from '../core/theme.js';
4
4
  import { pickIcon } from '../core/icons.js';
5
5
  import { padEndV, truncate, visualWidth, wrapAnsiToVisualWidth } from '../core/text.js';
6
+ import { addLocalDays, parseLocalMonday } from '../core/calendar-day.js';
6
7
  import { t, fmt, getCurrentLanguage } from '../i18n/index.js';
7
8
  function span(m, periods) {
8
9
  const s = periods.find((p) => p.period === m.startPeriod)?.start ?? '';
@@ -14,9 +15,12 @@ export function renderNextClassBanner(next, now, cols = Number.POSITIVE_INFINITY
14
15
  if (!next)
15
16
  return '';
16
17
  const p = countdownParts(next.start, now);
17
- const when = p.past ? trans.timetable.nowLabel
18
- : p.days > 0 ? `${p.days}d ${p.hours}h`
19
- : p.hours > 0 ? `${p.hours}h ${p.minutes}m`
18
+ const when = p.past
19
+ ? trans.timetable.nowLabel
20
+ : p.days > 0
21
+ ? `${p.days}d ${p.hours}h`
22
+ : p.hours > 0
23
+ ? `${p.hours}h ${p.minutes}m`
20
24
  : `${p.minutes}m`;
21
25
  const styleWhen = isCountdownUrgent(p) ? c.warn : type.hint;
22
26
  const whenStyled = styleWhen(when);
@@ -82,8 +86,12 @@ export function renderTodayClasses(meetings, periods, now) {
82
86
  export function weekdayShortLabel(wd) {
83
87
  const trans = t();
84
88
  const labels = [
85
- trans.timetable.weekdayMon, trans.timetable.weekdayTue, trans.timetable.weekdayWed,
86
- trans.timetable.weekdayThu, trans.timetable.weekdayFri, trans.timetable.weekdaySat,
89
+ trans.timetable.weekdayMon,
90
+ trans.timetable.weekdayTue,
91
+ trans.timetable.weekdayWed,
92
+ trans.timetable.weekdayThu,
93
+ trans.timetable.weekdayFri,
94
+ trans.timetable.weekdaySat,
87
95
  trans.timetable.weekdaySun,
88
96
  ];
89
97
  return labels[wd - 1] ?? '';
@@ -108,7 +116,7 @@ function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cur
108
116
  const connector = i === 0 ? topConnector : midConnector;
109
117
  const marker = isLive ? type.active(pickIcon('▶', '>')) : ' ';
110
118
  const timeCol = `${marker}${type.hint(startStr)} ${rule}${connector}${rule}`;
111
- const styleName = (name) => (isLive ? type.active(name) : (isDone ? type.hint(name) : type.body(name)));
119
+ const styleName = (name) => isLive ? type.active(name) : isDone ? type.hint(name) : type.body(name);
112
120
  let statusText = '';
113
121
  let compactStatusText = '';
114
122
  if (isDone) {
@@ -118,14 +126,14 @@ function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cur
118
126
  else if (isLive) {
119
127
  const end = new Date(now);
120
128
  const [eh, em] = endStr.split(':').map((x) => Number.parseInt(x, 10));
121
- end.setHours(eh || 0, em || 0, 0, 0);
129
+ end.setHours(eh !== undefined && Number.isFinite(eh) ? eh : 0, em !== undefined && Number.isFinite(em) ? em : 0, 0, 0);
122
130
  const remaining = countdownParts(end, now);
123
131
  const mins = remaining.days * 1440 + remaining.hours * 60 + remaining.minutes;
124
132
  statusText = `${trans.timetable.classLive} ${dot} ${fmt(trans.timetable.minutesRemaining, { minutes: String(mins) })}`;
125
133
  compactStatusText = `${mins}m`;
126
134
  }
127
- const showLoc = alwaysShowLocation ? Boolean(m.location) : (isLive && Boolean(m.location));
128
- const locationText = showLoc ? m.location ?? '' : '';
135
+ const showLoc = alwaysShowLocation ? Boolean(m.location) : isLive && Boolean(m.location);
136
+ const locationText = showLoc ? (m.location ?? '') : '';
129
137
  const renderLine = (name, status, location, currentTimeCol = timeCol, indent = space.indent) => {
130
138
  const statusCol = status ? ` ${type.hint(status)}` : '';
131
139
  const locationCol = location ? ` ${type.hint(location)}` : '';
@@ -155,16 +163,14 @@ function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cur
155
163
  continue;
156
164
  return renderLine(truncate(m.courseName, courseWidth), '', '', compactTimeCol, indent);
157
165
  }
158
- const timeOnly = [
159
- `${space.indent}${compactTimeCol}`,
160
- compactTimeCol,
161
- type.hint(startStr),
162
- ].find((candidate) => visualWidth(candidate) <= cols);
166
+ const timeOnly = [`${space.indent}${compactTimeCol}`, compactTimeCol, type.hint(startStr)].find((candidate) => visualWidth(candidate) <= cols);
163
167
  if (timeOnly)
164
168
  return timeOnly;
165
169
  return type.hint(startStr.slice(0, Math.max(0, Math.floor(cols))));
166
170
  });
167
- const last = sorted[sorted.length - 1];
171
+ const last = sorted.at(-1);
172
+ if (!last)
173
+ return lines.join('\n');
168
174
  const lastEnd = periods.find((p) => p.period === last.endPeriod)?.end ?? '23:59';
169
175
  const fullEnd = `${space.indent} ${type.hint(lastEnd)} ${rule}${bottomConnector}${rule} ${type.hint(trans.timetable.timelineEnd)}`;
170
176
  if (!Number.isFinite(cols) || visualWidth(fullEnd) <= cols) {
@@ -198,7 +204,7 @@ export function renderDaySwitcher(selectedWeekday, todayWeekday, cols = Number.P
198
204
  return type.cursor(`[${label}]`);
199
205
  return type.hint(label);
200
206
  });
201
- const renderRange = (start, end) => (`${space.indent}${type.hint(leftArrow)} ${labels.slice(start, end).join(' ')} ${type.hint(rightArrow)}`);
207
+ const renderRange = (start, end) => `${space.indent}${type.hint(leftArrow)} ${labels.slice(start, end).join(' ')} ${type.hint(rightArrow)}`;
202
208
  const full = renderRange(0, labels.length);
203
209
  if (!Number.isFinite(cols) || visualWidth(full) <= cols)
204
210
  return full;
@@ -224,37 +230,22 @@ const WEEKDAY_KEYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
224
230
  const GAP_THRESHOLD_MINUTES = 30;
225
231
  function minutesOf(hhmm) {
226
232
  const [h, m] = hhmm.split(':').map((x) => Number.parseInt(x, 10));
227
- return (h || 0) * 60 + (m || 0);
233
+ const hours = h !== undefined && Number.isFinite(h) ? h : 0;
234
+ const minutes = m !== undefined && Number.isFinite(m) ? m : 0;
235
+ return hours * 60 + minutes;
228
236
  }
229
- /** Centers raw (unstyled) text within a fixed width -- extra space splits
230
- * left/right (left gets the smaller half on an odd remainder). Used for
231
- * every weekday-header label and grid cell: most cells are short glyphs
232
- * ("." for no class, "|" for a continuation) sitting in a column sized for
233
- * that column's own longest real content, and left-anchoring them reads as
234
- * ragged leftover text rather than a clean grid -- centering them (and the
235
- * header labels above them) reads as an aligned table instead. Applied to
236
- * the raw content before any chalk styling wraps it, so this works
237
- * uniformly whether the eventual style adds a background (the cursor
238
- * token) or only a foreground color -- there's no special case to keep in
239
- * sync. */
240
237
  function centerInWidth(text, width) {
241
238
  const pad = Math.max(0, width - visualWidth(text));
242
239
  const left = Math.floor(pad / 2);
243
240
  const right = pad - left;
244
241
  return ' '.repeat(left) + text + ' '.repeat(right);
245
242
  }
246
- // A sensible floor for a column that's mostly empty cells and a short
247
- // weekday label -- prevents a completely classless day from collapsing to
248
- // an unreadably thin sliver.
249
243
  const MIN_COL_WIDTH = 8;
250
- export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cursor) {
251
- const week = meetingsInWeek(meetings, weekNumber);
252
- const todayWd = campusWeekday(now);
253
- // Row labels are the period's real clock start-end range ("08:00-08:45"),
254
- // always exactly 11 display columns — a bare start time answers "when do
255
- // I need to be there" but leaves "when am I done" (and the class's real
256
- // duration) to guesswork; the full range answers both. 12, not 11: one
257
- // column of separating space before the first cell.
244
+ export function renderWeekGrid(timetable, weekNumber, now, cols = 80, cursor) {
245
+ const schedule = createTimetableSchedule(timetable);
246
+ const week = schedule.meetingsInWeek(weekNumber);
247
+ const todayWd = schedule.weekdayAt(now);
248
+ const periods = timetable.periods;
258
249
  const rowHeadW = 12;
259
250
  const todayMark = pickIcon('•', '*');
260
251
  const connector = pickIcon('│', '|');
@@ -262,11 +253,6 @@ export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cu
262
253
  const sepGlyph = pickIcon('│', '|');
263
254
  const sep = type.hint(` ${sepGlyph} `);
264
255
  const sepW = 3; // " │ " / " | " -- always 3 display columns regardless of icon mode
265
- // Each weekday column is sized to *that day's own* content only, never a
266
- // different day's longer course name -- a single long Tuesday class no
267
- // longer forces Monday through Sunday to share its width. Course name and
268
- // location are on separate lines (see the row loop below), so neither has
269
- // to compete with the other for room within one column either.
270
256
  const idealColWidths = WEEKDAY_KEYS.map((_, i) => {
271
257
  const wd = i + 1;
272
258
  const dayMeetings = week.filter((m) => m.weekday === wd);
@@ -275,30 +261,13 @@ export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cu
275
261
  const headerW = visualWidth(weekdayShortLabel(wd)) + (wd === todayWd ? visualWidth(todayMark) : 0);
276
262
  return Math.max(nameW, locW, headerW, MIN_COL_WIDTH);
277
263
  });
278
- // If every column's own ideal width already fits the terminal, use it
279
- // outright -- an empty (floor-width) day must never eat into a genuinely
280
- // busy day's share just because both are capped by the same flat "1/7th
281
- // of the remaining space" division. Only when the ideal *total* doesn't
282
- // fit does every column shrink, proportionally to its own ideal width, so
283
- // the row's total width never exceeds `cols` -- unlike a flat floor that
284
- // stays fixed regardless of how little room is actually left.
285
264
  const fixedOverhead = space.indent.length + rowHeadW + 6 * sepW;
286
265
  const availableForCols = Math.max(0, cols - fixedOverhead);
287
266
  const totalIdealColW = idealColWidths.reduce((a, b) => a + b, 0);
288
267
  const colWidths = totalIdealColW <= availableForCols
289
268
  ? idealColWidths
290
- // Floored at 3, not 1 -- truncate() itself can never shrink text below
291
- // its own 3-column ellipsis ("..."), so a column narrower than that
292
- // would make even the shortest weekday header ("Mon") overflow its own
293
- // column when truncated. 3 is also exactly a bare weekday abbreviation's
294
- // width, so at this floor a header never actually needs truncating.
295
269
  : idealColWidths.map((w) => Math.max(3, Math.floor(w * (availableForCols / totalIdealColW))));
296
270
  const totalW = rowHeadW + colWidths.reduce((a, b) => a + b, 0) + 6 * sepW;
297
- // Consecutive periods of the same meeting collapse into one labeled cell
298
- // at its starting period — later periods in its span show a plain
299
- // connector instead of repeating the same course/location text down the
300
- // whole column. A genuine conflict (two meetings both starting at the
301
- // same weekday+period) shows whichever one is found first.
302
271
  const startingAt = (wd, period) => week.find((m) => m.weekday === wd && m.startPeriod === period);
303
272
  const continuingAt = (wd, period) => week.find((m) => m.weekday === wd && m.startPeriod < period && period <= m.endPeriod);
304
273
  const lines = [];
@@ -307,12 +276,8 @@ export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cu
307
276
  const wd = i + 1;
308
277
  const d = weekdayShortLabel(wd);
309
278
  const label = wd === todayWd ? `${d}${todayMark}` : d;
310
- // Column width always starts out >= the label's own width (headerW is
311
- // one of the terms idealColWidths maxes over), but proportional
312
- // shrinking on a too-narrow terminal can push a column's *scaled*
313
- // width below that -- truncate defensively so the header can never
314
- // render wider than the column it's supposed to sit in.
315
- const padded = centerInWidth(truncate(label, colWidths[i]), colWidths[i]);
279
+ const colWidth = colWidths[i] ?? 3;
280
+ const padded = centerInWidth(truncate(label, colWidth), colWidth);
316
281
  return wd === todayWd ? type.active(padded) : type.hint(padded);
317
282
  }).join(sep);
318
283
  lines.push(space.indent + blankHead + headerCells);
@@ -323,19 +288,15 @@ export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cu
323
288
  const locCells = [];
324
289
  for (let wdIdx = 0; wdIdx < 7; wdIdx++) {
325
290
  const wd = wdIdx + 1;
326
- const colW = colWidths[wdIdx];
291
+ const colW = colWidths[wdIdx] ?? 3;
327
292
  const isToday = wd === todayWd;
328
- const isCursor = cursor !== undefined && cursor.weekday === wd && cursor.period === p.period;
293
+ const isCursor = cursor?.weekday === wd && cursor.period === p.period;
329
294
  const starting = startingAt(wd, p.period);
330
295
  const isContinuation = !starting && continuingAt(wd, p.period);
331
- const rawName = starting ? starting.courseName : (isContinuation ? connector : emptyGlyph);
332
- const rawLoc = starting ? (starting.location ?? '') : (isContinuation ? connector : '');
296
+ const rawName = starting ? starting.courseName : isContinuation ? connector : emptyGlyph;
297
+ const rawLoc = starting ? (starting.location ?? '') : isContinuation ? connector : '';
333
298
  const paddedName = centerInWidth(truncate(rawName, colW), colW);
334
299
  const paddedLoc = centerInWidth(truncate(rawLoc, colW), colW);
335
- // Cursor styling covers both lines of the cell -- it's one selected
336
- // unit, not just its name half. Otherwise the course name (primary
337
- // info) gets full styling on today/cursor; the location (supporting
338
- // info) always stays dim, even on today's own column.
339
300
  if (isCursor) {
340
301
  nameCells.push(type.cursor(paddedName));
341
302
  locCells.push(type.cursor(paddedLoc));
@@ -362,12 +323,15 @@ function formatWeekRange(weeks) {
362
323
  if (weeks.length === 0)
363
324
  return '';
364
325
  const sorted = [...weeks].sort((a, b) => a - b);
365
- const isContiguous = sorted.every((w, i) => i === 0 || w === sorted[i - 1] + 1);
326
+ const isContiguous = sorted.every((week, index) => {
327
+ const previous = sorted[index - 1];
328
+ return index === 0 || (previous !== undefined && week === previous + 1);
329
+ });
366
330
  if (isContiguous) {
367
- return sorted.length > 1 ? `${sorted[0]}-${sorted[sorted.length - 1]}` : `${sorted[0]}`;
331
+ const first = sorted[0];
332
+ const last = sorted.at(-1);
333
+ return sorted.length > 1 ? `${first}-${last}` : `${first}`;
368
334
  }
369
- // A genuinely non-contiguous week pattern is rare but must not crash or
370
- // silently drop data -- fall back to listing every week.
371
335
  return sorted.join(', ');
372
336
  }
373
337
  export function renderMeetingDetail(meeting, periods, cols = Number.POSITIVE_INFINITY) {
@@ -378,15 +342,17 @@ export function renderMeetingDetail(meeting, periods, cols = Number.POSITIVE_INF
378
342
  if (meeting.location)
379
343
  rows.push([trans.timetable.detailLocation, meeting.location]);
380
344
  if (meeting.teacherNames.length > 0) {
381
- rows.push([trans.timetable.detailTeacher, meeting.teacherNames.join(trans.timetable.teacherSeparator)]);
345
+ rows.push([
346
+ trans.timetable.detailTeacher,
347
+ meeting.teacherNames.join(trans.timetable.teacherSeparator),
348
+ ]);
382
349
  }
383
350
  rows.push([trans.timetable.detailWeeks, formatWeekRange(meeting.weeks)]);
384
351
  const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
385
352
  const indent = visualWidth(space.indent) < width ? space.indent : '';
386
353
  const contentWidth = Math.max(1, width - visualWidth(indent));
387
354
  const labelWidth = rows.reduce((w, [label]) => Math.max(w, visualWidth(label)), 0);
388
- const lines = wrapAnsiToVisualWidth(type.heading(meeting.courseName), contentWidth)
389
- .map((part) => `${indent}${part}`);
355
+ const lines = wrapAnsiToVisualWidth(type.heading(meeting.courseName), contentWidth).map((part) => `${indent}${part}`);
390
356
  lines.push('');
391
357
  for (const [label, value] of rows) {
392
358
  const inlinePrefix = `${indent}${type.label(padEndV(label, labelWidth))} `;
@@ -430,23 +396,21 @@ export function renderUnresolvedItems(items, cols = Number.POSITIVE_INFINITY) {
430
396
  const detailPrefix = visualWidth(indent) + 2 < width ? `${indent}${type.hint(`${dot} `)}` : indent;
431
397
  const detailWidth = Math.max(1, width - visualWidth(detailPrefix));
432
398
  const continuation = ' '.repeat(visualWidth(detailPrefix));
433
- lines.push(...wrapAnsiToVisualWidth(type.hint(detail), detailWidth)
434
- .map((part, index) => `${index === 0 ? detailPrefix : continuation}${part}`));
399
+ lines.push(...wrapAnsiToVisualWidth(type.hint(detail), detailWidth).map((part, index) => `${index === 0 ? detailPrefix : continuation}${part}`));
435
400
  }
436
401
  return lines.join('\n');
437
402
  }
438
403
  const DENSITY_GLYPHS = [
439
- ['·', ' '], ['░', '.'], ['▒', ':'], ['▓', '-'], ['█', '='],
404
+ ['·', ' '],
405
+ ['░', '.'],
406
+ ['▒', ':'],
407
+ ['▓', '-'],
408
+ ['█', '='],
440
409
  ];
441
410
  function levelGlyph(level) {
442
- const pair = DENSITY_GLYPHS[Math.max(0, Math.min(4, level))] ?? DENSITY_GLYPHS[0];
411
+ const pair = DENSITY_GLYPHS[Math.max(0, Math.min(4, level))] ?? ['·', ' '];
443
412
  return pickIcon(pair[0], pair[1]);
444
413
  }
445
- /** Level 0 reads as an ordinary "no data" cell (matches renderWeekGrid's own
446
- * empty-cell treatment above); levels 1-3 use plain brand color; level 4
447
- * reuses type.active's exact bold+brand composition rather than inventing a
448
- * new top-tier shade — deliberately NOT the heatmap's green ramp, which
449
- * specifically means "club activity," not personal class load. */
450
414
  function applyDensityColor(glyphChar, level) {
451
415
  if (level <= 0)
452
416
  return type.hint(glyphChar);
@@ -455,8 +419,7 @@ function applyDensityColor(glyphChar, level) {
455
419
  return c.brand(glyphChar);
456
420
  }
457
421
  function weekStartDate(weekOneMonday, week) {
458
- const base = new Date(`${weekOneMonday}T00:00:00`);
459
- return new Date(base.getTime() + (week - 1) * 7 * 86400000);
422
+ return addLocalDays(parseLocalMonday(weekOneMonday), (week - 1) * 7);
460
423
  }
461
424
  function densityMonthText(weekOneMonday, startWeek, count, lang, maxWidth = Number.POSITIVE_INFINITY) {
462
425
  let text = '';
@@ -468,9 +431,9 @@ function densityMonthText(weekOneMonday, startWeek, count, lang, maxWidth = Numb
468
431
  if (month === previousMonth)
469
432
  continue;
470
433
  previousMonth = month;
471
- const label = lang === 'zh'
472
- ? `${month + 1}月`
473
- : new Intl.DateTimeFormat('en-US', { month: 'short' }).format(date);
434
+ const label = new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', {
435
+ month: 'short',
436
+ }).format(date);
474
437
  const targetCol = index * 2;
475
438
  if (targetCol + visualWidth(label) > maxWidth)
476
439
  continue;
@@ -551,8 +514,7 @@ export function renderTermDensity(meetings, weekOneMonday, currentWeek, cols = N
551
514
  const indent = visualWidth(space.indent) < width ? space.indent : '';
552
515
  const contentWidth = Math.max(1, width - visualWidth(indent));
553
516
  const weeksPerChunk = Math.max(1, Math.floor((contentWidth + 1) / 2));
554
- const lines = wrapAnsiToVisualWidth(type.heading(trans.timetable.termDensityTitle), contentWidth)
555
- .map((part) => `${indent}${part}`);
517
+ const lines = wrapAnsiToVisualWidth(type.heading(trans.timetable.termDensityTitle), contentWidth).map((part) => `${indent}${part}`);
556
518
  lines.push('');
557
519
  for (let start = 0; start < numWeeks; start += weeksPerChunk) {
558
520
  if (start > 0)
@@ -560,8 +522,10 @@ export function renderTermDensity(meetings, weekOneMonday, currentWeek, cols = N
560
522
  const count = Math.min(weeksPerChunk, numWeeks - start);
561
523
  const chunkMonthText = densityMonthText(weekOneMonday, minWeek + start, count, lang, contentWidth);
562
524
  lines.push(`${indent}${chunkMonthText}`);
563
- lines.push(`${indent}${levels.slice(start, start + count)
564
- .map((level) => applyDensityColor(levelGlyph(level), level)).join(' ')}`);
525
+ lines.push(`${indent}${levels
526
+ .slice(start, start + count)
527
+ .map((level) => applyDensityColor(levelGlyph(level), level))
528
+ .join(' ')}`);
565
529
  if (currentWeekIndex >= start && currentWeekIndex < start + count) {
566
530
  const relativeIndex = currentWeekIndex - start;
567
531
  lines.push(`${indent}${type.hint(densityMarkerText(relativeIndex, contentWidth, markerGlyph, trans.timetable.termDensityThisWeek))}`);
@@ -1,7 +1,27 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { getWritableConfigDir, getConfigDir, getWritableStateDir, getStateDir } from '../config/paths.js';
3
+ import { getWritableConfigDir, getConfigDir, getWritableStateDir, getStateDir, } from '../config/paths.js';
4
+ import { parseLocalMonday } from '../core/calendar-day.js';
5
+ const TERM_PART_RE = /^[A-Za-z0-9_-]{1,32}$/;
6
+ const TERM_KEY_RE = /^[A-Za-z0-9_-]{1,65}$/;
7
+ function requireTermKey(value) {
8
+ if (!TERM_KEY_RE.test(value))
9
+ throw new TypeError('Invalid academic term key.');
10
+ return value;
11
+ }
12
+ function isLocalMonday(value) {
13
+ try {
14
+ parseLocalMonday(value);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
4
21
  export function termKey(term) {
22
+ if (!TERM_PART_RE.test(term.academicYear) || !TERM_PART_RE.test(term.semester)) {
23
+ throw new TypeError('Invalid academic term code.');
24
+ }
5
25
  return `${term.academicYear}-${term.semester}`;
6
26
  }
7
27
  function readJson(file) {
@@ -17,42 +37,60 @@ function writeJson(file, value) {
17
37
  try {
18
38
  fs.chmodSync(file, 0o600);
19
39
  }
20
- catch { /* best effort */ }
40
+ catch {
41
+ /* best effort */
42
+ }
21
43
  }
22
44
  function weekOnePath(dir) {
23
45
  return path.join(dir ?? getWritableConfigDir(), 'week-one.json');
24
46
  }
25
47
  export function saveWeekOne(termKey, iso, dir) {
48
+ requireTermKey(termKey);
49
+ parseLocalMonday(iso);
26
50
  const file = weekOnePath(dir);
27
51
  const store = readJson(file) ?? {};
28
52
  store[termKey] = iso;
29
53
  writeJson(file, store);
30
54
  }
31
55
  export function loadWeekOne(termKey, dir) {
56
+ if (!TERM_KEY_RE.test(termKey))
57
+ return null;
32
58
  const file = path.join(dir ?? getConfigDir(), 'week-one.json');
33
59
  const store = readJson(file);
34
- return store?.[termKey] ?? null;
60
+ const value = store?.[termKey];
61
+ return typeof value === 'string' && isLocalMonday(value) ? value : null;
35
62
  }
36
63
  function cachePath(termKey, dir) {
37
- return path.join(dir ?? getWritableStateDir(), `timetable-${termKey}.json`);
64
+ const stateDir = path.resolve(dir ?? getWritableStateDir());
65
+ const file = path.resolve(stateDir, `timetable-${requireTermKey(termKey)}.json`);
66
+ if (path.dirname(file) !== stateDir)
67
+ throw new TypeError('Invalid timetable cache path.');
68
+ return file;
38
69
  }
39
70
  export function saveTimetableCache(termKey, data, dir) {
40
71
  writeJson(cachePath(termKey, dir), data);
41
72
  }
42
73
  export function loadTimetableCache(termKey, dir) {
43
- const file = path.join(dir ?? getStateDir(), `timetable-${termKey}.json`);
44
- return readJson(file);
74
+ if (!TERM_KEY_RE.test(termKey))
75
+ return null;
76
+ return readJson(cachePath(termKey, dir ?? getStateDir()));
45
77
  }
46
78
  function currentPointerPath(dir) {
47
79
  return path.join(dir ?? getWritableStateDir(), 'current-term.json');
48
80
  }
49
81
  export function saveCurrentPointer(termKey, weekOneMonday, dir) {
82
+ requireTermKey(termKey);
83
+ parseLocalMonday(weekOneMonday);
50
84
  writeJson(currentPointerPath(dir), { termKey, weekOneMonday });
51
85
  }
52
86
  export function loadCurrentPointer(dir) {
53
87
  const file = path.join(dir ?? getStateDir(), 'current-term.json');
54
88
  const value = readJson(file);
55
- if (!value || typeof value.termKey !== 'string' || typeof value.weekOneMonday !== 'string')
89
+ if (!value ||
90
+ typeof value.termKey !== 'string' ||
91
+ !TERM_KEY_RE.test(value.termKey) ||
92
+ typeof value.weekOneMonday !== 'string' ||
93
+ !isLocalMonday(value.weekOneMonday))
56
94
  return null;
57
95
  return { termKey: value.termKey, weekOneMonday: value.weekOneMonday };
58
96
  }
@@ -65,9 +103,13 @@ export function clearScheduleCache(dir) {
65
103
  try {
66
104
  fs.unlinkSync(path.join(stateDir, f));
67
105
  }
68
- catch { /* best effort */ }
106
+ catch {
107
+ /* best effort */
108
+ }
69
109
  }
70
110
  }
71
111
  }
72
- catch { /* best effort: dir may not exist */ }
112
+ catch {
113
+ /* best effort: dir may not exist */
114
+ }
73
115
  }