@jasy/cli 1.0.0-alpha.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 +72 -0
- package/assets/jasy.txt +23 -0
- package/assets/validation/README.md +21 -0
- package/assets/validation/en16931-cii.sef.json.gz +0 -0
- package/assets/validation/en16931-ubl.sef.json.gz +0 -0
- package/assets/validation/xrechnung-cii.sef.json.gz +0 -0
- package/assets/validation/xrechnung-ubl.sef.json.gz +0 -0
- package/dist/commands/export.d.ts +1 -0
- package/dist/commands/export.js +63 -0
- package/dist/commands/read.d.ts +1 -0
- package/dist/commands/read.js +73 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +102 -0
- package/dist/commands/verapdf.d.ts +1 -0
- package/dist/commands/verapdf.js +96 -0
- package/dist/core/detect.d.ts +11 -0
- package/dist/core/detect.js +33 -0
- package/dist/core/export.d.ts +10 -0
- package/dist/core/export.js +150 -0
- package/dist/core/extract.d.ts +2 -0
- package/dist/core/extract.js +80 -0
- package/dist/core/parse.d.ts +7 -0
- package/dist/core/parse.js +395 -0
- package/dist/core/pdfa.d.ts +12 -0
- package/dist/core/pdfa.js +47 -0
- package/dist/core/read.d.ts +14 -0
- package/dist/core/read.js +25 -0
- package/dist/core/validate.d.ts +18 -0
- package/dist/core/validate.js +65 -0
- package/dist/core/verapdf.d.ts +26 -0
- package/dist/core/verapdf.js +161 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +73 -0
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.js +356 -0
- package/dist/tui/file-open.d.ts +10 -0
- package/dist/tui/file-open.js +140 -0
- package/package.json +41 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { basename, resolve, dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { createScreen, createDraw, createInputManager } from "@jano-editor/ui";
|
|
14
|
+
import { readInvoice } from "../core/read.js";
|
|
15
|
+
import { describeInvoice } from "../core/detect.js";
|
|
16
|
+
import { checkPdfA3 } from "../core/pdfa.js";
|
|
17
|
+
import { validateInvoiceXml, profileFor } from "../core/validate.js";
|
|
18
|
+
import { exportInvoice } from "../core/export.js";
|
|
19
|
+
import { detectTools } from "../core/verapdf.js";
|
|
20
|
+
import { openFileDialog } from "./file-open.js";
|
|
21
|
+
// The interactive jasy terminal: `o` opens a file picker, then the loaded invoice is shown together
|
|
22
|
+
// with its checks - EN 16931 business rules + structural PDF/A-3. Same core as the `jasy read` command.
|
|
23
|
+
const BRAND = [26, 79, 138];
|
|
24
|
+
const INK = [230, 234, 240];
|
|
25
|
+
const MUTED = [123, 135, 148];
|
|
26
|
+
const FAINT = [80, 85, 95];
|
|
27
|
+
const OK = [90, 170, 110];
|
|
28
|
+
const ERR = [200, 90, 90];
|
|
29
|
+
const YELLOW = [243, 220, 41]; // JS yellow - the ▒ accent band on the crane
|
|
30
|
+
// the jasy origami crane (assets/jasy.txt), shown on the empty start screen; "▒" cells are the accent
|
|
31
|
+
const CRANE = (() => {
|
|
32
|
+
try {
|
|
33
|
+
const p = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "jasy.txt");
|
|
34
|
+
return readFileSync(p, "utf-8")
|
|
35
|
+
.replace(/\n+$/, "")
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((l) => l.replace(/\s+$/, "")); // keep leading spaces (alignment), drop trailing
|
|
38
|
+
}
|
|
39
|
+
catch (_a) {
|
|
40
|
+
return []; // no logo file - the start screen just shows the box
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
const CRANE_W = CRANE.length ? Math.max(...CRANE.map((l) => l.length)) : 0;
|
|
44
|
+
const TOP = 1;
|
|
45
|
+
const MAX_W = 72; // cap so the box doesn't sprawl on very wide terminals
|
|
46
|
+
// clip a line so it never spills past the framed box
|
|
47
|
+
const fit = (s, max) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
|
|
48
|
+
const money = (n) => n.toFixed(2);
|
|
49
|
+
export function launchTui() {
|
|
50
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
51
|
+
console.log("jasy - ZUGFeRD / XRechnung terminal. Run me in an interactive terminal (or try `jasy read <file>`).");
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
const screen = createScreen();
|
|
55
|
+
const draw = createDraw(screen);
|
|
56
|
+
const input = createInputManager();
|
|
57
|
+
let loaded = null;
|
|
58
|
+
let error = null;
|
|
59
|
+
let notice = null; // last export result, shown in the footer
|
|
60
|
+
let scroll = 0; // first visible body row (the body scrolls; the header stays pinned)
|
|
61
|
+
let pageSize = 1; // body rows per screen, set in render() - used by PgUp/PgDn
|
|
62
|
+
// checked once at startup: is the optional full-ISO PDF/A validator (veraPDF) ready to run?
|
|
63
|
+
const tools = detectTools();
|
|
64
|
+
const veraReady = !!(tools.verapdf && tools.java);
|
|
65
|
+
// The pinned top: a one-line confirmation of which file + what it is (stays put while body scrolls).
|
|
66
|
+
function buildHeader(w) {
|
|
67
|
+
if (error || !loaded)
|
|
68
|
+
return [];
|
|
69
|
+
const { read } = loaded;
|
|
70
|
+
return [
|
|
71
|
+
{ text: fit("✓ " + basename(loaded.path), w - 6), fg: OK },
|
|
72
|
+
{
|
|
73
|
+
text: fit(describeInvoice(read.meta) + " · " + read.xml.length + " B XML", w - 6),
|
|
74
|
+
fg: MUTED,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
// The scrollable body: the parsed invoice + its checks (or the empty / error message).
|
|
79
|
+
function buildBody(w) {
|
|
80
|
+
if (error)
|
|
81
|
+
return [{ text: fit("✗ " + error, w - 6), fg: ERR }];
|
|
82
|
+
if (!loaded) {
|
|
83
|
+
const rows = [
|
|
84
|
+
{ text: "No invoice loaded.", fg: MUTED },
|
|
85
|
+
{ text: "", fg: MUTED },
|
|
86
|
+
{ text: "Open a ZUGFeRD / XRechnung PDF or XML -", fg: MUTED },
|
|
87
|
+
{ text: "jasy extracts the XML, identifies it, and checks it.", fg: MUTED },
|
|
88
|
+
{ text: "", fg: MUTED },
|
|
89
|
+
];
|
|
90
|
+
// full-ISO PDF/A is optional (veraPDF) - tell the user how to enable it right here
|
|
91
|
+
if (veraReady) {
|
|
92
|
+
rows.push({ text: "Full PDF/A check (veraPDF)", fg: MUTED, status: "ready", statusFg: OK });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
rows.push({ text: "Full PDF/A check (veraPDF): not set up", fg: MUTED });
|
|
96
|
+
rows.push({ text: "→ run jasy verapdf to enable it", fg: FAINT });
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
const { read, pdfa, rules } = loaded;
|
|
101
|
+
const rows = [];
|
|
102
|
+
// the parsed invoice itself (number, parties, lines, totals)
|
|
103
|
+
const inv = read.invoice;
|
|
104
|
+
if (inv && read.totals) {
|
|
105
|
+
const t = read.totals;
|
|
106
|
+
rows.push({ text: fit(inv.number, w - 16), fg: INK, status: inv.issueDate, statusFg: MUTED });
|
|
107
|
+
rows.push({ text: fit(`${inv.seller.name} → ${inv.buyer.name}`, w - 6), fg: MUTED });
|
|
108
|
+
rows.push({ text: "", fg: MUTED });
|
|
109
|
+
for (const [i, l] of inv.lines.entries()) {
|
|
110
|
+
rows.push({
|
|
111
|
+
text: fit(`${l.quantity} ${l.unit} ${l.name}`, w - 14),
|
|
112
|
+
fg: INK,
|
|
113
|
+
status: money(t.lineNets[i]),
|
|
114
|
+
statusFg: MUTED,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
rows.push({
|
|
118
|
+
text: `net ${money(t.taxBasisTotal)} VAT ${money(t.taxTotal)}`,
|
|
119
|
+
fg: MUTED,
|
|
120
|
+
status: `${money(t.grandTotal)} ${inv.currency}`,
|
|
121
|
+
statusFg: INK,
|
|
122
|
+
});
|
|
123
|
+
rows.push({ text: "", fg: MUTED });
|
|
124
|
+
}
|
|
125
|
+
// XML business rules (Schematron) - EN 16931, + XRechnung BR-DE when applicable
|
|
126
|
+
rows.push({
|
|
127
|
+
text: (rules === null || rules === void 0 ? void 0 : rules.profile.startsWith("xrechnung")) ? "XRechnung rules" : "EN 16931 rules",
|
|
128
|
+
fg: INK,
|
|
129
|
+
status: rules ? (rules.valid ? "OK" : `${rules.errors.length} errors`) : "n/a",
|
|
130
|
+
statusFg: rules ? (rules.valid ? OK : ERR) : FAINT,
|
|
131
|
+
});
|
|
132
|
+
if (rules && !rules.valid) {
|
|
133
|
+
for (const e of rules.errors.slice(0, 4)) {
|
|
134
|
+
rows.push({ text: fit(" ✗ " + (e.id ? `[${e.id}] ` : "") + e.text, w - 6), fg: ERR });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// structural PDF/A-3 (per check)
|
|
138
|
+
rows.push({ text: "", fg: MUTED });
|
|
139
|
+
rows.push({
|
|
140
|
+
text: "PDF/A-3 structure",
|
|
141
|
+
fg: INK,
|
|
142
|
+
status: pdfa ? `${pdfa.checks.filter((c) => c.ok).length}/${pdfa.checks.length}` : "raw XML",
|
|
143
|
+
statusFg: pdfa ? (pdfa.ok ? OK : ERR) : FAINT,
|
|
144
|
+
});
|
|
145
|
+
if (pdfa) {
|
|
146
|
+
for (const c of pdfa.checks) {
|
|
147
|
+
rows.push({
|
|
148
|
+
text: fit(" " + c.label, w - 14),
|
|
149
|
+
fg: MUTED,
|
|
150
|
+
status: c.ok ? "OK" : "FAILED",
|
|
151
|
+
statusFg: c.ok ? OK : ERR,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
157
|
+
// draw the crane logo centred above the box: "▒" cells in JS-yellow, the rest in brand blue
|
|
158
|
+
function drawCrane(cols) {
|
|
159
|
+
const cx = Math.max(0, Math.floor((cols - CRANE_W) / 2));
|
|
160
|
+
CRANE.forEach((line, row) => {
|
|
161
|
+
let i = 0;
|
|
162
|
+
while (i < line.length) {
|
|
163
|
+
if (line[i] === " ") {
|
|
164
|
+
i++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const yellow = line[i] === "▒";
|
|
168
|
+
let j = i;
|
|
169
|
+
while (j < line.length && line[j] !== " " && (line[j] === "▒") === yellow)
|
|
170
|
+
j++;
|
|
171
|
+
draw.text(cx + i, TOP + row, line.slice(i, j), { fg: yellow ? YELLOW : BRAND });
|
|
172
|
+
i = j;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function render() {
|
|
177
|
+
const cols = screen.width;
|
|
178
|
+
const screenH = screen.height;
|
|
179
|
+
const w = Math.max(44, Math.min(cols - 2, MAX_W));
|
|
180
|
+
const x = Math.max(1, Math.floor((cols - w) / 2)); // centre the box
|
|
181
|
+
const statusEnd = x + w - 3;
|
|
182
|
+
const header = buildHeader(w); // pinned
|
|
183
|
+
const body = buildBody(w); // scrollable
|
|
184
|
+
const canExport = !!((loaded === null || loaded === void 0 ? void 0 : loaded.read.invoice) && loaded.read.totals);
|
|
185
|
+
// the crane sits above the box - only on the true start screen (nothing loaded) and only if it fits
|
|
186
|
+
const showCrane = !loaded && !error && CRANE.length > 0 && cols >= CRANE_W && screenH >= CRANE.length + 9;
|
|
187
|
+
const boxTop = TOP + (showCrane ? CRANE.length + 1 : 0);
|
|
188
|
+
// fit the box to the terminal height: pin the header, scroll the body in whatever is left
|
|
189
|
+
const headBlock = header.length ? header.length + 1 : 0; // header rows + a separator blank
|
|
190
|
+
const overhead = 5 + headBlock + (notice ? 1 : 0); // borders + blanks + footer around the body
|
|
191
|
+
const maxBodyH = Math.max(1, screenH - boxTop - overhead);
|
|
192
|
+
const viewH = Math.min(body.length, maxBodyH);
|
|
193
|
+
pageSize = viewH;
|
|
194
|
+
const maxScroll = Math.max(0, body.length - viewH);
|
|
195
|
+
scroll = Math.max(0, Math.min(scroll, maxScroll));
|
|
196
|
+
const visible = body.slice(scroll, scroll + viewH);
|
|
197
|
+
const boxH = 5 + headBlock + viewH + (notice ? 1 : 0);
|
|
198
|
+
const drawRow = (r, y) => {
|
|
199
|
+
var _a;
|
|
200
|
+
draw.text(x + 2, y, r.text, { fg: r.fg });
|
|
201
|
+
if (r.status)
|
|
202
|
+
draw.text(statusEnd - r.status.length, y, r.status, { fg: (_a = r.statusFg) !== null && _a !== void 0 ? _a : r.fg });
|
|
203
|
+
};
|
|
204
|
+
draw.clear();
|
|
205
|
+
if (showCrane)
|
|
206
|
+
drawCrane(cols);
|
|
207
|
+
draw.rect(x, boxTop, w, boxH, { border: "round" });
|
|
208
|
+
draw.text(x + 2, boxTop, " jasy · ZUGFeRD / XRechnung ", { fg: BRAND });
|
|
209
|
+
let y = boxTop + 2;
|
|
210
|
+
for (const r of header)
|
|
211
|
+
drawRow(r, y++);
|
|
212
|
+
if (header.length)
|
|
213
|
+
y++; // separator blank
|
|
214
|
+
const bodyTop = y;
|
|
215
|
+
for (const r of visible)
|
|
216
|
+
drawRow(r, y++);
|
|
217
|
+
// scrollbar on the right inner edge, only when the body overflows the viewport
|
|
218
|
+
if (maxScroll > 0) {
|
|
219
|
+
const thumbH = Math.max(1, Math.round((viewH * viewH) / body.length));
|
|
220
|
+
const thumbAt = Math.round((scroll * (viewH - thumbH)) / maxScroll);
|
|
221
|
+
for (let i = 0; i < viewH; i++) {
|
|
222
|
+
const on = i >= thumbAt && i < thumbAt + thumbH;
|
|
223
|
+
draw.text(x + w - 2, bodyTop + i, on ? "█" : "░", { fg: on ? BRAND : FAINT });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// footer: open, export shortcuts (when an invoice is parsed), quit - drawn left to right
|
|
227
|
+
const footerY = bodyTop + viewH + 1;
|
|
228
|
+
let fx = x + 2;
|
|
229
|
+
const key = (k, label) => {
|
|
230
|
+
draw.text(fx, footerY, k, { fg: BRAND });
|
|
231
|
+
draw.text(fx + k.length + 1, footerY, label, { fg: INK });
|
|
232
|
+
fx += k.length + 1 + label.length + 2;
|
|
233
|
+
};
|
|
234
|
+
key("o", loaded || error ? "open another" : "open");
|
|
235
|
+
if (canExport) {
|
|
236
|
+
key("j", "JSON");
|
|
237
|
+
key("t", "TXT");
|
|
238
|
+
key("x", "XLSX");
|
|
239
|
+
}
|
|
240
|
+
key("q", "quit");
|
|
241
|
+
if (notice) {
|
|
242
|
+
draw.text(x + 2, footerY + 1, fit(notice, w - 4), { fg: notice.startsWith("✓") ? OK : ERR });
|
|
243
|
+
}
|
|
244
|
+
draw.flush();
|
|
245
|
+
}
|
|
246
|
+
function scrollBy(delta) {
|
|
247
|
+
scroll += delta;
|
|
248
|
+
render(); // render() clamps scroll to the valid range
|
|
249
|
+
}
|
|
250
|
+
function quit() {
|
|
251
|
+
input.stop();
|
|
252
|
+
process.stdin.setRawMode(false);
|
|
253
|
+
screen.showCursor();
|
|
254
|
+
screen.leave();
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
function exportTo(format) {
|
|
258
|
+
const inv = loaded === null || loaded === void 0 ? void 0 : loaded.read.invoice;
|
|
259
|
+
if (!inv || !(loaded === null || loaded === void 0 ? void 0 : loaded.read.totals))
|
|
260
|
+
return;
|
|
261
|
+
const name = `${inv.number.replace(/[^\w.-]+/g, "_")}.${format === "txt" ? "txt" : format}`;
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(resolve(process.cwd(), name), exportInvoice(inv, loaded.read.totals, format));
|
|
264
|
+
notice = `✓ wrote ${name}`;
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
notice = `✗ export failed: ${e.message}`;
|
|
268
|
+
}
|
|
269
|
+
render();
|
|
270
|
+
}
|
|
271
|
+
function openFlow() {
|
|
272
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
273
|
+
const chosen = yield openFileDialog({ screen, draw, input, startDir: process.cwd(), quit });
|
|
274
|
+
if (chosen) {
|
|
275
|
+
notice = null;
|
|
276
|
+
scroll = 0;
|
|
277
|
+
try {
|
|
278
|
+
const bytes = readFileSync(chosen);
|
|
279
|
+
const read = readInvoice(bytes);
|
|
280
|
+
const pdfa = read.isPdf ? checkPdfA3(bytes) : null;
|
|
281
|
+
let rules = null;
|
|
282
|
+
if (read.meta.syntax !== "unknown") {
|
|
283
|
+
try {
|
|
284
|
+
rules = validateInvoiceXml(read.xml, profileFor(read.meta));
|
|
285
|
+
}
|
|
286
|
+
catch (_a) {
|
|
287
|
+
rules = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
loaded = { path: chosen, read, pdfa, rules };
|
|
291
|
+
error = null;
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
error = e.message;
|
|
295
|
+
loaded = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
render();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
screen.enter();
|
|
302
|
+
screen.hideCursor();
|
|
303
|
+
process.stdin.setRawMode(true); // no echo - keys & mouse go to JANO, not the tty
|
|
304
|
+
input.start();
|
|
305
|
+
const main = input.pushLayer("main");
|
|
306
|
+
main.on("key", (key) => {
|
|
307
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
308
|
+
quit();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (key.name === "o") {
|
|
312
|
+
void openFlow();
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if ((key.name === "j" || key.name === "t" || key.name === "x") && (loaded === null || loaded === void 0 ? void 0 : loaded.read.invoice)) {
|
|
316
|
+
exportTo(key.name === "j" ? "json" : key.name === "t" ? "txt" : "xlsx");
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
if (key.name === "up") {
|
|
320
|
+
scrollBy(-1);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (key.name === "down") {
|
|
324
|
+
scrollBy(1);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (key.name === "pageup") {
|
|
328
|
+
scrollBy(-pageSize);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
if (key.name === "pagedown") {
|
|
332
|
+
scrollBy(pageSize);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (key.name === "home") {
|
|
336
|
+
scrollBy(-Number.MAX_SAFE_INTEGER);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
if (key.name === "end") {
|
|
340
|
+
scrollBy(Number.MAX_SAFE_INTEGER);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
main.on("resize", () => render());
|
|
345
|
+
main.on("mouse:click", () => true);
|
|
346
|
+
main.on("mouse:drag", () => true);
|
|
347
|
+
main.on("mouse:release", () => true);
|
|
348
|
+
main.on("mouse:scroll", (e) => {
|
|
349
|
+
if (e.type === "scroll-up")
|
|
350
|
+
scrollBy(-3);
|
|
351
|
+
else if (e.type === "scroll-down")
|
|
352
|
+
scrollBy(3);
|
|
353
|
+
return true;
|
|
354
|
+
});
|
|
355
|
+
render();
|
|
356
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Screen, type Draw, type InputManager } from "@jano-editor/ui";
|
|
2
|
+
interface Deps {
|
|
3
|
+
screen: Screen;
|
|
4
|
+
draw: Draw;
|
|
5
|
+
input: InputManager;
|
|
6
|
+
startDir: string;
|
|
7
|
+
quit: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function openFileDialog({ screen, draw, input, startDir, quit, }: Deps): Promise<string | null>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, basename, join } from "node:path";
|
|
3
|
+
import { drawList, listMoveUp, listMoveDown, } from "@jano-editor/ui";
|
|
4
|
+
const BRAND = [26, 79, 138];
|
|
5
|
+
const INK = [230, 234, 240];
|
|
6
|
+
const MUTED = [123, 135, 148];
|
|
7
|
+
const FAINT = [80, 85, 95];
|
|
8
|
+
const ROWS = 10; // visible window height; the list scrolls beyond it
|
|
9
|
+
export function openFileDialog({ screen, draw, input, startDir, quit, }) {
|
|
10
|
+
return new Promise((done) => {
|
|
11
|
+
let path = startDir.endsWith("/") ? startDir : startDir + "/";
|
|
12
|
+
let items = [];
|
|
13
|
+
let state = { selectedIndex: 0, scrollOffset: 0 };
|
|
14
|
+
const layer = input.pushLayer("open");
|
|
15
|
+
const refresh = () => {
|
|
16
|
+
const dir = path.endsWith("/") ? path : dirname(path);
|
|
17
|
+
const prefix = path.endsWith("/") ? "" : basename(path);
|
|
18
|
+
// offer ".." to climb out of the folder (only while browsing, not mid-typing, and not at root)
|
|
19
|
+
const cur = dir.replace(/\/+$/, "") || "/";
|
|
20
|
+
const parent = dirname(cur);
|
|
21
|
+
const up = prefix === "" && parent !== cur
|
|
22
|
+
? [{ label: "../", value: parent === "/" ? "/" : parent + "/" }]
|
|
23
|
+
: [];
|
|
24
|
+
try {
|
|
25
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
26
|
+
.filter((e) => e.name.startsWith(prefix))
|
|
27
|
+
.filter((e) => e.isDirectory() || /\.(pdf|xml)$/i.test(e.name))
|
|
28
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
29
|
+
.map((e) => {
|
|
30
|
+
const full = join(dir, e.name);
|
|
31
|
+
return e.isDirectory()
|
|
32
|
+
? { label: e.name + "/", value: full + "/" }
|
|
33
|
+
: { label: e.name, value: full };
|
|
34
|
+
});
|
|
35
|
+
items = [...up, ...entries];
|
|
36
|
+
}
|
|
37
|
+
catch (_a) {
|
|
38
|
+
items = up;
|
|
39
|
+
}
|
|
40
|
+
state = { selectedIndex: 0, scrollOffset: 0 };
|
|
41
|
+
};
|
|
42
|
+
const render = () => {
|
|
43
|
+
const cols = screen.width;
|
|
44
|
+
const w = Math.max(44, Math.min(cols - 2, 72));
|
|
45
|
+
const x = Math.max(1, Math.floor((cols - w) / 2)); // centre the dialog
|
|
46
|
+
const y = 2;
|
|
47
|
+
const avail = w - 11; // keep the text + cursor strictly inside the right border
|
|
48
|
+
const shown = path.length > avail ? "…" + path.slice(-(avail - 1)) : path;
|
|
49
|
+
draw.clear();
|
|
50
|
+
draw.rect(x, y, w, ROWS + 6, { border: "round" });
|
|
51
|
+
draw.text(x + 2, y, " open invoice ", { fg: BRAND });
|
|
52
|
+
draw.text(x + 2, y + 1, "path:", { fg: MUTED });
|
|
53
|
+
draw.text(x + 8, y + 1, shown + "▌", { fg: INK });
|
|
54
|
+
if (items.length) {
|
|
55
|
+
drawList(draw, {
|
|
56
|
+
x: x + 2,
|
|
57
|
+
y: y + 3,
|
|
58
|
+
width: w - 4,
|
|
59
|
+
height: ROWS,
|
|
60
|
+
items,
|
|
61
|
+
selectedIndex: state.selectedIndex,
|
|
62
|
+
scrollOffset: state.scrollOffset,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
draw.text(x + 2, y + 3, "(no matching folders / .pdf / .xml here)", { fg: MUTED });
|
|
67
|
+
}
|
|
68
|
+
const pos = items.length ? `${state.selectedIndex + 1}/${items.length}` : "0";
|
|
69
|
+
draw.text(x + 2, y + ROWS + 4, `↑↓ pick (${pos}) · Tab complete · ⏎ open · Esc cancel`, {
|
|
70
|
+
fg: FAINT,
|
|
71
|
+
});
|
|
72
|
+
draw.flush();
|
|
73
|
+
};
|
|
74
|
+
const close = (result) => {
|
|
75
|
+
input.popLayer(layer);
|
|
76
|
+
done(result);
|
|
77
|
+
};
|
|
78
|
+
const current = () => items[state.selectedIndex];
|
|
79
|
+
layer.on("key", (key) => {
|
|
80
|
+
if (key.ctrl && key.name === "c") {
|
|
81
|
+
quit(); // never returns - restores the tty and exits
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (key.name === "escape" || (key.raw.length === 1 && key.raw[0] === 0x1b)) {
|
|
85
|
+
close(null);
|
|
86
|
+
}
|
|
87
|
+
else if (key.name === "up") {
|
|
88
|
+
state = listMoveUp(state, items);
|
|
89
|
+
render();
|
|
90
|
+
}
|
|
91
|
+
else if (key.name === "down") {
|
|
92
|
+
state = listMoveDown(state, items, ROWS);
|
|
93
|
+
render();
|
|
94
|
+
}
|
|
95
|
+
else if (key.name === "tab") {
|
|
96
|
+
const c = current();
|
|
97
|
+
if (c) {
|
|
98
|
+
path = c.value;
|
|
99
|
+
refresh();
|
|
100
|
+
render();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (key.name === "enter" || key.name === "return") {
|
|
104
|
+
const c = current();
|
|
105
|
+
if (c && c.value.endsWith("/")) {
|
|
106
|
+
path = c.value; // descend into the folder (or climb via "../")
|
|
107
|
+
refresh();
|
|
108
|
+
render();
|
|
109
|
+
}
|
|
110
|
+
else if (c) {
|
|
111
|
+
close(c.value); // load the file
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
try {
|
|
115
|
+
if (statSync(path).isFile())
|
|
116
|
+
close(path);
|
|
117
|
+
}
|
|
118
|
+
catch (_a) {
|
|
119
|
+
/* nothing to open */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (key.name === "backspace") {
|
|
124
|
+
path = path.slice(0, -1);
|
|
125
|
+
refresh();
|
|
126
|
+
render();
|
|
127
|
+
}
|
|
128
|
+
else if (!key.ctrl && !key.alt && key.name.length === 1) {
|
|
129
|
+
path += key.name;
|
|
130
|
+
refresh();
|
|
131
|
+
render();
|
|
132
|
+
}
|
|
133
|
+
return true; // modal swallows everything (Ctrl-C / Esc handled above)
|
|
134
|
+
});
|
|
135
|
+
layer.on("mouse:click", () => true);
|
|
136
|
+
layer.on("mouse:scroll", () => true);
|
|
137
|
+
refresh();
|
|
138
|
+
render();
|
|
139
|
+
});
|
|
140
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jasy/cli",
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
|
+
"description": "Interactive terminal to validate, read and export ZUGFeRD / XRechnung e-invoices.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"e-invoice",
|
|
8
|
+
"tui",
|
|
9
|
+
"validator",
|
|
10
|
+
"xrechnung",
|
|
11
|
+
"zugferd"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Florian Heuberger",
|
|
15
|
+
"bin": {
|
|
16
|
+
"jasy": "dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"assets"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@jano-editor/ui": "1.0.0-alpha.8",
|
|
26
|
+
"saxon-js": "^2.6.0",
|
|
27
|
+
"@jasy/zugferd": "1.0.0-alpha.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^25.9.3",
|
|
31
|
+
"tsx": "^4.19.2",
|
|
32
|
+
"xslt3": "^2.7.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"start": "node dist/index.js",
|
|
37
|
+
"dev": "tsx src/index.ts",
|
|
38
|
+
"watch": "tsx watch src/index.ts",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|