@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.
Files changed (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -1,12 +1,7 @@
1
- /**
2
- * Heatmap renderer for calendar activity.
3
- * GitHub-contributions-style grid: 7 weekday rows (Mon..Sun), week columns.
4
- */
5
1
  import chalk from 'chalk';
6
2
  import { pickIcon } from '../core/icons.js';
7
3
  import { space, type } from '../core/theme.js';
8
4
  import { t, getCurrentLanguage } from '../i18n/index.js';
9
- /** Parse a 'YYYY-MM-DD' date string into a UTC proxy Date (host-timezone-independent). */
10
5
  function parseBucketDate(date) {
11
6
  const parts = date.split('-').map(Number);
12
7
  const y = parts[0] ?? 0;
@@ -14,11 +9,9 @@ function parseBucketDate(date) {
14
9
  const d = parts[2] ?? 1;
15
10
  return new Date(Date.UTC(y, m - 1, d));
16
11
  }
17
- /** 0=Sun,1=Mon,...,6=Sat -> Mon-indexed 0..6 */
18
12
  function utcDayToMonIndex(utcDay) {
19
13
  return (utcDay + 6) % 7;
20
14
  }
21
- /** Map a count to a unicode intensity glyph (or ASCII fallback). */
22
15
  function countToGlyph(count) {
23
16
  if (count <= 0)
24
17
  return pickIcon('·', ' ');
@@ -30,46 +23,28 @@ function countToGlyph(count) {
30
23
  return pickIcon('▓', '-');
31
24
  return pickIcon('█', '=');
32
25
  }
33
- // A fixed 4-stop truecolor green ramp, not chalk's named green/greenBright --
34
- // those resolve to whatever the user's terminal theme defines for "green",
35
- // same problem the rest of the app's palette solved by specifying hex
36
- // directly (theme.ts's brand blue, brandGradient) instead of leaning on
37
- // ANSI color names. Kept muted/desaturated rather than a punchy grass
38
- // green so it sits quietly next to the app's own cool blue palette instead
39
- // of reading as a louder, disconnected accent.
40
26
  const HEATMAP_RAMP = ['#1b4332', '#2d6a4f', '#40916c', '#52b788'];
41
27
  const MAX_WEEK_COLUMNS = 53;
42
28
  const GRID_PREFIX_WIDTH = 6;
43
- /** Apply a green color ramp based on count. Identity when count is 0. */
44
29
  function applyColor(glyph, count, useColor) {
45
30
  if (!useColor || count <= 0)
46
31
  return glyph;
47
32
  const level = Math.min(count, HEATMAP_RAMP.length) - 1;
48
33
  return chalk.hex(HEATMAP_RAMP[level])(glyph);
49
34
  }
50
- /**
51
- * Render a GitHub-contributions-style heatmap grid.
52
- *
53
- * @param buckets Dense daily buckets from nbtcal's .heatmap() call.
54
- * @param today The "today" date (used to determine the end column).
55
- * @param options Optional rendering options.
56
- */
57
35
  export function renderHeatmap(buckets, today, options) {
58
36
  const useColor = options?.color === true;
59
37
  const trans = t();
60
- const cellWidth = options?.cols !== undefined
61
- && options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2;
38
+ const cellWidth = options?.cols !== undefined && options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2;
62
39
  const availableColumns = options?.cols === undefined
63
40
  ? MAX_WEEK_COLUMNS
64
41
  : Math.floor((options.cols - GRID_PREFIX_WIDTH) / cellWidth);
65
42
  const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns));
66
- // Build a lookup map: 'YYYY-MM-DD' -> count
67
43
  const countByDate = new Map();
68
44
  for (const b of buckets) {
69
45
  countByDate.set(b.date, b.count);
70
46
  }
71
47
  const todayProxy = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
72
- // Find the Monday that starts the week containing today
73
48
  const todayMonIndex = utcDayToMonIndex(todayProxy.getUTCDay());
74
49
  const gridEndMs = todayProxy.getTime() + (6 - todayMonIndex) * 86400000; // Sunday of today's week
75
50
  const gridStartMs = gridEndMs - (numCols * 7 - 1) * 86400000;
@@ -96,7 +71,11 @@ export function renderHeatmap(buckets, today, options) {
96
71
  columns.push(column);
97
72
  }
98
73
  const weekdayLabel = space.indent; // matches the grid rows' "Mo " prefix width
99
- const monthFmt = new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' });
74
+ const language = getCurrentLanguage();
75
+ const monthFmt = new Intl.DateTimeFormat(language === 'zh' ? 'zh-CN' : 'en-US', {
76
+ month: 'short',
77
+ timeZone: 'UTC',
78
+ });
100
79
  const cellsWidth = numCols * cellWidth;
101
80
  const monthChars = new Array(cellsWidth).fill(' ');
102
81
  let prevMonth = -1;
@@ -122,24 +101,22 @@ export function renderHeatmap(buckets, today, options) {
122
101
  }
123
102
  }
124
103
  const monthLabelLine = space.indent + weekdayLabel + monthChars.join('');
125
- // Weekday labels (Mon/Wed/Fri only, index 0/2/4 in Mon-indexed scheme)
126
- const lang = getCurrentLanguage();
127
- const weekdayNames = lang === 'zh'
128
- ? ['一', ' ', '三', ' ', '五', ' ', ' ']
129
- : ['Mo', ' ', 'We', ' ', 'Fr', ' ', ' '];
130
- // Build output lines
104
+ const weekdayNames = [
105
+ trans.timetable.weekdayMon.slice(0, 2),
106
+ ' ',
107
+ trans.timetable.weekdayWed.slice(0, 2),
108
+ ' ',
109
+ trans.timetable.weekdayFri.slice(0, 2),
110
+ ' ',
111
+ ' ',
112
+ ];
131
113
  const lines = [];
132
- // Title line — same space.indent + type.heading treatment every other
133
- // section heading in the app uses (this one was bare, so it sat flush
134
- // against the terminal edge instead of matching the app's 3-space margin).
135
114
  lines.push(space.indent + type.heading(trans.calendar.heatmap.title));
136
115
  lines.push('');
137
- // Month labels line
138
116
  lines.push(monthLabelLine);
139
- // Grid rows (7 rows: Mon..Sun)
140
117
  for (let row = 0; row < 7; row++) {
141
118
  const wdLabel = weekdayNames[row] ?? ' ';
142
- const cells = columns.map(col => {
119
+ const cells = columns.map((col) => {
143
120
  const cell = col[row];
144
121
  if (cell === null || cell === undefined)
145
122
  return ' ';
@@ -148,7 +125,6 @@ export function renderHeatmap(buckets, today, options) {
148
125
  });
149
126
  lines.push(`${space.indent}${wdLabel} ${cells.join(cellWidth === 2 ? ' ' : '')}`);
150
127
  }
151
- // Legend line
152
128
  const legendGlyphs = [
153
129
  pickIcon('·', ' '),
154
130
  pickIcon('░', '.'),
@@ -16,8 +16,7 @@ export function filterEvents(events, query) {
16
16
  const q = query.trim().toLowerCase();
17
17
  if (!q)
18
18
  return events;
19
- return events.filter((e) => (e.title ?? '').toLowerCase().includes(q) ||
20
- (e.location ?? '').toLowerCase().includes(q));
19
+ return events.filter((e) => (e.title ?? '').toLowerCase().includes(q) || (e.location ?? '').toLowerCase().includes(q));
21
20
  }
22
21
  export function countdownParts(target, now) {
23
22
  const ms = target.getTime() - now.getTime();
@@ -0,0 +1,27 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getStateDir, getWritableStateDir } from '../config/paths.js';
4
+ const FEED_FILE = 'calendar-feed.ics';
5
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
6
+ export function saveFeedCache(text, dir) {
7
+ try {
8
+ fs.writeFileSync(path.join(dir ?? getWritableStateDir(), FEED_FILE), text, {
9
+ encoding: 'utf8',
10
+ mode: 0o600,
11
+ });
12
+ }
13
+ catch {
14
+ /* best effort */
15
+ }
16
+ }
17
+ export function loadFeedCache(dir, maxAgeMs = MAX_AGE_MS) {
18
+ try {
19
+ const file = path.join(dir ?? getStateDir(), FEED_FILE);
20
+ if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs)
21
+ return null;
22
+ return fs.readFileSync(file, 'utf8');
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
@@ -1,16 +1,12 @@
1
- import { loadCalendar, FeedFetchError, FeedParseError, eventToICS } from '@nbtca/nbtcal';
1
+ import { fetchFeed, parseCalendar, createCalendar, FeedFetchError, FeedParseError, eventToICS, } from '@nbtca/nbtcal';
2
2
  import chalk from 'chalk';
3
- import { createSpinner, success, error } from '../core/ui.js';
4
3
  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';
7
4
  import { pickIcon } from '../core/icons.js';
8
- import { padEndV, truncate, visualWidth, wrapAnsiToVisualWidth } from '../core/text.js';
5
+ import { padEndV, sanitizeTerminalLine, sanitizeTerminalText, truncate, visualWidth, wrapAnsiWithIndent, wrapAnsiToVisualWidth, } from '../core/text.js';
9
6
  import { t } from '../i18n/index.js';
10
- import { enterScreen, breadcrumb } from '../core/transitions.js';
11
- import { URLS } from '../config/data.js';
12
- import { renderHeatmap } from './calendar-heatmap.js';
13
- import { countdownParts, isCountdownUrgent, buildExportFilename, weekRange, monthRange, filterEvents } from './calendar-query.js';
7
+ import { addLocalDays } from '../core/calendar-day.js';
8
+ import { countdownParts, isCountdownUrgent, buildExportFilename } from './calendar-query.js';
9
+ import { loadFeedCache, saveFeedCache } from './calendar-store.js';
14
10
  import { writeFileSync, existsSync } from 'fs';
15
11
  import { join } from 'path';
16
12
  function formatDate(date) {
@@ -27,25 +23,54 @@ function formatTime(date) {
27
23
  const minutes = String(date.getMinutes()).padStart(2, '0');
28
24
  return `${hours}:${minutes}`;
29
25
  }
30
- export async function loadCalendarOrThrow() {
26
+ const MEMO_TTL_MS = 5 * 60 * 1000;
27
+ let memo;
28
+ let inFlight;
29
+ export function peekCalendar() {
30
+ if (memo)
31
+ return memo.calendar;
32
+ const text = loadFeedCache();
33
+ if (text === null)
34
+ return undefined;
31
35
  try {
32
- return await loadCalendar({ timeoutMs: 15000 });
36
+ memo = { calendar: createCalendar(parseCalendar(text)), fetchedAt: 0 };
37
+ return memo.calendar;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ async function refetchCalendar(signal) {
44
+ try {
45
+ const text = await fetchFeed(undefined, {
46
+ timeoutMs: 15000,
47
+ ...(signal === undefined ? {} : { signal }),
48
+ });
49
+ memo = { calendar: createCalendar(parseCalendar(text)), fetchedAt: Date.now() };
50
+ saveFeedCache(text);
51
+ return memo.calendar;
33
52
  }
34
53
  catch (err) {
35
- const detail = err instanceof FeedFetchError || err instanceof FeedParseError
36
- ? err.message
37
- : String(err);
54
+ const detail = sanitizeTerminalLine(err instanceof FeedFetchError || err instanceof FeedParseError ? err.message : String(err));
38
55
  throw new Error(`${t().calendar.error}: ${detail}`);
39
56
  }
40
57
  }
58
+ export async function loadCalendarOrThrow(signal) {
59
+ if (memo && Date.now() - memo.fetchedAt < MEMO_TTL_MS)
60
+ return memo.calendar;
61
+ inFlight ??= refetchCalendar(signal).finally(() => {
62
+ inFlight = undefined;
63
+ });
64
+ return inFlight;
65
+ }
41
66
  export function toDisplayEvent(e) {
42
67
  const trans = t();
43
68
  return {
44
69
  date: formatDate(e.start),
45
70
  time: e.isAllDay ? '' : formatTime(e.start),
46
- title: e.title ?? trans.calendar.untitledEvent,
47
- location: e.location ?? trans.calendar.tbdLocation,
48
- description: e.description ?? '',
71
+ title: sanitizeTerminalLine(e.title ?? trans.calendar.untitledEvent),
72
+ location: sanitizeTerminalLine(e.location ?? trans.calendar.tbdLocation),
73
+ description: sanitizeTerminalText(e.description ?? ''),
49
74
  startDate: e.start,
50
75
  recurring: e.recurring,
51
76
  uid: e.uid,
@@ -59,7 +84,7 @@ export async function fetchInRange(start, end) {
59
84
  }
60
85
  export async function fetchHeatmapBuckets() {
61
86
  const now = new Date();
62
- const start = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
87
+ const start = addLocalDays(now, -365);
63
88
  return (await loadCalendarOrThrow()).heatmap({ start, end: now, bucket: 'day' });
64
89
  }
65
90
  export function serializeEvents(events) {
@@ -77,14 +102,30 @@ export function serializeEvents(events) {
77
102
  export function renderEventsTable(events, options) {
78
103
  const trans = t();
79
104
  const useColor = options?.color !== false;
80
- if (events.length === 0)
81
- return `${space.indent}${type.hint(trans.calendar.noEvents)}`;
105
+ const width = options?.width === undefined || !Number.isFinite(options.width)
106
+ ? Number.POSITIVE_INFINITY
107
+ : Math.max(1, Math.floor(options.width));
108
+ if (events.length === 0) {
109
+ return wrapAnsiWithIndent(type.hint(trans.calendar.noEvents), width, space.indent).join('\n');
110
+ }
82
111
  const id = (s) => s;
83
112
  const applyDim = useColor ? chalk.dim : id;
84
113
  const applyCyan = useColor ? chalk.cyan : id;
85
114
  const applyBold = useColor ? chalk.bold : id;
86
115
  const applyGray = useColor ? chalk.gray : id;
87
- // dateWidth must fit YYYY-MM-DD HH:MM (16 chars) for cross-year events
116
+ if (width < 68) {
117
+ const lines = [];
118
+ for (const event of events) {
119
+ if (lines.length > 0)
120
+ lines.push('');
121
+ const dateTime = event.time ? `${event.date} ${event.time}` : event.date;
122
+ const marker = event.recurring ? `${pickIcon('↻', '~')} ` : '';
123
+ lines.push(...wrapAnsiWithIndent(applyCyan(dateTime), width, space.indent));
124
+ lines.push(...wrapAnsiWithIndent(applyBold(`${marker}${event.title}`), width, space.indent));
125
+ lines.push(...wrapAnsiWithIndent(applyGray(`${pickIcon('⌖', '@')} ${event.location}`), width, space.indent));
126
+ }
127
+ return lines.join('\n');
128
+ }
88
129
  const dateWidth = 16;
89
130
  const titleWidth = 32;
90
131
  const locWidth = 14;
@@ -92,12 +133,8 @@ export function renderEventsTable(events, options) {
92
133
  const headerDate = padEndV(applyDim(trans.calendar.dateTime), dateWidth);
93
134
  const headerTitle = padEndV(applyDim(trans.calendar.eventName), titleWidth);
94
135
  const headerLoc = applyDim(trans.calendar.location);
95
- // divider covers exactly: dateWidth + 2-char sep + titleWidth + 2-char sep + locWidth
96
136
  const divider = applyDim(sep.repeat(dateWidth + 2 + titleWidth + 2 + locWidth));
97
- const lines = [
98
- ` ${headerDate} ${headerTitle} ${headerLoc}`,
99
- ` ${divider}`,
100
- ];
137
+ const lines = [` ${headerDate} ${headerTitle} ${headerLoc}`, ` ${divider}`];
101
138
  for (const event of events) {
102
139
  const dateTime = event.time ? `${event.date} ${event.time}` : event.date;
103
140
  const dateCol = padEndV(applyCyan(dateTime), dateWidth);
@@ -109,22 +146,12 @@ export function renderEventsTable(events, options) {
109
146
  }
110
147
  return lines.join('\n');
111
148
  }
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
149
  export function renderEventBrief(e, now) {
119
150
  const dot = pickIcon('·', '-');
120
- const isToday = e.startDate.getFullYear() === now.getFullYear()
121
- && e.startDate.getMonth() === now.getMonth()
122
- && e.startDate.getDate() === now.getDate();
151
+ const isToday = e.startDate.getFullYear() === now.getFullYear() &&
152
+ e.startDate.getMonth() === now.getMonth() &&
153
+ e.startDate.getDate() === now.getDate();
123
154
  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
155
  const marker = isToday ? type.active(pickIcon('●', '*')) : type.hint(pickIcon('·', '-'));
129
156
  const dateStyled = isToday ? type.active(dateTime) : type.hint(dateTime);
130
157
  const titleStyled = isToday ? type.active(e.title) : type.body(e.title);
@@ -157,157 +184,6 @@ export function renderCountdownBanner(event, now, cols = Number.POSITIVE_INFINIT
157
184
  .map((line, index) => `${index === 0 ? prefix : continuation}${line}`)
158
185
  .join('\n');
159
186
  }
160
- function renderSubscribeHint() {
161
- const icon = pickIcon('◆', '*');
162
- console.log(c.muted(` ${icon} ${t().calendar.subscribeHint}: ${URLS.calendar}`));
163
- }
164
- /** Startup preview: auto-loads and displays upcoming events, then returns. */
165
- export async function showEventsPreview() {
166
- const trans = t();
167
- const s = createSpinner(trans.calendar.loading);
168
- try {
169
- const cal = await loadCalendarOrThrow();
170
- const events = cal.upcoming({ days: 30 }).map(toDisplayEvent);
171
- if (events.length === 0) {
172
- s.stop(trans.calendar.noEvents);
173
- console.log();
174
- return;
175
- }
176
- s.stop(`${events.length} ${trans.calendar.eventsFound}`);
177
- console.log();
178
- console.log(renderEventsTable(events.slice(0, 5), { color: !!process.stdout.isTTY }));
179
- console.log();
180
- renderSubscribeHint();
181
- console.log();
182
- }
183
- catch {
184
- s.error(trans.calendar.error);
185
- console.log();
186
- }
187
- }
188
- /** Full interactive calendar hub: countdown + heatmap + a menu of range/search/past views. */
189
- export async function showCalendar() {
190
- const trans = t();
191
- await enterScreen(breadcrumb(trans.menu.events));
192
- const spinner = createSpinner(trans.calendar.loading);
193
- let cal;
194
- try {
195
- cal = await loadCalendarOrThrow();
196
- spinner.stop();
197
- }
198
- catch {
199
- spinner.error(trans.calendar.error);
200
- console.log(c.muted(' ' + trans.calendar.errorHint));
201
- console.log();
202
- return;
203
- }
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(),
222
- });
223
- if (action === null)
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);
230
- }
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)}`);
245
- console.log();
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) {
276
- console.log();
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)}`);
306
- console.log();
307
- return;
308
- }
309
- await showEventList(results, `${trans.calendar.search}: ${query.trim()}`);
310
- }
311
187
  export function exportEventIcs(event, dir = process.cwd()) {
312
188
  const base = buildExportFilename(event);
313
189
  let path = join(dir, base);