@nbtca/prompt 1.3.2 → 1.4.2
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 +45 -1
- 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 +450 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +195 -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 +463 -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/data.js +1 -2
- 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 +33 -22
- package/dist/core/menu.js +24 -9
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +121 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +4 -45
- 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 +222 -61
- package/dist/features/links.js +7 -7
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +573 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +253 -0
- package/dist/features/settings.js +43 -35
- package/dist/features/status.js +37 -16
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/theme.js +0 -3
- package/dist/features/update.js +16 -18
- package/dist/i18n/index.js +5 -47
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +61 -11
- 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 +18 -12
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createNbtTimetableClient, timetableToIcs, } from '@nbtca/nbtcal/timetable';
|
|
3
|
+
import { runMenu, menuFooter } from '../core/components/menu.js';
|
|
4
|
+
import { runTextInput } from '../core/components/text-input.js';
|
|
5
|
+
import { enterScreen, breadcrumb } from '../core/transitions.js';
|
|
6
|
+
import { createSpinner, success, error } from '../core/ui.js';
|
|
7
|
+
import { c, type, space } from '../core/theme.js';
|
|
8
|
+
import { t, fmt } from '../i18n/index.js';
|
|
9
|
+
import { createSessionStore } from '../auth/session-store.js';
|
|
10
|
+
import { withAuthenticatedSession, resolveTerm, relevantTerms, writePrivateIcs, isSessionExpired, safeMessage, JWXT_ORIGIN, } from './student-timetable.js';
|
|
11
|
+
import { currentWeekNumber, campusWeekday, meetingsOnDay, nextMeeting } from './schedule-query.js';
|
|
12
|
+
import { renderNextClassBanner, renderTodayClasses, renderTodayTimeline, renderWeekGrid } from './schedule-render.js';
|
|
13
|
+
import { termKey, loadWeekOne, saveWeekOne, saveTimetableCache, saveCurrentPointer, loadCurrentPointer, loadTimetableCache, clearScheduleCache, } from './schedule-store.js';
|
|
14
|
+
/** Loads a saved week-one Monday for `key`, or prompts for and persists a new one.
|
|
15
|
+
* Returns null when the user cancels or enters an unparsable date (caller aborts). */
|
|
16
|
+
async function ensureWeekOne(key) {
|
|
17
|
+
const saved = loadWeekOne(key);
|
|
18
|
+
if (saved)
|
|
19
|
+
return saved;
|
|
20
|
+
const value = await runTextInput({
|
|
21
|
+
message: t().timetable.promptWeekOne,
|
|
22
|
+
placeholder: 'YYYY-MM-DD',
|
|
23
|
+
});
|
|
24
|
+
if (value === null)
|
|
25
|
+
return null;
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed))
|
|
28
|
+
return null;
|
|
29
|
+
if (Number.isNaN(new Date(`${trimmed}T00:00:00`).getTime()))
|
|
30
|
+
return null;
|
|
31
|
+
saveWeekOne(key, trimmed);
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
/** Fetches a term's timetable behind a spinner, caching it on success. Returns null
|
|
35
|
+
* (after reporting the error) on failure so callers can keep the previous state. */
|
|
36
|
+
async function fetchTimetableWithSpinner(client, term, key, weekOne) {
|
|
37
|
+
const trans = t();
|
|
38
|
+
const spinner = createSpinner(trans.calendar.loading);
|
|
39
|
+
try {
|
|
40
|
+
const tt = await client.fetchTerm(term);
|
|
41
|
+
spinner.stop();
|
|
42
|
+
saveTimetableCache(key, tt);
|
|
43
|
+
saveCurrentPointer(key, weekOne);
|
|
44
|
+
return tt;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (isSessionExpired(err)) {
|
|
48
|
+
spinner.stop();
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
spinner.error(trans.timetable.genericError);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function renderHub(tt, weekOne, now) {
|
|
56
|
+
const week = currentWeekNumber(weekOne, now);
|
|
57
|
+
const today = meetingsOnDay(tt.meetings, campusWeekday(now), week);
|
|
58
|
+
const trans = t();
|
|
59
|
+
console.log();
|
|
60
|
+
const banner = renderNextClassBanner(nextMeeting(tt.meetings, tt.periods, weekOne, now), now);
|
|
61
|
+
console.log(banner || `${space.indent}${type.hint(trans.timetable.noNextClass)}`);
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(renderTodayClasses(today, tt.periods, now));
|
|
64
|
+
console.log();
|
|
65
|
+
return { week, today };
|
|
66
|
+
}
|
|
67
|
+
async function switchTerm(client, catalog) {
|
|
68
|
+
const trans = t();
|
|
69
|
+
const picked = await runMenu({
|
|
70
|
+
title: trans.timetable.hubSwitchTerm,
|
|
71
|
+
options: relevantTerms(catalog).map((tm) => ({
|
|
72
|
+
value: `${tm.academicYear}:${tm.semester}`,
|
|
73
|
+
label: tm.academicYearLabel,
|
|
74
|
+
hint: tm.current ? trans.common.current : undefined,
|
|
75
|
+
})),
|
|
76
|
+
footer: menuFooter(),
|
|
77
|
+
});
|
|
78
|
+
if (picked === null)
|
|
79
|
+
return null;
|
|
80
|
+
const term = resolveTerm(catalog, picked);
|
|
81
|
+
const key = termKey(term);
|
|
82
|
+
const weekOne = await ensureWeekOne(key);
|
|
83
|
+
if (!weekOne)
|
|
84
|
+
return null;
|
|
85
|
+
const tt = await fetchTimetableWithSpinner(client, term, key, weekOne);
|
|
86
|
+
if (!tt)
|
|
87
|
+
return null;
|
|
88
|
+
return { term, key, weekOne, tt };
|
|
89
|
+
}
|
|
90
|
+
function exportTimetable(tt, term, key, weekOne) {
|
|
91
|
+
const trans = t();
|
|
92
|
+
const ics = timetableToIcs(tt, { weekOneMonday: weekOne, calendarName: `NBT ${term.academicYearLabel}` });
|
|
93
|
+
const out = `timetable-${key}.ics`;
|
|
94
|
+
try {
|
|
95
|
+
writePrivateIcs(out, ics);
|
|
96
|
+
success(fmt(trans.timetable.exported, { count: tt.meetings.length, file: path.resolve(out) }));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
error(trans.timetable.genericError);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Interactive schedule hub: login/restore, resolve the current term, load or prompt
|
|
103
|
+
* for the week-one Monday, fetch the timetable, then loop a menu of today / week-grid /
|
|
104
|
+
* switch-term / export actions until the user cancels. */
|
|
105
|
+
export async function showSchedule() {
|
|
106
|
+
const trans = t();
|
|
107
|
+
await enterScreen(breadcrumb(trans.timetable.menuEntry));
|
|
108
|
+
try {
|
|
109
|
+
await withAuthenticatedSession(async (session) => {
|
|
110
|
+
const client = createNbtTimetableClient(session.timetableTransport, { baseUrl: JWXT_ORIGIN });
|
|
111
|
+
const catalog = await client.listTerms();
|
|
112
|
+
let term = resolveTerm(catalog);
|
|
113
|
+
let key = termKey(term);
|
|
114
|
+
let weekOne = await ensureWeekOne(key);
|
|
115
|
+
if (!weekOne)
|
|
116
|
+
return 0;
|
|
117
|
+
const initial = await fetchTimetableWithSpinner(client, term, key, weekOne);
|
|
118
|
+
if (!initial)
|
|
119
|
+
return 1;
|
|
120
|
+
let tt = initial;
|
|
121
|
+
while (true) {
|
|
122
|
+
const now = new Date();
|
|
123
|
+
const { week, today } = renderHub(tt, weekOne, now);
|
|
124
|
+
const action = await runMenu({
|
|
125
|
+
title: `${trans.timetable.menuEntry} ${c.muted(term.academicYearLabel)} ${c.muted(trans.timetable.weekLabel + String(week))}`,
|
|
126
|
+
options: [
|
|
127
|
+
{ value: 'today', label: trans.timetable.hubToday, hint: String(today.length) },
|
|
128
|
+
{ value: 'week', label: trans.timetable.hubWeek },
|
|
129
|
+
{ value: 'term', label: trans.timetable.hubSwitchTerm, hint: term.academicYearLabel },
|
|
130
|
+
{ value: 'export', label: trans.timetable.hubExport },
|
|
131
|
+
{ value: 'logout', label: trans.timetable.hubLogout },
|
|
132
|
+
],
|
|
133
|
+
footer: menuFooter(),
|
|
134
|
+
});
|
|
135
|
+
if (action === null)
|
|
136
|
+
return 0;
|
|
137
|
+
if (action === 'today') {
|
|
138
|
+
continue; // The next loop iteration repaints today's classes.
|
|
139
|
+
}
|
|
140
|
+
if (action === 'week') {
|
|
141
|
+
console.log();
|
|
142
|
+
console.log(renderWeekGrid(tt.meetings, tt.periods, week, now));
|
|
143
|
+
console.log();
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (action === 'term') {
|
|
147
|
+
const switched = await switchTerm(client, catalog);
|
|
148
|
+
if (!switched)
|
|
149
|
+
continue;
|
|
150
|
+
term = switched.term;
|
|
151
|
+
key = switched.key;
|
|
152
|
+
weekOne = switched.weekOne;
|
|
153
|
+
tt = switched.tt;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (action === 'export') {
|
|
157
|
+
exportTimetable(tt, term, key, weekOne);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (action === 'logout') {
|
|
161
|
+
createSessionStore().clear();
|
|
162
|
+
clearScheduleCache();
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}, {
|
|
167
|
+
oneShot: false,
|
|
168
|
+
isInteractive: true,
|
|
169
|
+
store: createSessionStore(),
|
|
170
|
+
stderr: process.stderr,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
error(safeMessage(err));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Best-effort, cache-only startup line: reads the last-used term pointer and its
|
|
178
|
+
* cached timetable (no network) and renders the next-class banner, or '' if the
|
|
179
|
+
* student isn't "set up" yet or anything about the cache is missing/corrupt. */
|
|
180
|
+
export function peekNextClassLine(now = new Date()) {
|
|
181
|
+
try {
|
|
182
|
+
const ptr = loadCurrentPointer();
|
|
183
|
+
if (!ptr)
|
|
184
|
+
return '';
|
|
185
|
+
const cached = loadTimetableCache(ptr.termKey);
|
|
186
|
+
if (!cached || !Array.isArray(cached.meetings) || !Array.isArray(cached.periods))
|
|
187
|
+
return '';
|
|
188
|
+
const next = nextMeeting(cached.meetings, cached.periods, ptr.weekOneMonday, now);
|
|
189
|
+
return renderNextClassBanner(next, now);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Cache-only (no network) render of today's classes for the current term, or
|
|
196
|
+
* [] if not set up. Uses the same renderTodayTimeline Schedule's own hub
|
|
197
|
+
* renders, so Home's preview and Schedule's detail view show the exact same
|
|
198
|
+
* visual language for the exact same data instead of drifting apart. */
|
|
199
|
+
export function peekTodayLines(now = new Date()) {
|
|
200
|
+
try {
|
|
201
|
+
const ptr = loadCurrentPointer();
|
|
202
|
+
if (!ptr)
|
|
203
|
+
return [];
|
|
204
|
+
const cached = loadTimetableCache(ptr.termKey);
|
|
205
|
+
if (!cached || !Array.isArray(cached.meetings) || !Array.isArray(cached.periods))
|
|
206
|
+
return [];
|
|
207
|
+
const week = currentWeekNumber(ptr.weekOneMonday, now);
|
|
208
|
+
const today = meetingsOnDay(cached.meetings, campusWeekday(now), week);
|
|
209
|
+
return renderTodayTimeline(today, cached.periods, now).split('\n');
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function peekWeekAheadInfo(now = new Date()) {
|
|
216
|
+
try {
|
|
217
|
+
const ptr = loadCurrentPointer();
|
|
218
|
+
if (!ptr)
|
|
219
|
+
return null;
|
|
220
|
+
const cached = loadTimetableCache(ptr.termKey);
|
|
221
|
+
if (!cached || !Array.isArray(cached.meetings))
|
|
222
|
+
return null;
|
|
223
|
+
const week = currentWeekNumber(ptr.weekOneMonday, now);
|
|
224
|
+
if (week < 1)
|
|
225
|
+
return null;
|
|
226
|
+
const meetings = cached.meetings;
|
|
227
|
+
const classDays = [1, 2, 3, 4, 5, 6, 7].map((wd) => meetingsOnDay(meetings, wd, week).length > 0);
|
|
228
|
+
const base = new Date(`${ptr.weekOneMonday}T00:00:00`);
|
|
229
|
+
const weekStartDate = new Date(base.getTime() + (week - 1) * 7 * 86400000);
|
|
230
|
+
return { weekStartDate, classDays };
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Best-effort, cache-only (no network) read of how many timetable items
|
|
237
|
+
* still need the student's attention — the same data buildHubField()
|
|
238
|
+
* (schedule.ts) already surfaces inside Schedule's own hub menu, exposed
|
|
239
|
+
* here so Home can show it too without a second source of truth. */
|
|
240
|
+
export function peekUnresolvedCount() {
|
|
241
|
+
try {
|
|
242
|
+
const ptr = loadCurrentPointer();
|
|
243
|
+
if (!ptr)
|
|
244
|
+
return 0;
|
|
245
|
+
const cached = loadTimetableCache(ptr.termKey);
|
|
246
|
+
if (!cached || !Array.isArray(cached.unresolvedItems))
|
|
247
|
+
return 0;
|
|
248
|
+
return cached.unresolvedItems.length;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified settings — language, theme, about
|
|
3
|
-
*/
|
|
4
|
-
import { select, isCancel, note } from '@clack/prompts';
|
|
5
1
|
import chalk from 'chalk';
|
|
6
2
|
import { applyColorModePreference, loadPreferences, resetPreferences, setColorMode, setIconMode, } from '../config/preferences.js';
|
|
7
3
|
import { pickIcon } from '../core/icons.js';
|
|
@@ -9,7 +5,10 @@ import { resetIconCache } from '../core/icons.js';
|
|
|
9
5
|
import { padEndV } from '../core/text.js';
|
|
10
6
|
import { success, warning } from '../core/ui.js';
|
|
11
7
|
import { APP_INFO, URLS } from '../config/data.js';
|
|
12
|
-
import { t, getCurrentLanguage,
|
|
8
|
+
import { t, getCurrentLanguage, saveLanguagePreference, clearTranslationCache } from '../i18n/index.js';
|
|
9
|
+
import { runMenu, menuFooter } from '../core/components/menu.js';
|
|
10
|
+
import { note } from '../core/components/note.js';
|
|
11
|
+
import { enterScreen, breadcrumb } from '../core/transitions.js';
|
|
13
12
|
function notifyResult(saved, successMsg, warningMsg) {
|
|
14
13
|
if (saved) {
|
|
15
14
|
success(successMsg);
|
|
@@ -37,12 +36,14 @@ function showAbout() {
|
|
|
37
36
|
note(content, trans.about.title);
|
|
38
37
|
}
|
|
39
38
|
export async function showSettingsMenu() {
|
|
39
|
+
await enterScreen(breadcrumb(t().menu.settings));
|
|
40
40
|
while (true) {
|
|
41
41
|
const trans = t();
|
|
42
42
|
const prefs = loadPreferences();
|
|
43
43
|
const currentLang = getCurrentLanguage();
|
|
44
|
-
const
|
|
45
|
-
|
|
44
|
+
const footer = menuFooter();
|
|
45
|
+
const action = await runMenu({
|
|
46
|
+
title: trans.theme.chooseAction,
|
|
46
47
|
options: [
|
|
47
48
|
{ value: 'language', label: trans.language.selectLanguage, hint: currentLang === 'zh' ? trans.language.zh : trans.language.en },
|
|
48
49
|
{ value: 'icon', label: trans.theme.iconMode, hint: prefs.iconMode },
|
|
@@ -50,42 +51,47 @@ export async function showSettingsMenu() {
|
|
|
50
51
|
{ value: 'reset', label: trans.theme.resetLabel },
|
|
51
52
|
{ value: 'about', label: trans.about.title },
|
|
52
53
|
],
|
|
54
|
+
footer,
|
|
53
55
|
});
|
|
54
|
-
if (
|
|
56
|
+
if (action === null)
|
|
55
57
|
return;
|
|
56
58
|
if (action === 'about') {
|
|
57
59
|
showAbout();
|
|
58
60
|
continue;
|
|
59
61
|
}
|
|
60
62
|
if (action === 'language') {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
const langOptions = [
|
|
64
|
+
{ value: 'zh', label: trans.language.zh, hint: currentLang === 'zh' ? trans.common.current : undefined },
|
|
65
|
+
{ value: 'en', label: trans.language.en, hint: currentLang === 'en' ? trans.common.current : undefined },
|
|
66
|
+
];
|
|
67
|
+
const language = await runMenu({
|
|
68
|
+
title: trans.language.selectLanguage,
|
|
69
|
+
options: langOptions,
|
|
70
|
+
footer,
|
|
71
|
+
initialIndex: Math.max(0, langOptions.findIndex(o => o.value === currentLang)),
|
|
68
72
|
});
|
|
69
|
-
if (
|
|
73
|
+
if (language === null)
|
|
70
74
|
continue;
|
|
71
75
|
if (language !== currentLang) {
|
|
72
|
-
const saved =
|
|
76
|
+
const saved = saveLanguagePreference(language);
|
|
73
77
|
clearTranslationCache();
|
|
74
78
|
notifyResult(saved, t().language.changed, t().language.changedSessionOnly);
|
|
75
79
|
}
|
|
76
80
|
continue;
|
|
77
81
|
}
|
|
78
82
|
if (action === 'icon') {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
const iconOptions = [
|
|
84
|
+
{ value: 'auto', label: trans.theme.modeAuto, hint: prefs.iconMode === 'auto' ? trans.common.current : undefined },
|
|
85
|
+
{ value: 'ascii', label: trans.theme.modeAscii, hint: prefs.iconMode === 'ascii' ? trans.common.current : undefined },
|
|
86
|
+
{ value: 'unicode', label: trans.theme.modeUnicode, hint: prefs.iconMode === 'unicode' ? trans.common.current : undefined },
|
|
87
|
+
];
|
|
88
|
+
const mode = await runMenu({
|
|
89
|
+
title: trans.theme.chooseIconMode,
|
|
90
|
+
options: iconOptions,
|
|
91
|
+
footer,
|
|
92
|
+
initialIndex: Math.max(0, iconOptions.findIndex(o => o.value === prefs.iconMode)),
|
|
87
93
|
});
|
|
88
|
-
if (
|
|
94
|
+
if (mode === null)
|
|
89
95
|
continue;
|
|
90
96
|
const saved = setIconMode(mode);
|
|
91
97
|
resetIconCache();
|
|
@@ -93,16 +99,18 @@ export async function showSettingsMenu() {
|
|
|
93
99
|
continue;
|
|
94
100
|
}
|
|
95
101
|
if (action === 'color') {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
const colorOptions = [
|
|
103
|
+
{ value: 'auto', label: trans.theme.modeAuto, hint: prefs.colorMode === 'auto' ? trans.common.current : undefined },
|
|
104
|
+
{ value: 'on', label: trans.theme.modeOn, hint: prefs.colorMode === 'on' ? trans.common.current : undefined },
|
|
105
|
+
{ value: 'off', label: trans.theme.modeOff, hint: prefs.colorMode === 'off' ? trans.common.current : undefined },
|
|
106
|
+
];
|
|
107
|
+
const mode = await runMenu({
|
|
108
|
+
title: trans.theme.chooseColorMode,
|
|
109
|
+
options: colorOptions,
|
|
110
|
+
footer,
|
|
111
|
+
initialIndex: Math.max(0, colorOptions.findIndex(o => o.value === prefs.colorMode)),
|
|
104
112
|
});
|
|
105
|
-
if (
|
|
113
|
+
if (mode === null)
|
|
106
114
|
continue;
|
|
107
115
|
const saved = setColorMode(mode);
|
|
108
116
|
applyColorModePreference(false);
|
package/dist/features/status.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { APP_INFO, URLS } from '../config/data.js';
|
|
3
|
+
import { getCapabilities } from '../core/capabilities.js';
|
|
4
|
+
import { createPainter } from '../core/components/painter.js';
|
|
3
5
|
import { pickIcon } from '../core/icons.js';
|
|
4
6
|
import { padEndV, visualWidth } from '../core/text.js';
|
|
5
7
|
import { c } from '../core/theme.js';
|
|
6
8
|
import { createSpinner } from '../core/ui.js';
|
|
9
|
+
import { enterScreen, breadcrumb } from '../core/transitions.js';
|
|
7
10
|
import { t } from '../i18n/index.js';
|
|
8
11
|
function getServiceTargets() {
|
|
9
12
|
const trans = t();
|
|
10
13
|
return [
|
|
11
|
-
// NBTCA-owned services
|
|
12
14
|
{ name: trans.status.serviceHomepage, url: URLS.homepage, group: 'nbtca' },
|
|
13
15
|
{ name: trans.status.serviceDocs, url: URLS.docs, group: 'nbtca' },
|
|
14
16
|
{ name: trans.status.serviceIcal, url: URLS.calendar, group: 'nbtca' },
|
|
15
17
|
{ name: trans.status.serviceRepair, url: URLS.repair, group: 'nbtca' },
|
|
16
|
-
// External platforms
|
|
17
18
|
{ name: trans.status.serviceGithub, url: URLS.github, group: 'external' },
|
|
18
19
|
{ name: trans.status.serviceRoadmap, url: URLS.roadmap, group: 'external' },
|
|
19
|
-
// Intranet services (campus LAN only)
|
|
20
20
|
{ name: trans.status.serviceCloud, url: URLS.cloud, group: 'intranet', intranet: true },
|
|
21
21
|
{ name: trans.status.serviceMirror, url: URLS.mirror, group: 'intranet', intranet: true },
|
|
22
22
|
];
|
|
@@ -44,7 +44,7 @@ async function checkService(name, url, timeoutMs) {
|
|
|
44
44
|
return { name, url, ok: false, latencyMs, error };
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
async function checkServiceWithRetry(target, timeoutMs, retries) {
|
|
47
|
+
export async function checkServiceWithRetry(target, timeoutMs, retries) {
|
|
48
48
|
let lastResult = await checkService(target.name, target.url, timeoutMs);
|
|
49
49
|
if (!lastResult.ok) {
|
|
50
50
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
@@ -116,9 +116,12 @@ export function renderServiceStatusTable(items, options) {
|
|
|
116
116
|
lines.push(` ${applyDim(sep.repeat(nameWidth + statusWidth + 12))}`);
|
|
117
117
|
currentGroup = item.group;
|
|
118
118
|
}
|
|
119
|
-
const nameCol = padEndV(item.intranet ? applyDim(item.name) : applyCyan(item.name), nameWidth);
|
|
119
|
+
const nameCol = padEndV(item.pending ? applyDim(item.name) : (item.intranet ? applyDim(item.name) : applyCyan(item.name)), nameWidth);
|
|
120
120
|
let statusLabel;
|
|
121
|
-
if (item.
|
|
121
|
+
if (item.pending) {
|
|
122
|
+
statusLabel = applyDim(pickIcon('…', '.'));
|
|
123
|
+
}
|
|
124
|
+
else if (item.ok) {
|
|
122
125
|
statusLabel = applyGreen(`${onIcon} ${trans.status.up}`);
|
|
123
126
|
}
|
|
124
127
|
else if (item.intranet) {
|
|
@@ -128,7 +131,7 @@ export function renderServiceStatusTable(items, options) {
|
|
|
128
131
|
statusLabel = applyRed(`${offIcon} ${trans.status.down}`);
|
|
129
132
|
}
|
|
130
133
|
const statusCol = padEndV(statusLabel, statusWidth);
|
|
131
|
-
const latencyCol = item.ok && item.latencyMs != null
|
|
134
|
+
const latencyCol = !item.pending && item.ok && item.latencyMs != null
|
|
132
135
|
? applyLatency(item.latencyMs)
|
|
133
136
|
: applyDim('—');
|
|
134
137
|
lines.push(` ${nameCol} ${statusCol} ${latencyCol}`);
|
|
@@ -137,17 +140,35 @@ export function renderServiceStatusTable(items, options) {
|
|
|
137
140
|
}
|
|
138
141
|
export async function showServiceStatus() {
|
|
139
142
|
const trans = t();
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
await enterScreen(breadcrumb(trans.menu.status));
|
|
144
|
+
const targets = getServiceTargets();
|
|
145
|
+
if (getCapabilities().reducedMotion) {
|
|
146
|
+
const spinner = createSpinner(trans.status.checking);
|
|
147
|
+
const items = await checkServices();
|
|
148
|
+
const hasFailures = hasServiceFailures(items);
|
|
149
|
+
if (hasFailures)
|
|
150
|
+
spinner.error(trans.status.summaryFail);
|
|
151
|
+
else
|
|
152
|
+
spinner.stop(trans.status.summaryOk);
|
|
153
|
+
console.log();
|
|
154
|
+
console.log(renderServiceStatusTable(items, { color: !!process.stdout.isTTY }));
|
|
155
|
+
console.log();
|
|
156
|
+
return items;
|
|
148
157
|
}
|
|
158
|
+
const items = targets.map((tg) => ({
|
|
159
|
+
name: tg.name, url: tg.url, ok: false, group: tg.group, intranet: tg.intranet, pending: true,
|
|
160
|
+
}));
|
|
161
|
+
const paint = createPainter(() => renderServiceStatusTable(items, { color: getCapabilities().color }));
|
|
149
162
|
console.log();
|
|
150
|
-
|
|
163
|
+
paint();
|
|
164
|
+
await Promise.all(targets.map(async (target, i) => {
|
|
165
|
+
const status = await checkServiceWithRetry(target, 6000, 1);
|
|
166
|
+
items[i] = { ...status, pending: false };
|
|
167
|
+
paint();
|
|
168
|
+
}));
|
|
169
|
+
console.log('\n');
|
|
170
|
+
const hasFailures = hasServiceFailures(items);
|
|
171
|
+
console.log(hasFailures ? c.warn(trans.status.summaryFail) : c.success(trans.status.summaryOk));
|
|
151
172
|
console.log();
|
|
152
173
|
return items;
|
|
153
174
|
}
|