@nbtca/prompt 1.3.2 → 1.4.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 +44 -0
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +457 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +236 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +472 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +40 -15
- package/dist/core/menu.js +24 -6
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +127 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +5 -29
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +258 -55
- package/dist/features/links.js +7 -4
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +574 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +260 -0
- package/dist/features/settings.js +41 -30
- package/dist/features/status.js +37 -13
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/update.js +16 -8
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +59 -5
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +10 -7
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { countdownParts, isCountdownUrgent } from './calendar-query.js';
|
|
2
|
+
import { meetingsInWeek, campusWeekday } from './schedule-query.js';
|
|
3
|
+
import { c, type, space, glyph } from '../core/theme.js';
|
|
4
|
+
import { pickIcon } from '../core/icons.js';
|
|
5
|
+
import { padEndV, truncate, visualWidth, wrapAnsiToVisualWidth } from '../core/text.js';
|
|
6
|
+
import { t, fmt, getCurrentLanguage } from '../i18n/index.js';
|
|
7
|
+
function span(m, periods) {
|
|
8
|
+
const s = periods.find((p) => p.period === m.startPeriod)?.start ?? '';
|
|
9
|
+
const e = periods.find((p) => p.period === m.endPeriod)?.end ?? '';
|
|
10
|
+
return e ? `${s}${pickIcon('–', '-')}${e}` : s;
|
|
11
|
+
}
|
|
12
|
+
export function renderNextClassBanner(next, now, cols = Number.POSITIVE_INFINITY) {
|
|
13
|
+
const trans = t();
|
|
14
|
+
if (!next)
|
|
15
|
+
return '';
|
|
16
|
+
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`
|
|
20
|
+
: `${p.minutes}m`;
|
|
21
|
+
const styleWhen = isCountdownUrgent(p) ? c.warn : type.hint;
|
|
22
|
+
const whenStyled = styleWhen(when);
|
|
23
|
+
const dot = pickIcon('·', '-');
|
|
24
|
+
const marker = type.active(glyph.cursor());
|
|
25
|
+
const separator = ` ${dot} `;
|
|
26
|
+
const detailedPrefix = `${space.indent}${marker} ${type.label(trans.timetable.nextClass)}${separator}`;
|
|
27
|
+
const suffix = `${separator}${whenStyled}`;
|
|
28
|
+
const courseName = next.meeting.courseName;
|
|
29
|
+
const location = next.meeting.location ? `${separator}${next.meeting.location}` : '';
|
|
30
|
+
const full = `${detailedPrefix}${type.body(courseName)}${location}${suffix}`;
|
|
31
|
+
if (!Number.isFinite(cols) || visualWidth(full) <= cols)
|
|
32
|
+
return full;
|
|
33
|
+
const withoutLocation = `${detailedPrefix}${type.body(courseName)}${suffix}`;
|
|
34
|
+
if (visualWidth(withoutLocation) <= cols)
|
|
35
|
+
return withoutLocation;
|
|
36
|
+
const prefixes = [detailedPrefix, `${space.indent}${marker} `, `${marker} `];
|
|
37
|
+
for (const prefix of prefixes) {
|
|
38
|
+
const courseWidth = Math.floor(cols - visualWidth(prefix) - visualWidth(suffix));
|
|
39
|
+
if (courseWidth < 3)
|
|
40
|
+
continue;
|
|
41
|
+
return `${prefix}${type.body(truncate(courseName, courseWidth))}${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
const countdownOnly = [
|
|
44
|
+
`${space.indent}${marker} ${whenStyled}`,
|
|
45
|
+
`${marker} ${whenStyled}`,
|
|
46
|
+
whenStyled,
|
|
47
|
+
].find((candidate) => visualWidth(candidate) <= cols);
|
|
48
|
+
if (countdownOnly)
|
|
49
|
+
return countdownOnly;
|
|
50
|
+
const whenWidth = Math.max(0, Math.floor(cols));
|
|
51
|
+
if (whenWidth === 0)
|
|
52
|
+
return '';
|
|
53
|
+
if (whenWidth >= 3)
|
|
54
|
+
return styleWhen(truncate(when, whenWidth));
|
|
55
|
+
let compactWhen = '';
|
|
56
|
+
for (const char of when) {
|
|
57
|
+
if (visualWidth(compactWhen + char) > whenWidth)
|
|
58
|
+
break;
|
|
59
|
+
compactWhen += char;
|
|
60
|
+
}
|
|
61
|
+
return styleWhen(compactWhen);
|
|
62
|
+
}
|
|
63
|
+
export function renderTodayClasses(meetings, periods, now) {
|
|
64
|
+
const trans = t();
|
|
65
|
+
const sorted = [...meetings].sort((a, b) => a.startPeriod - b.startPeriod);
|
|
66
|
+
if (sorted.length === 0)
|
|
67
|
+
return `${space.indent}${type.hint(trans.timetable.noClassToday)}`;
|
|
68
|
+
const dot = pickIcon('·', '-');
|
|
69
|
+
const marker = pickIcon('▸', '>');
|
|
70
|
+
const lines = sorted.map((m) => {
|
|
71
|
+
const time = span(m, periods);
|
|
72
|
+
const startStr = periods.find((p) => p.period === m.startPeriod)?.start ?? '00:00';
|
|
73
|
+
const endStr = periods.find((p) => p.period === m.endPeriod)?.end ?? '23:59';
|
|
74
|
+
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
75
|
+
const live = nowStr >= startStr && nowStr <= endStr;
|
|
76
|
+
const head = live ? `${type.active(marker)} ` : ' ';
|
|
77
|
+
const loc = m.location ? ` ${dot} ${type.hint(m.location)}` : '';
|
|
78
|
+
return `${space.indent}${head}${type.hint(time)} ${live ? type.active(m.courseName) : type.body(m.courseName)}${loc}`;
|
|
79
|
+
});
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
export function weekdayShortLabel(wd) {
|
|
83
|
+
const trans = t();
|
|
84
|
+
const labels = [
|
|
85
|
+
trans.timetable.weekdayMon, trans.timetable.weekdayTue, trans.timetable.weekdayWed,
|
|
86
|
+
trans.timetable.weekdayThu, trans.timetable.weekdayFri, trans.timetable.weekdaySat,
|
|
87
|
+
trans.timetable.weekdaySun,
|
|
88
|
+
];
|
|
89
|
+
return labels[wd - 1] ?? '';
|
|
90
|
+
}
|
|
91
|
+
function renderTimeline(meetings, periods, now, isToday, alwaysShowLocation, cursorPeriod, cols = Number.POSITIVE_INFINITY) {
|
|
92
|
+
const trans = t();
|
|
93
|
+
const sorted = [...meetings].sort((a, b) => a.startPeriod - b.startPeriod);
|
|
94
|
+
if (sorted.length === 0)
|
|
95
|
+
return `${space.indent}${type.hint(trans.timetable.noClassToday)}`;
|
|
96
|
+
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
97
|
+
const dot = pickIcon('·', '-');
|
|
98
|
+
const rule = pickIcon('─', '-');
|
|
99
|
+
const midConnector = pickIcon('┼', '+');
|
|
100
|
+
const topConnector = pickIcon('┬', '+');
|
|
101
|
+
const bottomConnector = pickIcon('┴', '+');
|
|
102
|
+
const lines = sorted.map((m, i) => {
|
|
103
|
+
const startStr = periods.find((p) => p.period === m.startPeriod)?.start ?? '00:00';
|
|
104
|
+
const endStr = periods.find((p) => p.period === m.endPeriod)?.end ?? '23:59';
|
|
105
|
+
const isLive = isToday && nowStr >= startStr && nowStr <= endStr;
|
|
106
|
+
const isDone = isToday && nowStr > endStr;
|
|
107
|
+
const isCursor = cursorPeriod !== undefined && m.startPeriod <= cursorPeriod && cursorPeriod <= m.endPeriod;
|
|
108
|
+
const connector = i === 0 ? topConnector : midConnector;
|
|
109
|
+
const marker = isLive ? type.active(pickIcon('▶', '>')) : ' ';
|
|
110
|
+
const timeCol = `${marker}${type.hint(startStr)} ${rule}${connector}${rule}`;
|
|
111
|
+
const styleName = (name) => (isLive ? type.active(name) : (isDone ? type.hint(name) : type.body(name)));
|
|
112
|
+
let statusText = '';
|
|
113
|
+
let compactStatusText = '';
|
|
114
|
+
if (isDone) {
|
|
115
|
+
statusText = trans.timetable.classDone;
|
|
116
|
+
compactStatusText = statusText;
|
|
117
|
+
}
|
|
118
|
+
else if (isLive) {
|
|
119
|
+
const end = new Date(now);
|
|
120
|
+
const [eh, em] = endStr.split(':').map((x) => Number.parseInt(x, 10));
|
|
121
|
+
end.setHours(eh || 0, em || 0, 0, 0);
|
|
122
|
+
const remaining = countdownParts(end, now);
|
|
123
|
+
const mins = remaining.days * 1440 + remaining.hours * 60 + remaining.minutes;
|
|
124
|
+
statusText = `${trans.timetable.classLive} ${dot} ${fmt(trans.timetable.minutesRemaining, { minutes: String(mins) })}`;
|
|
125
|
+
compactStatusText = `${mins}m`;
|
|
126
|
+
}
|
|
127
|
+
const showLoc = alwaysShowLocation ? Boolean(m.location) : (isLive && Boolean(m.location));
|
|
128
|
+
const locationText = showLoc ? m.location ?? '' : '';
|
|
129
|
+
const renderLine = (name, status, location, currentTimeCol = timeCol, indent = space.indent) => {
|
|
130
|
+
const statusCol = status ? ` ${type.hint(status)}` : '';
|
|
131
|
+
const locationCol = location ? ` ${type.hint(location)}` : '';
|
|
132
|
+
const content = `${currentTimeCol} ${styleName(name)}${statusCol}${locationCol}`;
|
|
133
|
+
return `${indent}${isCursor ? type.cursor(content) : content}`;
|
|
134
|
+
};
|
|
135
|
+
const full = renderLine(m.courseName, statusText, locationText);
|
|
136
|
+
if (!Number.isFinite(cols) || visualWidth(full) <= cols)
|
|
137
|
+
return full;
|
|
138
|
+
const compactWithLocation = renderLine(m.courseName, compactStatusText, locationText);
|
|
139
|
+
if (visualWidth(compactWithLocation) <= cols)
|
|
140
|
+
return compactWithLocation;
|
|
141
|
+
const compact = renderLine(m.courseName, compactStatusText, '');
|
|
142
|
+
if (visualWidth(compact) <= cols)
|
|
143
|
+
return compact;
|
|
144
|
+
const adaptiveStatuses = compactStatusText ? [compactStatusText, ''] : [''];
|
|
145
|
+
for (const status of adaptiveStatuses) {
|
|
146
|
+
const courseWidth = Math.floor(cols - visualWidth(renderLine('', status, '')));
|
|
147
|
+
if (courseWidth < 3)
|
|
148
|
+
continue;
|
|
149
|
+
return renderLine(truncate(m.courseName, courseWidth), status, '');
|
|
150
|
+
}
|
|
151
|
+
const compactTimeCol = `${marker}${type.hint(startStr)}`;
|
|
152
|
+
for (const indent of [space.indent, '']) {
|
|
153
|
+
const courseWidth = Math.floor(cols - visualWidth(renderLine('', '', '', compactTimeCol, indent)));
|
|
154
|
+
if (courseWidth < 3)
|
|
155
|
+
continue;
|
|
156
|
+
return renderLine(truncate(m.courseName, courseWidth), '', '', compactTimeCol, indent);
|
|
157
|
+
}
|
|
158
|
+
const timeOnly = [
|
|
159
|
+
`${space.indent}${compactTimeCol}`,
|
|
160
|
+
compactTimeCol,
|
|
161
|
+
type.hint(startStr),
|
|
162
|
+
].find((candidate) => visualWidth(candidate) <= cols);
|
|
163
|
+
if (timeOnly)
|
|
164
|
+
return timeOnly;
|
|
165
|
+
return type.hint(startStr.slice(0, Math.max(0, Math.floor(cols))));
|
|
166
|
+
});
|
|
167
|
+
const last = sorted[sorted.length - 1];
|
|
168
|
+
const lastEnd = periods.find((p) => p.period === last.endPeriod)?.end ?? '23:59';
|
|
169
|
+
const fullEnd = `${space.indent} ${type.hint(lastEnd)} ${rule}${bottomConnector}${rule} ${type.hint(trans.timetable.timelineEnd)}`;
|
|
170
|
+
if (!Number.isFinite(cols) || visualWidth(fullEnd) <= cols) {
|
|
171
|
+
lines.push(fullEnd);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const compactEnd = [
|
|
175
|
+
`${space.indent}${type.hint(lastEnd)} ${rule}${bottomConnector}${rule}`,
|
|
176
|
+
`${space.indent}${type.hint(lastEnd)}`,
|
|
177
|
+
type.hint(lastEnd),
|
|
178
|
+
].find((candidate) => visualWidth(candidate) <= cols);
|
|
179
|
+
lines.push(compactEnd ?? type.hint(lastEnd.slice(0, Math.max(0, Math.floor(cols)))));
|
|
180
|
+
}
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
183
|
+
export function renderTodayTimeline(meetings, periods, now, cols = Number.POSITIVE_INFINITY) {
|
|
184
|
+
return renderTimeline(meetings, periods, now, true, false, undefined, cols);
|
|
185
|
+
}
|
|
186
|
+
export function renderDayTimeline(meetings, periods, now, isToday, cursorPeriod, cols = Number.POSITIVE_INFINITY) {
|
|
187
|
+
return renderTimeline(meetings, periods, now, isToday, true, cursorPeriod, cols);
|
|
188
|
+
}
|
|
189
|
+
export function renderDaySwitcher(selectedWeekday, todayWeekday, cols = Number.POSITIVE_INFINITY) {
|
|
190
|
+
const leftArrow = pickIcon('←', '<');
|
|
191
|
+
const rightArrow = pickIcon('→', '>');
|
|
192
|
+
const todayMark = pickIcon('•', '*');
|
|
193
|
+
const selectedIndex = Math.max(0, Math.min(WEEKDAY_KEYS.length - 1, selectedWeekday - 1));
|
|
194
|
+
const labels = WEEKDAY_KEYS.map((_, i) => {
|
|
195
|
+
const wd = i + 1;
|
|
196
|
+
const label = `${weekdayShortLabel(wd)}${wd === todayWeekday ? todayMark : ''}`;
|
|
197
|
+
if (wd === selectedWeekday)
|
|
198
|
+
return type.cursor(`[${label}]`);
|
|
199
|
+
return type.hint(label);
|
|
200
|
+
});
|
|
201
|
+
const renderRange = (start, end) => (`${space.indent}${type.hint(leftArrow)} ${labels.slice(start, end).join(' ')} ${type.hint(rightArrow)}`);
|
|
202
|
+
const full = renderRange(0, labels.length);
|
|
203
|
+
if (!Number.isFinite(cols) || visualWidth(full) <= cols)
|
|
204
|
+
return full;
|
|
205
|
+
let best;
|
|
206
|
+
for (let start = 0; start <= selectedIndex; start += 1) {
|
|
207
|
+
for (let end = selectedIndex + 1; end <= labels.length; end += 1) {
|
|
208
|
+
if (visualWidth(renderRange(start, end)) > cols)
|
|
209
|
+
continue;
|
|
210
|
+
const count = end - start;
|
|
211
|
+
const imbalance = Math.abs(selectedIndex - start - (end - selectedIndex - 1));
|
|
212
|
+
if (!best || count > best.count || (count === best.count && imbalance < best.imbalance)) {
|
|
213
|
+
best = { start, end, count, imbalance };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (best)
|
|
218
|
+
return renderRange(best.start, best.end);
|
|
219
|
+
const selected = labels[selectedIndex] ?? '';
|
|
220
|
+
const selectedOnly = `${space.indent}${selected}`;
|
|
221
|
+
return visualWidth(selectedOnly) <= cols ? selectedOnly : selected;
|
|
222
|
+
}
|
|
223
|
+
const WEEKDAY_KEYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
224
|
+
const GAP_THRESHOLD_MINUTES = 30;
|
|
225
|
+
function minutesOf(hhmm) {
|
|
226
|
+
const [h, m] = hhmm.split(':').map((x) => Number.parseInt(x, 10));
|
|
227
|
+
return (h || 0) * 60 + (m || 0);
|
|
228
|
+
}
|
|
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
|
+
function centerInWidth(text, width) {
|
|
241
|
+
const pad = Math.max(0, width - visualWidth(text));
|
|
242
|
+
const left = Math.floor(pad / 2);
|
|
243
|
+
const right = pad - left;
|
|
244
|
+
return ' '.repeat(left) + text + ' '.repeat(right);
|
|
245
|
+
}
|
|
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
|
+
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.
|
|
258
|
+
const rowHeadW = 12;
|
|
259
|
+
const todayMark = pickIcon('•', '*');
|
|
260
|
+
const connector = pickIcon('│', '|');
|
|
261
|
+
const emptyGlyph = pickIcon('·', '.');
|
|
262
|
+
const sepGlyph = pickIcon('│', '|');
|
|
263
|
+
const sep = type.hint(` ${sepGlyph} `);
|
|
264
|
+
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
|
+
const idealColWidths = WEEKDAY_KEYS.map((_, i) => {
|
|
271
|
+
const wd = i + 1;
|
|
272
|
+
const dayMeetings = week.filter((m) => m.weekday === wd);
|
|
273
|
+
const nameW = dayMeetings.reduce((max, m) => Math.max(max, visualWidth(m.courseName)), 0);
|
|
274
|
+
const locW = dayMeetings.reduce((max, m) => Math.max(max, m.location ? visualWidth(m.location) : 0), 0);
|
|
275
|
+
const headerW = visualWidth(weekdayShortLabel(wd)) + (wd === todayWd ? visualWidth(todayMark) : 0);
|
|
276
|
+
return Math.max(nameW, locW, headerW, MIN_COL_WIDTH);
|
|
277
|
+
});
|
|
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
|
+
const fixedOverhead = space.indent.length + rowHeadW + 6 * sepW;
|
|
286
|
+
const availableForCols = Math.max(0, cols - fixedOverhead);
|
|
287
|
+
const totalIdealColW = idealColWidths.reduce((a, b) => a + b, 0);
|
|
288
|
+
const colWidths = totalIdealColW <= availableForCols
|
|
289
|
+
? 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
|
+
: idealColWidths.map((w) => Math.max(3, Math.floor(w * (availableForCols / totalIdealColW))));
|
|
296
|
+
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
|
+
const startingAt = (wd, period) => week.find((m) => m.weekday === wd && m.startPeriod === period);
|
|
304
|
+
const continuingAt = (wd, period) => week.find((m) => m.weekday === wd && m.startPeriod < period && period <= m.endPeriod);
|
|
305
|
+
const lines = [];
|
|
306
|
+
const blankHead = padEndV('', rowHeadW);
|
|
307
|
+
const headerCells = WEEKDAY_KEYS.map((_, i) => {
|
|
308
|
+
const wd = i + 1;
|
|
309
|
+
const d = weekdayShortLabel(wd);
|
|
310
|
+
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]);
|
|
317
|
+
return wd === todayWd ? type.active(padded) : type.hint(padded);
|
|
318
|
+
}).join(sep);
|
|
319
|
+
lines.push(space.indent + blankHead + headerCells);
|
|
320
|
+
const sorted = [...periods].sort((a, b) => a.period - b.period);
|
|
321
|
+
sorted.forEach((p, i) => {
|
|
322
|
+
const rowHead = type.hint(padEndV(`${p.start}-${p.end}`, rowHeadW));
|
|
323
|
+
const nameCells = [];
|
|
324
|
+
const locCells = [];
|
|
325
|
+
for (let wdIdx = 0; wdIdx < 7; wdIdx++) {
|
|
326
|
+
const wd = wdIdx + 1;
|
|
327
|
+
const colW = colWidths[wdIdx];
|
|
328
|
+
const isToday = wd === todayWd;
|
|
329
|
+
const isCursor = cursor !== undefined && cursor.weekday === wd && cursor.period === p.period;
|
|
330
|
+
const starting = startingAt(wd, p.period);
|
|
331
|
+
const isContinuation = !starting && continuingAt(wd, p.period);
|
|
332
|
+
const rawName = starting ? starting.courseName : (isContinuation ? connector : emptyGlyph);
|
|
333
|
+
const rawLoc = starting ? (starting.location ?? '') : (isContinuation ? connector : '');
|
|
334
|
+
const paddedName = centerInWidth(truncate(rawName, colW), colW);
|
|
335
|
+
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
|
+
if (isCursor) {
|
|
341
|
+
nameCells.push(type.cursor(paddedName));
|
|
342
|
+
locCells.push(type.cursor(paddedLoc));
|
|
343
|
+
}
|
|
344
|
+
else if (starting) {
|
|
345
|
+
nameCells.push(isToday ? type.active(paddedName) : type.body(paddedName));
|
|
346
|
+
locCells.push(type.hint(paddedLoc));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
nameCells.push(type.hint(paddedName));
|
|
350
|
+
locCells.push(type.hint(paddedLoc));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
lines.push(space.indent + rowHead + nameCells.join(sep));
|
|
354
|
+
lines.push(space.indent + blankHead + locCells.join(sep));
|
|
355
|
+
const next = sorted[i + 1];
|
|
356
|
+
if (next && minutesOf(next.start) - minutesOf(p.end) > GAP_THRESHOLD_MINUTES) {
|
|
357
|
+
lines.push(space.indent + type.hint(pickIcon('╌', '-').repeat(totalW)));
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
return lines.join('\n');
|
|
361
|
+
}
|
|
362
|
+
function formatWeekRange(weeks) {
|
|
363
|
+
if (weeks.length === 0)
|
|
364
|
+
return '';
|
|
365
|
+
const sorted = [...weeks].sort((a, b) => a - b);
|
|
366
|
+
const isContiguous = sorted.every((w, i) => i === 0 || w === sorted[i - 1] + 1);
|
|
367
|
+
if (isContiguous) {
|
|
368
|
+
return sorted.length > 1 ? `${sorted[0]}-${sorted[sorted.length - 1]}` : `${sorted[0]}`;
|
|
369
|
+
}
|
|
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
|
+
return sorted.join(', ');
|
|
373
|
+
}
|
|
374
|
+
export function renderMeetingDetail(meeting, periods, cols = Number.POSITIVE_INFINITY) {
|
|
375
|
+
const trans = t();
|
|
376
|
+
const rows = [
|
|
377
|
+
[trans.timetable.detailTime, `${weekdayShortLabel(meeting.weekday)} ${span(meeting, periods)}`],
|
|
378
|
+
];
|
|
379
|
+
if (meeting.location)
|
|
380
|
+
rows.push([trans.timetable.detailLocation, meeting.location]);
|
|
381
|
+
if (meeting.teacherNames.length > 0) {
|
|
382
|
+
rows.push([trans.timetable.detailTeacher, meeting.teacherNames.join(trans.timetable.teacherSeparator)]);
|
|
383
|
+
}
|
|
384
|
+
rows.push([trans.timetable.detailWeeks, formatWeekRange(meeting.weeks)]);
|
|
385
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
386
|
+
const indent = visualWidth(space.indent) < width ? space.indent : '';
|
|
387
|
+
const contentWidth = Math.max(1, width - visualWidth(indent));
|
|
388
|
+
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}`);
|
|
391
|
+
lines.push('');
|
|
392
|
+
for (const [label, value] of rows) {
|
|
393
|
+
const inlinePrefix = `${indent}${type.label(padEndV(label, labelWidth))} `;
|
|
394
|
+
const inlineValueWidth = width - visualWidth(inlinePrefix);
|
|
395
|
+
if (!Number.isFinite(width) || inlineValueWidth >= 12) {
|
|
396
|
+
const parts = wrapAnsiToVisualWidth(type.body(value), inlineValueWidth);
|
|
397
|
+
const continuation = ' '.repeat(visualWidth(inlinePrefix));
|
|
398
|
+
lines.push(...parts.map((part, index) => `${index === 0 ? inlinePrefix : continuation}${part}`));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
lines.push(...wrapAnsiToVisualWidth(type.label(label), contentWidth).map((part) => `${indent}${part}`));
|
|
402
|
+
const valueIndent = visualWidth(indent) + 2 < width ? `${indent} ` : indent;
|
|
403
|
+
const valueWidth = Math.max(1, width - visualWidth(valueIndent));
|
|
404
|
+
lines.push(...wrapAnsiToVisualWidth(type.body(value), valueWidth).map((part) => `${valueIndent}${part}`));
|
|
405
|
+
}
|
|
406
|
+
return lines.join('\n');
|
|
407
|
+
}
|
|
408
|
+
export function renderUnresolvedItems(items, cols = Number.POSITIVE_INFINITY) {
|
|
409
|
+
const trans = t();
|
|
410
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
411
|
+
const indent = visualWidth(space.indent) < width ? space.indent : '';
|
|
412
|
+
const contentWidth = Math.max(1, width - visualWidth(indent));
|
|
413
|
+
if (items.length === 0) {
|
|
414
|
+
return wrapAnsiToVisualWidth(type.hint(trans.timetable.unresolvedEmpty), contentWidth)
|
|
415
|
+
.map((part) => `${indent}${part}`)
|
|
416
|
+
.join('\n');
|
|
417
|
+
}
|
|
418
|
+
const dot = pickIcon('·', '-');
|
|
419
|
+
const lines = [];
|
|
420
|
+
for (const item of items) {
|
|
421
|
+
const name = item.sourceFields.kcmc ?? trans.timetable.unresolvedUnknownItem;
|
|
422
|
+
const detail = item.sourceFields.sjkcgs ?? item.sourceFields.qsjsz ?? '';
|
|
423
|
+
const full = `${indent}${type.body(name)}${detail ? ` ${dot} ${type.hint(detail)}` : ''}`;
|
|
424
|
+
if (!Number.isFinite(width) || visualWidth(full) <= width) {
|
|
425
|
+
lines.push(full);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
lines.push(...wrapAnsiToVisualWidth(type.body(name), contentWidth).map((part) => `${indent}${part}`));
|
|
429
|
+
if (!detail)
|
|
430
|
+
continue;
|
|
431
|
+
const detailPrefix = visualWidth(indent) + 2 < width ? `${indent}${type.hint(`${dot} `)}` : indent;
|
|
432
|
+
const detailWidth = Math.max(1, width - visualWidth(detailPrefix));
|
|
433
|
+
const continuation = ' '.repeat(visualWidth(detailPrefix));
|
|
434
|
+
lines.push(...wrapAnsiToVisualWidth(type.hint(detail), detailWidth)
|
|
435
|
+
.map((part, index) => `${index === 0 ? detailPrefix : continuation}${part}`));
|
|
436
|
+
}
|
|
437
|
+
return lines.join('\n');
|
|
438
|
+
}
|
|
439
|
+
const DENSITY_GLYPHS = [
|
|
440
|
+
['·', ' '], ['░', '.'], ['▒', ':'], ['▓', '-'], ['█', '='],
|
|
441
|
+
];
|
|
442
|
+
function levelGlyph(level) {
|
|
443
|
+
const pair = DENSITY_GLYPHS[Math.max(0, Math.min(4, level))] ?? DENSITY_GLYPHS[0];
|
|
444
|
+
return pickIcon(pair[0], pair[1]);
|
|
445
|
+
}
|
|
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
|
+
function applyDensityColor(glyphChar, level) {
|
|
452
|
+
if (level <= 0)
|
|
453
|
+
return type.hint(glyphChar);
|
|
454
|
+
if (level >= 4)
|
|
455
|
+
return type.active(glyphChar);
|
|
456
|
+
return c.brand(glyphChar);
|
|
457
|
+
}
|
|
458
|
+
function weekStartDate(weekOneMonday, week) {
|
|
459
|
+
const base = new Date(`${weekOneMonday}T00:00:00`);
|
|
460
|
+
return new Date(base.getTime() + (week - 1) * 7 * 86400000);
|
|
461
|
+
}
|
|
462
|
+
function densityMonthText(weekOneMonday, startWeek, count, lang, maxWidth = Number.POSITIVE_INFINITY) {
|
|
463
|
+
let text = '';
|
|
464
|
+
let visualCol = 0;
|
|
465
|
+
let previousMonth = -1;
|
|
466
|
+
for (let index = 0; index < count; index += 1) {
|
|
467
|
+
const date = weekStartDate(weekOneMonday, startWeek + index);
|
|
468
|
+
const month = date.getMonth();
|
|
469
|
+
if (month === previousMonth)
|
|
470
|
+
continue;
|
|
471
|
+
previousMonth = month;
|
|
472
|
+
const label = lang === 'zh'
|
|
473
|
+
? `${month + 1}月`
|
|
474
|
+
: new Intl.DateTimeFormat('en-US', { month: 'short' }).format(date);
|
|
475
|
+
const targetCol = index * 2;
|
|
476
|
+
if (targetCol + visualWidth(label) > maxWidth)
|
|
477
|
+
continue;
|
|
478
|
+
if (targetCol > visualCol)
|
|
479
|
+
text += ' '.repeat(targetCol - visualCol);
|
|
480
|
+
text += label;
|
|
481
|
+
visualCol = targetCol + visualWidth(label);
|
|
482
|
+
}
|
|
483
|
+
return text;
|
|
484
|
+
}
|
|
485
|
+
function densityMarkerText(index, width, marker, label) {
|
|
486
|
+
const markerCol = index * 2;
|
|
487
|
+
const after = `${' '.repeat(markerCol)}${marker} ${label}`;
|
|
488
|
+
if (visualWidth(after) <= width)
|
|
489
|
+
return after;
|
|
490
|
+
const beforeStart = markerCol - visualWidth(label) - 1;
|
|
491
|
+
if (beforeStart >= 0)
|
|
492
|
+
return `${' '.repeat(beforeStart)}${label} ${marker}`;
|
|
493
|
+
return `${' '.repeat(markerCol)}${marker}`;
|
|
494
|
+
}
|
|
495
|
+
export function renderTermDensity(meetings, weekOneMonday, currentWeek, cols = Number.POSITIVE_INFINITY) {
|
|
496
|
+
const trans = t();
|
|
497
|
+
const lang = getCurrentLanguage();
|
|
498
|
+
let minWeek = currentWeek;
|
|
499
|
+
let maxWeek = currentWeek;
|
|
500
|
+
for (const m of meetings) {
|
|
501
|
+
for (const w of m.weeks) {
|
|
502
|
+
if (w < minWeek)
|
|
503
|
+
minWeek = w;
|
|
504
|
+
if (w > maxWeek)
|
|
505
|
+
maxWeek = w;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const numWeeks = maxWeek - minWeek + 1;
|
|
509
|
+
const weekSlots = [];
|
|
510
|
+
for (let w = minWeek; w <= maxWeek; w++) {
|
|
511
|
+
let slots = 0;
|
|
512
|
+
for (const m of meetings) {
|
|
513
|
+
if (m.weeks.includes(w))
|
|
514
|
+
slots += m.endPeriod - m.startPeriod + 1;
|
|
515
|
+
}
|
|
516
|
+
weekSlots.push(slots);
|
|
517
|
+
}
|
|
518
|
+
const max = Math.max(0, ...weekSlots);
|
|
519
|
+
const levels = weekSlots.map((v) => {
|
|
520
|
+
if (v === 0 || max === 0)
|
|
521
|
+
return 0;
|
|
522
|
+
if (v <= max * 0.25)
|
|
523
|
+
return 1;
|
|
524
|
+
if (v <= max * 0.5)
|
|
525
|
+
return 2;
|
|
526
|
+
if (v <= max * 0.75)
|
|
527
|
+
return 3;
|
|
528
|
+
return 4;
|
|
529
|
+
});
|
|
530
|
+
const monthLabelText = densityMonthText(weekOneMonday, minWeek, numWeeks, lang);
|
|
531
|
+
const monthLabelLine = `${space.indent}${monthLabelText}`;
|
|
532
|
+
const glyphLine = `${space.indent}${levels.map((lvl) => applyDensityColor(levelGlyph(lvl), lvl)).join(' ')}`;
|
|
533
|
+
const currentWeekIndex = Math.max(0, currentWeek - minWeek);
|
|
534
|
+
const markerGlyph = pickIcon('↑', '^');
|
|
535
|
+
const markerLine = `${space.indent}${type.hint(`${' '.repeat(currentWeekIndex * 2)}${markerGlyph} ${trans.timetable.termDensityThisWeek}`)}`;
|
|
536
|
+
const legendGlyphs = [0, 1, 2, 3, 4].map((lvl) => applyDensityColor(levelGlyph(lvl), lvl));
|
|
537
|
+
const legendContent = `${type.hint(trans.calendar.heatmap.legendLess)} ${legendGlyphs.join('')} ${type.hint(trans.calendar.heatmap.legendMore)}`;
|
|
538
|
+
const legendLine = `${space.indent}${legendContent}`;
|
|
539
|
+
const fullLines = [
|
|
540
|
+
`${space.indent}${type.heading(trans.timetable.termDensityTitle)}`,
|
|
541
|
+
'',
|
|
542
|
+
monthLabelLine,
|
|
543
|
+
glyphLine,
|
|
544
|
+
markerLine,
|
|
545
|
+
'',
|
|
546
|
+
legendLine,
|
|
547
|
+
];
|
|
548
|
+
const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
|
|
549
|
+
if (!Number.isFinite(width) || fullLines.every((line) => visualWidth(line) <= width)) {
|
|
550
|
+
return fullLines.join('\n');
|
|
551
|
+
}
|
|
552
|
+
const indent = visualWidth(space.indent) < width ? space.indent : '';
|
|
553
|
+
const contentWidth = Math.max(1, width - visualWidth(indent));
|
|
554
|
+
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}`);
|
|
557
|
+
lines.push('');
|
|
558
|
+
for (let start = 0; start < numWeeks; start += weeksPerChunk) {
|
|
559
|
+
if (start > 0)
|
|
560
|
+
lines.push('');
|
|
561
|
+
const count = Math.min(weeksPerChunk, numWeeks - start);
|
|
562
|
+
const chunkMonthText = densityMonthText(weekOneMonday, minWeek + start, count, lang, contentWidth);
|
|
563
|
+
lines.push(`${indent}${chunkMonthText}`);
|
|
564
|
+
lines.push(`${indent}${levels.slice(start, start + count)
|
|
565
|
+
.map((level) => applyDensityColor(levelGlyph(level), level)).join(' ')}`);
|
|
566
|
+
if (currentWeekIndex >= start && currentWeekIndex < start + count) {
|
|
567
|
+
const relativeIndex = currentWeekIndex - start;
|
|
568
|
+
lines.push(`${indent}${type.hint(densityMarkerText(relativeIndex, contentWidth, markerGlyph, trans.timetable.termDensityThisWeek))}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
lines.push('');
|
|
572
|
+
lines.push(...wrapAnsiToVisualWidth(legendContent, contentWidth).map((part) => `${indent}${part}`));
|
|
573
|
+
return lines.join('\n');
|
|
574
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getWritableConfigDir, getConfigDir, getWritableStateDir, getStateDir } from '../config/paths.js';
|
|
4
|
+
export function termKey(term) {
|
|
5
|
+
return `${term.academicYear}-${term.semester}`;
|
|
6
|
+
}
|
|
7
|
+
function readJson(file) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function writeJson(file, value) {
|
|
16
|
+
fs.writeFileSync(file, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
17
|
+
try {
|
|
18
|
+
fs.chmodSync(file, 0o600);
|
|
19
|
+
}
|
|
20
|
+
catch { /* best effort */ }
|
|
21
|
+
}
|
|
22
|
+
function weekOnePath(dir) {
|
|
23
|
+
return path.join(dir ?? getWritableConfigDir(), 'week-one.json');
|
|
24
|
+
}
|
|
25
|
+
export function saveWeekOne(termKey, iso, dir) {
|
|
26
|
+
const file = weekOnePath(dir);
|
|
27
|
+
const store = readJson(file) ?? {};
|
|
28
|
+
store[termKey] = iso;
|
|
29
|
+
writeJson(file, store);
|
|
30
|
+
}
|
|
31
|
+
export function loadWeekOne(termKey, dir) {
|
|
32
|
+
const file = path.join(dir ?? getConfigDir(), 'week-one.json');
|
|
33
|
+
const store = readJson(file);
|
|
34
|
+
return store?.[termKey] ?? null;
|
|
35
|
+
}
|
|
36
|
+
function cachePath(termKey, dir) {
|
|
37
|
+
return path.join(dir ?? getWritableStateDir(), `timetable-${termKey}.json`);
|
|
38
|
+
}
|
|
39
|
+
export function saveTimetableCache(termKey, data, dir) {
|
|
40
|
+
writeJson(cachePath(termKey, dir), data);
|
|
41
|
+
}
|
|
42
|
+
export function loadTimetableCache(termKey, dir) {
|
|
43
|
+
const file = path.join(dir ?? getStateDir(), `timetable-${termKey}.json`);
|
|
44
|
+
return readJson(file);
|
|
45
|
+
}
|
|
46
|
+
function currentPointerPath(dir) {
|
|
47
|
+
return path.join(dir ?? getWritableStateDir(), 'current-term.json');
|
|
48
|
+
}
|
|
49
|
+
export function saveCurrentPointer(termKey, weekOneMonday, dir) {
|
|
50
|
+
writeJson(currentPointerPath(dir), { termKey, weekOneMonday });
|
|
51
|
+
}
|
|
52
|
+
export function loadCurrentPointer(dir) {
|
|
53
|
+
const file = path.join(dir ?? getStateDir(), 'current-term.json');
|
|
54
|
+
const value = readJson(file);
|
|
55
|
+
if (!value || typeof value.termKey !== 'string' || typeof value.weekOneMonday !== 'string')
|
|
56
|
+
return null;
|
|
57
|
+
return { termKey: value.termKey, weekOneMonday: value.weekOneMonday };
|
|
58
|
+
}
|
|
59
|
+
/** Remove the cached timetables and the current-term pointer (e.g. on logout). Best-effort. */
|
|
60
|
+
export function clearScheduleCache(dir) {
|
|
61
|
+
const stateDir = dir ?? getStateDir();
|
|
62
|
+
try {
|
|
63
|
+
for (const f of fs.readdirSync(stateDir)) {
|
|
64
|
+
if (f === 'current-term.json' || (f.startsWith('timetable-') && f.endsWith('.json'))) {
|
|
65
|
+
try {
|
|
66
|
+
fs.unlinkSync(path.join(stateDir, f));
|
|
67
|
+
}
|
|
68
|
+
catch { /* best effort */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch { /* best effort: dir may not exist */ }
|
|
73
|
+
}
|