@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.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +167 -64
- 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 +16 -23
- package/dist/app/keys.js +110 -2
- package/dist/app/views/docs-render.js +34 -26
- package/dist/app/views/docs.js +280 -68
- package/dist/app/views/events-render.js +21 -27
- package/dist/app/views/events.js +57 -33
- package/dist/app/views/home.js +67 -47
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +51 -74
- package/dist/app/views/schedule.js +246 -101
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- 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/cli.js +570 -0
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +21 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/canvas.js +1 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +64 -38
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +34 -7
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +186 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +156 -19
- 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-store.js +27 -0
- package/dist/features/calendar.js +66 -190
- package/dist/features/docs-client.js +225 -0
- package/dist/features/docs.js +615 -298
- package/dist/features/links.js +44 -29
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +117 -60
- package/dist/features/student-timetable.js +74 -97
- package/dist/features/theme.js +9 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +12 -29
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +8 -3
- package/dist/i18n/locales/zh.json +8 -3
- package/dist/index.js +6 -474
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +28 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- package/dist/logo/ca-logo.png +0 -0
package/dist/features/links.js
CHANGED
|
@@ -1,36 +1,51 @@
|
|
|
1
|
-
import open from 'open';
|
|
2
1
|
import chalk from 'chalk';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
17
|
-
console.log(chalk.dim(` ${url}`));
|
|
40
|
+
return false;
|
|
18
41
|
}
|
|
19
42
|
}
|
|
20
|
-
export async function
|
|
21
|
-
const
|
|
22
|
-
await
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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,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
|
-
|
|
311
|
-
|
|
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
|
|
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 :
|
|
332
|
-
const rawLoc = starting ? (starting.location ?? '') :
|
|
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((
|
|
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
|
-
|
|
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([
|
|
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))] ??
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
|
564
|
-
.
|
|
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 {
|
|
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
|
}
|