@nbtca/prompt 1.4.1 → 1.5.0

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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +27 -58
  3. package/SECURITY.md +16 -45
  4. package/dist/app/app.js +53 -55
  5. package/dist/app/chrome.js +67 -50
  6. package/dist/app/fields/list-field.js +12 -25
  7. package/dist/app/fields/text-field.js +3 -8
  8. package/dist/app/frame.js +2 -21
  9. package/dist/app/keys.js +10 -2
  10. package/dist/app/views/docs-render.js +31 -24
  11. package/dist/app/views/docs.js +211 -67
  12. package/dist/app/views/events-render.js +19 -26
  13. package/dist/app/views/events.js +44 -31
  14. package/dist/app/views/home.js +33 -76
  15. package/dist/app/views/schedule-grid-cursor.js +9 -18
  16. package/dist/app/views/schedule-render.js +47 -71
  17. package/dist/app/views/schedule.js +158 -90
  18. package/dist/app/views/settings-render.js +8 -19
  19. package/dist/app/views/settings.js +93 -18
  20. package/dist/auth/cookie-transport.js +31 -32
  21. package/dist/auth/errors.js +3 -1
  22. package/dist/auth/nbt-auth.js +42 -25
  23. package/dist/auth/session-store.js +17 -9
  24. package/dist/config/data.js +10 -13
  25. package/dist/config/preferences.js +14 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/capabilities.js +6 -3
  28. package/dist/core/components/confirm.js +9 -8
  29. package/dist/core/components/menu.js +41 -16
  30. package/dist/core/components/messages.js +12 -4
  31. package/dist/core/components/painter.js +3 -1
  32. package/dist/core/components/spinner.js +17 -6
  33. package/dist/core/components/text-input.js +24 -18
  34. package/dist/core/icons.js +2 -2
  35. package/dist/core/logo.js +25 -21
  36. package/dist/core/motion.js +25 -19
  37. package/dist/core/text.js +182 -75
  38. package/dist/core/theme.js +0 -28
  39. package/dist/core/transitions.js +2 -2
  40. package/dist/core/ui.js +15 -30
  41. package/dist/core/vim-keys.js +9 -15
  42. package/dist/features/about.js +23 -0
  43. package/dist/features/calendar-heatmap.js +16 -40
  44. package/dist/features/calendar-query.js +1 -2
  45. package/dist/features/calendar.js +12 -185
  46. package/dist/features/docs.js +439 -320
  47. package/dist/features/schedule-render.js +65 -102
  48. package/dist/features/schedule-store.js +51 -9
  49. package/dist/features/schedule-view.js +46 -220
  50. package/dist/features/status.js +44 -59
  51. package/dist/features/student-timetable.js +73 -95
  52. package/dist/features/theme.js +6 -5
  53. package/dist/features/timetable-sanitize.js +40 -0
  54. package/dist/features/update.js +9 -37
  55. package/dist/i18n/index.js +87 -65
  56. package/dist/i18n/locales/en.json +1 -1
  57. package/dist/i18n/locales/zh.json +1 -1
  58. package/dist/index.js +85 -64
  59. package/dist/logo/ca-dotmatrix.txt +16 -18
  60. package/dist/main.js +7 -48
  61. package/package.json +30 -18
  62. package/bin/nbtca-welcome.js +0 -2
  63. package/dist/core/components/screen.js +0 -18
  64. package/dist/core/menu.js +0 -71
  65. package/dist/features/links.js +0 -39
  66. package/dist/features/schedule-query.js +0 -47
  67. package/dist/features/settings.js +0 -130
  68. package/dist/logo/ca-logo.png +0 -0
@@ -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,31 +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) is rare and, like the pre-existing lookup, just
302
- // shows whichever one is found first.
303
271
  const startingAt = (wd, period) => week.find((m) => m.weekday === wd && m.startPeriod === period);
304
272
  const continuingAt = (wd, period) => week.find((m) => m.weekday === wd && m.startPeriod < period && period <= m.endPeriod);
305
273
  const lines = [];
@@ -308,12 +276,8 @@ export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cu
308
276
  const wd = i + 1;
309
277
  const d = weekdayShortLabel(wd);
310
278
  const label = wd === todayWd ? `${d}${todayMark}` : d;
311
- // Column width always starts out >= the label's own width (headerW is
312
- // one of the terms idealColWidths maxes over), but proportional
313
- // shrinking on a too-narrow terminal can push a column's *scaled*
314
- // width below that -- truncate defensively so the header can never
315
- // render wider than the column it's supposed to sit in.
316
- const padded = centerInWidth(truncate(label, colWidths[i]), colWidths[i]);
279
+ const colWidth = colWidths[i] ?? 3;
280
+ const padded = centerInWidth(truncate(label, colWidth), colWidth);
317
281
  return wd === todayWd ? type.active(padded) : type.hint(padded);
318
282
  }).join(sep);
319
283
  lines.push(space.indent + blankHead + headerCells);
@@ -324,19 +288,15 @@ export function renderWeekGrid(meetings, periods, weekNumber, now, cols = 80, cu
324
288
  const locCells = [];
325
289
  for (let wdIdx = 0; wdIdx < 7; wdIdx++) {
326
290
  const wd = wdIdx + 1;
327
- const colW = colWidths[wdIdx];
291
+ const colW = colWidths[wdIdx] ?? 3;
328
292
  const isToday = wd === todayWd;
329
- const isCursor = cursor !== undefined && cursor.weekday === wd && cursor.period === p.period;
293
+ const isCursor = cursor?.weekday === wd && cursor.period === p.period;
330
294
  const starting = startingAt(wd, p.period);
331
295
  const isContinuation = !starting && continuingAt(wd, p.period);
332
- const rawName = starting ? starting.courseName : (isContinuation ? connector : emptyGlyph);
333
- const rawLoc = starting ? (starting.location ?? '') : (isContinuation ? connector : '');
296
+ const rawName = starting ? starting.courseName : isContinuation ? connector : emptyGlyph;
297
+ const rawLoc = starting ? (starting.location ?? '') : isContinuation ? connector : '';
334
298
  const paddedName = centerInWidth(truncate(rawName, colW), colW);
335
299
  const paddedLoc = centerInWidth(truncate(rawLoc, colW), colW);
336
- // Cursor styling covers both lines of the cell -- it's one selected
337
- // unit, not just its name half. Otherwise the course name (primary
338
- // info) gets full styling on today/cursor; the location (supporting
339
- // info) always stays dim, even on today's own column.
340
300
  if (isCursor) {
341
301
  nameCells.push(type.cursor(paddedName));
342
302
  locCells.push(type.cursor(paddedLoc));
@@ -363,12 +323,15 @@ function formatWeekRange(weeks) {
363
323
  if (weeks.length === 0)
364
324
  return '';
365
325
  const sorted = [...weeks].sort((a, b) => a - b);
366
- 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
+ });
367
330
  if (isContiguous) {
368
- 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}`;
369
334
  }
370
- // A genuinely non-contiguous week pattern is rare but must not crash or
371
- // silently drop data -- fall back to listing every week.
372
335
  return sorted.join(', ');
373
336
  }
374
337
  export function renderMeetingDetail(meeting, periods, cols = Number.POSITIVE_INFINITY) {
@@ -379,15 +342,17 @@ export function renderMeetingDetail(meeting, periods, cols = Number.POSITIVE_INF
379
342
  if (meeting.location)
380
343
  rows.push([trans.timetable.detailLocation, meeting.location]);
381
344
  if (meeting.teacherNames.length > 0) {
382
- 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
+ ]);
383
349
  }
384
350
  rows.push([trans.timetable.detailWeeks, formatWeekRange(meeting.weeks)]);
385
351
  const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
386
352
  const indent = visualWidth(space.indent) < width ? space.indent : '';
387
353
  const contentWidth = Math.max(1, width - visualWidth(indent));
388
354
  const labelWidth = rows.reduce((w, [label]) => Math.max(w, visualWidth(label)), 0);
389
- const lines = wrapAnsiToVisualWidth(type.heading(meeting.courseName), contentWidth)
390
- .map((part) => `${indent}${part}`);
355
+ const lines = wrapAnsiToVisualWidth(type.heading(meeting.courseName), contentWidth).map((part) => `${indent}${part}`);
391
356
  lines.push('');
392
357
  for (const [label, value] of rows) {
393
358
  const inlinePrefix = `${indent}${type.label(padEndV(label, labelWidth))} `;
@@ -431,23 +396,21 @@ export function renderUnresolvedItems(items, cols = Number.POSITIVE_INFINITY) {
431
396
  const detailPrefix = visualWidth(indent) + 2 < width ? `${indent}${type.hint(`${dot} `)}` : indent;
432
397
  const detailWidth = Math.max(1, width - visualWidth(detailPrefix));
433
398
  const continuation = ' '.repeat(visualWidth(detailPrefix));
434
- lines.push(...wrapAnsiToVisualWidth(type.hint(detail), detailWidth)
435
- .map((part, index) => `${index === 0 ? detailPrefix : continuation}${part}`));
399
+ lines.push(...wrapAnsiToVisualWidth(type.hint(detail), detailWidth).map((part, index) => `${index === 0 ? detailPrefix : continuation}${part}`));
436
400
  }
437
401
  return lines.join('\n');
438
402
  }
439
403
  const DENSITY_GLYPHS = [
440
- ['·', ' '], ['░', '.'], ['▒', ':'], ['▓', '-'], ['█', '='],
404
+ ['·', ' '],
405
+ ['░', '.'],
406
+ ['▒', ':'],
407
+ ['▓', '-'],
408
+ ['█', '='],
441
409
  ];
442
410
  function levelGlyph(level) {
443
- 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))] ?? ['·', ' '];
444
412
  return pickIcon(pair[0], pair[1]);
445
413
  }
446
- /** Level 0 reads as an ordinary "no data" cell (matches renderWeekGrid's own
447
- * empty-cell treatment above); levels 1-3 use plain brand color; level 4
448
- * reuses type.active's exact bold+brand composition rather than inventing a
449
- * new top-tier shade — deliberately NOT the heatmap's green ramp, which
450
- * specifically means "club activity," not personal class load. */
451
414
  function applyDensityColor(glyphChar, level) {
452
415
  if (level <= 0)
453
416
  return type.hint(glyphChar);
@@ -456,8 +419,7 @@ function applyDensityColor(glyphChar, level) {
456
419
  return c.brand(glyphChar);
457
420
  }
458
421
  function weekStartDate(weekOneMonday, week) {
459
- const base = new Date(`${weekOneMonday}T00:00:00`);
460
- return new Date(base.getTime() + (week - 1) * 7 * 86400000);
422
+ return addLocalDays(parseLocalMonday(weekOneMonday), (week - 1) * 7);
461
423
  }
462
424
  function densityMonthText(weekOneMonday, startWeek, count, lang, maxWidth = Number.POSITIVE_INFINITY) {
463
425
  let text = '';
@@ -469,9 +431,9 @@ function densityMonthText(weekOneMonday, startWeek, count, lang, maxWidth = Numb
469
431
  if (month === previousMonth)
470
432
  continue;
471
433
  previousMonth = month;
472
- const label = lang === 'zh'
473
- ? `${month + 1}月`
474
- : 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);
475
437
  const targetCol = index * 2;
476
438
  if (targetCol + visualWidth(label) > maxWidth)
477
439
  continue;
@@ -552,8 +514,7 @@ export function renderTermDensity(meetings, weekOneMonday, currentWeek, cols = N
552
514
  const indent = visualWidth(space.indent) < width ? space.indent : '';
553
515
  const contentWidth = Math.max(1, width - visualWidth(indent));
554
516
  const weeksPerChunk = Math.max(1, Math.floor((contentWidth + 1) / 2));
555
- const lines = wrapAnsiToVisualWidth(type.heading(trans.timetable.termDensityTitle), contentWidth)
556
- .map((part) => `${indent}${part}`);
517
+ const lines = wrapAnsiToVisualWidth(type.heading(trans.timetable.termDensityTitle), contentWidth).map((part) => `${indent}${part}`);
557
518
  lines.push('');
558
519
  for (let start = 0; start < numWeeks; start += weeksPerChunk) {
559
520
  if (start > 0)
@@ -561,8 +522,10 @@ export function renderTermDensity(meetings, weekOneMonday, currentWeek, cols = N
561
522
  const count = Math.min(weeksPerChunk, numWeeks - start);
562
523
  const chunkMonthText = densityMonthText(weekOneMonday, minWeek + start, count, lang, contentWidth);
563
524
  lines.push(`${indent}${chunkMonthText}`);
564
- lines.push(`${indent}${levels.slice(start, start + count)
565
- .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(' ')}`);
566
529
  if (currentWeekIndex >= start && currentWeekIndex < start + count) {
567
530
  const relativeIndex = currentWeekIndex - start;
568
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
  }