qr-terminal 1.0.0
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 +159 -0
- package/index.js +63 -0
- package/package.json +47 -0
- package/src/braille.js +104 -0
- package/src/destruct.js +123 -0
- package/src/engine.js +286 -0
- package/src/export.js +76 -0
- package/src/formatters.js +44 -0
- package/src/handshake.js +112 -0
- package/src/live-preview.js +199 -0
- package/src/luma.js +58 -0
- package/src/matrix-rain.js +115 -0
- package/src/particle.js +91 -0
- package/src/prompts.js +449 -0
- package/src/shortlink.js +58 -0
- package/src/ui.js +183 -0
package/src/prompts.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main interactive flow.
|
|
3
|
+
* Orchestrates inquirer prompts → live preview → QR generation → export.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { select, input, confirm, checkbox, password } from '@inquirer/prompts';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import ora from 'ora';
|
|
9
|
+
import clipboard from 'clipboardy';
|
|
10
|
+
|
|
11
|
+
import { createMatrix, renderHalfBlock, renderASCII } from './engine.js';
|
|
12
|
+
import { liveInput } from './live-preview.js';
|
|
13
|
+
import { formatWifi, formatVCard, ERROR_LEVELS, GRADIENT_OPTIONS } from './formatters.js';
|
|
14
|
+
import { exportPNG, exportSVG, exportTXT } from './export.js';
|
|
15
|
+
import { shortenUrl, isValidUrl, estimateSavings } from './shortlink.js';
|
|
16
|
+
import { printQR, revealQR, printDivider, detectDarkMode, log } from './ui.js';
|
|
17
|
+
import { animateParticleFlyIn } from './particle.js';
|
|
18
|
+
import { matrixRainQR } from './matrix-rain.js';
|
|
19
|
+
import { renderBraille } from './braille.js';
|
|
20
|
+
import { startHandshakeServer } from './handshake.js';
|
|
21
|
+
import { createDestructQR, showExplosion } from './destruct.js';
|
|
22
|
+
|
|
23
|
+
export async function runMainFlow() {
|
|
24
|
+
// ──────────────────────────────────────────────
|
|
25
|
+
// STEP 1 — Input type selection
|
|
26
|
+
// ──────────────────────────────────────────────
|
|
27
|
+
const inputType = await select({
|
|
28
|
+
message: chalk.bold('What do you want to encode?'),
|
|
29
|
+
choices: [
|
|
30
|
+
{
|
|
31
|
+
name: ' 🔗 URL / Link',
|
|
32
|
+
value: 'url',
|
|
33
|
+
description: 'Website, app deep-link, or any HTTP address',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: ' 📶 WiFi Credentials',
|
|
37
|
+
value: 'wifi',
|
|
38
|
+
description: 'Tap-to-join network QR (iOS 11+ / Android 10+)',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: ' 👤 vCard Contact',
|
|
42
|
+
value: 'vcard',
|
|
43
|
+
description: 'Share your contact card — imports directly to address book',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: ' 🔒 Secret Text',
|
|
47
|
+
value: 'secret',
|
|
48
|
+
description: 'Tokens, keys, passwords, or any sensitive payload',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ──────────────────────────────────────────────
|
|
54
|
+
// STEP 2 — Error correction level
|
|
55
|
+
// ──────────────────────────────────────────────
|
|
56
|
+
const errorLevel = await select({
|
|
57
|
+
message: chalk.bold('Error correction level?'),
|
|
58
|
+
choices: Object.entries(ERROR_LEVELS).map(([k, v]) => ({
|
|
59
|
+
name: v.label,
|
|
60
|
+
value: k,
|
|
61
|
+
description: v.desc,
|
|
62
|
+
})),
|
|
63
|
+
default: 'M',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ──────────────────────────────────────────────
|
|
67
|
+
// STEP 3 — Visual gradient theme
|
|
68
|
+
// ──────────────────────────────────────────────
|
|
69
|
+
const gradient = await select({
|
|
70
|
+
message: chalk.bold('Color theme for the QR code?'),
|
|
71
|
+
choices: GRADIENT_OPTIONS,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── Render engine selection ─────────────────────────────────────────────
|
|
75
|
+
const renderMode = await select({
|
|
76
|
+
message: chalk.bold('Render engine?'),
|
|
77
|
+
choices: [
|
|
78
|
+
{ name: ' ▀▄ Half-Block — Standard high-density (default)', value: 'halfblock' },
|
|
79
|
+
{ name: ' ⣿ Braille — 4× resolution "Retina" mode', value: 'braille' },
|
|
80
|
+
{ name: ' ✦ Particle — Fly-in "Transformer" animation', value: 'particle' },
|
|
81
|
+
{ name: ' ░ Matrix Rain — Animated cascade → gradient settle', value: 'matrix' },
|
|
82
|
+
{ name: ' 📡 Device Link — Scan to connect phone → terminal', value: 'handshake'},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ──────────────────────────────────────────────
|
|
87
|
+
// STEP 4 — Collect type-specific data
|
|
88
|
+
// ──────────────────────────────────────────────
|
|
89
|
+
let qrData = '';
|
|
90
|
+
let dataLabel = '';
|
|
91
|
+
|
|
92
|
+
switch (inputType) {
|
|
93
|
+
|
|
94
|
+
// ── URL ─────────────────────────────────────
|
|
95
|
+
case 'url': {
|
|
96
|
+
console.log(chalk.dim('\n Live preview active — QR updates as you type\n'));
|
|
97
|
+
|
|
98
|
+
let rawUrl = await liveInput({ label: 'URL', errorLevel, gradient });
|
|
99
|
+
|
|
100
|
+
// Auto-prepend protocol if missing
|
|
101
|
+
if (rawUrl && !rawUrl.match(/^https?:\/\//i)) {
|
|
102
|
+
rawUrl = 'https://' + rawUrl;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!rawUrl) { log.error('No URL provided.'); return; }
|
|
106
|
+
|
|
107
|
+
qrData = rawUrl;
|
|
108
|
+
dataLabel = 'URL';
|
|
109
|
+
|
|
110
|
+
// Optional URL shortening
|
|
111
|
+
if (isValidUrl(qrData) && qrData.length > 40) {
|
|
112
|
+
const shouldShorten = await confirm({
|
|
113
|
+
message: `Shorten via TinyURL? ${chalk.dim('(reduces QR complexity)')}`,
|
|
114
|
+
default: false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (shouldShorten) {
|
|
118
|
+
const spinner = ora({
|
|
119
|
+
text: 'Connecting to TinyURL…',
|
|
120
|
+
spinner: 'dots',
|
|
121
|
+
}).start();
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const short = await shortenUrl(qrData);
|
|
125
|
+
const savings = estimateSavings(qrData.length, short.length);
|
|
126
|
+
spinner.succeed(chalk.green('Shortened: ') + chalk.white(short) + chalk.dim(` (${savings})`));
|
|
127
|
+
qrData = short;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
spinner.fail(chalk.yellow('Could not reach TinyURL. Using original URL.'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── WiFi ────────────────────────────────────
|
|
137
|
+
case 'wifi': {
|
|
138
|
+
console.log();
|
|
139
|
+
const ssid = await input({
|
|
140
|
+
message: chalk.cyan('Network name (SSID):'),
|
|
141
|
+
validate: (v) => v.trim().length > 0 || 'SSID cannot be empty',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const security = await select({
|
|
145
|
+
message: chalk.cyan('Security protocol:'),
|
|
146
|
+
choices: [
|
|
147
|
+
{ name: 'WPA / WPA2 / WPA3 (recommended)', value: 'WPA' },
|
|
148
|
+
{ name: 'WEP (legacy)', value: 'WEP' },
|
|
149
|
+
{ name: 'Open / No password', value: 'nopass' },
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
let wifiPassword = '';
|
|
154
|
+
if (security !== 'nopass') {
|
|
155
|
+
wifiPassword = await password({
|
|
156
|
+
message: chalk.cyan('Password:'),
|
|
157
|
+
mask: '●',
|
|
158
|
+
validate: (v) => v.length > 0 || 'Password cannot be empty',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const hidden = await confirm({
|
|
163
|
+
message: chalk.cyan('Is this a hidden network?'),
|
|
164
|
+
default: false,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
qrData = formatWifi({ ssid, password: wifiPassword, security, hidden });
|
|
168
|
+
dataLabel = `WiFi: ${ssid}`;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── vCard ───────────────────────────────────
|
|
173
|
+
case 'vcard': {
|
|
174
|
+
console.log();
|
|
175
|
+
const firstName = await input({ message: chalk.cyan('First name:') });
|
|
176
|
+
const lastName = await input({ message: chalk.cyan('Last name:'), default: '' });
|
|
177
|
+
const phone = await input({ message: chalk.cyan('Phone:'), default: '' });
|
|
178
|
+
const email = await input({ message: chalk.cyan('Email:'), default: '' });
|
|
179
|
+
const org = await input({ message: chalk.cyan('Organization:'), default: '' });
|
|
180
|
+
const url = await input({ message: chalk.cyan('Website:'), default: '' });
|
|
181
|
+
|
|
182
|
+
if (!firstName && !lastName) { log.error('Name is required.'); return; }
|
|
183
|
+
|
|
184
|
+
qrData = formatVCard({ firstName, lastName, phone, email, org, url });
|
|
185
|
+
dataLabel = `vCard: ${[firstName, lastName].filter(Boolean).join(' ')}`;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Secret ──────────────────────────────────
|
|
190
|
+
case 'secret': {
|
|
191
|
+
console.log();
|
|
192
|
+
const secret = await password({
|
|
193
|
+
message: chalk.cyan('Secret payload:'),
|
|
194
|
+
mask: '●',
|
|
195
|
+
validate: (v) => v.length > 0 || 'Cannot encode an empty secret',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
qrData = secret;
|
|
199
|
+
dataLabel = 'Secret Text';
|
|
200
|
+
log.warn('Secret encoded — share this QR only with intended recipients.');
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!qrData) return;
|
|
206
|
+
|
|
207
|
+
// ──────────────────────────────────────────────
|
|
208
|
+
// STEP 5 — Generate & render the QR code
|
|
209
|
+
// ──────────────────────────────────────────────
|
|
210
|
+
printDivider('Generating');
|
|
211
|
+
|
|
212
|
+
let modules;
|
|
213
|
+
const spinner = ora({ text: 'Building QR matrix…', spinner: 'arc' }).start();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
modules = createMatrix(qrData, { errorLevel });
|
|
217
|
+
spinner.succeed(chalk.green('QR matrix ready.'));
|
|
218
|
+
} catch (e) {
|
|
219
|
+
spinner.fail(chalk.red('QR generation failed: ' + e.message));
|
|
220
|
+
log.tip('Try a shorter input or increase error correction to H.');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ──────────────────────────────────────────────
|
|
225
|
+
// STEP 5b — Render with chosen engine
|
|
226
|
+
// ──────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
// Special case: handshake mode
|
|
229
|
+
if (renderMode === 'handshake') {
|
|
230
|
+
const spinner2 = ora({ text: 'Starting device link server…', spinner: 'arc' }).start();
|
|
231
|
+
let server;
|
|
232
|
+
try {
|
|
233
|
+
server = await startHandshakeServer();
|
|
234
|
+
spinner2.succeed(`Server ready → ${chalk.underline(server.url)}`);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
spinner2.fail('Could not start server: ' + e.message);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Generate QR for the server URL
|
|
241
|
+
const linkModules = createMatrix(server.url, { errorLevel: 'M' });
|
|
242
|
+
const linkLines = renderHalfBlock(linkModules, { gradient: 'neon' });
|
|
243
|
+
|
|
244
|
+
console.log();
|
|
245
|
+
console.log(chalk.bold.cyanBright(' ┌─ Scan to Connect ──────────────────────────────────┐'));
|
|
246
|
+
await revealQR(linkLines, { speed: 10 });
|
|
247
|
+
console.log(chalk.dim(' Open on your phone: ') + chalk.white(server.url));
|
|
248
|
+
console.log();
|
|
249
|
+
|
|
250
|
+
const waitSpinner = ora({
|
|
251
|
+
text: chalk.dim('Waiting for device to connect…'),
|
|
252
|
+
spinner: 'dots',
|
|
253
|
+
color: 'cyan',
|
|
254
|
+
}).start();
|
|
255
|
+
|
|
256
|
+
await server.waitForDevice;
|
|
257
|
+
waitSpinner.succeed(chalk.bold.green('DEVICE CONNECTED ✓'));
|
|
258
|
+
console.log(chalk.cyan('\n Phone is now linked. Messages will appear below.\n'));
|
|
259
|
+
console.log(chalk.dim(' Press Ctrl+C to close the connection.\n'));
|
|
260
|
+
|
|
261
|
+
// Stream phone messages to terminal
|
|
262
|
+
server.onMessage((text) => {
|
|
263
|
+
console.log(chalk.bold.cyanBright('\n 📱 From phone: ') + chalk.white(text) + '\n');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Keep alive until Ctrl+C
|
|
267
|
+
await new Promise((_, reject) => {
|
|
268
|
+
process.on('SIGINT', () => {
|
|
269
|
+
server.close();
|
|
270
|
+
console.log(chalk.dim('\n Connection closed.'));
|
|
271
|
+
reject(new Error('Cancelled'));
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const qrLines = renderMode === 'braille'
|
|
278
|
+
? renderBraille(modules, { gradient })
|
|
279
|
+
: renderHalfBlock(modules, { gradient });
|
|
280
|
+
|
|
281
|
+
console.log();
|
|
282
|
+
if (dataLabel) {
|
|
283
|
+
const qrWidth = qrLines[0].replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
284
|
+
console.log(
|
|
285
|
+
chalk.bold.cyanBright(' ┌─ ') +
|
|
286
|
+
chalk.bold.white(dataLabel) +
|
|
287
|
+
chalk.bold.cyanBright(' ' + '─'.repeat(Math.max(0, qrWidth - dataLabel.length - 3)) + '┐')
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (renderMode === 'particle') {
|
|
292
|
+
await animateParticleFlyIn(modules, { gradient, fps: 30, duration: 1100 });
|
|
293
|
+
} else if (renderMode === 'matrix') {
|
|
294
|
+
await matrixRainQR(modules, { gradient, duration: 2200, fps: 24 });
|
|
295
|
+
} else {
|
|
296
|
+
await revealQR(qrLines, { speed: 12 });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const version = Math.round((modules.size - 17) / 4);
|
|
300
|
+
const stats = [
|
|
301
|
+
chalk.bold.cyanBright(`v${version} QR`),
|
|
302
|
+
chalk.dim(`${modules.size}×${modules.size} modules`),
|
|
303
|
+
chalk.dim(`${qrLines.length * 2} → ${qrLines.length} rows`),
|
|
304
|
+
chalk.dim(`EC: ${errorLevel}`),
|
|
305
|
+
chalk.dim(renderMode),
|
|
306
|
+
];
|
|
307
|
+
if (inputType !== 'secret') {
|
|
308
|
+
const prev = qrData.slice(0, 35) + (qrData.length > 35 ? '…' : '');
|
|
309
|
+
stats.push(chalk.dim.italic(`"${prev}"`));
|
|
310
|
+
}
|
|
311
|
+
console.log(' ' + stats.join(chalk.dim(' · ')));
|
|
312
|
+
console.log();
|
|
313
|
+
|
|
314
|
+
// ──────────────────────────────────────────────
|
|
315
|
+
// STEP 6 — Post-generation actions
|
|
316
|
+
// ──────────────────────────────────────────────
|
|
317
|
+
printDivider('Export');
|
|
318
|
+
|
|
319
|
+
const actions = await checkbox({
|
|
320
|
+
message: chalk.bold('What would you like to do with this QR code?'),
|
|
321
|
+
choices: [
|
|
322
|
+
{
|
|
323
|
+
name: ' 📋 Copy ASCII to clipboard (paste into Slack, README, terminal)',
|
|
324
|
+
value: 'clipboard',
|
|
325
|
+
checked: true,
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: ' 🖼 Save as PNG (high-res image)',
|
|
329
|
+
value: 'png',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: ' 📐 Save as SVG (scalable vector)',
|
|
333
|
+
value: 'svg',
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: ' 📄 Save as TXT (plain Unicode half-blocks)',
|
|
337
|
+
value: 'txt',
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: ' ☠ Self-destruct share (host file via public QR, auto-destroy on download)',
|
|
341
|
+
value: 'destruct',
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
console.log();
|
|
347
|
+
|
|
348
|
+
for (const action of actions) {
|
|
349
|
+
switch (action) {
|
|
350
|
+
|
|
351
|
+
case 'clipboard': {
|
|
352
|
+
const sp = ora({ text: 'Writing to clipboard…', spinner: 'dots' }).start();
|
|
353
|
+
try {
|
|
354
|
+
await clipboard.write(renderASCII(modules));
|
|
355
|
+
sp.succeed('ASCII QR code copied to clipboard!');
|
|
356
|
+
} catch (e) {
|
|
357
|
+
sp.fail('Clipboard unavailable: ' + e.message);
|
|
358
|
+
log.tip('Try running with elevated permissions, or use the TXT export instead.');
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case 'png': {
|
|
364
|
+
const fileName = await input({
|
|
365
|
+
message: 'PNG filename:',
|
|
366
|
+
default: sanitizeFilename(dataLabel) + '.png',
|
|
367
|
+
});
|
|
368
|
+
const sp = ora({ text: 'Rendering PNG…', spinner: 'arc' }).start();
|
|
369
|
+
try {
|
|
370
|
+
await exportPNG(qrData, fileName, { errorLevel });
|
|
371
|
+
sp.succeed(`PNG saved → ${chalk.underline(fileName)}`);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
sp.fail('PNG export failed: ' + e.message);
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
case 'svg': {
|
|
379
|
+
const fileName = await input({
|
|
380
|
+
message: 'SVG filename:',
|
|
381
|
+
default: sanitizeFilename(dataLabel) + '.svg',
|
|
382
|
+
});
|
|
383
|
+
const sp = ora({ text: 'Rendering SVG…', spinner: 'arc' }).start();
|
|
384
|
+
try {
|
|
385
|
+
await exportSVG(qrData, fileName, { errorLevel });
|
|
386
|
+
sp.succeed(`SVG saved → ${chalk.underline(fileName)}`);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
sp.fail('SVG export failed: ' + e.message);
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
case 'txt': {
|
|
394
|
+
const fileName = await input({
|
|
395
|
+
message: 'TXT filename:',
|
|
396
|
+
default: sanitizeFilename(dataLabel) + '.txt',
|
|
397
|
+
});
|
|
398
|
+
const sp = ora({ text: 'Writing text file…', spinner: 'dots' }).start();
|
|
399
|
+
try {
|
|
400
|
+
await exportTXT(qrData, fileName, { errorLevel });
|
|
401
|
+
sp.succeed(`TXT saved → ${chalk.underline(fileName)}`);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
sp.fail('TXT export failed: ' + e.message);
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
case 'destruct': {
|
|
409
|
+
const filePath = await input({ message: 'File to share (path):' });
|
|
410
|
+
const sp = ora({ text: 'Creating tunnel…', spinner: 'arc' }).start();
|
|
411
|
+
try {
|
|
412
|
+
const session = await createDestructQR(filePath);
|
|
413
|
+
sp.succeed(`Tunnel ready: ${chalk.underline(session.url)}`);
|
|
414
|
+
|
|
415
|
+
const destructModules = createMatrix(session.url, { errorLevel: 'M' });
|
|
416
|
+
const destructLines = renderHalfBlock(destructModules, { gradient: 'fire' });
|
|
417
|
+
console.log();
|
|
418
|
+
for (const line of destructLines) console.log(' ' + line);
|
|
419
|
+
console.log();
|
|
420
|
+
log.warn('Scan to download. File will self-destruct after one download.');
|
|
421
|
+
|
|
422
|
+
const waitSp = ora({ text: 'Waiting for download…', spinner: 'dots', color: 'red' }).start();
|
|
423
|
+
await session.waitForDownload;
|
|
424
|
+
waitSp.succeed(chalk.red('FILE CONSUMED — TUNNEL CLOSED'));
|
|
425
|
+
await showExplosion();
|
|
426
|
+
} catch (e) {
|
|
427
|
+
sp.fail('Self-destruct failed: ' + e.message);
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
console.log();
|
|
435
|
+
printDivider();
|
|
436
|
+
log.tip('Hold your phone camera over the QR code — no scanner app needed on iOS 11+ / Android 10+.');
|
|
437
|
+
log.info(`Encoded ${qrData.length} characters with ${errorLevel}-level error correction.`);
|
|
438
|
+
console.log();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
function sanitizeFilename(label) {
|
|
444
|
+
return label
|
|
445
|
+
.toLowerCase()
|
|
446
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
447
|
+
.replace(/^-+|-+$/g, '')
|
|
448
|
+
.slice(0, 40) || 'qr-code';
|
|
449
|
+
}
|
package/src/shortlink.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Shortener integration.
|
|
3
|
+
* Uses TinyURL's free API (no auth key required).
|
|
4
|
+
* Keeping the encoded text short → lower QR version → smaller, cleaner matrix.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shorten a URL via TinyURL API.
|
|
11
|
+
* Falls back gracefully on network errors.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} url - The long URL to shorten
|
|
14
|
+
* @returns {Promise<string>} - The shortened URL
|
|
15
|
+
*/
|
|
16
|
+
export async function shortenUrl(url) {
|
|
17
|
+
const response = await axios.get('https://tinyurl.com/api-create.php', {
|
|
18
|
+
params: { url },
|
|
19
|
+
timeout: 7000,
|
|
20
|
+
headers: { 'User-Agent': 'qr-terminal/1.0.0' },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const result = response.data?.trim();
|
|
24
|
+
if (!result || !result.startsWith('http')) {
|
|
25
|
+
throw new Error('TinyURL returned an unexpected response');
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a string is a valid, parseable URL.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} str
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function isValidUrl(str) {
|
|
37
|
+
try {
|
|
38
|
+
const u = new URL(str);
|
|
39
|
+
return u.protocol === 'http:' || u.protocol === 'https:';
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Estimate how much "QR space" a URL will save after shortening.
|
|
47
|
+
* QR version increases roughly every ~17 chars of extra data.
|
|
48
|
+
*
|
|
49
|
+
* @param {number} originalLength
|
|
50
|
+
* @param {number} shortenedLength
|
|
51
|
+
* @returns {string} - Human-readable savings description
|
|
52
|
+
*/
|
|
53
|
+
export function estimateSavings(originalLength, shortenedLength) {
|
|
54
|
+
const saved = originalLength - shortenedLength;
|
|
55
|
+
const percent = Math.round((saved / originalLength) * 100);
|
|
56
|
+
if (saved <= 0) return 'no change';
|
|
57
|
+
return `−${saved} chars (${percent}% smaller QR matrix)`;
|
|
58
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI helpers: header, animated QR reveal, dark-mode detection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import figlet from 'figlet';
|
|
7
|
+
import boxen from 'boxen';
|
|
8
|
+
import gradient from 'gradient-string';
|
|
9
|
+
import { getQrVersion } from './engine.js';
|
|
10
|
+
|
|
11
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
12
|
+
const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
13
|
+
|
|
14
|
+
// ── Header ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function printHeader() {
|
|
17
|
+
let art = '';
|
|
18
|
+
try {
|
|
19
|
+
art = figlet.textSync('QR Terminal', { font: 'Slant', horizontalLayout: 'full' });
|
|
20
|
+
} catch {
|
|
21
|
+
art = figlet.textSync('QR Terminal');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const coloredArt = gradient(['#FF416C', '#FF4B2B', '#F7971E', '#FFD200', '#4ECDC4', '#556270'])(art);
|
|
25
|
+
const subtitle = chalk.dim(' High-Density Half-Block Rendering Engine · v1.0.0');
|
|
26
|
+
const badges = [
|
|
27
|
+
chalk.bgRgb(40,40,80).rgb(100,180,255)(' URL '),
|
|
28
|
+
chalk.bgRgb(40,40,80).rgb(100,255,160)(' WiFi '),
|
|
29
|
+
chalk.bgRgb(40,40,80).rgb(255,180,100)(' vCard '),
|
|
30
|
+
chalk.bgRgb(40,40,80).rgb(255,100,180)(' Secret '),
|
|
31
|
+
].join(chalk.dim(' '));
|
|
32
|
+
|
|
33
|
+
const content = `${coloredArt}\n${subtitle}\n\n ${badges}`;
|
|
34
|
+
|
|
35
|
+
console.log(
|
|
36
|
+
boxen(content, {
|
|
37
|
+
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
|
38
|
+
margin: { top: 0, bottom: 1, left: 1, right: 0 },
|
|
39
|
+
borderStyle: 'round',
|
|
40
|
+
borderColor: 'cyan',
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Animated QR reveal ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scanline reveal animation.
|
|
49
|
+
*
|
|
50
|
+
* Phase 1 — Ghost scan: print each row as dim '░' blocks with a brief
|
|
51
|
+
* bright cyan scanline flash before the real content settles.
|
|
52
|
+
* Phase 2 — Lock-on pulse: after all rows are revealed, sweep back up and
|
|
53
|
+
* reprint every line 40 RGB units brighter, then settle to normal.
|
|
54
|
+
* Simulates a camera locking focus on the QR code.
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} lines - chalk-colored QR rows from renderHalfBlock()
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {number} opts.speed - ms per row (default 14)
|
|
59
|
+
*/
|
|
60
|
+
export async function revealQR(lines, { speed = 14 } = {}) {
|
|
61
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
62
|
+
|
|
63
|
+
// Phase 1: row-by-row scanline reveal
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const raw = strip(line);
|
|
66
|
+
const w = raw.length;
|
|
67
|
+
|
|
68
|
+
// Scanline flash — bright cyan bar
|
|
69
|
+
process.stdout.write(' ' + chalk.bgCyan.bold(' '.repeat(w)) + '\r');
|
|
70
|
+
await sleep(speed);
|
|
71
|
+
|
|
72
|
+
// Settle to the real colored line
|
|
73
|
+
process.stdout.write(' ' + line + '\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Phase 2: brief full-QR lock-on pulse
|
|
77
|
+
await sleep(60);
|
|
78
|
+
|
|
79
|
+
// Move cursor back to the top of the QR block
|
|
80
|
+
process.stdout.write(`\x1b[${lines.length}A`);
|
|
81
|
+
|
|
82
|
+
// Print brightened version
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const bright = brighten(line, 45);
|
|
85
|
+
process.stdout.write(' ' + bright + '\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await sleep(90);
|
|
89
|
+
|
|
90
|
+
// Restore normal
|
|
91
|
+
process.stdout.write(`\x1b[${lines.length}A`);
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
process.stdout.write(' ' + line + '\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Print QR code with a stats box underneath.
|
|
101
|
+
*/
|
|
102
|
+
export function printQR(qrLines, { label = '', size = 0, dataPreview = '', errorLevel = 'M' } = {}) {
|
|
103
|
+
const version = getQrVersion(size);
|
|
104
|
+
const cols = Math.min(process.stdout.columns || 80, 80);
|
|
105
|
+
const qrWidth = strip(qrLines[0] ?? '').length + 2;
|
|
106
|
+
|
|
107
|
+
// Label bar
|
|
108
|
+
if (label) {
|
|
109
|
+
const bar = chalk.bold.cyanBright(' ┌─ ') +
|
|
110
|
+
chalk.bold.white(label) +
|
|
111
|
+
chalk.bold.cyanBright(' ' + '─'.repeat(Math.max(0, qrWidth - label.length - 4)) + '┐');
|
|
112
|
+
console.log(bar);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// QR rows (printed by caller via revealQR or directly)
|
|
116
|
+
for (const line of qrLines) {
|
|
117
|
+
console.log(' ' + line);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Stats bar
|
|
121
|
+
const stats = [
|
|
122
|
+
chalk.bold.cyanBright(`v${version} QR`),
|
|
123
|
+
chalk.dim(`${size}×${size} modules`),
|
|
124
|
+
chalk.dim(`${qrLines.length * 2} → ${qrLines.length} rows`),
|
|
125
|
+
chalk.dim(`EC: ${errorLevel}`),
|
|
126
|
+
];
|
|
127
|
+
if (dataPreview) {
|
|
128
|
+
const preview = dataPreview.slice(0, 35) + (dataPreview.length > 35 ? '…' : '');
|
|
129
|
+
stats.push(chalk.dim.italic(`"${preview}"`));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(' ' + stats.join(chalk.dim(' · ')));
|
|
133
|
+
console.log();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Detect if the user's terminal has a dark background.
|
|
140
|
+
* Uses COLORFGBG (set by iTerm2, xterm, etc.) and falls back to true.
|
|
141
|
+
*/
|
|
142
|
+
export function detectDarkMode() {
|
|
143
|
+
const colorfgbg = process.env.COLORFGBG;
|
|
144
|
+
if (colorfgbg) {
|
|
145
|
+
const parts = colorfgbg.split(';');
|
|
146
|
+
const bg = parseInt(parts[parts.length - 1], 10);
|
|
147
|
+
if (!isNaN(bg)) return bg < 8;
|
|
148
|
+
}
|
|
149
|
+
const profile = (process.env.ITERM_PROFILE || process.env.TERM_PROFILE || '').toLowerCase();
|
|
150
|
+
if (profile.includes('light')) return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function printDivider(label = '') {
|
|
155
|
+
const cols = Math.min(process.stdout.columns || 72, 72);
|
|
156
|
+
const inner = label ? ` ${label} ` : '';
|
|
157
|
+
const line = '─'.repeat(Math.max(0, cols - 4 - inner.length));
|
|
158
|
+
console.log(chalk.dim(` ${inner}${line}`));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const log = {
|
|
162
|
+
success: msg => console.log(chalk.green(' ✓ ') + chalk.white(msg)),
|
|
163
|
+
error: msg => console.log(chalk.red(' ✗ ') + chalk.white(msg)),
|
|
164
|
+
info: msg => console.log(chalk.cyan(' ℹ ') + chalk.dim(msg)),
|
|
165
|
+
warn: msg => console.log(chalk.yellow(' ⚠ ') + chalk.dim(msg)),
|
|
166
|
+
tip: msg => console.log(chalk.magenta(' ★ ')+ chalk.white(msg)),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Increase all RGB values in ANSI-colored string by `amount`.
|
|
173
|
+
* Used for the lock-on pulse effect.
|
|
174
|
+
*/
|
|
175
|
+
function brighten(line, amount) {
|
|
176
|
+
return line.replace(
|
|
177
|
+
/\x1b\[38;2;(\d+);(\d+);(\d+)m/g,
|
|
178
|
+
(_, r, g, b) => {
|
|
179
|
+
const clamp = v => Math.min(255, parseInt(v) + amount);
|
|
180
|
+
return `\x1b[38;2;${clamp(r)};${clamp(g)};${clamp(b)}m`;
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
}
|