qr-terminal 1.0.0 → 1.1.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/index.js +18 -2
- package/package.json +17 -3
- package/src/export-gif.js +170 -0
- package/src/export-pdf.js +80 -0
- package/src/history.js +65 -0
- package/src/prompts.js +181 -59
- package/src/shareable.js +17 -0
- package/src/ui.js +1 -1
package/index.js
CHANGED
|
@@ -16,10 +16,26 @@ import { printHeader, log } from './src/ui.js';
|
|
|
16
16
|
import { runMainFlow } from './src/prompts.js';
|
|
17
17
|
import { detectTerminalBgColor } from './src/luma.js';
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// ── CLI flags ────────────────────────────────────────────────────────────────
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const isPlain = args.includes('--plain') || args.includes('--no-color');
|
|
22
|
+
|
|
23
|
+
if (isPlain) {
|
|
24
|
+
process.env._QR_PLAIN = '1';
|
|
25
|
+
process.env.FORCE_COLOR = '0';
|
|
26
|
+
} else {
|
|
27
|
+
// Ensure stdout supports TrueColor
|
|
28
|
+
process.env.FORCE_COLOR = process.env.FORCE_COLOR ?? '3';
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
async function main() {
|
|
32
|
+
if (isPlain) {
|
|
33
|
+
// Plain mode: minimal header, no color
|
|
34
|
+
console.log('QR Terminal — plain/no-color mode\n');
|
|
35
|
+
await runMainFlow();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
// ── Splash ──────────────────────────────────────────────────────────────
|
|
24
40
|
process.stdout.write('\x1Bc'); // Full terminal clear (preserves scroll buffer)
|
|
25
41
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qr-terminal",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Cinematic terminal QR code generator — half-block, braille, particle fly-in, matrix rain, device handshake",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Cinematic terminal QR code generator — half-block, braille, particle fly-in, matrix rain, device handshake, history, PDF/GIF export, shareable links",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -15,7 +15,19 @@
|
|
|
15
15
|
"engines": {
|
|
16
16
|
"node": ">=18.0.0"
|
|
17
17
|
},
|
|
18
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"qr",
|
|
20
|
+
"qr-code",
|
|
21
|
+
"terminal",
|
|
22
|
+
"cli",
|
|
23
|
+
"tui",
|
|
24
|
+
"unicode",
|
|
25
|
+
"half-block",
|
|
26
|
+
"braille",
|
|
27
|
+
"gradient",
|
|
28
|
+
"websocket",
|
|
29
|
+
"generator"
|
|
30
|
+
],
|
|
19
31
|
"author": "Boweii22",
|
|
20
32
|
"license": "MIT",
|
|
21
33
|
"repository": {
|
|
@@ -38,9 +50,11 @@
|
|
|
38
50
|
"chalk": "^5.3.0",
|
|
39
51
|
"clipboardy": "^4.0.0",
|
|
40
52
|
"figlet": "^1.7.0",
|
|
53
|
+
"gif-encoder-2": "^1.0.5",
|
|
41
54
|
"gradient-string": "^2.0.2",
|
|
42
55
|
"localtunnel": "^2.0.2",
|
|
43
56
|
"ora": "^8.1.0",
|
|
57
|
+
"pdfkit": "^0.18.0",
|
|
44
58
|
"qrcode": "^1.5.4",
|
|
45
59
|
"ws": "^8.18.0"
|
|
46
60
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GIF export — two modes:
|
|
3
|
+
* exportStaticGIF — single-frame colored QR (like PNG but as GIF)
|
|
4
|
+
* exportParticleGIF — animated fly-in: modules scatter → assemble
|
|
5
|
+
*
|
|
6
|
+
* Uses gif-encoder-2 (CJS) via createRequire.
|
|
7
|
+
* Renders pixel data directly without a canvas context.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
import { writeFile } from 'fs/promises';
|
|
12
|
+
import { GRADIENT_PRESETS, computeColor } from './engine.js';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
const MODULE_PX = 8; // pixels per QR module
|
|
17
|
+
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fill a MODULE_PX × MODULE_PX square into a Uint8ClampedArray RGBA buffer.
|
|
22
|
+
*/
|
|
23
|
+
function drawModule(pixels, width, col, row, r, g, b) {
|
|
24
|
+
const px = col * MODULE_PX;
|
|
25
|
+
const py = row * MODULE_PX;
|
|
26
|
+
for (let dy = 0; dy < MODULE_PX; dy++) {
|
|
27
|
+
for (let dx = 0; dx < MODULE_PX; dx++) {
|
|
28
|
+
const idx = ((py + dy) * width + (px + dx)) * 4;
|
|
29
|
+
if (idx < 0 || idx + 3 >= pixels.length) continue;
|
|
30
|
+
pixels[idx] = r;
|
|
31
|
+
pixels[idx + 1] = g;
|
|
32
|
+
pixels[idx + 2] = b;
|
|
33
|
+
pixels[idx + 3] = 255;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a white RGBA pixel buffer with the QR matrix drawn into it.
|
|
40
|
+
*/
|
|
41
|
+
function buildStaticFrame(modules, preset, quietZone) {
|
|
42
|
+
const { size, data } = modules;
|
|
43
|
+
const total = size + quietZone * 2;
|
|
44
|
+
const width = total * MODULE_PX;
|
|
45
|
+
const height = total * MODULE_PX;
|
|
46
|
+
const pixels = new Uint8ClampedArray(width * height * 4).fill(255);
|
|
47
|
+
|
|
48
|
+
for (let r = 0; r < total; r++) {
|
|
49
|
+
for (let c = 0; c < total; c++) {
|
|
50
|
+
const mr = r - quietZone;
|
|
51
|
+
const mc = c - quietZone;
|
|
52
|
+
const isDark = (mr >= 0 && mr < size && mc >= 0 && mc < size) && data[mr * size + mc];
|
|
53
|
+
if (!isDark) continue;
|
|
54
|
+
|
|
55
|
+
const tx = total > 1 ? c / (total - 1) : 0;
|
|
56
|
+
const ty = total > 1 ? r / (total - 1) : 0;
|
|
57
|
+
const [cr, cg, cb] = computeColor(preset, tx, ty);
|
|
58
|
+
drawModule(pixels, width, c, r, cr, cg, cb);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { pixels, width, height };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build particle descriptors at module level (one particle per dark module).
|
|
67
|
+
*/
|
|
68
|
+
function buildModuleParticles(modules, preset, quietZone) {
|
|
69
|
+
const { size, data } = modules;
|
|
70
|
+
const total = size + quietZone * 2;
|
|
71
|
+
|
|
72
|
+
const particles = [];
|
|
73
|
+
for (let r = 0; r < total; r++) {
|
|
74
|
+
for (let c = 0; c < total; c++) {
|
|
75
|
+
const mr = r - quietZone;
|
|
76
|
+
const mc = c - quietZone;
|
|
77
|
+
const isDark = (mr >= 0 && mr < size && mc >= 0 && mc < size) && data[mr * size + mc];
|
|
78
|
+
if (!isDark) continue;
|
|
79
|
+
|
|
80
|
+
const tx = total > 1 ? c / (total - 1) : 0;
|
|
81
|
+
const ty = total > 1 ? r / (total - 1) : 0;
|
|
82
|
+
const color = computeColor(preset, tx, ty);
|
|
83
|
+
particles.push({ targetRow: r, targetCol: c, color });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { particles, total };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Export a single-frame GIF (same visual as PNG, GIF container).
|
|
93
|
+
*/
|
|
94
|
+
export async function exportStaticGIF(modules, filePath, { gradient = 'none' } = {}) {
|
|
95
|
+
const GIFEncoder = require('gif-encoder-2');
|
|
96
|
+
const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
|
|
97
|
+
const quietZone = 4;
|
|
98
|
+
|
|
99
|
+
const { pixels, width, height } = buildStaticFrame(modules, preset, quietZone);
|
|
100
|
+
|
|
101
|
+
const encoder = new GIFEncoder(width, height);
|
|
102
|
+
encoder.start();
|
|
103
|
+
encoder.setRepeat(0);
|
|
104
|
+
encoder.setDelay(100);
|
|
105
|
+
encoder.addFrame(pixels);
|
|
106
|
+
encoder.finish();
|
|
107
|
+
|
|
108
|
+
await writeFile(filePath, encoder.out.getData());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Export an animated particle fly-in GIF.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} modules - QR modules from createMatrix()
|
|
115
|
+
* @param {string} filePath - Output path (e.g. "qr-anim.gif")
|
|
116
|
+
* @param {object} opts
|
|
117
|
+
* @param {string} opts.gradient - Color theme
|
|
118
|
+
* @param {number} opts.frames - Number of animation frames (default 30)
|
|
119
|
+
* @param {number} opts.delay - ms per frame (default 50)
|
|
120
|
+
*/
|
|
121
|
+
export async function exportParticleGIF(modules, filePath, {
|
|
122
|
+
gradient = 'none',
|
|
123
|
+
frames = 30,
|
|
124
|
+
delay = 50,
|
|
125
|
+
} = {}) {
|
|
126
|
+
const GIFEncoder = require('gif-encoder-2');
|
|
127
|
+
const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
|
|
128
|
+
const quietZone = 4;
|
|
129
|
+
|
|
130
|
+
const { particles, total } = buildModuleParticles(modules, preset, quietZone);
|
|
131
|
+
const width = total * MODULE_PX;
|
|
132
|
+
const height = total * MODULE_PX;
|
|
133
|
+
|
|
134
|
+
// Randomise start positions and stagger delays
|
|
135
|
+
const seeded = particles.map(p => ({
|
|
136
|
+
...p,
|
|
137
|
+
startRow: Math.random() * total,
|
|
138
|
+
startCol: (Math.random() - 0.5) * total * 2 + total / 2,
|
|
139
|
+
delay: Math.random() * 0.30,
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const encoder = new GIFEncoder(width, height, 'neuquant', false, frames + 1);
|
|
143
|
+
encoder.start();
|
|
144
|
+
encoder.setRepeat(0);
|
|
145
|
+
encoder.setDelay(delay);
|
|
146
|
+
encoder.setQuality(10);
|
|
147
|
+
|
|
148
|
+
for (let f = 0; f <= frames; f++) {
|
|
149
|
+
const globalT = f / frames;
|
|
150
|
+
const pixels = new Uint8ClampedArray(width * height * 4).fill(255);
|
|
151
|
+
|
|
152
|
+
for (const p of seeded) {
|
|
153
|
+
const localT = Math.max(0, (globalT - p.delay) / (1 - p.delay));
|
|
154
|
+
const e = 1 - Math.pow(1 - localT, 3); // cubic ease-out
|
|
155
|
+
|
|
156
|
+
const row = Math.round(p.startRow + (p.targetRow - p.startRow) * e);
|
|
157
|
+
const col = Math.round(p.startCol + (p.targetCol - p.startCol) * e);
|
|
158
|
+
|
|
159
|
+
if (row < 0 || row >= total || col < 0 || col >= total) continue;
|
|
160
|
+
|
|
161
|
+
const [r, g, b] = p.color;
|
|
162
|
+
drawModule(pixels, width, col, row, r, g, b);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
encoder.addFrame(pixels);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
encoder.finish();
|
|
169
|
+
await writeFile(filePath, encoder.out.getData());
|
|
170
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF export — embed a high-res QR code in an A4 PDF document.
|
|
3
|
+
* Uses pdfkit (CJS) via createRequire, with qrcode's PNG buffer as the image source.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
import { writeFile } from 'fs/promises';
|
|
8
|
+
import QRCode from 'qrcode';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} text - Data to encode
|
|
14
|
+
* @param {string} filePath - Output path (e.g. "qr-code.pdf")
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} opts.errorLevel - QR error correction level (default 'M')
|
|
17
|
+
* @param {string} opts.label - Human-readable label printed below the QR
|
|
18
|
+
* @param {string} opts.darkColor - Hex color for dark modules (default '#000000')
|
|
19
|
+
* @param {string} opts.lightColor - Hex color for light modules (default '#ffffff')
|
|
20
|
+
*/
|
|
21
|
+
export async function exportPDF(text, filePath, {
|
|
22
|
+
errorLevel = 'M',
|
|
23
|
+
label = '',
|
|
24
|
+
darkColor = '#000000',
|
|
25
|
+
lightColor = '#ffffff',
|
|
26
|
+
} = {}) {
|
|
27
|
+
const PDFDocument = require('pdfkit');
|
|
28
|
+
|
|
29
|
+
// Generate the QR as a PNG buffer
|
|
30
|
+
const pngBuffer = await QRCode.toBuffer(text, {
|
|
31
|
+
type: 'png',
|
|
32
|
+
width: 600,
|
|
33
|
+
margin: 4,
|
|
34
|
+
errorCorrectionLevel: errorLevel,
|
|
35
|
+
color: { dark: darkColor, light: lightColor },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
|
40
|
+
const chunks = [];
|
|
41
|
+
|
|
42
|
+
doc.on('data', chunk => chunks.push(chunk));
|
|
43
|
+
doc.on('error', reject);
|
|
44
|
+
doc.on('end', async () => {
|
|
45
|
+
try {
|
|
46
|
+
await writeFile(filePath, Buffer.concat(chunks));
|
|
47
|
+
resolve();
|
|
48
|
+
} catch (e) {
|
|
49
|
+
reject(e);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const pageW = doc.page.width;
|
|
54
|
+
const qrSize = 340;
|
|
55
|
+
const x = (pageW - qrSize) / 2;
|
|
56
|
+
const y = 80;
|
|
57
|
+
|
|
58
|
+
doc.image(pngBuffer, x, y, { width: qrSize });
|
|
59
|
+
|
|
60
|
+
if (label) {
|
|
61
|
+
doc
|
|
62
|
+
.moveDown()
|
|
63
|
+
.fontSize(14)
|
|
64
|
+
.fillColor('#222222')
|
|
65
|
+
.text(label, 50, y + qrSize + 20, { align: 'center', width: pageW - 100 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
doc
|
|
69
|
+
.fontSize(9)
|
|
70
|
+
.fillColor('#999999')
|
|
71
|
+
.text(
|
|
72
|
+
`Generated by QR Terminal · ${new Date().toLocaleDateString()}`,
|
|
73
|
+
50,
|
|
74
|
+
doc.page.height - 60,
|
|
75
|
+
{ align: 'center', width: pageW - 100 },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
doc.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History — persist and recall previously generated QR codes.
|
|
3
|
+
* Stored in ~/.qr-history.json, newest first, max 20 entries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
const HISTORY_PATH = join(homedir(), '.qr-history.json');
|
|
11
|
+
const MAX_ENTRIES = 20;
|
|
12
|
+
|
|
13
|
+
export async function loadHistory() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(HISTORY_PATH, 'utf8');
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
} catch {
|
|
18
|
+
return { entries: [] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Save a generated QR to history.
|
|
24
|
+
* Secrets are stored without their payload (dataLabel only).
|
|
25
|
+
*/
|
|
26
|
+
export async function saveToHistory({ inputType, dataLabel, qrData, gradient, errorLevel, renderMode }) {
|
|
27
|
+
const history = await loadHistory();
|
|
28
|
+
|
|
29
|
+
const entry = {
|
|
30
|
+
id: Date.now().toString(36),
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
inputType,
|
|
33
|
+
dataLabel,
|
|
34
|
+
qrData: inputType === 'secret' ? null : qrData,
|
|
35
|
+
gradient,
|
|
36
|
+
errorLevel,
|
|
37
|
+
renderMode,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
history.entries.unshift(entry);
|
|
41
|
+
if (history.entries.length > MAX_ENTRIES) {
|
|
42
|
+
history.entries = history.entries.slice(0, MAX_ENTRIES);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await writeFile(HISTORY_PATH, JSON.stringify(history, null, 2), 'utf8');
|
|
46
|
+
return entry;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a history entry as an @inquirer/prompts choice object.
|
|
51
|
+
*/
|
|
52
|
+
export function historyChoice(entry, index) {
|
|
53
|
+
const d = new Date(entry.timestamp);
|
|
54
|
+
const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
55
|
+
const preview = entry.qrData
|
|
56
|
+
? (entry.qrData.slice(0, 48) + (entry.qrData.length > 48 ? '…' : ''))
|
|
57
|
+
: '[secret — payload not stored]';
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name: ` ${String(index + 1).padStart(2)} · ${entry.dataLabel.padEnd(22)}${preview}`,
|
|
61
|
+
value: entry,
|
|
62
|
+
description: `${dateStr} · theme: ${entry.gradient} · EC: ${entry.errorLevel} · engine: ${entry.renderMode}`,
|
|
63
|
+
disabled: !entry.qrData ? '(secret)' : false,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/prompts.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Orchestrates inquirer prompts → live preview → QR generation → export.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { select, input, confirm,
|
|
6
|
+
import { select, input, confirm, password } from '@inquirer/prompts';
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import ora from 'ora';
|
|
9
9
|
import clipboard from 'clipboardy';
|
|
@@ -12,6 +12,8 @@ import { createMatrix, renderHalfBlock, renderASCII } from './engine.js';
|
|
|
12
12
|
import { liveInput } from './live-preview.js';
|
|
13
13
|
import { formatWifi, formatVCard, ERROR_LEVELS, GRADIENT_OPTIONS } from './formatters.js';
|
|
14
14
|
import { exportPNG, exportSVG, exportTXT } from './export.js';
|
|
15
|
+
import { exportPDF } from './export-pdf.js';
|
|
16
|
+
import { exportStaticGIF, exportParticleGIF } from './export-gif.js';
|
|
15
17
|
import { shortenUrl, isValidUrl, estimateSavings } from './shortlink.js';
|
|
16
18
|
import { printQR, revealQR, printDivider, detectDarkMode, log } from './ui.js';
|
|
17
19
|
import { animateParticleFlyIn } from './particle.js';
|
|
@@ -19,6 +21,10 @@ import { matrixRainQR } from './matrix-rain.js';
|
|
|
19
21
|
import { renderBraille } from './braille.js';
|
|
20
22
|
import { startHandshakeServer } from './handshake.js';
|
|
21
23
|
import { createDestructQR, showExplosion } from './destruct.js';
|
|
24
|
+
import { loadHistory, saveToHistory, historyChoice } from './history.js';
|
|
25
|
+
import { buildShareableLink } from './shareable.js';
|
|
26
|
+
|
|
27
|
+
const isPlain = process.env._QR_PLAIN === '1';
|
|
22
28
|
|
|
23
29
|
export async function runMainFlow() {
|
|
24
30
|
// ──────────────────────────────────────────────
|
|
@@ -47,9 +53,43 @@ export async function runMainFlow() {
|
|
|
47
53
|
value: 'secret',
|
|
48
54
|
description: 'Tokens, keys, passwords, or any sensitive payload',
|
|
49
55
|
},
|
|
56
|
+
{
|
|
57
|
+
name: ' 🕐 View History',
|
|
58
|
+
value: 'history',
|
|
59
|
+
description: 'Recall and re-render a previously generated QR code',
|
|
60
|
+
},
|
|
50
61
|
],
|
|
51
62
|
});
|
|
52
63
|
|
|
64
|
+
// ──────────────────────────────────────────────
|
|
65
|
+
// HISTORY — recall a past QR
|
|
66
|
+
// ──────────────────────────────────────────────
|
|
67
|
+
if (inputType === 'history') {
|
|
68
|
+
const history = await loadHistory();
|
|
69
|
+
if (!history.entries.length) {
|
|
70
|
+
log.info('No history yet — generate a QR first.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry = await select({
|
|
75
|
+
message: chalk.bold('Pick a previous QR to re-render:'),
|
|
76
|
+
choices: history.entries.map((e, i) => historyChoice(e, i)),
|
|
77
|
+
pageSize: 10,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Re-run with saved settings
|
|
81
|
+
await renderAndExport({
|
|
82
|
+
inputType: entry.inputType,
|
|
83
|
+
qrData: entry.qrData,
|
|
84
|
+
dataLabel: entry.dataLabel,
|
|
85
|
+
gradient: entry.gradient,
|
|
86
|
+
errorLevel: entry.errorLevel,
|
|
87
|
+
renderMode: entry.renderMode,
|
|
88
|
+
fromHistory: true,
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
53
93
|
// ──────────────────────────────────────────────
|
|
54
94
|
// STEP 2 — Error correction level
|
|
55
95
|
// ──────────────────────────────────────────────
|
|
@@ -66,13 +106,13 @@ export async function runMainFlow() {
|
|
|
66
106
|
// ──────────────────────────────────────────────
|
|
67
107
|
// STEP 3 — Visual gradient theme
|
|
68
108
|
// ──────────────────────────────────────────────
|
|
69
|
-
const gradient = await select({
|
|
109
|
+
const gradient = isPlain ? 'none' : await select({
|
|
70
110
|
message: chalk.bold('Color theme for the QR code?'),
|
|
71
111
|
choices: GRADIENT_OPTIONS,
|
|
72
112
|
});
|
|
73
113
|
|
|
74
114
|
// ── Render engine selection ─────────────────────────────────────────────
|
|
75
|
-
const renderMode = await select({
|
|
115
|
+
const renderMode = isPlain ? 'halfblock' : await select({
|
|
76
116
|
message: chalk.bold('Render engine?'),
|
|
77
117
|
choices: [
|
|
78
118
|
{ name: ' ▀▄ Half-Block — Standard high-density (default)', value: 'halfblock' },
|
|
@@ -93,11 +133,12 @@ export async function runMainFlow() {
|
|
|
93
133
|
|
|
94
134
|
// ── URL ─────────────────────────────────────
|
|
95
135
|
case 'url': {
|
|
96
|
-
console.log(chalk.dim('\n Live preview active — QR updates as you type\n'));
|
|
136
|
+
if (!isPlain) console.log(chalk.dim('\n Live preview active — QR updates as you type\n'));
|
|
97
137
|
|
|
98
|
-
let rawUrl =
|
|
138
|
+
let rawUrl = isPlain
|
|
139
|
+
? await input({ message: 'URL:' })
|
|
140
|
+
: await liveInput({ label: 'URL', errorLevel, gradient });
|
|
99
141
|
|
|
100
|
-
// Auto-prepend protocol if missing
|
|
101
142
|
if (rawUrl && !rawUrl.match(/^https?:\/\//i)) {
|
|
102
143
|
rawUrl = 'https://' + rawUrl;
|
|
103
144
|
}
|
|
@@ -107,22 +148,17 @@ export async function runMainFlow() {
|
|
|
107
148
|
qrData = rawUrl;
|
|
108
149
|
dataLabel = 'URL';
|
|
109
150
|
|
|
110
|
-
|
|
111
|
-
if (isValidUrl(qrData) && qrData.length > 40) {
|
|
151
|
+
if (!isPlain && isValidUrl(qrData) && qrData.length > 40) {
|
|
112
152
|
const shouldShorten = await confirm({
|
|
113
153
|
message: `Shorten via TinyURL? ${chalk.dim('(reduces QR complexity)')}`,
|
|
114
154
|
default: false,
|
|
115
155
|
});
|
|
116
156
|
|
|
117
157
|
if (shouldShorten) {
|
|
118
|
-
const spinner = ora({
|
|
119
|
-
text: 'Connecting to TinyURL…',
|
|
120
|
-
spinner: 'dots',
|
|
121
|
-
}).start();
|
|
122
|
-
|
|
158
|
+
const spinner = ora({ text: 'Connecting to TinyURL…', spinner: 'dots' }).start();
|
|
123
159
|
try {
|
|
124
|
-
const short
|
|
125
|
-
const savings
|
|
160
|
+
const short = await shortenUrl(qrData);
|
|
161
|
+
const savings = estimateSavings(qrData.length, short.length);
|
|
126
162
|
spinner.succeed(chalk.green('Shortened: ') + chalk.white(short) + chalk.dim(` (${savings})`));
|
|
127
163
|
qrData = short;
|
|
128
164
|
} catch (e) {
|
|
@@ -153,8 +189,8 @@ export async function runMainFlow() {
|
|
|
153
189
|
let wifiPassword = '';
|
|
154
190
|
if (security !== 'nopass') {
|
|
155
191
|
wifiPassword = await password({
|
|
156
|
-
message:
|
|
157
|
-
mask:
|
|
192
|
+
message: chalk.cyan('Password:'),
|
|
193
|
+
mask: '●',
|
|
158
194
|
validate: (v) => v.length > 0 || 'Password cannot be empty',
|
|
159
195
|
});
|
|
160
196
|
}
|
|
@@ -173,11 +209,11 @@ export async function runMainFlow() {
|
|
|
173
209
|
case 'vcard': {
|
|
174
210
|
console.log();
|
|
175
211
|
const firstName = await input({ message: chalk.cyan('First name:') });
|
|
176
|
-
const lastName = await input({ message: chalk.cyan('Last name:'),
|
|
177
|
-
const phone = await input({ message: chalk.cyan('Phone:'),
|
|
178
|
-
const email = await input({ message: chalk.cyan('Email:'),
|
|
212
|
+
const lastName = await input({ message: chalk.cyan('Last name:'), default: '' });
|
|
213
|
+
const phone = await input({ message: chalk.cyan('Phone:'), default: '' });
|
|
214
|
+
const email = await input({ message: chalk.cyan('Email:'), default: '' });
|
|
179
215
|
const org = await input({ message: chalk.cyan('Organization:'), default: '' });
|
|
180
|
-
const url = await input({ message: chalk.cyan('Website:'),
|
|
216
|
+
const url = await input({ message: chalk.cyan('Website:'), default: '' });
|
|
181
217
|
|
|
182
218
|
if (!firstName && !lastName) { log.error('Name is required.'); return; }
|
|
183
219
|
|
|
@@ -190,8 +226,8 @@ export async function runMainFlow() {
|
|
|
190
226
|
case 'secret': {
|
|
191
227
|
console.log();
|
|
192
228
|
const secret = await password({
|
|
193
|
-
message:
|
|
194
|
-
mask:
|
|
229
|
+
message: chalk.cyan('Secret payload:'),
|
|
230
|
+
mask: '●',
|
|
195
231
|
validate: (v) => v.length > 0 || 'Cannot encode an empty secret',
|
|
196
232
|
});
|
|
197
233
|
|
|
@@ -204,6 +240,20 @@ export async function runMainFlow() {
|
|
|
204
240
|
|
|
205
241
|
if (!qrData) return;
|
|
206
242
|
|
|
243
|
+
await renderAndExport({ inputType, qrData, dataLabel, gradient, errorLevel, renderMode });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Core render + export pipeline ────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
async function renderAndExport({
|
|
249
|
+
inputType,
|
|
250
|
+
qrData,
|
|
251
|
+
dataLabel,
|
|
252
|
+
gradient,
|
|
253
|
+
errorLevel,
|
|
254
|
+
renderMode,
|
|
255
|
+
fromHistory = false,
|
|
256
|
+
}) {
|
|
207
257
|
// ──────────────────────────────────────────────
|
|
208
258
|
// STEP 5 — Generate & render the QR code
|
|
209
259
|
// ──────────────────────────────────────────────
|
|
@@ -221,9 +271,7 @@ export async function runMainFlow() {
|
|
|
221
271
|
return;
|
|
222
272
|
}
|
|
223
273
|
|
|
224
|
-
//
|
|
225
|
-
// STEP 5b — Render with chosen engine
|
|
226
|
-
// ──────────────────────────────────────────────
|
|
274
|
+
// ── Render ──────────────────────────────────────────────────────────────
|
|
227
275
|
|
|
228
276
|
// Special case: handshake mode
|
|
229
277
|
if (renderMode === 'handshake') {
|
|
@@ -237,7 +285,6 @@ export async function runMainFlow() {
|
|
|
237
285
|
return;
|
|
238
286
|
}
|
|
239
287
|
|
|
240
|
-
// Generate QR for the server URL
|
|
241
288
|
const linkModules = createMatrix(server.url, { errorLevel: 'M' });
|
|
242
289
|
const linkLines = renderHalfBlock(linkModules, { gradient: 'neon' });
|
|
243
290
|
|
|
@@ -258,12 +305,10 @@ export async function runMainFlow() {
|
|
|
258
305
|
console.log(chalk.cyan('\n Phone is now linked. Messages will appear below.\n'));
|
|
259
306
|
console.log(chalk.dim(' Press Ctrl+C to close the connection.\n'));
|
|
260
307
|
|
|
261
|
-
// Stream phone messages to terminal
|
|
262
308
|
server.onMessage((text) => {
|
|
263
309
|
console.log(chalk.bold.cyanBright('\n 📱 From phone: ') + chalk.white(text) + '\n');
|
|
264
310
|
});
|
|
265
311
|
|
|
266
|
-
// Keep alive until Ctrl+C
|
|
267
312
|
await new Promise((_, reject) => {
|
|
268
313
|
process.on('SIGINT', () => {
|
|
269
314
|
server.close();
|
|
@@ -274,6 +319,14 @@ export async function runMainFlow() {
|
|
|
274
319
|
return;
|
|
275
320
|
}
|
|
276
321
|
|
|
322
|
+
// ── Plain mode — print raw ASCII, no color ──────────────────────────────
|
|
323
|
+
if (isPlain) {
|
|
324
|
+
console.log();
|
|
325
|
+
console.log(renderASCII(modules));
|
|
326
|
+
console.log();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
277
330
|
const qrLines = renderMode === 'braille'
|
|
278
331
|
? renderBraille(modules, { gradient })
|
|
279
332
|
: renderHalfBlock(modules, { gradient });
|
|
@@ -312,40 +365,49 @@ export async function runMainFlow() {
|
|
|
312
365
|
console.log();
|
|
313
366
|
|
|
314
367
|
// ──────────────────────────────────────────────
|
|
315
|
-
// STEP 6 —
|
|
368
|
+
// STEP 6 — Save to history
|
|
369
|
+
// ──────────────────────────────────────────────
|
|
370
|
+
if (!fromHistory) {
|
|
371
|
+
try {
|
|
372
|
+
await saveToHistory({ inputType, dataLabel, qrData, gradient, errorLevel, renderMode });
|
|
373
|
+
} catch {
|
|
374
|
+
// history is non-critical — fail silently
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ──────────────────────────────────────────────
|
|
379
|
+
// STEP 7 — Post-generation actions (looped select)
|
|
316
380
|
// ──────────────────────────────────────────────
|
|
317
381
|
printDivider('Export');
|
|
318
382
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
{
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
});
|
|
383
|
+
const exportChoices = [
|
|
384
|
+
{ name: ' 📋 Copy ASCII to clipboard', value: 'clipboard' },
|
|
385
|
+
{ name: ' 🖼 Save as PNG', value: 'png' },
|
|
386
|
+
{ name: ' 📐 Save as SVG', value: 'svg' },
|
|
387
|
+
{ name: ' 📄 Save as PDF', value: 'pdf' },
|
|
388
|
+
{ name: ' 🎞 Save as GIF', value: 'gif' },
|
|
389
|
+
{ name: ' 📝 Save as TXT', value: 'txt' },
|
|
390
|
+
...(inputType !== 'secret'
|
|
391
|
+
? [{ name: ' 🔗 Get shareable link', value: 'share' }]
|
|
392
|
+
: []),
|
|
393
|
+
{ name: ' ☠ Self-destruct share', value: 'destruct' },
|
|
394
|
+
{ name: ' ✓ Done', value: 'done' },
|
|
395
|
+
];
|
|
345
396
|
|
|
346
|
-
|
|
397
|
+
// Reset terminal state after animations before prompting
|
|
398
|
+
process.stdout.write('\x1b[?25h\x1b[0m');
|
|
399
|
+
|
|
400
|
+
// eslint-disable-next-line no-constant-condition
|
|
401
|
+
while (true) {
|
|
402
|
+
const action = await select({
|
|
403
|
+
message: chalk.bold('What would you like to do? (pick one, repeat as needed)'),
|
|
404
|
+
choices: exportChoices,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
if (action === 'done') break;
|
|
408
|
+
|
|
409
|
+
console.log();
|
|
347
410
|
|
|
348
|
-
for (const action of actions) {
|
|
349
411
|
switch (action) {
|
|
350
412
|
|
|
351
413
|
case 'clipboard': {
|
|
@@ -390,6 +452,44 @@ export async function runMainFlow() {
|
|
|
390
452
|
break;
|
|
391
453
|
}
|
|
392
454
|
|
|
455
|
+
case 'pdf': {
|
|
456
|
+
const fileName = await input({
|
|
457
|
+
message: 'PDF filename:',
|
|
458
|
+
default: sanitizeFilename(dataLabel) + '.pdf',
|
|
459
|
+
});
|
|
460
|
+
const sp = ora({ text: 'Rendering PDF…', spinner: 'arc' }).start();
|
|
461
|
+
try {
|
|
462
|
+
await exportPDF(qrData, fileName, { errorLevel, label: dataLabel });
|
|
463
|
+
sp.succeed(`PDF saved → ${chalk.underline(fileName)}`);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
sp.fail('PDF export failed: ' + e.message);
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
case 'gif': {
|
|
471
|
+
const animated = renderMode === 'particle' || renderMode === 'matrix';
|
|
472
|
+
const fileName = await input({
|
|
473
|
+
message: 'GIF filename:',
|
|
474
|
+
default: sanitizeFilename(dataLabel) + (animated ? '-anim' : '') + '.gif',
|
|
475
|
+
});
|
|
476
|
+
const sp = ora({
|
|
477
|
+
text: animated ? 'Rendering animated GIF…' : 'Rendering GIF…',
|
|
478
|
+
spinner: 'arc',
|
|
479
|
+
}).start();
|
|
480
|
+
try {
|
|
481
|
+
if (animated) {
|
|
482
|
+
await exportParticleGIF(modules, fileName, { gradient, frames: 30, delay: 50 });
|
|
483
|
+
} else {
|
|
484
|
+
await exportStaticGIF(modules, fileName, { gradient });
|
|
485
|
+
}
|
|
486
|
+
sp.succeed(`GIF saved → ${chalk.underline(fileName)}${animated ? chalk.dim(' (animated)') : ''}`);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
sp.fail('GIF export failed: ' + e.message);
|
|
489
|
+
}
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
|
|
393
493
|
case 'txt': {
|
|
394
494
|
const fileName = await input({
|
|
395
495
|
message: 'TXT filename:',
|
|
@@ -405,6 +505,27 @@ export async function runMainFlow() {
|
|
|
405
505
|
break;
|
|
406
506
|
}
|
|
407
507
|
|
|
508
|
+
case 'share': {
|
|
509
|
+
const link = buildShareableLink(qrData, { errorLevel });
|
|
510
|
+
console.log(
|
|
511
|
+
chalk.bold.cyanBright(' 🔗 Shareable link:\n') +
|
|
512
|
+
' ' + chalk.underline.white(link) + '\n'
|
|
513
|
+
);
|
|
514
|
+
const copyLink = await confirm({
|
|
515
|
+
message: 'Copy link to clipboard?',
|
|
516
|
+
default: true,
|
|
517
|
+
});
|
|
518
|
+
if (copyLink) {
|
|
519
|
+
try {
|
|
520
|
+
await clipboard.write(link);
|
|
521
|
+
log.success('Link copied to clipboard.');
|
|
522
|
+
} catch {
|
|
523
|
+
log.warn('Could not write to clipboard.');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
|
|
408
529
|
case 'destruct': {
|
|
409
530
|
const filePath = await input({ message: 'File to share (path):' });
|
|
410
531
|
const sp = ora({ text: 'Creating tunnel…', spinner: 'arc' }).start();
|
|
@@ -429,9 +550,10 @@ export async function runMainFlow() {
|
|
|
429
550
|
break;
|
|
430
551
|
}
|
|
431
552
|
}
|
|
553
|
+
|
|
554
|
+
console.log();
|
|
432
555
|
}
|
|
433
556
|
|
|
434
|
-
console.log();
|
|
435
557
|
printDivider();
|
|
436
558
|
log.tip('Hold your phone camera over the QR code — no scanner app needed on iOS 11+ / Android 10+.');
|
|
437
559
|
log.info(`Encoded ${qrData.length} characters with ${errorLevel}-level error correction.`);
|
package/src/shareable.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shareable link — build a browser-openable URL that renders the QR in an image.
|
|
3
|
+
* Uses QuickChart.io's free public QR API (no auth required, rate-limited generously).
|
|
4
|
+
*
|
|
5
|
+
* Opening the link in any browser shows a 400×400 QR code PNG inline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function buildShareableLink(qrData, { errorLevel = 'M', size = 400 } = {}) {
|
|
9
|
+
const params = new URLSearchParams({
|
|
10
|
+
text: qrData,
|
|
11
|
+
size: size.toString(),
|
|
12
|
+
margin: '4',
|
|
13
|
+
ecLevel: errorLevel,
|
|
14
|
+
format: 'png',
|
|
15
|
+
});
|
|
16
|
+
return `https://quickchart.io/qr?${params.toString()}`;
|
|
17
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -22,7 +22,7 @@ export function printHeader() {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const coloredArt = gradient(['#FF416C', '#FF4B2B', '#F7971E', '#FFD200', '#4ECDC4', '#556270'])(art);
|
|
25
|
-
const subtitle = chalk.dim(' High-Density Half-Block Rendering Engine · v1.
|
|
25
|
+
const subtitle = chalk.dim(' High-Density Half-Block Rendering Engine · v1.1.0');
|
|
26
26
|
const badges = [
|
|
27
27
|
chalk.bgRgb(40,40,80).rgb(100,180,255)(' URL '),
|
|
28
28
|
chalk.bgRgb(40,40,80).rgb(100,255,160)(' WiFi '),
|