@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.
- package/LICENSE +1 -1
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +53 -55
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +2 -21
- package/dist/app/keys.js +10 -2
- package/dist/app/views/docs-render.js +31 -24
- package/dist/app/views/docs.js +211 -67
- package/dist/app/views/events-render.js +19 -26
- package/dist/app/views/events.js +44 -31
- package/dist/app/views/home.js +33 -76
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +47 -71
- package/dist/app/views/schedule.js +158 -90
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +93 -18
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/config/data.js +10 -13
- package/dist/config/preferences.js +14 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +41 -16
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +17 -6
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +25 -21
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +182 -75
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -30
- package/dist/core/vim-keys.js +9 -15
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar.js +12 -185
- package/dist/features/docs.js +439 -320
- package/dist/features/schedule-render.js +65 -102
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -220
- package/dist/features/status.js +44 -59
- package/dist/features/student-timetable.js +73 -95
- package/dist/features/theme.js +6 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +9 -37
- package/dist/i18n/index.js +87 -65
- package/dist/i18n/locales/en.json +1 -1
- package/dist/i18n/locales/zh.json +1 -1
- package/dist/index.js +85 -64
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +30 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -71
- package/dist/features/links.js +0 -39
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -130
- 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
|
|
18
|
-
|
|
19
|
-
|
|
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,
|
|
86
|
-
trans.timetable.
|
|
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) =>
|
|
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
|
|
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) :
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
|
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 :
|
|
333
|
-
const rawLoc = starting ? (starting.location ?? '') :
|
|
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((
|
|
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
|
-
|
|
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([
|
|
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))] ??
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
|
565
|
-
.
|
|
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 {
|
|
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
|
-
|
|
60
|
+
const value = store?.[termKey];
|
|
61
|
+
return typeof value === 'string' && isLocalMonday(value) ? value : null;
|
|
35
62
|
}
|
|
36
63
|
function cachePath(termKey, dir) {
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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 ||
|
|
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 {
|
|
106
|
+
catch {
|
|
107
|
+
/* best effort */
|
|
108
|
+
}
|
|
69
109
|
}
|
|
70
110
|
}
|
|
71
111
|
}
|
|
72
|
-
catch {
|
|
112
|
+
catch {
|
|
113
|
+
/* best effort: dir may not exist */
|
|
114
|
+
}
|
|
73
115
|
}
|