@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.
Files changed (65) hide show
  1. package/README.md +44 -0
  2. package/SECURITY.md +47 -0
  3. package/dist/app/app.js +202 -0
  4. package/dist/app/chrome.js +104 -0
  5. package/dist/app/fields/list-field.js +174 -0
  6. package/dist/app/fields/text-field.js +38 -0
  7. package/dist/app/frame.js +48 -0
  8. package/dist/app/keys.js +20 -0
  9. package/dist/app/tabs.js +11 -0
  10. package/dist/app/view.js +1 -0
  11. package/dist/app/views/docs-render.js +82 -0
  12. package/dist/app/views/docs.js +457 -0
  13. package/dist/app/views/events-render.js +111 -0
  14. package/dist/app/views/events.js +228 -0
  15. package/dist/app/views/home.js +236 -0
  16. package/dist/app/views/schedule-grid-cursor.js +52 -0
  17. package/dist/app/views/schedule-render.js +317 -0
  18. package/dist/app/views/schedule.js +472 -0
  19. package/dist/app/views/settings-render.js +53 -0
  20. package/dist/app/views/settings.js +153 -0
  21. package/dist/auth/cookie-transport.js +222 -0
  22. package/dist/auth/errors.js +18 -0
  23. package/dist/auth/nbt-auth.js +239 -0
  24. package/dist/auth/session-store.js +118 -0
  25. package/dist/config/paths.js +22 -2
  26. package/dist/core/canvas.js +23 -0
  27. package/dist/core/capabilities.js +42 -0
  28. package/dist/core/components/confirm.js +75 -0
  29. package/dist/core/components/input-session.js +24 -0
  30. package/dist/core/components/menu.js +122 -0
  31. package/dist/core/components/messages.js +16 -0
  32. package/dist/core/components/note.js +18 -0
  33. package/dist/core/components/painter.js +26 -0
  34. package/dist/core/components/screen.js +18 -0
  35. package/dist/core/components/spinner.js +47 -0
  36. package/dist/core/components/text-input.js +98 -0
  37. package/dist/core/logo.js +40 -15
  38. package/dist/core/menu.js +24 -6
  39. package/dist/core/motion.js +86 -0
  40. package/dist/core/text.js +127 -5
  41. package/dist/core/theme.js +61 -0
  42. package/dist/core/transitions.js +19 -0
  43. package/dist/core/ui.js +5 -29
  44. package/dist/features/calendar-heatmap.js +29 -27
  45. package/dist/features/calendar-query.js +50 -0
  46. package/dist/features/calendar.js +192 -98
  47. package/dist/features/docs.js +258 -55
  48. package/dist/features/links.js +7 -4
  49. package/dist/features/schedule-query.js +47 -0
  50. package/dist/features/schedule-render.js +574 -0
  51. package/dist/features/schedule-store.js +73 -0
  52. package/dist/features/schedule-view.js +260 -0
  53. package/dist/features/settings.js +41 -30
  54. package/dist/features/status.js +37 -13
  55. package/dist/features/student-timetable.js +346 -0
  56. package/dist/features/update.js +16 -8
  57. package/dist/i18n/locales/en.json +149 -6
  58. package/dist/i18n/locales/zh.json +149 -6
  59. package/dist/index.js +59 -5
  60. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  61. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  62. package/dist/logo/ca-dotmatrix.txt +18 -16
  63. package/dist/logo/ca-logo.png +0 -0
  64. package/dist/main.js +33 -13
  65. package/package.json +10 -7
@@ -1,13 +1,18 @@
1
- import { loadCalendar, FeedFetchError, FeedParseError } from '@nbtca/nbtcal';
1
+ import { loadCalendar, FeedFetchError, FeedParseError, eventToICS } from '@nbtca/nbtcal';
2
2
  import chalk from 'chalk';
3
- import { select, isCancel } from '@clack/prompts';
4
- import { createSpinner } from '../core/ui.js';
5
- import { c } from '../core/theme.js';
3
+ import { createSpinner, success, error } from '../core/ui.js';
4
+ import { c, type, space, glyph } from '../core/theme.js';
5
+ import { runMenu, menuFooter } from '../core/components/menu.js';
6
+ import { runTextInput } from '../core/components/text-input.js';
6
7
  import { pickIcon } from '../core/icons.js';
7
- import { padEndV, truncate } from '../core/text.js';
8
+ import { padEndV, truncate, visualWidth, wrapAnsiToVisualWidth } from '../core/text.js';
8
9
  import { t } from '../i18n/index.js';
10
+ import { enterScreen, breadcrumb } from '../core/transitions.js';
9
11
  import { URLS } from '../config/data.js';
10
12
  import { renderHeatmap } from './calendar-heatmap.js';
13
+ import { countdownParts, isCountdownUrgent, buildExportFilename, weekRange, monthRange, filterEvents } from './calendar-query.js';
14
+ import { writeFileSync, existsSync } from 'fs';
15
+ import { join } from 'path';
11
16
  function formatDate(date) {
12
17
  const now = new Date();
13
18
  const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -22,7 +27,7 @@ function formatTime(date) {
22
27
  const minutes = String(date.getMinutes()).padStart(2, '0');
23
28
  return `${hours}:${minutes}`;
24
29
  }
25
- async function loadCalendarOrThrow() {
30
+ export async function loadCalendarOrThrow() {
26
31
  try {
27
32
  return await loadCalendar({ timeoutMs: 15000 });
28
33
  }
@@ -42,11 +47,16 @@ export function toDisplayEvent(e) {
42
47
  location: e.location ?? trans.calendar.tbdLocation,
43
48
  description: e.description ?? '',
44
49
  startDate: e.start,
50
+ recurring: e.recurring,
51
+ uid: e.uid,
45
52
  };
46
53
  }
47
54
  export async function fetchEvents() {
48
55
  return (await loadCalendarOrThrow()).upcoming({ days: 30 }).map(toDisplayEvent);
49
56
  }
57
+ export async function fetchInRange(start, end) {
58
+ return (await loadCalendarOrThrow()).inRange(start, end).map(toDisplayEvent);
59
+ }
50
60
  export async function fetchHeatmapBuckets() {
51
61
  const now = new Date();
52
62
  const start = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
@@ -60,13 +70,15 @@ export function serializeEvents(events) {
60
70
  location: event.location,
61
71
  description: event.description,
62
72
  startDateISO: event.startDate.toISOString(),
73
+ recurring: event.recurring,
74
+ uid: event.uid,
63
75
  }));
64
76
  }
65
77
  export function renderEventsTable(events, options) {
66
78
  const trans = t();
67
79
  const useColor = options?.color !== false;
68
80
  if (events.length === 0)
69
- return ` ${trans.calendar.noEvents}`;
81
+ return `${space.indent}${type.hint(trans.calendar.noEvents)}`;
70
82
  const id = (s) => s;
71
83
  const applyDim = useColor ? chalk.dim : id;
72
84
  const applyCyan = useColor ? chalk.cyan : id;
@@ -89,32 +101,66 @@ export function renderEventsTable(events, options) {
89
101
  for (const event of events) {
90
102
  const dateTime = event.time ? `${event.date} ${event.time}` : event.date;
91
103
  const dateCol = padEndV(applyCyan(dateTime), dateWidth);
92
- const titleCol = padEndV(applyBold(truncate(event.title, titleWidth)), titleWidth);
104
+ const marker = event.recurring ? `${pickIcon('↻', '~')} ` : '';
105
+ const titleText = truncate(`${marker}${event.title}`, titleWidth);
106
+ const titleCol = padEndV(applyBold(titleText), titleWidth);
93
107
  const locCol = applyGray(truncate(event.location, locWidth));
94
108
  lines.push(` ${dateCol} ${titleCol} ${locCol}`);
95
109
  }
96
110
  return lines.join('\n');
97
111
  }
112
+ /** One compact line for an "at a glance" activity briefing: date/time · title,
113
+ * with today's events picked out (bold + accent) so the one thing worth
114
+ * noticing doesn't read the same as everything else in the list — and a
115
+ * recurring marker, matching renderEventsTable's convention. Shared by Home's
116
+ * dashboard panel and the Events hub so the two surfaces read consistently
117
+ * instead of drifting into slightly different formats. */
118
+ export function renderEventBrief(e, now) {
119
+ const dot = pickIcon('·', '-');
120
+ const isToday = e.startDate.getFullYear() === now.getFullYear()
121
+ && e.startDate.getMonth() === now.getMonth()
122
+ && e.startDate.getDate() === now.getDate();
123
+ const dateTime = `${e.date}${e.time ? ' ' + e.time : ''}`;
124
+ // "Today" gets one consistent brand-colored signal across marker/date/
125
+ // title — c.warn (yellow) stays reserved for genuine time-pressure
126
+ // urgency (isCountdownUrgent below), not "happens today" alone, so the
127
+ // two alert levels never compete for the same color on one line.
128
+ const marker = isToday ? type.active(pickIcon('●', '*')) : type.hint(pickIcon('·', '-'));
129
+ const dateStyled = isToday ? type.active(dateTime) : type.hint(dateTime);
130
+ const titleStyled = isToday ? type.active(e.title) : type.body(e.title);
131
+ const recurringMark = e.recurring ? ` ${pickIcon('↻', '~')}` : '';
132
+ return `${space.indent}${marker} ${dateStyled} ${dot} ${titleStyled}${recurringMark}`;
133
+ }
134
+ export function renderCountdownBanner(event, now, cols = Number.POSITIVE_INFINITY) {
135
+ if (!event)
136
+ return '';
137
+ const trans = t();
138
+ const p = countdownParts(event.startDate, now);
139
+ const inp = trans.calendar.inPrefix;
140
+ const when = p.past
141
+ ? trans.calendar.startingNow
142
+ : p.days > 0
143
+ ? `${inp} ${p.days}d ${p.hours}h`
144
+ : p.hours > 0
145
+ ? `${inp} ${p.hours}h ${p.minutes}m`
146
+ : `${inp} ${p.minutes}m`;
147
+ const whenStyled = isCountdownUrgent(p) ? c.warn(when) : type.hint(when);
148
+ const dot = pickIcon('·', '-');
149
+ const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
150
+ const cursor = type.active(glyph.cursor());
151
+ const prefixes = [`${space.indent}${cursor} `, `${cursor} `, cursor, ''];
152
+ const prefix = prefixes.find((candidate) => visualWidth(candidate) < width) ?? '';
153
+ const continuation = ' '.repeat(visualWidth(prefix));
154
+ const contentWidth = Math.max(1, width - visualWidth(prefix));
155
+ const content = `${type.label(trans.calendar.next)} ${dot} ${type.body(event.title)} ${dot} ${whenStyled}`;
156
+ return wrapAnsiToVisualWidth(content, contentWidth)
157
+ .map((line, index) => `${index === 0 ? prefix : continuation}${line}`)
158
+ .join('\n');
159
+ }
98
160
  function renderSubscribeHint() {
99
161
  const icon = pickIcon('◆', '*');
100
162
  console.log(c.muted(` ${icon} ${t().calendar.subscribeHint}: ${URLS.calendar}`));
101
163
  }
102
- async function showEventDetail(event) {
103
- const trans = t();
104
- console.log();
105
- console.log(chalk.bold.cyan(` ${event.title}`));
106
- console.log(c.muted(` ${event.date}${event.time ? ' ' + event.time : ''} ${pickIcon('·', '|')} ${event.location}`));
107
- if (event.description) {
108
- console.log();
109
- for (const line of event.description.trim().split('\n')) {
110
- console.log(` ${line}`);
111
- }
112
- }
113
- else {
114
- console.log(c.muted(` ${trans.calendar.noDescription}`));
115
- }
116
- console.log();
117
- }
118
164
  /** Startup preview: auto-loads and displays upcoming events, then returns. */
119
165
  export async function showEventsPreview() {
120
166
  const trans = t();
@@ -139,94 +185,142 @@ export async function showEventsPreview() {
139
185
  console.log();
140
186
  }
141
187
  }
142
- /** Past events: shows historical events from the last 30 days with detail selection. */
143
- async function showPastEvents() {
188
+ /** Full interactive calendar hub: countdown + heatmap + a menu of range/search/past views. */
189
+ export async function showCalendar() {
144
190
  const trans = t();
145
- const s = createSpinner(trans.calendar.pastLoading);
191
+ await enterScreen(breadcrumb(trans.menu.events));
192
+ const spinner = createSpinner(trans.calendar.loading);
193
+ let cal;
146
194
  try {
147
- const cal = await loadCalendarOrThrow();
148
- const events = cal.past({ days: 30 }).reverse().map(toDisplayEvent);
149
- if (events.length === 0) {
150
- s.stop(trans.calendar.noPastEvents);
151
- console.log();
152
- return;
153
- }
154
- s.stop(`${events.length} ${trans.calendar.eventsFound}`);
155
- console.log();
156
- console.log(renderEventsTable(events, { color: true }));
157
- console.log();
158
- const options = [
159
- ...events.map((e, i) => ({
160
- value: String(i),
161
- label: `${e.date}${e.time ? ' ' + e.time : ''} ${e.title}`,
162
- hint: e.location,
163
- })),
164
- { value: '__back__', label: c.muted(trans.common.back) },
165
- ];
166
- const selected = await select({ message: trans.calendar.viewPastDetail, options });
167
- if (!isCancel(selected) && selected !== '__back__') {
168
- const event = events[Number.parseInt(selected, 10)];
169
- if (event)
170
- await showEventDetail(event);
171
- }
195
+ cal = await loadCalendarOrThrow();
196
+ spinner.stop();
172
197
  }
173
198
  catch {
174
- s.error(trans.calendar.error);
199
+ spinner.error(trans.calendar.error);
175
200
  console.log(c.muted(' ' + trans.calendar.errorHint));
176
201
  console.log();
202
+ return;
177
203
  }
178
- }
179
- /** Full interactive calendar: heatmap + event list + detail selection. */
180
- export async function showCalendar() {
181
- const trans = t();
182
- const s = createSpinner(trans.calendar.loading);
183
- try {
184
- const cal = await loadCalendarOrThrow();
185
- const events = cal.upcoming({ days: 30 }).map(toDisplayEvent);
186
- const now = new Date();
187
- const heatmapBuckets = cal.heatmap({
188
- start: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000),
189
- end: now,
190
- bucket: 'day',
204
+ const now = new Date();
205
+ const upcoming = cal.upcoming({ days: 30 });
206
+ console.log();
207
+ console.log(renderCountdownBanner(upcoming[0] ? toDisplayEvent(upcoming[0]) : undefined, now));
208
+ console.log();
209
+ console.log(renderHeatmap(cal.heatmap({ start: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000), end: now, bucket: 'day' }), now, { color: true }));
210
+ console.log();
211
+ while (true) {
212
+ const action = await runMenu({
213
+ title: trans.calendar.viewDetail,
214
+ options: [
215
+ { value: 'upcoming', label: trans.menu.events, hint: String(upcoming.length) },
216
+ { value: 'week', label: trans.calendar.thisWeek },
217
+ { value: 'month', label: trans.calendar.thisMonth },
218
+ { value: 'search', label: trans.calendar.search },
219
+ { value: 'past', label: trans.calendar.pastEvents },
220
+ ],
221
+ footer: menuFooter(),
191
222
  });
192
- if (events.length === 0) {
193
- s.stop(trans.calendar.noEvents);
194
- console.log();
195
- console.log(renderHeatmap(heatmapBuckets, now, { color: true }));
196
- console.log();
223
+ if (action === null)
197
224
  return;
225
+ if (action === 'upcoming')
226
+ await showEventList(upcoming, trans.menu.events);
227
+ else if (action === 'week') {
228
+ const r = weekRange(now);
229
+ await showEventList(cal.inRange(r.start, r.end), trans.calendar.thisWeek);
198
230
  }
199
- s.stop(`${events.length} ${trans.calendar.eventsFound}`);
200
- console.log();
201
- console.log(renderHeatmap(heatmapBuckets, now, { color: true }));
231
+ else if (action === 'month') {
232
+ const r = monthRange(now);
233
+ await showEventList(cal.inRange(r.start, r.end), trans.calendar.thisMonth);
234
+ }
235
+ else if (action === 'search')
236
+ await showSearch(cal);
237
+ else if (action === 'past')
238
+ await showEventList(cal.past({ days: 30 }).reverse(), trans.calendar.pastEvents);
239
+ }
240
+ }
241
+ async function showEventList(events, title) {
242
+ const trans = t();
243
+ if (events.length === 0) {
244
+ console.log(`${space.indent}${type.hint(trans.calendar.noEvents)}`);
202
245
  console.log();
203
- console.log(renderEventsTable(events, { color: true }));
246
+ return;
247
+ }
248
+ const display = events.map(toDisplayEvent);
249
+ console.log();
250
+ console.log(renderEventsTable(display, { color: true }));
251
+ console.log();
252
+ const selected = await runMenu({
253
+ title,
254
+ options: events.map((_e, i) => ({
255
+ value: String(i),
256
+ label: `${display[i].date}${display[i].time ? ' ' + display[i].time : ''} ${display[i].title}`,
257
+ hint: display[i].location,
258
+ })),
259
+ footer: menuFooter(),
260
+ });
261
+ if (selected === null)
262
+ return;
263
+ const raw = events[Number.parseInt(selected, 10)];
264
+ if (raw)
265
+ await showEventDetailRaw(raw);
266
+ }
267
+ async function showEventDetailRaw(raw) {
268
+ const trans = t();
269
+ const e = toDisplayEvent(raw);
270
+ console.log();
271
+ console.log(chalk.bold.cyan(` ${e.title}`));
272
+ console.log(c.muted(` ${e.date}${e.time ? ' ' + e.time : ''} ${pickIcon('·', '|')} ${e.location}`));
273
+ if (raw.recurring)
274
+ console.log(c.muted(` ${pickIcon('↻', '~')} ${trans.calendar.recurringLabel}`));
275
+ if (e.description) {
204
276
  console.log();
205
- renderSubscribeHint();
277
+ for (const line of e.description.trim().split('\n'))
278
+ console.log(` ${line}`);
279
+ }
280
+ else
281
+ console.log(c.muted(` ${trans.calendar.noDescription}`));
282
+ console.log();
283
+ const action = await runMenu({
284
+ title: e.title,
285
+ options: [{ value: 'export', label: trans.calendar.exportIcs }],
286
+ footer: menuFooter(),
287
+ });
288
+ if (action === 'export') {
289
+ const res = exportEventIcs(raw);
290
+ if (res.ok)
291
+ success(`${trans.calendar.exportSuccess}: ${res.path}`);
292
+ else
293
+ error(`${trans.calendar.exportError}: ${res.error ?? ''}`);
294
+ }
295
+ }
296
+ async function showSearch(cal) {
297
+ const trans = t();
298
+ const query = await runTextInput({ message: trans.calendar.searchPrompt, placeholder: trans.calendar.searchPlaceholder });
299
+ if (query === null || !query.trim())
300
+ return;
301
+ const now = new Date();
302
+ const pool = cal.inRange(now, new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000));
303
+ const results = filterEvents(pool, query);
304
+ if (results.length === 0) {
305
+ console.log(`${space.indent}${type.hint(trans.calendar.searchNoResults)}`);
206
306
  console.log();
207
- const options = [
208
- ...events.map((e, i) => ({
209
- value: String(i),
210
- label: `${e.date}${e.time ? ' ' + e.time : ''} ${e.title}`,
211
- hint: e.location,
212
- })),
213
- { value: '__past__', label: chalk.dim(trans.calendar.pastEvents) },
214
- { value: '__back__', label: c.muted(trans.common.back) },
215
- ];
216
- const selected = await select({ message: trans.calendar.viewDetail, options });
217
- if (isCancel(selected) || selected === '__back__')
218
- return;
219
- if (selected === '__past__') {
220
- await showPastEvents();
221
- return;
307
+ return;
308
+ }
309
+ await showEventList(results, `${trans.calendar.search}: ${query.trim()}`);
310
+ }
311
+ export function exportEventIcs(event, dir = process.cwd()) {
312
+ const base = buildExportFilename(event);
313
+ let path = join(dir, base);
314
+ try {
315
+ let n = 1;
316
+ while (existsSync(path)) {
317
+ path = join(dir, base.replace(/\.ics$/, `-${n}.ics`));
318
+ n++;
222
319
  }
223
- const event = events[Number.parseInt(selected, 10)];
224
- if (event)
225
- await showEventDetail(event);
320
+ writeFileSync(path, eventToICS(event), 'utf-8');
321
+ return { ok: true, path };
226
322
  }
227
- catch {
228
- s.error(trans.calendar.error);
229
- console.log(c.muted(' ' + trans.calendar.errorHint));
230
- console.log();
323
+ catch (err) {
324
+ return { ok: false, path, error: err instanceof Error ? err.message : String(err) };
231
325
  }
232
326
  }