@nbtca/prompt 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +457 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +236 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +472 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +40 -15
- package/dist/core/menu.js +24 -6
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +127 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +5 -29
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +258 -55
- package/dist/features/links.js +7 -4
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +574 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +260 -0
- package/dist/features/settings.js +41 -30
- package/dist/features/status.js +37 -13
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/update.js +16 -8
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +59 -5
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +10 -7
package/dist/features/docs.js
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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,108 @@ 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
|
-
|
|
404
|
+
// Sourced from a live audit of nbtca/documents (2026-07-18): `about` and
|
|
405
|
+
// `concepts` are two whole new top-level sections added in the repo's wiki
|
|
406
|
+
// reconstruction (5abcc4d, 5beee27) -- omitted here, buildSections() below
|
|
407
|
+
// silently drops every file under them, which is exactly what happened
|
|
408
|
+
// before this fix caught up to the upstream restructuring. `about` leads
|
|
409
|
+
// (org intro for newcomers) and `concepts` sits after the practical guide
|
|
410
|
+
// as reference material.
|
|
411
|
+
const TOP_SECTION_ORDER = ['about', 'guide', 'repair', 'concepts', 'archived'];
|
|
277
412
|
const TOP_SECTION_SKIP = new Set(['docs', 'index.md', 'README.md']);
|
|
413
|
+
// tutorial/ and process/ are two folders on disk but one section everywhere
|
|
414
|
+
// a reader actually sees them: nbtca/documents' own site nav collapses both
|
|
415
|
+
// under a single "指南/Guide" entry, and tutorial/sidebar.ts spells out why
|
|
416
|
+
// ("「指南」= 教程(学技术)+流程(办社务)高内聚合并为一栏") -- presenting
|
|
417
|
+
// them as two separate top-level categories in the terminal was true to the
|
|
418
|
+
// folder layout but false to how the content is actually meant to be read.
|
|
419
|
+
const SECTION_ALIAS = { tutorial: 'guide', process: 'guide' };
|
|
420
|
+
export function localizeDocSections(sections, trans = t()) {
|
|
421
|
+
const labels = {
|
|
422
|
+
about: trans.docs.categoryAbout,
|
|
423
|
+
guide: trans.docs.categoryGuide,
|
|
424
|
+
repair: trans.docs.categoryRepair,
|
|
425
|
+
concepts: trans.docs.categoryConcepts,
|
|
426
|
+
archived: trans.docs.categoryArchived,
|
|
427
|
+
};
|
|
428
|
+
return sections.map((section) => ({ ...section, label: labels[section.key] ?? section.label }));
|
|
429
|
+
}
|
|
278
430
|
/**
|
|
279
431
|
* Convert a kebab-case filename to a display-friendly title.
|
|
280
432
|
* Preserves Chinese characters and date prefixes.
|
|
281
433
|
*/
|
|
282
|
-
function cleanFileName(name) {
|
|
434
|
+
export function cleanFileName(name) {
|
|
283
435
|
const base = name.replace(/\.md$/, '');
|
|
284
436
|
if (/^[\d.]/.test(base))
|
|
285
437
|
return base;
|
|
@@ -287,40 +439,81 @@ function cleanFileName(name) {
|
|
|
287
439
|
.replace(/[-_]/g, ' ')
|
|
288
440
|
.replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
|
|
289
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Real titles (each document's own top-level `# heading`) for the
|
|
444
|
+
* curated tutorial/process/repair sections, keyed by repo-relative path.
|
|
445
|
+
* These are hand-authored English-filename docs with Chinese content —
|
|
446
|
+
* mechanically title-casing the filename ("Clean Drive C") reads as a
|
|
447
|
+
* different, lower-quality product than the document's own title ("C盘
|
|
448
|
+
* 清理标准化流程"). Deliberately scoped to these three sections only:
|
|
449
|
+
* `archived/`'s meeting notes are informal and often share the same
|
|
450
|
+
* generic real heading across many different dates (e.g. five different
|
|
451
|
+
* files all titled just "维修日") — there, the current filename-derived,
|
|
452
|
+
* date-prefixed label is more useful for telling entries apart than the
|
|
453
|
+
* real heading would be, so it is intentionally left as-is.
|
|
454
|
+
*
|
|
455
|
+
* Pulled from a live audit of the actual nbtca/documents content
|
|
456
|
+
* (2026-07-16). A doc added later without an entry here simply falls
|
|
457
|
+
* back to `cleanFileName` — never an error, never a blank label.
|
|
458
|
+
*/
|
|
459
|
+
const KNOWN_DOC_TITLES = {
|
|
460
|
+
'tutorial/2025/clean-drive-c.md': 'C盘清理标准化流程',
|
|
461
|
+
'tutorial/2025/edu-email.md': '教育邮箱用途',
|
|
462
|
+
'tutorial/2025/github-education-verification.md': 'Github Education 认证指南',
|
|
463
|
+
'tutorial/2025/github-workflow.md': '快速上手社团目前的Github工作流',
|
|
464
|
+
'tutorial/2025/google-calendar.md': '谷歌日历使用指南',
|
|
465
|
+
'tutorial/2025/nginx-usage.md': '快速上手你的nginx',
|
|
466
|
+
'tutorial/2025/tailscale-usage.md': '社团自建 Tailscale 使用指南',
|
|
467
|
+
'tutorial/manual/hardware-establish.md': '计算机硬件系统的搭建与维护',
|
|
468
|
+
'tutorial/manual/net-usage.md': '国际互联网的使用',
|
|
469
|
+
'tutorial/manual/os-skills.md': '基础操作系统的使用技术',
|
|
470
|
+
'tutorial/manual/windows-from-scratch.md': '从零开始安装 Windows',
|
|
471
|
+
'process/2025/apply-for-credits.md': '申请第二课堂学分',
|
|
472
|
+
'process/2025/borrow-classroom.md': '借教室',
|
|
473
|
+
'process/2025/event-organization.md': '活动举办文档(待完善)',
|
|
474
|
+
'process/2025/nbtca-post.md': '撰写并发布你的第一篇NBTCA博客',
|
|
475
|
+
'process/2025/reimbursement-process.md': '报销流程',
|
|
476
|
+
'repair/checklist.md': '维修日检查单',
|
|
477
|
+
'repair/guide.md': '维修操作指南',
|
|
478
|
+
'repair/repair-day.md': '维修日',
|
|
479
|
+
'repair/tools.md': '软件仓库(校内镜像站)',
|
|
480
|
+
'repair/weekend.md': '维修工单系统 (weekend)',
|
|
481
|
+
};
|
|
482
|
+
/** Display title for a tutorial/process/repair doc: the real, known title
|
|
483
|
+
* when we have one, otherwise the same filename-derived fallback used
|
|
484
|
+
* everywhere else (including for every archived/ doc, which never has a
|
|
485
|
+
* known-title entry by design). */
|
|
486
|
+
export function displayDocTitle(path, name) {
|
|
487
|
+
return KNOWN_DOC_TITLES[path] ?? cleanFileName(name);
|
|
488
|
+
}
|
|
290
489
|
/** 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
|
-
};
|
|
490
|
+
export function buildSections(all) {
|
|
299
491
|
const groups = new Map();
|
|
300
492
|
for (const item of all) {
|
|
301
493
|
const parts = item.path.split('/');
|
|
302
494
|
if (parts.length < 2)
|
|
303
495
|
continue;
|
|
304
|
-
const
|
|
305
|
-
if (TOP_SECTION_SKIP.has(
|
|
496
|
+
const rawTop = parts[0];
|
|
497
|
+
if (TOP_SECTION_SKIP.has(rawTop))
|
|
306
498
|
continue;
|
|
499
|
+
const top = SECTION_ALIAS[rawTop] ?? rawTop;
|
|
307
500
|
if (!TOP_SECTION_ORDER.includes(top))
|
|
308
501
|
continue;
|
|
309
502
|
if (!groups.has(top))
|
|
310
503
|
groups.set(top, []);
|
|
311
504
|
groups.get(top).push(item);
|
|
312
505
|
}
|
|
313
|
-
return TOP_SECTION_ORDER
|
|
506
|
+
return localizeDocSections(TOP_SECTION_ORDER
|
|
314
507
|
.filter(k => groups.has(k))
|
|
315
508
|
.map(k => ({
|
|
316
509
|
key: k,
|
|
317
|
-
label:
|
|
510
|
+
label: k,
|
|
318
511
|
count: groups.get(k).length,
|
|
319
512
|
files: groups.get(k),
|
|
320
|
-
}));
|
|
513
|
+
})));
|
|
321
514
|
}
|
|
322
515
|
/** Group archived files by their second path component (year / manual / etc.). */
|
|
323
|
-
function getArchivedGroups(files) {
|
|
516
|
+
export function getArchivedGroups(files) {
|
|
324
517
|
const groups = new Map();
|
|
325
518
|
for (const item of files) {
|
|
326
519
|
const group = item.path.split('/')[1] ?? 'other';
|
|
@@ -330,12 +523,19 @@ function getArchivedGroups(files) {
|
|
|
330
523
|
}
|
|
331
524
|
return groups;
|
|
332
525
|
}
|
|
526
|
+
/** Raw fetch, no spinner/UI — throws on failure. Shared by the classic and
|
|
527
|
+
* native-view loaders. */
|
|
528
|
+
export async function fetchAllDocs() {
|
|
529
|
+
return docsClient.listAll();
|
|
530
|
+
}
|
|
531
|
+
export async function fetchSections() {
|
|
532
|
+
return buildSections(await fetchAllDocs());
|
|
533
|
+
}
|
|
333
534
|
async function loadSections() {
|
|
334
535
|
const trans = t();
|
|
335
536
|
const s = createSpinner(trans.docs.loading);
|
|
336
537
|
try {
|
|
337
|
-
const
|
|
338
|
-
const sections = buildSections(all);
|
|
538
|
+
const sections = await fetchSections();
|
|
339
539
|
s.stop();
|
|
340
540
|
return sections;
|
|
341
541
|
}
|
|
@@ -417,14 +617,15 @@ async function showDocSection(section) {
|
|
|
417
617
|
const files = section.files.filter(f => f.name !== 'index.md' && !f.name.startsWith('index.'));
|
|
418
618
|
if (files.length === 0)
|
|
419
619
|
return;
|
|
420
|
-
const selected = await
|
|
421
|
-
|
|
620
|
+
const selected = await runMenu({
|
|
621
|
+
title: section.label,
|
|
422
622
|
options: [
|
|
423
623
|
...files.map(f => ({ value: f.path, label: cleanFileName(f.name) })),
|
|
424
624
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
425
625
|
],
|
|
626
|
+
footer: menuFooter(),
|
|
426
627
|
});
|
|
427
|
-
if (
|
|
628
|
+
if (selected === null || selected === '__back__')
|
|
428
629
|
return;
|
|
429
630
|
await viewMarkdownFile(selected);
|
|
430
631
|
}
|
|
@@ -443,8 +644,8 @@ async function showArchivedSection(files) {
|
|
|
443
644
|
return 1;
|
|
444
645
|
return a.localeCompare(b);
|
|
445
646
|
});
|
|
446
|
-
const groupKey = await
|
|
447
|
-
|
|
647
|
+
const groupKey = await runMenu({
|
|
648
|
+
title: trans.docs.categoryArchived,
|
|
448
649
|
options: [
|
|
449
650
|
...sortedKeys.map(k => ({
|
|
450
651
|
value: k,
|
|
@@ -453,13 +654,14 @@ async function showArchivedSection(files) {
|
|
|
453
654
|
})),
|
|
454
655
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
455
656
|
],
|
|
657
|
+
footer: menuFooter(),
|
|
456
658
|
});
|
|
457
|
-
if (
|
|
659
|
+
if (groupKey === null || groupKey === '__back__')
|
|
458
660
|
return;
|
|
459
661
|
const groupFiles = groups.get(groupKey) ?? [];
|
|
460
662
|
const subDirs = new Set(groupFiles.map(f => f.path.split('/')[2]).filter(Boolean));
|
|
461
|
-
const fileSelected = await
|
|
462
|
-
|
|
663
|
+
const fileSelected = await runMenu({
|
|
664
|
+
title: `${trans.docs.categoryArchived} · ${groupKey}`,
|
|
463
665
|
options: [
|
|
464
666
|
...groupFiles.map(f => {
|
|
465
667
|
const sub = f.path.split('/').slice(2, -1).join('/');
|
|
@@ -471,17 +673,18 @@ async function showArchivedSection(files) {
|
|
|
471
673
|
}),
|
|
472
674
|
{ value: '__back__', label: chalk.dim(trans.common.back) },
|
|
473
675
|
],
|
|
676
|
+
footer: menuFooter(),
|
|
474
677
|
});
|
|
475
|
-
if (
|
|
678
|
+
if (fileSelected === null || fileSelected === '__back__')
|
|
476
679
|
return;
|
|
477
680
|
await viewMarkdownFile(fileSelected);
|
|
478
681
|
}
|
|
479
682
|
// ─── Document viewer ──────────────────────────────────────────────────────────
|
|
480
|
-
async function viewMarkdownFile(filePath) {
|
|
683
|
+
export async function viewMarkdownFile(filePath) {
|
|
481
684
|
const trans = t();
|
|
685
|
+
ensureMarkedConfigured();
|
|
686
|
+
const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
|
|
482
687
|
try {
|
|
483
|
-
ensureMarkedConfigured();
|
|
484
|
-
const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
|
|
485
688
|
const rawContent = await fetchFileContent(filePath);
|
|
486
689
|
const fingerprint = contentFingerprint(rawContent);
|
|
487
690
|
const cachedRendered = getFreshRender(filePath);
|
|
@@ -506,26 +709,25 @@ async function viewMarkdownFile(filePath) {
|
|
|
506
709
|
await displayWithLess(renderedDoc.rendered, renderedDoc.title, filePath, renderedDoc.readTime, toc);
|
|
507
710
|
}
|
|
508
711
|
const needsBrowser = hasMarkdownTable(rawContent) || hasMermaidBlock(rawContent);
|
|
509
|
-
const action = await
|
|
510
|
-
|
|
712
|
+
const action = await runMenu({
|
|
713
|
+
title: trans.docs.chooseAction,
|
|
511
714
|
options: [
|
|
512
715
|
{ value: 'back', label: trans.docs.backToList },
|
|
513
716
|
{ value: 'browser', label: trans.docs.openBrowser,
|
|
514
717
|
hint: needsBrowser ? trans.docs.tableHint : undefined },
|
|
515
718
|
],
|
|
719
|
+
footer: menuFooter(),
|
|
516
720
|
});
|
|
517
|
-
if (
|
|
721
|
+
if (action === 'browser') {
|
|
518
722
|
await openDocsInBrowser(filePath);
|
|
519
723
|
}
|
|
520
724
|
}
|
|
521
725
|
catch (err) {
|
|
522
|
-
error(trans.docs.loadError);
|
|
726
|
+
s.error(trans.docs.loadError);
|
|
523
727
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
524
728
|
console.log(chalk.gray(` ${trans.docs.errorHint}: ${errMsg}`));
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
setVimKeysActive(true);
|
|
528
|
-
if (!isCancel(openBrowser) && openBrowser) {
|
|
729
|
+
const openBrowser = await runConfirm({ message: trans.docs.openBrowserPrompt });
|
|
730
|
+
if (openBrowser === true) {
|
|
529
731
|
await openDocsInBrowser(filePath);
|
|
530
732
|
}
|
|
531
733
|
}
|
|
@@ -550,13 +752,11 @@ export async function openDocsInBrowser(path) {
|
|
|
550
752
|
// ─── Search ────────────────────────────────────────────────────────────────────
|
|
551
753
|
async function searchDocs() {
|
|
552
754
|
const trans = t();
|
|
553
|
-
|
|
554
|
-
const query = await text({
|
|
755
|
+
const query = await runTextInput({
|
|
555
756
|
message: trans.docs.searchPrompt,
|
|
556
757
|
placeholder: trans.docs.searchPlaceholder,
|
|
557
758
|
});
|
|
558
|
-
|
|
559
|
-
if (isCancel(query) || !query.trim())
|
|
759
|
+
if (query === null || !query.trim())
|
|
560
760
|
return;
|
|
561
761
|
const keyword = query.trim().toLowerCase();
|
|
562
762
|
const s = createSpinner(trans.docs.searching);
|
|
@@ -568,8 +768,8 @@ async function searchDocs() {
|
|
|
568
768
|
warning(trans.docs.searchNoResults);
|
|
569
769
|
return;
|
|
570
770
|
}
|
|
571
|
-
const selected = await
|
|
572
|
-
|
|
771
|
+
const selected = await runMenu({
|
|
772
|
+
title: trans.docs.chooseDoc,
|
|
573
773
|
options: [
|
|
574
774
|
...results.map(r => ({
|
|
575
775
|
value: r.path,
|
|
@@ -578,8 +778,9 @@ async function searchDocs() {
|
|
|
578
778
|
})),
|
|
579
779
|
{ value: '__back__', label: chalk.dim(trans.docs.returnToMenu) },
|
|
580
780
|
],
|
|
781
|
+
footer: menuFooter(),
|
|
581
782
|
});
|
|
582
|
-
if (
|
|
783
|
+
if (selected === null || selected === '__back__')
|
|
583
784
|
return;
|
|
584
785
|
await viewMarkdownFile(selected);
|
|
585
786
|
}
|
|
@@ -589,20 +790,22 @@ async function searchDocs() {
|
|
|
589
790
|
}
|
|
590
791
|
// ─── Menu ─────────────────────────────────────────────────────────────────────
|
|
591
792
|
export async function showDocsMenu() {
|
|
793
|
+
await enterScreen(breadcrumb(t().menu.docs));
|
|
592
794
|
let sections = await loadSections();
|
|
593
795
|
if (!sections)
|
|
594
796
|
return;
|
|
595
797
|
while (true) {
|
|
596
798
|
const trans = t();
|
|
597
|
-
const action = await
|
|
598
|
-
|
|
799
|
+
const action = await runMenu({
|
|
800
|
+
title: trans.docs.chooseCategory,
|
|
599
801
|
options: [
|
|
600
802
|
...sections.map(sec => ({ value: sec.key, label: sec.label })),
|
|
601
803
|
{ value: 'search', label: chalk.dim(trans.docs.searchPrompt.replace(':', '')) },
|
|
602
804
|
{ value: 'browser', label: chalk.dim(trans.docs.openBrowser) },
|
|
603
805
|
],
|
|
806
|
+
footer: menuFooter(),
|
|
604
807
|
});
|
|
605
|
-
if (
|
|
808
|
+
if (action === null)
|
|
606
809
|
return;
|
|
607
810
|
if (action === 'search') {
|
|
608
811
|
await searchDocs();
|
package/dist/features/links.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import open from 'open';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
-
import {
|
|
6
|
+
import { runMenu, menuFooter } from '../core/components/menu.js';
|
|
7
7
|
import { createSpinner } from '../core/ui.js';
|
|
8
8
|
import { URLS } from '../config/data.js';
|
|
9
9
|
import { t } from '../i18n/index.js';
|
|
10
|
+
import { enterScreen, breadcrumb } from '../core/transitions.js';
|
|
10
11
|
async function openUrl(url) {
|
|
11
12
|
const trans = t();
|
|
12
13
|
const s = createSpinner(trans.links.opening);
|
|
@@ -21,16 +22,18 @@ async function openUrl(url) {
|
|
|
21
22
|
}
|
|
22
23
|
export async function showLinksMenu() {
|
|
23
24
|
const trans = t();
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
await enterScreen(breadcrumb(trans.menu.links));
|
|
26
|
+
const selected = await runMenu({
|
|
27
|
+
title: trans.links.choose,
|
|
26
28
|
options: [
|
|
27
29
|
{ value: URLS.homepage, label: trans.links.website },
|
|
28
30
|
{ value: URLS.github, label: trans.links.github },
|
|
29
31
|
{ value: URLS.roadmap, label: trans.links.roadmap },
|
|
30
32
|
{ value: URLS.repair, label: trans.links.repair },
|
|
31
33
|
],
|
|
34
|
+
footer: menuFooter(),
|
|
32
35
|
});
|
|
33
|
-
if (
|
|
36
|
+
if (selected === null)
|
|
34
37
|
return;
|
|
35
38
|
await openUrl(selected);
|
|
36
39
|
}
|
|
@@ -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
|
+
}
|