15 Commits

Author SHA1 Message Date
65743d8562 One day I'll figure this shit out 2025-05-28 09:09:41 -06:00
0f9c377853 change: selects now use inputmanager
fix: bad exit logic
feat: field rename now supports renaming things with multiple widgets
2025-05-27 12:44:45 -06:00
7a394c642a change: input manager and prompt rewrite 2025-05-21 21:32:49 -06:00
569c67583d change: degridifies listFormField 2025-05-21 19:05:54 -06:00
123bf51001 fix: I really am thick 2025-05-21 12:55:45 -06:00
a858ea4b60 fix: I am stupid and forgot to press enter 2025-05-21 12:26:40 -06:00
b43a837c6a fix: pasting in prompt no worky 2025-05-21 12:12:37 -06:00
041129dc83 fix: arrow keys in prompts now move cursor, also implements delete key 2025-05-21 11:46:51 -06:00
89a3df17e6 fix: field rename skips saves for unmodified files 2025-05-21 11:40:17 -06:00
001b90744b fix: jsr install breaks because of missing asciiart file 2025-05-20 15:13:36 -06:00
cdeef54f68 fix: local ASCII Art inclusion 2025-05-20 13:45:40 -06:00
90f1547e02 fix: flickering eyebleed 2025-05-20 10:53:37 -06:00
e5b173155a feat: change evaluation now adds case transformation for capture groups 2025-05-20 10:22:49 -06:00
19eaf2d664 feat: field rename multiple files 2025-05-20 09:55:52 -06:00
9573291582 Merge pull request '1.0.1' (#8) from 1.0.1 into main
All checks were successful
Create Version Tag / version-check (push) Successful in 22s
Create Version Tag / build-release (push) Successful in 2m8s
Create Version Tag / publish-release (push) Successful in 27s
Reviewed-on: #8
2025-05-07 12:30:54 -07:00
18 changed files with 1571 additions and 328 deletions

View File

@@ -30,7 +30,7 @@ jobs:
uses: https://git.cyborggrizzly.com/bearmetal/ci-actions/deno-release@main uses: https://git.cyborggrizzly.com/bearmetal/ci-actions/deno-release@main
with: with:
entrypoint: main.ts entrypoint: main.ts
compile-flags: "--allow-read --allow-write --allow-env --allow-net" compile-flags: "--allow-read --allow-write --allow-env --allow-net --include asciiart.txt"
env: env:
GITEA_TOKEN: ${{ secrets.GIT_PAT }} GITEA_TOKEN: ${{ secrets.GIT_PAT }}

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@
.env .env
log.txt log.txt
log log
test2.pdf

View File

@@ -1,6 +1,10 @@
# Changelog # Changelog
## v1.0.1 (2025-07-25) ## v1.0.2 (2025-05-20)
<!-- auto-changelog -->
## v1.0.1 (2025-05-7)
<!-- auto-changelog --> <!-- auto-changelog -->
@@ -8,7 +12,7 @@
- help flags can cause issues - help flags can cause issues
## v1.0.0 (2025-07-25) ## v1.0.0 (2025-05-7)
### Features ### Features

View File

@@ -20,7 +20,8 @@ Deno >=2.2 (not required if downloading .exe)
### Deno install ### Deno install
`deno task install` -> installs as global command `checkfields` `deno install -g --allow-read --allow-write --allow-net --allow-env jsr:@bearmetal/pdf-tools`
-> installs as global command `pdf-tools`
### Compile ### Compile

196
cli/InputManager.ts Normal file
View File

@@ -0,0 +1,196 @@
interface EventMap {
keypress: CLIKeypressEvent;
char: CLICharEvent;
activate: Event;
deactivate: Event;
exit: Event;
enter: Event;
backspace: Event;
delete: Event;
"arrow-left": Event;
"arrow-right": Event;
"arrow-up": Event;
"arrow-down": Event;
[key: string]: Event;
}
interface EventDetailMap {
keypress: {
key: number;
sequence?: Uint8Array;
};
char: EventDetailMap["keypress"] & {
char: string;
};
}
export type TypedEventTarget<EventMap extends object> = {
new (): IntermediateEventTarget<EventMap>;
};
export interface IntermediateEventTarget<EventMap> extends EventTarget {
addEventListener<K extends keyof EventMap>(
type: K,
listener: (
event: EventMap[K] extends Event ? EventMap[K] : Event,
) => EventMap[K] extends Event ? void : never,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof EventMap>(
type: K,
listener: (
event: EventMap[K] extends Event ? EventMap[K] : Event,
) => EventMap[K] extends Event ? void : never,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
}
const ManagerEventTarget = EventTarget as TypedEventTarget<EventMap>;
export class CLICharEvent extends CustomEvent<EventDetailMap["char"]> {
constructor(detail: EventDetailMap["char"]) {
super("char", { detail, cancelable: true });
}
}
export class CLIKeypressEvent extends CustomEvent<EventDetailMap["keypress"]> {
constructor(detail: EventDetailMap["keypress"]) {
super("keypress", { detail, cancelable: true });
}
}
export class InputManager extends ManagerEventTarget {
private static instance = new InputManager();
private active = false;
static getInstance(): InputManager {
return this.instance ??= new InputManager();
}
static addEventListener = InputManager.prototype.addEventListener.bind(
this.instance,
);
static removeEventListener = InputManager.prototype.removeEventListener.bind(
this.instance,
);
static dispatchEvent = InputManager.prototype.dispatchEvent.bind(
this.instance,
);
activate({ raw = true }: { raw?: boolean } = {}) {
if (this.active) return;
this.active = true;
this.dispatchEvent(new Event("activate"));
this.listen(raw);
}
deactivate({ dactivateRaw = true }: { dactivateRaw?: boolean } = {}) {
if (!this.active) return;
this.active = false;
this.dispatchEvent(new Event("deactivate"));
if (dactivateRaw) Deno.stdin.setRaw(false);
}
once<T extends string>(type: T): Promise<Event> {
return new Promise((resolve) => {
const handler = (event: Event) => {
this.removeEventListener(type, handler);
resolve(event);
};
this.addEventListener(type, handler);
});
}
private async listen(raw: boolean) {
if (raw) await Deno.stdin.setRaw(true);
const buf = new Uint8Array(64);
while (this.active) {
const n = await Deno.stdin.read(buf);
if (n === null) break;
let i = 0;
while (i < n) {
const byte = buf[i];
// Ctrl+C
if (byte === 3) {
this.dispatchEvent(new Event("exit"));
await Deno.stdin.setRaw(false);
Deno.exit(130);
}
// Enter
if (byte === 13) {
this.dispatchEvent(new Event("enter"));
i++;
continue;
}
// Backspace
if (byte === 127 || byte === 8) {
this.dispatchEvent(new Event("backspace"));
i++;
continue;
}
// Escape sequences
if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
const code = buf[i + 2];
switch (code) {
case 65:
this.dispatchEvent(new Event("arrow-up"));
break;
case 66:
this.dispatchEvent(new Event("arrow-down"));
break;
case 67:
this.dispatchEvent(new Event("arrow-right"));
break;
case 68:
this.dispatchEvent(new Event("arrow-left"));
break;
case 51:
if (i + 3 < n && buf[i + 3] === 126) {
this.dispatchEvent(new Event("delete"));
i += 4;
continue;
}
break;
}
i += 3;
continue;
}
// Printable ASCII
if (byte >= 32 && byte <= 126) {
this.dispatchEvent(
new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }),
);
i++;
continue;
}
// Unknown
this.dispatchEvent(
new CLIKeypressEvent({ key: byte, sequence: buf.slice(i, i + 1) }),
);
i++;
}
}
if (raw) await Deno.stdin.setRaw(false);
}
}

View File

@@ -22,7 +22,7 @@ export class TerminalLayout {
Deno.addSignalListener("SIGINT", () => { Deno.addSignalListener("SIGINT", () => {
this.clearAll(); this.clearAll();
Deno.exit(0); // Deno.exit(0);
}); });
} }
@@ -44,7 +44,9 @@ export class TerminalLayout {
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);
} }
this.debounceTimer = setTimeout( this.debounceTimer = setTimeout(
() => this.renderLayout(), () => {
this.renderLayout();
},
this.debounceDelay, this.debounceDelay,
); );
} }
@@ -76,6 +78,10 @@ export class TerminalLayout {
block.renderInternal(usedLines + 1); block.renderInternal(usedLines + 1);
usedLines += lines.length; usedLines += lines.length;
} }
for (const name of this.layoutOrder) {
const block = this.blocks[name];
block.runPostRenderAction?.();
}
} }
clearAll() { clearAll() {
@@ -111,9 +117,12 @@ export class TerminalBlock {
private renderHeight: number = 0; private renderHeight: number = 0;
private lastRenderRow = 1; private lastRenderRow = 1;
private lastRendered: string[] = [];
private preserveHistory = false; private preserveHistory = false;
constructor(private prepend: string = "") {} constructor(private prepend: string = "") {
}
setPreserveHistory(preserveHistory: boolean) { setPreserveHistory(preserveHistory: boolean) {
this.preserveHistory = preserveHistory; this.preserveHistory = preserveHistory;
@@ -193,27 +202,39 @@ export class TerminalBlock {
} }
renderInternal(startRow?: number) { renderInternal(startRow?: number) {
this.lastRenderRow = startRow ?? this.lastRenderRow; const outputLines: string[] = [];
this.clear(); // uses old renderedLineCount
const outputLines = this.renderLines.map((line) => for (let i = 0; i < this.renderLines.length; i++) {
`${this.prepend}${line}\x1b[K` const line = `${this.prepend}${this.renderLines[i]}`;
); const previous = this.lastRendered[i];
const output = outputLines.join("\n"); if (line !== previous) {
if (startRow !== undefined) { const moveToLine = `\x1b[${(startRow ?? this.lastRenderRow) + i};1H`;
const moveCursor = `\x1b[${startRow};1H`; outputLines.push(moveToLine + line + "\x1b[K");
Deno.stdout.writeSync(new TextEncoder().encode(moveCursor + output)); }
} else {
Deno.stdout.writeSync(new TextEncoder().encode(output));
} }
// update rendered line count *after* rendering if (this.lastRendered.length > this.renderLines.length) {
this.renderedLineCount = outputLines.reduce( const baseRow = startRow ?? this.lastRenderRow;
(count, line) => for (let i = this.renderLines.length; i < this.lastRendered.length; i++) {
count + const moveToLine = `\x1b[${baseRow + i};1H\x1b[2K`;
Math.ceil((line.length) / (Deno.consoleSize().columns || 80)), Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
0, }
); }
const baseRow = startRow ?? this.lastRenderRow;
const excessLines = this.renderHeight - this.renderLines.length;
for (let i = 0; i < excessLines; i++) {
const moveToLine = `\x1b[${
baseRow + this.renderLines.length + i
};1H\x1b[2K`;
Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
}
this.lastRendered = [...this.renderLines];
this.renderedLineCount = this.renderHeight;
this.lastRenderRow = baseRow;
const output = outputLines.join("\n");
Deno.stdout.writeSync(new TextEncoder().encode(output));
} }
clear() { clear() {
@@ -242,6 +263,17 @@ export class TerminalBlock {
return this.fixedHeight ?? 0; return this.fixedHeight ?? 0;
} }
private _postRenderAction?: () => void;
setPostRenderAction(action: (this: TerminalBlock) => void) {
this._postRenderAction = action;
}
runPostRenderAction() {
if (this._postRenderAction) {
this._postRenderAction.call(this);
this._postRenderAction = undefined;
}
}
get lineCount() { get lineCount() {
return this.renderLines.length; return this.renderLines.length;
} }

View File

@@ -6,6 +6,7 @@ import { selectMenuInteractive } from "./selectMenu.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { cliAlert, cliLog } from "./prompts.ts"; import { cliAlert, cliLog } from "./prompts.ts";
import type { ITool } from "../types.ts"; import type { ITool } from "../types.ts";
import { InputManager } from "./InputManager.ts";
// Register tools here (filename, no extension) // Register tools here (filename, no extension)
const toolRegistry: [string, Promise<{ default: ITool }>][] = [ const toolRegistry: [string, Promise<{ default: ITool }>][] = [
@@ -55,6 +56,12 @@ export class PdfToolsCli {
} }
public async run() { public async run() {
const im = InputManager.getInstance();
im.activate();
im.addEventListener("exit", () => {
this.closeMessage = "Exiting...";
this.cleanup();
});
try { try {
await this.importTools(); await this.importTools();
const titleBlock = new TerminalBlock(); const titleBlock = new TerminalBlock();
@@ -78,11 +85,13 @@ export class PdfToolsCli {
} }
} finally { } finally {
this.cleanup(); this.cleanup();
Deno.exit(0);
} }
} }
private cleanup() { private cleanup() {
this.terminalLayout.clearAll(); this.terminalLayout.clearAll();
InputManager.getInstance().deactivate();
Deno.stdin.setRaw(false); Deno.stdin.setRaw(false);
if (this.closeMessage) console.log(this.closeMessage); if (this.closeMessage) console.log(this.closeMessage);
} }

View File

@@ -2,6 +2,116 @@
import { Cursor } from "./cursor.ts"; import { Cursor } from "./cursor.ts";
import { colorize } from "./style.ts"; import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts";
// export async function cliPrompt(
// message: string,
// block?: TerminalBlock,
// ): Promise<string> {
// const encoder = new TextEncoder();
// const input: string[] = [];
// let cursorPos = 0;
// await Deno.stdin.setRaw(true);
// const cursorVisible = Cursor["visible"];
// Cursor.show();
// let range: [number, number] = [0, 1];
// if (block) {
// range = block.setLines([message + " "]);
// } else {
// Deno.stdout.writeSync(encoder.encode(message + " "));
// }
// const render = () => {
// const line = message + " " + input.join("");
// const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
// if (block) {
// block.setPostRenderAction(function () {
// Deno.stdout.writeSync(
// encoder.encode(`\x1b[${this["lastRenderRow"]};1H`),
// );
// Deno.stdout.writeSync(encoder.encode(moveTo));
// });
// range = block.setLines([line], range);
// } else {
// Deno.stdout.writeSync(encoder.encode("\x1b[K" + line + moveTo));
// }
// };
// render();
// const buf = new Uint8Array(64); // large enough for most pastes
// inputLoop:
// while (true) {
// const n = await Deno.stdin.read(buf);
// if (n === null) break;
// for (let i = 0; i < n; i++) {
// const byte = buf[i];
// // Ctrl+C
// if (byte === 3) {
// block?.clear();
// block?.["layout"]?.clearAll();
// await Deno.stdin.setRaw(false);
// Deno.exit(130);
// }
// if (byte === 13) { // Enter
// break inputLoop;
// }
// // Escape sequence?
// if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
// const third = buf[i + 2];
// if (third === 68 && cursorPos > 0) cursorPos--; // Left
// else if (third === 67 && cursorPos < input.length) cursorPos++; // Right
// else if (third === 51 && i + 3 < n && buf[i + 3] === 126) { // Delete
// if (cursorPos < input.length) input.splice(cursorPos, 1);
// i += 1; // consume tilde
// }
// i += 2; // consume ESC [ X
// continue;
// }
// // Backspace
// if (byte === 127 || byte === 8) {
// if (cursorPos > 0) {
// input.splice(cursorPos - 1, 1);
// cursorPos--;
// }
// continue;
// }
// // Delete (ASCII 46)
// if (byte === 46 && cursorPos < input.length) {
// input.splice(cursorPos, 1);
// continue;
// }
// // Printable
// if (byte >= 32 && byte <= 126) {
// input.splice(cursorPos, 0, String.fromCharCode(byte));
// cursorPos++;
// }
// // Other cases: ignore
// }
// render();
// }
// await Deno.stdin.setRaw(false);
// if (!cursorVisible) {
// Cursor.hide();
// }
// Deno.stdout.writeSync(encoder.encode("\n"));
// return input.join("");
// }
export async function cliPrompt( export async function cliPrompt(
message: string, message: string,
@@ -9,70 +119,141 @@ export async function cliPrompt(
): Promise<string> { ): Promise<string> {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const input: string[] = []; const input: string[] = [];
let cursorPos = 0;
let range: [number, number] | undefined;
await Deno.stdin.setRaw(true);
const cursorVisible = Cursor["visible"];
Cursor.show(); Cursor.show();
let range: [number, number] = [0, 1]; const im = InputManager.getInstance();
if (block) { im.activate();
range = block.setLines([message + " "]);
} else {
Deno.stdout.writeSync(encoder.encode(message + " "));
}
const buf = new Uint8Array(1);
while (true) {
const n = await Deno.stdin.read(buf);
if (n === null) break;
const byte = buf[0];
if (byte === 3) { // Ctrl+C
Deno.stdin.setRaw(false);
block?.["layout"]?.clearAll();
block?.clear();
Deno.exit(130);
}
if (byte === 13) { // Enter
break;
} else if (byte === 127 || byte === 8) { // Backspace
input.pop();
} else if (byte >= 32 && byte <= 126) { // Printable chars
input.push(String.fromCharCode(byte));
}
const render = () => {
const line = message + " " + input.join(""); const line = message + " " + input.join("");
const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
if (block) { if (block) {
block.setPostRenderAction(() => {
Deno.stdout.writeSync(encoder.encode(moveTo));
});
range = block.setLines([line], range); range = block.setLines([line], range);
} else { } else {
Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line)); Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo));
}
};
const exit = () => {
im.removeEventListener("enter", onEnter);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("delete", onDelete);
im.removeEventListener("arrow-left", onLeft);
im.removeEventListener("arrow-right", onRight);
im.removeEventListener("char", onKey);
Cursor.hide();
};
let resolve: null | ((value: string) => void) = null;
const onEnter = () => {
exit();
resolve?.(input.join(""));
};
const onBackspace = () => {
if (cursorPos > 0) {
input.splice(cursorPos - 1, 1);
cursorPos--;
render();
}
};
const onDelete = () => {
if (cursorPos < input.length) {
input.splice(cursorPos, 1);
render();
}
};
const onLeft = () => {
if (cursorPos > 0) {
cursorPos--;
render();
}
};
const onRight = () => {
if (cursorPos < input.length) {
cursorPos++;
render();
}
};
const onKey = (e: Event) => {
const ke = (e as CLICharEvent).detail;
input.splice(cursorPos, 0, ke.char);
cursorPos++;
render();
};
render();
return await new Promise<string>((res) => {
resolve = res;
im.addEventListener("enter", onEnter);
im.addEventListener("backspace", onBackspace);
im.addEventListener("delete", onDelete);
im.addEventListener("arrow-left", onLeft);
im.addEventListener("arrow-right", onRight);
im.addEventListener("char", onKey);
});
}
export async function cliConfirm(message: string, block?: TerminalBlock) {
const im = InputManager.getInstance();
let inpout = "";
function isValidInput(input: string) {
switch (input) {
case "y":
case "n":
return inpout.length === 0;
case "e":
return inpout === "y";
case "s":
return inpout === "ye";
case "o":
return inpout === "n";
default:
return false;
} }
} }
await Deno.stdin.setRaw(false); function onKey(e: CLICharEvent) {
if (!cursorVisible) { const ke = e.detail;
Cursor.hide(); const char = String.fromCharCode(ke.key);
if (isValidInput(char)) {
inpout += char;
} else {
e.stopImmediatePropagation();
}
} }
Deno.stdout.writeSync(encoder.encode("\n")); im.addEventListener("char", onKey);
const value = await cliPrompt(message + " (y/n)", block).then((v) =>
return input.join(""); v.charAt(0).toLowerCase() === "y"
}
export function cliConfirm(message: string, block?: TerminalBlock) {
return cliPrompt(message + " (y/n)", block).then((v) =>
v.toLowerCase() === "y"
); );
im.removeEventListener("char", onKey);
return value;
} }
export function cliAlert(message: string, block?: TerminalBlock) { export async function cliAlert(message: string, block?: TerminalBlock) {
return cliPrompt( const im = InputManager.getInstance();
const onKey = (e: CLICharEvent) => {
e.stopImmediatePropagation();
};
im.addEventListener("char", onKey);
await cliPrompt(
message + colorize(" Press Enter to continue", "gray"), message + colorize(" Press Enter to continue", "gray"),
block, block,
).then((v) => { );
return v; im.removeEventListener("char", onKey);
});
} }
export function cliLog( export function cliLog(
@@ -92,18 +273,29 @@ if (import.meta.main) {
const layout = new TerminalLayout(); const layout = new TerminalLayout();
const title = new TerminalBlock(); const title = new TerminalBlock();
const block = new TerminalBlock(); const block = new TerminalBlock();
const footer = new TerminalBlock();
block.setPreserveHistory(true); block.setPreserveHistory(true);
// ScrollManager.enable(block); // ScrollManager.enable(block);
title.setLines(["Hello, World!"]); title.setLines(["Hello, World!"]);
title.setFixedHeight(1); title.setFixedHeight(1);
footer.setLines(["Press Ctrl+C to exit"]);
footer.setFixedHeight(1);
layout.register("title", title); layout.register("title", title);
layout.register("block", block); layout.register("block", block);
layout.register("footer", footer);
InputManager.addEventListener("exit", () => {
layout.clearAll();
// console.clear();
Deno.exit(0);
});
Deno.addSignalListener("SIGINT", () => { Deno.addSignalListener("SIGINT", () => {
layout.clearAll(); layout.clearAll();
// console.clear(); // console.clear();
Deno.exit(0); // Deno.exit(0);
}); });
const name = await cliPrompt("Enter your name:", block); const name = await cliPrompt("Enter your name:", block);
cliLog(`Hello, ${name}!`, block); cliLog(`Hello, ${name}!`, block);

View File

@@ -1,6 +1,8 @@
import type { callback } from "../types.ts"; import type { callback } from "../types.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts";
import { cliLog } from "./prompts.ts";
import { colorize } from "./style.ts"; import { colorize } from "./style.ts";
import { TerminalBlock } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
interface ISelectMenuConfig { interface ISelectMenuConfig {
terminalBlock?: TerminalBlock; terminalBlock?: TerminalBlock;
@@ -62,50 +64,65 @@ export async function selectMenuInteractive(
let inputBuffer = ""; let inputBuffer = "";
// Function to handle input const im = InputManager.getInstance();
async function handleInput() { im.activate();
const buf = new Uint8Array(3); // arrow keys send 3 bytes
while (true) {
renderMenu();
const n = await Deno.stdin.read(buf);
if (n === null) break;
const [a, b, c] = buf; const onUp = (e: Event) => {
e.stopImmediatePropagation();
selected = (selected - 1 + options.length) % options.length;
renderMenu();
};
if (a === 3) { const onDown = (e: Event) => {
Deno.stdin.setRaw(false); e.stopImmediatePropagation();
terminalBlock?.["layout"]?.clearAll(); selected = (selected + 1) % options.length;
Deno.exit(130); renderMenu();
} };
if (a === 13) { // Enter key const onKey = (e: CLICharEvent) => {
if (inputBuffer) { e.stopImmediatePropagation();
const parsed = parseInt(inputBuffer); const ke = e.detail;
if (!isNaN(parsed)) { const char = String.fromCharCode(ke.key);
selected = parsed - 1; inputBuffer += char;
} };
inputBuffer = "";
} const onBackspace = (e: Event) => {
break; e.stopImmediatePropagation();
} else if (a === 27 && b === 91) { // Arrow keys inputBuffer = inputBuffer.slice(0, -1);
inputBuffer = ""; };
if (c === 65) { // Up
selected = (selected - 1 + options.length) % options.length; let resolve: null | ((value: string) => void) = null;
} else if (c === 66) { // Down
selected = (selected + 1) % options.length; const onEnter = (e: Event) => {
} e.stopImmediatePropagation();
} else if (a >= 48 && a <= 57) { if (inputBuffer) {
inputBuffer += String.fromCharCode(a); const parsed = parseInt(inputBuffer);
} else if (a === 8) { if (!isNaN(parsed)) {
inputBuffer = inputBuffer.slice(0, -1); selected = parsed - 1;
} }
inputBuffer = "";
} }
im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onKey);
im.removeEventListener("backspace", onBackspace);
im.removeEventListener("enter", onEnter);
resolve?.(options[selected]);
};
renderMenu();
await new Promise<string>((res) => {
resolve = res;
im.addEventListener("char", onKey);
im.addEventListener("backspace", onBackspace);
im.addEventListener("enter", onEnter);
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
});
Deno.stdin.setRaw(false);
return options[selected];
}
terminalBlock.setLines(["Selected: " + options[selected]], range); terminalBlock.setLines(["Selected: " + options[selected]], range);
return await handleInput();
return options[selected];
} }
export async function multiSelectMenuInteractive( export async function multiSelectMenuInteractive(
@@ -117,9 +134,7 @@ export async function multiSelectMenuInteractive(
let selected = 0; let selected = 0;
let selectedOptions: number[] = config?.initialSelections || []; let selectedOptions: number[] = config?.initialSelections || [];
const rawValues = new Set( const rawValues = options.map((i) => typeof i === "string" ? i : i[0]);
options.map((i) => typeof i === "string" ? i : i[0]),
).values().toArray();
if (rawValues.length !== options.length) { if (rawValues.length !== options.length) {
throw new Error("Duplicate options in multi-select menu"); throw new Error("Duplicate options in multi-select menu");
@@ -158,44 +173,52 @@ export async function multiSelectMenuInteractive(
range = terminalBlock.setLines(lines, range); range = terminalBlock.setLines(lines, range);
} }
// Function to handle input const im = InputManager.getInstance();
async function handleInput() { im.activate();
const buf = new Uint8Array(3); // arrow keys send 3 bytes
while (true) {
renderMenu();
const n = await Deno.stdin.read(buf);
if (n === null) break;
const [a, b, c] = buf; let resolve = null as null | ((value: number[]) => void);
if (a === 3) { const onUp = (e: Event) => {
Deno.stdin.setRaw(false); e.stopImmediatePropagation();
terminalBlock?.["layout"]?.clearAll(); selected = (selected - 1 + options.length) % options.length;
Deno.exit(130); renderMenu();
} };
if (a === 13) { // Enter key const onDown = (e: Event) => {
break; e.stopImmediatePropagation();
} else if (a === 27 && b === 91) { // Arrow keys selected = (selected + 1) % options.length;
if (c === 65) { // Up renderMenu();
selected = (selected - 1 + options.length) % options.length; };
} else if (c === 66) { // Down
selected = (selected + 1) % options.length; const onSpace = (e: CLICharEvent) => {
} if (e.detail.char !== " ") return;
} else if (a === 32) { // Space e.stopImmediatePropagation();
Deno.stdout.writeSync(new TextEncoder().encode("\x07")); if (selectedOptions.includes(selected)) {
if (selectedOptions.includes(selected)) { selectedOptions = selectedOptions.filter((i) => i !== selected);
selectedOptions = selectedOptions.filter((i) => i !== selected); } else {
} else { selectedOptions.push(selected);
selectedOptions.push(selected);
}
}
} }
renderMenu();
};
Deno.stdin.setRaw(false); const onEnter = (e: Event) => {
return selectedOptions; e.stopImmediatePropagation();
} resolve?.(selectedOptions);
const selections = await handleInput(); im.removeEventListener("arrow-up", onUp);
im.removeEventListener("arrow-down", onDown);
im.removeEventListener("char", onSpace);
im.removeEventListener("enter", onEnter);
};
renderMenu();
const selections = await new Promise<number[]>((res) => {
resolve = res;
im.addEventListener("arrow-up", onUp);
im.addEventListener("arrow-down", onDown);
im.addEventListener("char", onSpace);
im.addEventListener("enter", onEnter);
});
for (const optionI of selections) { for (const optionI of selections) {
const option = options[optionI]; const option = options[optionI];
if (Array.isArray(option)) { if (Array.isArray(option)) {
@@ -208,17 +231,17 @@ export async function multiSelectMenuInteractive(
} }
if (import.meta.main) { if (import.meta.main) {
// const layout = new TerminalLayout(); const layout = new TerminalLayout();
// const block = new TerminalBlock(); const block = new TerminalBlock();
// const titleBlock = new TerminalBlock(); const titleBlock = new TerminalBlock();
// const postBlock = new TerminalBlock(); const postBlock = new TerminalBlock();
// titleBlock.setLines(["An incredible fruit menu!"]); InputManager.addEventListener("exit", () => layout.clearAll());
// postBlock.setLines(["I'm here too!"]); titleBlock.setLines(["An incredible fruit menu!"]);
// titleBlock.setFixedHeight(1); postBlock.setLines(["I'm here too!"]);
// postBlock.setFixedHeight(1); titleBlock.setFixedHeight(1);
// layout.register("title", titleBlock); postBlock.setFixedHeight(1);
// layout.register("block", block); layout.register("title", titleBlock);
// layout.register("post", postBlock); layout.register("block", block);
// const val = await selectMenuInteractive("choose a fruit", [ // const val = await selectMenuInteractive("choose a fruit", [
// "apple", // "apple",
@@ -276,8 +299,8 @@ if (import.meta.main) {
"ximenia", "ximenia",
"yuzu", "yuzu",
"zucchini", "zucchini",
]); ], { terminalBlock: block });
console.log(val); cliLog(val || "No value");
// Deno.stdout.writeSync(new TextEncoder().encode("\x07")); // Deno.stdout.writeSync(new TextEncoder().encode("\x07"));
} }

View File

@@ -1,10 +1,10 @@
{ {
"name": "@bearmetal/pdf-tools", "name": "@bearmetal/pdf-tools",
"version": "1.0.1", "version": "1.0.8-l",
"license": "GPL 3.0", "license": "GPL 3.0",
"tasks": { "tasks": {
"dev": "deno run -A --env-file=.env --watch main.ts", "dev": "deno run -A --env-file=.env main.ts",
"compile": "deno compile -o compare-form-fields.exe --target x86_64-pc-windows-msvc -R ./main.ts", "compile": "deno compile -o pdf-tools.exe --target x86_64-pc-windows-msvc --include ./asciiart.txt -A ./main.ts",
"install": "deno install -fgq --import-map ./deno.json -n checkfields -R ./main.ts", "install": "deno install -fgq --import-map ./deno.json -n checkfields -R ./main.ts",
"debug": "deno run -A --env-file=.env --inspect-wait --watch main.ts" "debug": "deno run -A --env-file=.env --inspect-wait --watch main.ts"
}, },
@@ -12,7 +12,8 @@
"@std/assert": "jsr:@std/assert@1", "@std/assert": "jsr:@std/assert@1",
"@std/path": "jsr:@std/path@^1.0.9", "@std/path": "jsr:@std/path@^1.0.9",
"pdf-lib": "npm:pdf-lib@^1.17.1", "pdf-lib": "npm:pdf-lib@^1.17.1",
"util/": "./util/" "util/": "./util/",
"@/": "./"
}, },
"exports": { "exports": {
".": "./main.ts" ".": "./main.ts"

View File

@@ -1,5 +1,10 @@
/// <reference types="./types.ts" /> /// <reference types="./types.ts" />
import { PdfToolsCli } from "./cli/index.ts"; import { PdfToolsCli } from "./cli/index.ts";
// import { log } from "util/logfile.ts";
// try {
const app = new PdfToolsCli(); const app = new PdfToolsCli();
app.run(); app.run();
// } catch (e) {
// // log(e);
// }

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -20,31 +20,10 @@ export class ListFormFields implements ITool {
const form = await loadPdfForm(pdfPath); const form = await loadPdfForm(pdfPath);
const fields = form.getFields(); const fields = form.getFields();
const height = this.block.getRenderHeight() - 1; const fieldNames = fields.map((f) => f.getName());
const fieldNames = fields.sort((a, b) => {
const aRect = a.acroField.getWidgets().find((e) => e.Rect())?.Rect()
?.asRectangle();
const bRect = b.acroField.getWidgets().find((e) => e.Rect())?.Rect()
?.asRectangle();
if (aRect && bRect) {
if (aRect.x !== bRect.x) {
return aRect.x - bRect.x; // Sort left to right
} else {
return bRect.y - aRect.y; // If x is equal, sort top to bottom
}
}
return a.getName().localeCompare(b.getName());
}).map((f) => f.getName());
const maxLength = Math.max(...fieldNames.map((f) => f.length)) + 4;
const lines = []; const lines = [];
for (let i = 0; i < height; i++) { for (const fieldName of fieldNames) {
let line = ""; lines.push(fieldName);
for (let j = 0; j < fieldNames.length; j += height) {
const fieldName = fieldNames[i + j] ?? "";
line += fieldName.padEnd(maxLength, " ");
}
lines.push(line);
} }
this.block.setLines(lines, [0, 1]); this.block.setLines(lines, [0, 1]);
await cliAlert("", this.block); await cliAlert("", this.block);

View File

@@ -1,30 +1,30 @@
export async function getAsciiArt(art: string) { import { join } from "@std/path";
const artFilePath = Deno.env.get("BEARMETAL_ASCII_PATH") ||
getBearmetalAsciiPath();
if (!artFilePath) return art;
let artFileText: string;
if (artFilePath.startsWith("http")) {
artFileText = await fetch(artFilePath).then((res) => res.text());
} else {
artFileText = await Deno.readTextFile(artFilePath);
}
const parserRX = /begin\s+(\w+)\s*\n([\s\S]*?)\s*end\s*/g;
let result = parserRX.exec(artFileText);
while (result !== null) { export async function getAsciiArt(art: string) {
const [_, name, artText] = result; try {
if (name === art) return artText; const artFilePath =
result = parserRX.exec(artFileText); Deno.env.get("BEARMETAL_ASCII_PATH") || import.meta.dirname
? join(import.meta.dirname || "", "../asciiart.txt")
: "https://git.cyborggrizzly.com/BearMetal/pdf-tools/raw/branch/main/asciiart.txt";
let artFileText: string;
if (artFilePath?.startsWith("http")) {
artFileText = await fetch(artFilePath).then((res) => res.text());
} else {
artFileText = await Deno.readTextFile(
artFilePath,
);
}
const parserRX = /begin\s+(\w+)\s*\n([\s\S]*?)\s*end\s*/g;
let result = parserRX.exec(artFileText);
while (result !== null) {
const [_, name, artText] = result;
if (name === art) return artText;
result = parserRX.exec(artFileText);
}
} catch (e) {
console.log(e);
alert();
} }
return art; return art;
} }
function getBearmetalAsciiPath() {
const filenameRX = /asciiarts?\.txt$/;
for (const filename of Deno.readDirSync(".")) {
if (filename.isFile && filenameRX.test(filename.name)) {
return filename.name;
}
}
return "https://git.cyborggrizzly.com/BearMetal/pdf-tools/raw/branch/main/asciiart.txt";
}

View File

@@ -11,9 +11,45 @@ function lowerToTrainCase(str: string) {
); );
} }
function lowerToCamelCase(str: string) { /**
return str.trim().replace(/(?:\s)\w/g, (match) => match.toUpperCase()) * @param str
.replaceAll(" ", ""); * @returns camelCased string (single letter words are lower cased, e.g. SSN -> ssn)
*/
function lowerToCamelCase(str: string): string {
const words = str.trim().split(/\s+/);
const result: string[] = [];
let i = 0;
while (i < words.length) {
if (words[i].length === 1) {
// Weve hit the start of a chain of single-letter words
let j = i;
while (j < words.length && words[j].length === 1) {
j++;
}
const chainIsAtStart = i === 0;
// Process that entire chain
for (let k = i; k < j; k++) {
result[k] = chainIsAtStart
? words[k].toLowerCase()
: words[k].toUpperCase();
}
i = j;
} else {
// Normal multi-letter word
if (i === 0) {
// first word: all lower
result[i] = words[i].toLowerCase();
} else {
// subsequent words: capitalize first letter
result[i] = words[i][0].toUpperCase() +
words[i].slice(1).toLowerCase();
}
i++;
}
}
return result.join("");
} }
function lowerToSnakeCase(str: string) { function lowerToSnakeCase(str: string) {
@@ -88,10 +124,10 @@ function coerceCaseToLower(str: string, caseType: CaseType) {
case "macro": case "macro":
case "snake": case "snake":
case "upper": case "upper":
return str.replace("_", " ").toLowerCase(); return str.replaceAll("_", " ").toLowerCase();
case "train": case "train":
case "kebab": case "kebab":
return str.replace("-", " ").toLowerCase(); return str.replaceAll("-", " ").toLowerCase();
default: default:
return str.toLowerCase(); return str.toLowerCase();
} }
@@ -124,3 +160,7 @@ export function toCase(str: string, toCase: CaseType) {
return str; return str;
} }
} }
if (import.meta.main) {
console.log(toCase("SSN", "camel"));
}

View File

@@ -7,9 +7,9 @@ const logFile = Deno.openSync("./log.txt", {
logFile.truncateSync(0); logFile.truncateSync(0);
export function log(message: any) { export function log(...message: any) {
if (typeof message === "object") { if (typeof message === "object") {
message = JSON.stringify(message); message = Deno.inspect(message);
} }
logFile.writeSync(new TextEncoder().encode(message + "\n")); logFile.writeSync(new TextEncoder().encode(message + "\n"));
} }

View File

@@ -15,10 +15,10 @@ export async function loadPdf(path: string) {
export async function savePdf(doc: PDFDocument, path: string) { export async function savePdf(doc: PDFDocument, path: string) {
doc.getForm().getFields().forEach((field) => { doc.getForm().getFields().forEach((field) => {
if (field instanceof PDFTextField) { if (field instanceof PDFTextField) {
field.disableRichFormatting(); field.disableRichFormatting?.();
} }
}); });
const pdfBytes = await doc.save(); const pdfBytes = await doc.save({ updateFieldAppearances: true });
if (Deno.env.get("DRYRUN") || path.includes("dryrun")) return; if (Deno.env.get("DRYRUN") || path.includes("dryrun")) return;
await Deno.writeFile(path, pdfBytes); await Deno.writeFile(path, pdfBytes);
} }