@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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getCapabilities } from './capabilities.js';
|
|
2
|
+
import { ansi } from './canvas.js';
|
|
3
|
+
export function sleep(ms) {
|
|
4
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
}
|
|
6
|
+
export async function typeReveal(lines, opts = {}) {
|
|
7
|
+
const write = opts.write ?? ((s) => { process.stdout.write(s); });
|
|
8
|
+
const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
|
|
9
|
+
if (reduced) {
|
|
10
|
+
write(lines.join('\n') + '\n');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const stepMs = opts.stepMs ?? 45;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
write(line + '\n');
|
|
16
|
+
await sleep(stepMs);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const BRAILLE_BASE = 0x2800;
|
|
20
|
+
function brailleMask(ch) {
|
|
21
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
22
|
+
return code >= BRAILLE_BASE && code <= BRAILLE_BASE + 0xff ? code - BRAILLE_BASE : -1;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reveals a block of braille dot-matrix art by assembling it dot-by-dot in
|
|
26
|
+
* scattered order, rather than popping it in line by line -- a reveal that
|
|
27
|
+
* matches what the art actually is (an addressable grid of dots) instead of
|
|
28
|
+
* reusing the plain-text typewriter effect built for prose. Non-braille
|
|
29
|
+
* characters (spaces, stray ASCII) pass through unchanged on every frame.
|
|
30
|
+
*/
|
|
31
|
+
export async function materializeBraille(art, paint, opts = {}) {
|
|
32
|
+
const write = opts.write ?? ((s) => { process.stdout.write(s); });
|
|
33
|
+
const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
|
|
34
|
+
const lines = art.split('\n');
|
|
35
|
+
const charGrid = lines.map((line) => [...line]);
|
|
36
|
+
const maskGrid = charGrid.map((row) => row.map(brailleMask));
|
|
37
|
+
if (reduced || !maskGrid.some((row) => row.some((m) => m > 0))) {
|
|
38
|
+
write(paint(art) + '\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const dots = [];
|
|
42
|
+
maskGrid.forEach((row, r) => row.forEach((mask, c) => {
|
|
43
|
+
if (mask <= 0)
|
|
44
|
+
return;
|
|
45
|
+
for (let bit = 0; bit < 8; bit++)
|
|
46
|
+
if (mask & (1 << bit))
|
|
47
|
+
dots.push([r, c, bit]);
|
|
48
|
+
}));
|
|
49
|
+
const rand = opts.random ?? Math.random;
|
|
50
|
+
for (let i = dots.length - 1; i > 0; i--) {
|
|
51
|
+
const j = Math.floor(rand() * (i + 1));
|
|
52
|
+
const a = dots[i], b = dots[j];
|
|
53
|
+
if (a && b) {
|
|
54
|
+
dots[i] = b;
|
|
55
|
+
dots[j] = a;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const acc = maskGrid.map((row) => row.map(() => 0));
|
|
59
|
+
const renderFrame = () => charGrid.map((row, r) => row.map((original, c) => {
|
|
60
|
+
const mask = maskGrid[r]?.[c] ?? -1;
|
|
61
|
+
if (mask <= 0)
|
|
62
|
+
return original;
|
|
63
|
+
return String.fromCodePoint(BRAILLE_BASE + (acc[r]?.[c] ?? 0));
|
|
64
|
+
}).join('')).join('\n');
|
|
65
|
+
const frameCount = Math.max(1, opts.frames ?? 12);
|
|
66
|
+
const frameMs = opts.frameMs ?? 35;
|
|
67
|
+
let shown = 0;
|
|
68
|
+
for (let f = 1; f <= frameCount; f++) {
|
|
69
|
+
const target = Math.round((dots.length * f) / frameCount);
|
|
70
|
+
while (shown < target) {
|
|
71
|
+
const d = dots[shown];
|
|
72
|
+
if (d) {
|
|
73
|
+
const [r, c, bit] = d;
|
|
74
|
+
const row = acc[r];
|
|
75
|
+
if (row)
|
|
76
|
+
row[c] = (row[c] ?? 0) | (1 << bit);
|
|
77
|
+
}
|
|
78
|
+
shown++;
|
|
79
|
+
}
|
|
80
|
+
write(paint(renderFrame()) + '\n');
|
|
81
|
+
if (f < frameCount) {
|
|
82
|
+
await sleep(frameMs);
|
|
83
|
+
write(ansi.cursorUp(lines.length) + ansi.cursorToCol0 + ansi.eraseDown);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/core/text.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Codepoints that occupy zero terminal columns: combining modifiers that
|
|
2
|
+
* merge into the glyph immediately before them, rather than rendering as
|
|
3
|
+
* their own character (zero-width joiner, variation selectors). Emoji
|
|
4
|
+
* sequences like a ZWJ family emoji or "❤️" (heart + VS-16) render as one
|
|
5
|
+
* glyph — counting the modifier itself would overcount by a full column. */
|
|
6
|
+
function isZeroWidth(cp) {
|
|
7
|
+
return cp === 0x200D // zero-width joiner
|
|
8
|
+
|| cp === 0xFE0E // variation selector-15 (text presentation)
|
|
9
|
+
|| cp === 0xFE0F; // variation selector-16 (emoji presentation)
|
|
10
|
+
}
|
|
11
|
+
/** Width of a single Unicode character: 2 for CJK/fullwidth/emoji, 1 otherwise. */
|
|
2
12
|
function charWidth(ch) {
|
|
3
13
|
const cp = ch.codePointAt(0) ?? 0;
|
|
4
14
|
return ((cp >= 0x1100 && cp <= 0x115F) ||
|
|
@@ -14,7 +24,15 @@ function charWidth(ch) {
|
|
|
14
24
|
(cp >= 0x20000 && cp <= 0x2A6DF) ||
|
|
15
25
|
(cp >= 0x2A700 && cp <= 0x2CEAF) ||
|
|
16
26
|
(cp >= 0x2CEB0 && cp <= 0x2EBEF) ||
|
|
17
|
-
(cp >= 0x30000 && cp <= 0x323AF)
|
|
27
|
+
(cp >= 0x30000 && cp <= 0x323AF) ||
|
|
28
|
+
// Emoji block (Misc Symbols & Pictographs, Emoticons, Transport, Chess
|
|
29
|
+
// Symbols, Supplemental Symbols & Pictographs, Extended-A). Real
|
|
30
|
+
// terminals render these as double-width glyphs; undercounting even one
|
|
31
|
+
// emoji is enough to push a line one column past the terminal width and
|
|
32
|
+
// trigger an unwanted auto-wrap — this is a real bug that was found: an
|
|
33
|
+
// emoji-titled event line loading in scrolled the app's header out of
|
|
34
|
+
// view because of exactly this miscount.
|
|
35
|
+
(cp >= 0x1F300 && cp <= 0x1FAFF)) ? 2 : 1;
|
|
18
36
|
}
|
|
19
37
|
/** Strip ANSI escape sequences from a string. */
|
|
20
38
|
// eslint-disable-next-line no-control-regex
|
|
@@ -22,12 +40,17 @@ const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
|
22
40
|
export function stripAnsi(str) {
|
|
23
41
|
return str.replace(ANSI_RE, '');
|
|
24
42
|
}
|
|
25
|
-
/** Total visual width of a string (CJK
|
|
43
|
+
/** Total visual width of a string (CJK/emoji count as 2, zero-width
|
|
44
|
+
* modifiers count as 0, ANSI codes ignored). */
|
|
26
45
|
export function visualWidth(str) {
|
|
27
46
|
const plain = stripAnsi(str);
|
|
28
47
|
let w = 0;
|
|
29
|
-
for (const ch of plain)
|
|
48
|
+
for (const ch of plain) {
|
|
49
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
50
|
+
if (isZeroWidth(cp))
|
|
51
|
+
continue;
|
|
30
52
|
w += charWidth(ch);
|
|
53
|
+
}
|
|
31
54
|
return w;
|
|
32
55
|
}
|
|
33
56
|
/** Pad string to target visual width with trailing spaces. */
|
|
@@ -42,7 +65,8 @@ export function truncate(str, maxWidth) {
|
|
|
42
65
|
let w = 0;
|
|
43
66
|
let i = 0;
|
|
44
67
|
for (const ch of str) {
|
|
45
|
-
const
|
|
68
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
69
|
+
const cw = isZeroWidth(cp) ? 0 : charWidth(ch);
|
|
46
70
|
if (w + cw > maxWidth - 3)
|
|
47
71
|
break;
|
|
48
72
|
w += cw;
|
|
@@ -50,3 +74,101 @@ export function truncate(str, maxWidth) {
|
|
|
50
74
|
}
|
|
51
75
|
return str.slice(0, i) + '...';
|
|
52
76
|
}
|
|
77
|
+
function tokenizeForWrapping(str) {
|
|
78
|
+
const tokens = [];
|
|
79
|
+
let index = 0;
|
|
80
|
+
while (index < str.length) {
|
|
81
|
+
const ansi = /^\x1b\[[0-9;]*m/.exec(str.slice(index));
|
|
82
|
+
if (ansi) {
|
|
83
|
+
tokens.push({ raw: ansi[0], width: 0, whitespace: false, sgr: true });
|
|
84
|
+
index += ansi[0].length;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const cp = str.codePointAt(index) ?? 0;
|
|
88
|
+
const raw = String.fromCodePoint(cp);
|
|
89
|
+
tokens.push({
|
|
90
|
+
raw,
|
|
91
|
+
width: isZeroWidth(cp) ? 0 : charWidth(raw),
|
|
92
|
+
whitespace: /\s/u.test(raw),
|
|
93
|
+
sgr: false,
|
|
94
|
+
});
|
|
95
|
+
index += raw.length;
|
|
96
|
+
}
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
|
99
|
+
function advanceSgr(active, tokens, start, end) {
|
|
100
|
+
let next = active;
|
|
101
|
+
for (let index = start; index < end; index += 1) {
|
|
102
|
+
const token = tokens[index];
|
|
103
|
+
if (!token?.sgr)
|
|
104
|
+
continue;
|
|
105
|
+
const params = token.raw.slice(2, -1).split(';');
|
|
106
|
+
if (params.includes('0') || params[0] === '') {
|
|
107
|
+
next = params.length === 1 ? '' : token.raw;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
next += token.raw;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
function renderWrappedSegment(tokens, start, end, prefix) {
|
|
116
|
+
const body = tokens.slice(start, end).map((token) => token.raw).join('');
|
|
117
|
+
const styled = prefix + body;
|
|
118
|
+
return prefix || tokens.slice(start, end).some((token) => token.sgr)
|
|
119
|
+
? `${styled}\x1b[0m`
|
|
120
|
+
: styled;
|
|
121
|
+
}
|
|
122
|
+
export function wrapAnsiToVisualWidth(str, maxWidth) {
|
|
123
|
+
const widthLimit = Math.max(1, Math.floor(maxWidth));
|
|
124
|
+
if (!Number.isFinite(maxWidth) || visualWidth(str) <= widthLimit)
|
|
125
|
+
return [str];
|
|
126
|
+
const tokens = tokenizeForWrapping(str);
|
|
127
|
+
const lines = [];
|
|
128
|
+
let activeSgr = '';
|
|
129
|
+
let start = 0;
|
|
130
|
+
while (start < tokens.length) {
|
|
131
|
+
let width = 0;
|
|
132
|
+
let hasVisible = false;
|
|
133
|
+
let lastWhitespace = -1;
|
|
134
|
+
let index = start;
|
|
135
|
+
while (index < tokens.length) {
|
|
136
|
+
const token = tokens[index];
|
|
137
|
+
if (!token)
|
|
138
|
+
break;
|
|
139
|
+
if (token.sgr || token.width === 0) {
|
|
140
|
+
index += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (width + token.width > widthLimit && hasVisible)
|
|
144
|
+
break;
|
|
145
|
+
width += token.width;
|
|
146
|
+
hasVisible = true;
|
|
147
|
+
if (token.whitespace)
|
|
148
|
+
lastWhitespace = index;
|
|
149
|
+
index += 1;
|
|
150
|
+
}
|
|
151
|
+
if (index >= tokens.length) {
|
|
152
|
+
lines.push(renderWrappedSegment(tokens, start, tokens.length, activeSgr));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const overflow = tokens[index];
|
|
156
|
+
let end = index;
|
|
157
|
+
let next = index;
|
|
158
|
+
if (overflow?.whitespace) {
|
|
159
|
+
next = index + 1;
|
|
160
|
+
}
|
|
161
|
+
else if (lastWhitespace >= start) {
|
|
162
|
+
end = lastWhitespace;
|
|
163
|
+
next = lastWhitespace + 1;
|
|
164
|
+
}
|
|
165
|
+
if (end === start) {
|
|
166
|
+
end = Math.max(index, start + 1);
|
|
167
|
+
next = end;
|
|
168
|
+
}
|
|
169
|
+
lines.push(renderWrappedSegment(tokens, start, end, activeSgr));
|
|
170
|
+
activeSgr = advanceSgr(activeSgr, tokens, start, next);
|
|
171
|
+
start = next;
|
|
172
|
+
}
|
|
173
|
+
return lines.length > 0 ? lines : [''];
|
|
174
|
+
}
|
package/dist/core/theme.js
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import gradient from 'gradient-string';
|
|
3
|
+
import { pickIcon } from './icons.js';
|
|
4
|
+
// The one gradient the brand uses anywhere it appears -- the startup logo,
|
|
5
|
+
// and (in text form) the persistent header wordmark. Defined once here so
|
|
6
|
+
// both stay the same three stops instead of drifting apart.
|
|
7
|
+
export const brandGradient = gradient([
|
|
8
|
+
{ color: '#124689', pos: 0 },
|
|
9
|
+
{ color: '#0ea5e9', pos: 0.55 },
|
|
10
|
+
{ color: '#06b6d4', pos: 1 },
|
|
11
|
+
]);
|
|
12
|
+
/** The brand wordmark treatment: bold text painted in `brandGradient`,
|
|
13
|
+
* falling back to plain text under NO_COLOR (gradient-string doesn't
|
|
14
|
+
* auto-respect it the way chalk's own colors do). Currently used by the
|
|
15
|
+
* header's persistent "nbtca" mark (`app/chrome.ts`) -- named here, not
|
|
16
|
+
* left as a one-off local helper, so any future chrome element that wants
|
|
17
|
+
* "the brand gradient, as a wordmark" has a single place to reuse instead
|
|
18
|
+
* of re-deriving the NO_COLOR/bold/gradient combination again. */
|
|
19
|
+
export function brandMark(s) {
|
|
20
|
+
if (process.env['NO_COLOR'])
|
|
21
|
+
return s;
|
|
22
|
+
return chalk.bold(brandGradient(s));
|
|
23
|
+
}
|
|
2
24
|
export const c = {
|
|
3
25
|
brand: (s) => chalk.hex('#0ea5e9')(s),
|
|
4
26
|
accent: (s) => chalk.cyan(s),
|
|
@@ -20,3 +42,42 @@ export const c = {
|
|
|
20
42
|
return chalk.red(s);
|
|
21
43
|
},
|
|
22
44
|
};
|
|
45
|
+
export const glyph = {
|
|
46
|
+
cursor: () => pickIcon('→', '>'),
|
|
47
|
+
rule: () => pickIcon('─', '-'),
|
|
48
|
+
bullet: () => pickIcon('·', '.'),
|
|
49
|
+
dot: () => pickIcon('●', '*'),
|
|
50
|
+
updown: () => pickIcon('↑↓', 'up/down'),
|
|
51
|
+
enter: () => pickIcon('⏎', 'enter'),
|
|
52
|
+
// Two-level "how full" bar cell (Home's day-progress, Schedule's
|
|
53
|
+
// term-progress) — a single source of truth so every such bar in the
|
|
54
|
+
// app reads as the same visual language. Not the same vocabulary as
|
|
55
|
+
// calendar-heatmap's 5-level intensity scale, which is a deliberately
|
|
56
|
+
// finer-grained density visualization, not a binary fill/empty bar.
|
|
57
|
+
barFilled: () => pickIcon('█', '#'),
|
|
58
|
+
barEmpty: () => pickIcon('░', '-'),
|
|
59
|
+
};
|
|
60
|
+
export const space = {
|
|
61
|
+
indent: ' ',
|
|
62
|
+
};
|
|
63
|
+
export const type = {
|
|
64
|
+
heading: (s) => chalk.bold.white(s),
|
|
65
|
+
label: (s) => chalk.white(s),
|
|
66
|
+
body: (s) => s,
|
|
67
|
+
hint: (s) => chalk.dim(s),
|
|
68
|
+
/** The one thing on this screen your eye should land on: the app's own
|
|
69
|
+
* name, the tab you're on, the row a menu's cursor sits on, the class
|
|
70
|
+
* that's happening right now. `heading` marks a section as structure;
|
|
71
|
+
* `active` marks a single point as attention — never both on the same
|
|
72
|
+
* element, and never more than one or two `active` uses per screen, or
|
|
73
|
+
* the signal stops meaning anything. Brand color (#0ea5e9) precisely
|
|
74
|
+
* because there is exactly one brand-worthy thing to say on each of
|
|
75
|
+
* these screens, and this is where it belongs. */
|
|
76
|
+
active: (s) => chalk.bold(c.brand(s)),
|
|
77
|
+
/** The grid cursor's own visual signal: a solid brand-colored background
|
|
78
|
+
* block, deliberately distinct from `active` (bold text on the default
|
|
79
|
+
* background) so "this is today" and "this is where your cursor is" never
|
|
80
|
+
* share one visual language, even when the cursor lands on today's own
|
|
81
|
+
* column. */
|
|
82
|
+
cursor: (s) => chalk.bgHex('#0ea5e9').black(s),
|
|
83
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { clearScreen } from './ui.js';
|
|
2
|
+
import { typeReveal } from './motion.js';
|
|
3
|
+
import { glyph, type, space } from './theme.js';
|
|
4
|
+
import { screenWidth } from './components/screen.js';
|
|
5
|
+
import { pickIcon } from './icons.js';
|
|
6
|
+
export function breadcrumb(label) {
|
|
7
|
+
return `nbtca ${pickIcon('›', '>')} ${label}`;
|
|
8
|
+
}
|
|
9
|
+
export function buildScreenHeaderLines(crumb) {
|
|
10
|
+
return [
|
|
11
|
+
space.indent + type.heading(crumb),
|
|
12
|
+
space.indent + type.hint(glyph.rule().repeat(screenWidth())),
|
|
13
|
+
'',
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
export async function enterScreen(crumb) {
|
|
17
|
+
clearScreen();
|
|
18
|
+
await typeReveal(buildScreenHeaderLines(crumb));
|
|
19
|
+
}
|
package/dist/core/ui.js
CHANGED
|
@@ -1,35 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimalist UI component library
|
|
3
|
-
* Delegates to
|
|
3
|
+
* Delegates to self-rendered widgets for terminal output
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { success, error, warning, info } from './components/messages.js';
|
|
6
|
+
import { startSpinner } from './components/spinner.js';
|
|
6
7
|
import chalk from 'chalk';
|
|
7
8
|
import { pickIcon } from './icons.js';
|
|
8
9
|
import { t } from '../i18n/index.js';
|
|
9
|
-
|
|
10
|
-
* Display success message
|
|
11
|
-
*/
|
|
12
|
-
export function success(msg) {
|
|
13
|
-
log.success(msg);
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Display error message
|
|
17
|
-
*/
|
|
18
|
-
export function error(msg) {
|
|
19
|
-
log.error(msg);
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Display info message
|
|
23
|
-
*/
|
|
24
|
-
export function info(msg) {
|
|
25
|
-
log.info(msg);
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Display warning message
|
|
29
|
-
*/
|
|
30
|
-
export function warning(msg) {
|
|
31
|
-
log.warn(msg);
|
|
32
|
-
}
|
|
10
|
+
export { success, error, warning, info };
|
|
33
11
|
/**
|
|
34
12
|
* Display divider line
|
|
35
13
|
*/
|
|
@@ -59,9 +37,7 @@ export function printNewLine(count = 1) {
|
|
|
59
37
|
* Caller is responsible for calling .stop(msg) or .stop(msg, 1) on error.
|
|
60
38
|
*/
|
|
61
39
|
export function createSpinner(msg) {
|
|
62
|
-
|
|
63
|
-
s.start(msg);
|
|
64
|
-
return s;
|
|
40
|
+
return startSpinner(msg);
|
|
65
41
|
}
|
|
66
42
|
export function handleGracefulExit(err) {
|
|
67
43
|
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { pickIcon } from '../core/icons.js';
|
|
7
|
+
import { space, type } from '../core/theme.js';
|
|
7
8
|
import { t, getCurrentLanguage } from '../i18n/index.js';
|
|
8
9
|
/** Parse a 'YYYY-MM-DD' date string into a UTC proxy Date (host-timezone-independent). */
|
|
9
10
|
function parseBucketDate(date) {
|
|
@@ -29,17 +30,22 @@ function countToGlyph(count) {
|
|
|
29
30
|
return pickIcon('▓', '-');
|
|
30
31
|
return pickIcon('█', '=');
|
|
31
32
|
}
|
|
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
|
+
const HEATMAP_RAMP = ['#1b4332', '#2d6a4f', '#40916c', '#52b788'];
|
|
41
|
+
const MAX_WEEK_COLUMNS = 53;
|
|
42
|
+
const GRID_PREFIX_WIDTH = 6;
|
|
32
43
|
/** Apply a green color ramp based on count. Identity when count is 0. */
|
|
33
44
|
function applyColor(glyph, count, useColor) {
|
|
34
45
|
if (!useColor || count <= 0)
|
|
35
46
|
return glyph;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (count === 2)
|
|
39
|
-
return chalk.green(glyph);
|
|
40
|
-
if (count === 3)
|
|
41
|
-
return chalk.greenBright(glyph);
|
|
42
|
-
return chalk.bold.greenBright(glyph);
|
|
47
|
+
const level = Math.min(count, HEATMAP_RAMP.length) - 1;
|
|
48
|
+
return chalk.hex(HEATMAP_RAMP[level])(glyph);
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Render a GitHub-contributions-style heatmap grid.
|
|
@@ -51,21 +57,21 @@ function applyColor(glyph, count, useColor) {
|
|
|
51
57
|
export function renderHeatmap(buckets, today, options) {
|
|
52
58
|
const useColor = options?.color === true;
|
|
53
59
|
const trans = t();
|
|
60
|
+
const cellWidth = options?.cols !== undefined
|
|
61
|
+
&& options.cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2;
|
|
62
|
+
const availableColumns = options?.cols === undefined
|
|
63
|
+
? MAX_WEEK_COLUMNS
|
|
64
|
+
: Math.floor((options.cols - GRID_PREFIX_WIDTH) / cellWidth);
|
|
65
|
+
const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns));
|
|
54
66
|
// Build a lookup map: 'YYYY-MM-DD' -> count
|
|
55
67
|
const countByDate = new Map();
|
|
56
68
|
for (const b of buckets) {
|
|
57
69
|
countByDate.set(b.date, b.count);
|
|
58
70
|
}
|
|
59
|
-
// Determine the grid window.
|
|
60
|
-
// The grid ends at "today" (aligned to Mon-start week column).
|
|
61
|
-
// The grid starts 365 days before today.
|
|
62
71
|
const todayProxy = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
|
63
72
|
// Find the Monday that starts the week containing today
|
|
64
73
|
const todayMonIndex = utcDayToMonIndex(todayProxy.getUTCDay());
|
|
65
74
|
const gridEndMs = todayProxy.getTime() + (6 - todayMonIndex) * 86400000; // Sunday of today's week
|
|
66
|
-
// Grid start: 52 full weeks back from the start of today's week, plus today's partial week
|
|
67
|
-
// Total columns = 53 weeks
|
|
68
|
-
const numCols = 53;
|
|
69
75
|
const gridStartMs = gridEndMs - (numCols * 7 - 1) * 86400000;
|
|
70
76
|
const gridStart = new Date(gridStartMs);
|
|
71
77
|
const columns = [];
|
|
@@ -89,15 +95,9 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
89
95
|
}
|
|
90
96
|
columns.push(column);
|
|
91
97
|
}
|
|
92
|
-
|
|
93
|
-
// single-width, so alignment holds in any language). Each grid column occupies
|
|
94
|
-
// 2 terminal cells (glyph + joining space); we write each month's label into a
|
|
95
|
-
// character buffer starting at the column where that month begins, letting it
|
|
96
|
-
// overflow rightward into the spacing of the following columns (months are
|
|
97
|
-
// ~4-5 columns apart, so labels never collide).
|
|
98
|
-
const weekdayLabel = ' '; // 3-char prefix, matches the grid rows' "Mo " prefix
|
|
98
|
+
const weekdayLabel = space.indent; // matches the grid rows' "Mo " prefix width
|
|
99
99
|
const monthFmt = new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' });
|
|
100
|
-
const cellsWidth = numCols *
|
|
100
|
+
const cellsWidth = numCols * cellWidth;
|
|
101
101
|
const monthChars = new Array(cellsWidth).fill(' ');
|
|
102
102
|
let prevMonth = -1;
|
|
103
103
|
for (let col = 0; col < numCols; col++) {
|
|
@@ -115,13 +115,13 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
115
115
|
if (month !== prevMonth) {
|
|
116
116
|
prevMonth = month;
|
|
117
117
|
const label = monthFmt.format(labelDate); // e.g. "Jun"
|
|
118
|
-
const start = col *
|
|
118
|
+
const start = col * cellWidth;
|
|
119
119
|
for (let i = 0; i < label.length && start + i < cellsWidth; i++) {
|
|
120
120
|
monthChars[start + i] = label[i] ?? ' ';
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
const monthLabelLine = weekdayLabel + monthChars.join('');
|
|
124
|
+
const monthLabelLine = space.indent + weekdayLabel + monthChars.join('');
|
|
125
125
|
// Weekday labels (Mon/Wed/Fri only, index 0/2/4 in Mon-indexed scheme)
|
|
126
126
|
const lang = getCurrentLanguage();
|
|
127
127
|
const weekdayNames = lang === 'zh'
|
|
@@ -129,8 +129,10 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
129
129
|
: ['Mo', ' ', 'We', ' ', 'Fr', ' ', ' '];
|
|
130
130
|
// Build output lines
|
|
131
131
|
const lines = [];
|
|
132
|
-
// Title line
|
|
133
|
-
|
|
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
|
+
lines.push(space.indent + type.heading(trans.calendar.heatmap.title));
|
|
134
136
|
lines.push('');
|
|
135
137
|
// Month labels line
|
|
136
138
|
lines.push(monthLabelLine);
|
|
@@ -144,7 +146,7 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
144
146
|
const glyph = countToGlyph(cell.count);
|
|
145
147
|
return applyColor(glyph, cell.count, useColor);
|
|
146
148
|
});
|
|
147
|
-
lines.push(`${wdLabel} ${cells.join(' ')}`);
|
|
149
|
+
lines.push(`${space.indent}${wdLabel} ${cells.join(cellWidth === 2 ? ' ' : '')}`);
|
|
148
150
|
}
|
|
149
151
|
// Legend line
|
|
150
152
|
const legendGlyphs = [
|
|
@@ -164,6 +166,6 @@ export function renderHeatmap(buckets, today, options) {
|
|
|
164
166
|
]
|
|
165
167
|
: legendGlyphs;
|
|
166
168
|
lines.push('');
|
|
167
|
-
lines.push(`${trans.calendar.heatmap.legendLess} ${legendColored.join('')} ${trans.calendar.heatmap.legendMore}`);
|
|
169
|
+
lines.push(`${space.indent}${type.hint(trans.calendar.heatmap.legendLess)} ${legendColored.join('')} ${type.hint(trans.calendar.heatmap.legendMore)}`);
|
|
168
170
|
return lines.join('\n');
|
|
169
171
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function weekRange(now) {
|
|
2
|
+
const start = new Date(now);
|
|
3
|
+
start.setHours(0, 0, 0, 0);
|
|
4
|
+
const mondayOffset = (start.getDay() + 6) % 7; // days since Monday
|
|
5
|
+
start.setDate(start.getDate() - mondayOffset);
|
|
6
|
+
const end = new Date(start);
|
|
7
|
+
end.setDate(end.getDate() + 7);
|
|
8
|
+
return { start, end };
|
|
9
|
+
}
|
|
10
|
+
export function monthRange(now) {
|
|
11
|
+
const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
12
|
+
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
|
|
13
|
+
return { start, end };
|
|
14
|
+
}
|
|
15
|
+
export function filterEvents(events, query) {
|
|
16
|
+
const q = query.trim().toLowerCase();
|
|
17
|
+
if (!q)
|
|
18
|
+
return events;
|
|
19
|
+
return events.filter((e) => (e.title ?? '').toLowerCase().includes(q) ||
|
|
20
|
+
(e.location ?? '').toLowerCase().includes(q));
|
|
21
|
+
}
|
|
22
|
+
export function countdownParts(target, now) {
|
|
23
|
+
const ms = target.getTime() - now.getTime();
|
|
24
|
+
if (ms <= 0)
|
|
25
|
+
return { past: true, days: 0, hours: 0, minutes: 0 };
|
|
26
|
+
const totalMin = Math.floor(ms / 60000);
|
|
27
|
+
return {
|
|
28
|
+
past: false,
|
|
29
|
+
days: Math.floor(totalMin / 1440),
|
|
30
|
+
hours: Math.floor((totalMin % 1440) / 60),
|
|
31
|
+
minutes: totalMin % 60,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** True once a countdown is close enough to call out visually (default: 15
|
|
35
|
+
* minutes or less). A `past` countdown is never urgent — there's nothing
|
|
36
|
+
* left to hurry for. */
|
|
37
|
+
export function isCountdownUrgent(p, thresholdMinutes = 15) {
|
|
38
|
+
if (p.past)
|
|
39
|
+
return false;
|
|
40
|
+
const totalMinutes = p.days * 1440 + p.hours * 60 + p.minutes;
|
|
41
|
+
return totalMinutes <= thresholdMinutes;
|
|
42
|
+
}
|
|
43
|
+
export function buildExportFilename(event) {
|
|
44
|
+
const cleaned = (event.title ?? '')
|
|
45
|
+
.replace(/[^\p{L}\p{N}\-_ ]/gu, '')
|
|
46
|
+
.trim()
|
|
47
|
+
.replace(/\s+/g, '-')
|
|
48
|
+
.slice(0, 60);
|
|
49
|
+
return `${cleaned || 'event'}.ics`;
|
|
50
|
+
}
|