@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.
Files changed (69) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -1
  3. package/SECURITY.md +47 -0
  4. package/dist/app/app.js +202 -0
  5. package/dist/app/chrome.js +104 -0
  6. package/dist/app/fields/list-field.js +174 -0
  7. package/dist/app/fields/text-field.js +38 -0
  8. package/dist/app/frame.js +48 -0
  9. package/dist/app/keys.js +20 -0
  10. package/dist/app/tabs.js +11 -0
  11. package/dist/app/view.js +1 -0
  12. package/dist/app/views/docs-render.js +82 -0
  13. package/dist/app/views/docs.js +450 -0
  14. package/dist/app/views/events-render.js +111 -0
  15. package/dist/app/views/events.js +228 -0
  16. package/dist/app/views/home.js +195 -0
  17. package/dist/app/views/schedule-grid-cursor.js +52 -0
  18. package/dist/app/views/schedule-render.js +317 -0
  19. package/dist/app/views/schedule.js +463 -0
  20. package/dist/app/views/settings-render.js +53 -0
  21. package/dist/app/views/settings.js +153 -0
  22. package/dist/auth/cookie-transport.js +222 -0
  23. package/dist/auth/errors.js +18 -0
  24. package/dist/auth/nbt-auth.js +239 -0
  25. package/dist/auth/session-store.js +118 -0
  26. package/dist/config/data.js +1 -2
  27. package/dist/config/paths.js +22 -2
  28. package/dist/core/canvas.js +23 -0
  29. package/dist/core/capabilities.js +42 -0
  30. package/dist/core/components/confirm.js +75 -0
  31. package/dist/core/components/input-session.js +24 -0
  32. package/dist/core/components/menu.js +122 -0
  33. package/dist/core/components/messages.js +16 -0
  34. package/dist/core/components/note.js +18 -0
  35. package/dist/core/components/painter.js +26 -0
  36. package/dist/core/components/screen.js +18 -0
  37. package/dist/core/components/spinner.js +47 -0
  38. package/dist/core/components/text-input.js +98 -0
  39. package/dist/core/logo.js +33 -22
  40. package/dist/core/menu.js +24 -9
  41. package/dist/core/motion.js +86 -0
  42. package/dist/core/text.js +121 -5
  43. package/dist/core/theme.js +61 -0
  44. package/dist/core/transitions.js +19 -0
  45. package/dist/core/ui.js +4 -45
  46. package/dist/features/calendar-heatmap.js +29 -27
  47. package/dist/features/calendar-query.js +50 -0
  48. package/dist/features/calendar.js +192 -98
  49. package/dist/features/docs.js +222 -61
  50. package/dist/features/links.js +7 -7
  51. package/dist/features/schedule-query.js +47 -0
  52. package/dist/features/schedule-render.js +573 -0
  53. package/dist/features/schedule-store.js +73 -0
  54. package/dist/features/schedule-view.js +253 -0
  55. package/dist/features/settings.js +43 -35
  56. package/dist/features/status.js +37 -16
  57. package/dist/features/student-timetable.js +346 -0
  58. package/dist/features/theme.js +0 -3
  59. package/dist/features/update.js +16 -18
  60. package/dist/i18n/index.js +5 -47
  61. package/dist/i18n/locales/en.json +149 -6
  62. package/dist/i18n/locales/zh.json +149 -6
  63. package/dist/index.js +61 -11
  64. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  65. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  66. package/dist/logo/ca-dotmatrix.txt +18 -16
  67. package/dist/logo/ca-logo.png +0 -0
  68. package/dist/main.js +33 -13
  69. package/package.json +18 -12
@@ -2,13 +2,15 @@ import { marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import chalk from 'chalk';
4
4
  import open from 'open';
5
- import { select, isCancel, confirm, text } from '@clack/prompts';
6
- import { error, warning, createSpinner } from '../core/ui.js';
5
+ import { runMenu, menuFooter } from '../core/components/menu.js';
6
+ import { runTextInput } from '../core/components/text-input.js';
7
+ import { runConfirm } from '../core/components/confirm.js';
8
+ import { warning, createSpinner } from '../core/ui.js';
7
9
  import { pickIcon } from '../core/icons.js';
8
10
  import { spawn, execFileSync } from 'child_process';
9
11
  import { URLS } from '../config/data.js';
10
12
  import { t, fmt } from '../i18n/index.js';
11
- import { setVimKeysActive } from '../core/vim-keys.js';
13
+ import { enterScreen, breadcrumb } from '../core/transitions.js';
12
14
  import { createDocsClient } from '@nbtca/docs';
13
15
  function detectTerminalType() {
14
16
  const term = (process.env['TERM'] || '').toLowerCase();
@@ -48,16 +50,56 @@ function hasGlow() {
48
50
  _hasGlow = commandExists('glow');
49
51
  return _hasGlow;
50
52
  }
53
+ // nbtca/documents links internally with relative/root-relative paths
54
+ // (`./what-is-nbtca`, `/concepts/school`) that only resolve in a browser --
55
+ // a terminal pager can neither follow nor hover-preview them, so showing
56
+ // the path is dead weight. Matches './x', '../x', and '/x' but not a bare
57
+ // '/' (an internal href is never *just* a slash in this content).
58
+ function isInternalHref(href) {
59
+ return /^\.{0,2}\/./.test(href);
60
+ }
51
61
  let _markedConfigured = false;
52
- function ensureMarkedConfigured() {
62
+ export function ensureMarkedConfigured() {
53
63
  if (_markedConfigured)
54
64
  return;
55
65
  _markedConfigured = true;
56
- marked.use(markedTerminal(getRendererOptions(getTerminalType())));
66
+ const extension = markedTerminal(getRendererOptions(getTerminalType()));
67
+ const renderer = extension.renderer ?? (extension.renderer = {});
68
+ const renderExternalLink = renderer.link;
69
+ if (renderExternalLink) {
70
+ renderer.link = function (token) {
71
+ if (isInternalHref(token.href))
72
+ return chalk.cyan.underline(token.text);
73
+ return renderExternalLink.call(this, token);
74
+ };
75
+ }
76
+ // marked-terminal's own `text` renderer always uses the token's raw
77
+ // `.text` string, never `.tokens` -- fine for a plain text run, but a
78
+ // *tight* list item (nbtca/documents' convention throughout: no blank
79
+ // line between "- " entries) tokenizes its content as a `text` token
80
+ // with markdown links inside still sitting unparsed in `.tokens`, not
81
+ // resolved into `.text`. Result: every link inside every bullet list
82
+ // rendered as completely raw, un-clickable-looking `[text](url)` syntax
83
+ // (only paragraph-level links, which *do* go through inline-parsing,
84
+ // picked up the `link` override above at all). Recursing into
85
+ // `parser.parseInline` here when `.tokens` exists routes list-item links
86
+ // through the same override, matching how paragraph/heading already do.
87
+ const renderPlainText = renderer.text;
88
+ if (renderPlainText) {
89
+ renderer.text = function (token) {
90
+ const withTokens = token;
91
+ if (Array.isArray(withTokens.tokens) && withTokens.tokens.length > 0) {
92
+ return this
93
+ .parser.parseInline(withTokens.tokens);
94
+ }
95
+ return renderPlainText.call(this, token);
96
+ };
97
+ }
98
+ marked.use(extension);
57
99
  }
58
100
  // ─── marked-terminal renderer ─────────────────────────────────────────────────
59
101
  function getRendererOptions(type) {
60
- const width = Math.min(process.stdout.columns || 80, 80);
102
+ const width = 80;
61
103
  const unicodeTableChars = {
62
104
  top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
63
105
  bottom: '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
@@ -74,6 +116,14 @@ function getRendererOptions(type) {
74
116
  width,
75
117
  emoji: true,
76
118
  unescape: true,
119
+ // marked-terminal defaults this to true, prefixing every heading with
120
+ // its literal '#'/'##'/etc. markdown syntax. displayWithLess() already
121
+ // prints its own clean title line above the content, and most docs'
122
+ // first line is an H1 matching that same title -- so the raw '#
123
+ // Title' immediately below just repeated it a second time, syntax
124
+ // marks and all. firstHeading/heading's bold+color already
125
+ // distinguishes heading levels without the extra prefix.
126
+ showSectionPrefix: false,
77
127
  firstHeading: chalk.bold.cyan,
78
128
  heading: chalk.bold.white,
79
129
  codespan: chalk.yellowBright,
@@ -186,7 +236,7 @@ const CONTAINER_ICONS_ASCII = {
186
236
  const CONTAINER_ICONS_UNICODE = {
187
237
  info: 'ℹ️', tip: '💡', warning: '⚠️', danger: '🚨', details: '▶️'
188
238
  };
189
- function cleanMarkdownContent(content, type = getTerminalType()) {
239
+ export function cleanMarkdownContent(content, type = getTerminalType()) {
190
240
  let c = content;
191
241
  // 1. YAML frontmatter
192
242
  c = c.replace(/^---\n[\s\S]*?\n---\n?/m, '');
@@ -203,6 +253,14 @@ function cleanMarkdownContent(content, type = getTerminalType()) {
203
253
  return `> ${icon} **${label}**\n>\n${quoted}\n`;
204
254
  });
205
255
  c = c.replace(/^:::\s*\w*.*$/gm, '');
256
+ // Internal wiki links (./foo, /concepts/foo) are handled at the renderer
257
+ // level (ensureMarkedConfigured's link override below), not here -- an
258
+ // earlier version of this rewrote link syntax into pre-colored raw ANSI
259
+ // text before marked() ever saw it, which broke when marked-terminal's
260
+ // own text reflow/wrapping ran on top of already-escaped text, corrupting
261
+ // the escape sequences into literal visible "[36m...[24m" garbage.
262
+ // Overriding the renderer instead lets marked-terminal own all ANSI
263
+ // output, so nothing downstream can mangle it.
206
264
  // 4. GitHub / GitLab callout alerts (> [!NOTE])
207
265
  c = c.replace(/^>\s*\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/gim, (_, type) => `> **${type.charAt(0) + type.slice(1).toLowerCase()}:**`);
208
266
  // 5. [[toc]] — no value in terminal
@@ -272,14 +330,91 @@ function hasMarkdownTable(content) {
272
330
  function hasMermaidBlock(content) {
273
331
  return /^```mermaid\b/m.test(content);
274
332
  }
333
+ /** Every internal ([text](href) where isInternalHref(href)) link in a
334
+ * document, in reading order, raw href not yet resolved to a real path. */
335
+ function extractInternalLinks(markdown) {
336
+ const links = [];
337
+ const re = /\[([^\]]+)\]\(([^)]+)\)/g;
338
+ let m;
339
+ while ((m = re.exec(markdown))) {
340
+ const href = m[2] ?? '';
341
+ if (isInternalHref(href))
342
+ links.push({ text: m[1] ?? '', href });
343
+ }
344
+ return links;
345
+ }
346
+ /** Resolves a wiki-style href (relative to the *linking* document, VitePress
347
+ * conventions: no .md extension, trailing '/' means that dir's index) into
348
+ * a real repo-relative path matching DocItem.path -- e.g. './what-is-nbtca'
349
+ * from within 'about/index.md' -> 'about/what-is-nbtca.md'; '/concepts/'
350
+ * (root-relative, works from anywhere) -> 'concepts/index.md'. */
351
+ function resolveInternalHref(href, fromPath) {
352
+ const fromDir = fromPath.includes('/') ? fromPath.slice(0, fromPath.lastIndexOf('/')) : '';
353
+ const combined = href.startsWith('/') ? href.slice(1) : (fromDir ? `${fromDir}/${href}` : href);
354
+ const stack = [];
355
+ for (const part of combined.split('/')) {
356
+ if (part === '' || part === '.')
357
+ continue;
358
+ if (part === '..') {
359
+ stack.pop();
360
+ continue;
361
+ }
362
+ stack.push(part);
363
+ }
364
+ let target = stack.join('/');
365
+ if (target === '' || href.endsWith('/'))
366
+ target += (target ? '/' : '') + 'index';
367
+ if (!target.endsWith('.md'))
368
+ target += '.md';
369
+ return target;
370
+ }
371
+ /** Loads and renders a doc for the native in-app reader -- the same
372
+ * fetch/clean/render/cache pipeline viewMarkdownFile uses, minus the
373
+ * spinner/pager/post-read menu, which belong to the classic-pager
374
+ * presentation layer, not this one. */
375
+ export async function loadDocForReader(filePath) {
376
+ ensureMarkedConfigured();
377
+ const rawContent = await fetchFileContent(filePath);
378
+ const fingerprint = contentFingerprint(rawContent);
379
+ const cached = getFreshRender(filePath);
380
+ let renderedDoc;
381
+ if (cached && cached.fingerprint === fingerprint) {
382
+ renderedDoc = cached;
383
+ }
384
+ else {
385
+ const cleaned = cleanMarkdownContent(rawContent, getTerminalType());
386
+ const title = extractDocTitle(rawContent, cleaned) || cleanFileName(filePath.split('/').pop() ?? filePath);
387
+ const readTime = estimateReadTime(cleaned);
388
+ const rendered = await marked(cleaned);
389
+ renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
390
+ setRender(filePath, renderedDoc);
391
+ }
392
+ const seen = new Set();
393
+ const links = [];
394
+ for (const raw of extractInternalLinks(renderedDoc.cleaned)) {
395
+ const resolved = resolveInternalHref(raw.href, filePath);
396
+ if (seen.has(resolved))
397
+ continue;
398
+ seen.add(resolved);
399
+ links.push({ text: raw.text, href: resolved });
400
+ }
401
+ return { path: filePath, title: renderedDoc.title, lines: renderedDoc.rendered.split('\n'), links };
402
+ }
275
403
  // ─── Document tree ────────────────────────────────────────────────────────────
276
- const TOP_SECTION_ORDER = ['tutorial', 'process', 'repair', 'archived'];
404
+ const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
277
405
  const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
278
- /**
279
- * Convert a kebab-case filename to a display-friendly title.
280
- * Preserves Chinese characters and date prefixes.
281
- */
282
- function cleanFileName(name) {
406
+ const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
407
+ export function localizeDocSections(sections, trans = t()) {
408
+ const labels = {
409
+ about: trans.docs.categoryAbout,
410
+ guide: trans.docs.categoryGuide,
411
+ repair: trans.docs.categoryRepair,
412
+ concepts: trans.docs.categoryConcepts,
413
+ archived: trans.docs.categoryArchived,
414
+ };
415
+ return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
416
+ }
417
+ export function cleanFileName(name) {
283
418
  const base = name.replace(/\.md$/, '');
284
419
  if (/^[\d.]/.test(base))
285
420
  return base;
@@ -287,40 +422,58 @@ function cleanFileName(name) {
287
422
  .replace(/[-_]/g, ' ')
288
423
  .replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
289
424
  }
290
- /** Group flat DocItem list into top-level sections. */
291
- function buildSections(all) {
292
- const trans = t();
293
- const labelMap = {
294
- tutorial: trans.docs.categoryTutorial,
295
- process: trans.docs.categoryProcess,
296
- repair: trans.docs.categoryRepair,
297
- archived: trans.docs.categoryArchived,
298
- };
425
+ const KNOWN_DOC_TITLES = {
426
+ 'tutorial/2025/clean-drive-c.md': 'C盘清理标准化流程',
427
+ 'tutorial/2025/edu-email.md': '教育邮箱用途',
428
+ 'tutorial/2025/github-education-verification.md': 'Github Education 认证指南',
429
+ 'tutorial/2025/github-workflow.md': '快速上手社团目前的Github工作流',
430
+ 'tutorial/2025/google-calendar.md': '谷歌日历使用指南',
431
+ 'tutorial/2025/nginx-usage.md': '快速上手你的nginx',
432
+ 'tutorial/2025/tailscale-usage.md': '社团自建 Tailscale 使用指南',
433
+ 'tutorial/manual/hardware-establish.md': '计算机硬件系统的搭建与维护',
434
+ 'tutorial/manual/net-usage.md': '国际互联网的使用',
435
+ 'tutorial/manual/os-skills.md': '基础操作系统的使用技术',
436
+ 'tutorial/manual/windows-from-scratch.md': '从零开始安装 Windows',
437
+ 'process/2025/apply-for-credits.md': '申请第二课堂学分',
438
+ 'process/2025/borrow-classroom.md': '借教室',
439
+ 'process/2025/event-organization.md': '活动举办文档(待完善)',
440
+ 'process/2025/nbtca-post.md': '撰写并发布你的第一篇NBTCA博客',
441
+ 'process/2025/reimbursement-process.md': '报销流程',
442
+ 'repair/checklist.md': '维修日检查单',
443
+ 'repair/guide.md': '维修操作指南',
444
+ 'repair/repair-day.md': '维修日',
445
+ 'repair/tools.md': '软件仓库(校内镜像站)',
446
+ 'repair/weekend.md': '维修工单系统 (weekend)',
447
+ };
448
+ export function displayDocTitle(path, name) {
449
+ return KNOWN_DOC_TITLES[path] ?? cleanFileName(name);
450
+ }
451
+ export function buildSections(all) {
299
452
  const groups = new Map();
300
453
  for (const item of all) {
301
454
  const parts = item.path.split('/');
302
455
  if (parts.length < 2)
303
456
  continue;
304
- const top = parts[0];
305
- if (TOP_SECTION_SKIP.has(top))
457
+ const rawTop = parts[0];
458
+ if (TOP_SECTION_SKIP.has(rawTop))
306
459
  continue;
460
+ const top = SECTION_ALIAS[rawTop] ?? rawTop;
307
461
  if (!TOP_SECTION_ORDER.includes(top))
308
462
  continue;
309
463
  if (!groups.has(top))
310
464
  groups.set(top, []);
311
465
  groups.get(top).push(item);
312
466
  }
313
- return TOP_SECTION_ORDER
467
+ return localizeDocSections(TOP_SECTION_ORDER
314
468
  .filter(k => groups.has(k))
315
469
  .map(k => ({
316
470
  key: k,
317
- label: labelMap[k] ?? k,
471
+ label: k,
318
472
  count: groups.get(k).length,
319
473
  files: groups.get(k),
320
- }));
474
+ })));
321
475
  }
322
- /** Group archived files by their second path component (year / manual / etc.). */
323
- function getArchivedGroups(files) {
476
+ export function getArchivedGroups(files) {
324
477
  const groups = new Map();
325
478
  for (const item of files) {
326
479
  const group = item.path.split('/')[1] ?? 'other';
@@ -330,12 +483,17 @@ function getArchivedGroups(files) {
330
483
  }
331
484
  return groups;
332
485
  }
486
+ export async function fetchAllDocs() {
487
+ return docsClient.listAll();
488
+ }
489
+ export async function fetchSections() {
490
+ return buildSections(await fetchAllDocs());
491
+ }
333
492
  async function loadSections() {
334
493
  const trans = t();
335
494
  const s = createSpinner(trans.docs.loading);
336
495
  try {
337
- const all = await docsClient.listAll();
338
- const sections = buildSections(all);
496
+ const sections = await fetchSections();
339
497
  s.stop();
340
498
  return sections;
341
499
  }
@@ -417,14 +575,15 @@ async function showDocSection(section) {
417
575
  const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
418
576
  if (files.length === 0)
419
577
  return;
420
- const selected = await select({
421
- message: section.label,
578
+ const selected = await runMenu({
579
+ title: section.label,
422
580
  options: [
423
581
  ...files.map(f => ({ value: f.path, label: cleanFileName(f.name) })),
424
582
  { value: '__back__', label: chalk.dim(trans.common.back) },
425
583
  ],
584
+ footer: menuFooter(),
426
585
  });
427
- if (isCancel(selected) || selected === '__back__')
586
+ if (selected === null || selected === '__back__')
428
587
  return;
429
588
  await viewMarkdownFile(selected);
430
589
  }
@@ -443,8 +602,8 @@ async function showArchivedSection(files) {
443
602
  return 1;
444
603
  return a.localeCompare(b);
445
604
  });
446
- const groupKey = await select({
447
- message: trans.docs.categoryArchived,
605
+ const groupKey = await runMenu({
606
+ title: trans.docs.categoryArchived,
448
607
  options: [
449
608
  ...sortedKeys.map(k => ({
450
609
  value: k,
@@ -453,13 +612,14 @@ async function showArchivedSection(files) {
453
612
  })),
454
613
  { value: '__back__', label: chalk.dim(trans.common.back) },
455
614
  ],
615
+ footer: menuFooter(),
456
616
  });
457
- if (isCancel(groupKey) || groupKey === '__back__')
617
+ if (groupKey === null || groupKey === '__back__')
458
618
  return;
459
619
  const groupFiles = groups.get(groupKey) ?? [];
460
620
  const subDirs = new Set(groupFiles.map(f => f.path.split('/')[2]).filter(Boolean));
461
- const fileSelected = await select({
462
- message: `${trans.docs.categoryArchived} · ${groupKey}`,
621
+ const fileSelected = await runMenu({
622
+ title: `${trans.docs.categoryArchived} · ${groupKey}`,
463
623
  options: [
464
624
  ...groupFiles.map(f => {
465
625
  const sub = f.path.split('/').slice(2, -1).join('/');
@@ -471,17 +631,18 @@ async function showArchivedSection(files) {
471
631
  }),
472
632
  { value: '__back__', label: chalk.dim(trans.common.back) },
473
633
  ],
634
+ footer: menuFooter(),
474
635
  });
475
- if (isCancel(fileSelected) || fileSelected === '__back__')
636
+ if (fileSelected === null || fileSelected === '__back__')
476
637
  return;
477
638
  await viewMarkdownFile(fileSelected);
478
639
  }
479
640
  // ─── Document viewer ──────────────────────────────────────────────────────────
480
- async function viewMarkdownFile(filePath) {
641
+ export async function viewMarkdownFile(filePath) {
481
642
  const trans = t();
643
+ ensureMarkedConfigured();
644
+ const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
482
645
  try {
483
- ensureMarkedConfigured();
484
- const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
485
646
  const rawContent = await fetchFileContent(filePath);
486
647
  const fingerprint = contentFingerprint(rawContent);
487
648
  const cachedRendered = getFreshRender(filePath);
@@ -506,26 +667,25 @@ async function viewMarkdownFile(filePath) {
506
667
  await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
507
668
  }
508
669
  const needsBrowser = hasMarkdownTable(rawContent) || hasMermaidBlock(rawContent);
509
- const action = await select({
510
- message: trans.docs.chooseAction,
670
+ const action = await runMenu({
671
+ title: trans.docs.chooseAction,
511
672
  options: [
512
673
  { value: 'back', label: trans.docs.backToList },
513
674
  { value: 'browser', label: trans.docs.openBrowser,
514
675
  hint: needsBrowser ? trans.docs.tableHint : undefined },
515
676
  ],
677
+ footer: menuFooter(),
516
678
  });
517
- if (!isCancel(action) && action === 'browser') {
679
+ if (action === 'browser') {
518
680
  await openDocsInBrowser(filePath);
519
681
  }
520
682
  }
521
683
  catch (err) {
522
- error(trans.docs.loadError);
684
+ s.error(trans.docs.loadError);
523
685
  const errMsg = err instanceof Error ? err.message : String(err);
524
686
  console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
525
- setVimKeysActive(false);
526
- const openBrowser = await confirm({ message: trans.docs.openBrowserPrompt });
527
- setVimKeysActive(true);
528
- if (!isCancel(openBrowser) && openBrowser) {
687
+ const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
688
+ if (openBrowser === true) {
529
689
  await openDocsInBrowser(filePath);
530
690
  }
531
691
  }
@@ -550,13 +710,11 @@ export async function openDocsInBrowser(path) {
550
710
  // ─── Search ────────────────────────────────────────────────────────────────────
551
711
  async function searchDocs() {
552
712
  const trans = t();
553
- setVimKeysActive(false);
554
- const query = await text({
713
+ const query = await runTextInput({
555
714
  message: trans.docs.searchPrompt,
556
715
  placeholder: trans.docs.searchPlaceholder,
557
716
  });
558
- setVimKeysActive(true);
559
- if (isCancel(query) || !query.trim())
717
+ if (query === null || !query.trim())
560
718
  return;
561
719
  const keyword = query.trim().toLowerCase();
562
720
  const s = createSpinner(trans.docs.searching);
@@ -568,8 +726,8 @@ async function searchDocs() {
568
726
  warning(trans.docs.searchNoResults);
569
727
  return;
570
728
  }
571
- const selected = await select({
572
- message: trans.docs.chooseDoc,
729
+ const selected = await runMenu({
730
+ title: trans.docs.chooseDoc,
573
731
  options: [
574
732
  ...results.map(r => ({
575
733
  value: r.path,
@@ -578,8 +736,9 @@ async function searchDocs() {
578
736
  })),
579
737
  { value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
580
738
  ],
739
+ footer: menuFooter(),
581
740
  });
582
- if (isCancel(selected) || selected === '__back__')
741
+ if (selected === null || selected === '__back__')
583
742
  return;
584
743
  await viewMarkdownFile(selected);
585
744
  }
@@ -589,20 +748,22 @@ async function searchDocs() {
589
748
  }
590
749
  // ─── Menu ─────────────────────────────────────────────────────────────────────
591
750
  export async function showDocsMenu() {
751
+ await enterScreen(breadcrumb(t().menu.docs));
592
752
  let sections = await loadSections();
593
753
  if (!sections)
594
754
  return;
595
755
  while (true) {
596
756
  const trans = t();
597
- const action = await select({
598
- message: trans.docs.chooseCategory,
757
+ const action = await runMenu({
758
+ title: trans.docs.chooseCategory,
599
759
  options: [
600
760
  ...sections.map(sec => ({ value: sec.key, label: sec.label })),
601
761
  { value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
602
762
  { value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
603
763
  ],
764
+ footer: menuFooter(),
604
765
  });
605
- if (isCancel(action))
766
+ if (action === null)
606
767
  return;
607
768
  if (action === 'search') {
608
769
  await searchDocs();
@@ -1,12 +1,10 @@
1
- /**
2
- * Links — open NBTCA resources in browser
3
- */
4
1
  import open from 'open';
5
2
  import chalk from 'chalk';
6
- import { select, isCancel } from '@clack/prompts';
3
+ import { runMenu, menuFooter } from '../core/components/menu.js';
7
4
  import { createSpinner } from '../core/ui.js';
8
5
  import { URLS } from '../config/data.js';
9
6
  import { t } from '../i18n/index.js';
7
+ import { enterScreen, breadcrumb } from '../core/transitions.js';
10
8
  async function openUrl(url) {
11
9
  const trans = t();
12
10
  const s = createSpinner(trans.links.opening);
@@ -21,16 +19,18 @@ async function openUrl(url) {
21
19
  }
22
20
  export async function showLinksMenu() {
23
21
  const trans = t();
24
- const selected = await select({
25
- message: trans.links.choose,
22
+ await enterScreen(breadcrumb(trans.menu.links));
23
+ const selected = await runMenu({
24
+ title: trans.links.choose,
26
25
  options: [
27
26
  { value: URLS.homepage, label: trans.links.website },
28
27
  { value: URLS.github, label: trans.links.github },
29
28
  { value: URLS.roadmap, label: trans.links.roadmap },
30
29
  { value: URLS.repair, label: trans.links.repair },
31
30
  ],
31
+ footer: menuFooter(),
32
32
  });
33
- if (isCancel(selected))
33
+ if (selected === null)
34
34
  return;
35
35
  await openUrl(selected);
36
36
  }
@@ -0,0 +1,47 @@
1
+ const DAY_MS = 86400000;
2
+ export function currentWeekNumber(weekOneMonday, now) {
3
+ const base = new Date(`${weekOneMonday}T00:00:00`);
4
+ const days = Math.floor((now.getTime() - base.getTime()) / DAY_MS);
5
+ return Math.floor(days / 7) + 1;
6
+ }
7
+ export function campusWeekday(now) {
8
+ return ((now.getDay() + 6) % 7) + 1;
9
+ }
10
+ export function meetingsInWeek(meetings, week) {
11
+ return meetings.filter((mtg) => mtg.weeks.includes(week));
12
+ }
13
+ export function meetingsOnDay(meetings, weekday, week) {
14
+ return meetings
15
+ .filter((mtg) => mtg.weekday === weekday && mtg.weeks.includes(week))
16
+ .sort((a, b) => a.startPeriod - b.startPeriod);
17
+ }
18
+ export function periodStartDate(weekOneMonday, week, weekday, period, periods) {
19
+ const p = periods.find((x) => x.period === period);
20
+ if (!p)
21
+ return null;
22
+ const base = new Date(`${weekOneMonday}T00:00:00`);
23
+ const date = new Date(base.getTime() + ((week - 1) * 7 + (weekday - 1)) * DAY_MS);
24
+ const parts = p.start.split(':');
25
+ date.setHours(Number.parseInt(parts[0] ?? '0', 10), Number.parseInt(parts[1] ?? '0', 10), 0, 0);
26
+ return date;
27
+ }
28
+ export function nextMeeting(meetings, periods, weekOneMonday, now) {
29
+ let best = null;
30
+ for (const meeting of meetings) {
31
+ for (const week of meeting.weeks) {
32
+ const start = periodStartDate(weekOneMonday, week, meeting.weekday, meeting.startPeriod, periods);
33
+ if (start && start.getTime() > now.getTime() && (!best || start.getTime() < best.start.getTime())) {
34
+ best = { meeting, start };
35
+ }
36
+ }
37
+ }
38
+ return best;
39
+ }
40
+ /** The meeting occupying a grid cell, whether it starts there or is a later
41
+ * period of a meeting that started earlier the same day -- one condition
42
+ * (`startPeriod <= period <= endPeriod`) covers both cases, matching
43
+ * renderWeekGrid's own starting/continuing lookup so "does this cell have a
44
+ * meeting" and "what does the grid actually draw there" never disagree. */
45
+ export function meetingAtCursor(meetings, week, cursor) {
46
+ return meetingsInWeek(meetings, week).find((m) => m.weekday === cursor.weekday && m.startPeriod <= cursor.period && cursor.period <= m.endPeriod) ?? null;
47
+ }