From 9535222fb7c0abb890e91f0bd073ecc0ec8ce41e Mon Sep 17 00:00:00 2001 From: Emma Date: Wed, 30 Apr 2025 01:17:45 -0600 Subject: [PATCH] improves block functionality adds cli compatible prompts/logs adds logfile function for debug adds multiselect support new fieldRename adds listFieldNames --- .gitignore | 5 +- cli/TerminalLayout.ts | 86 +++++++++++++++++------ cli/cursor.ts | 29 ++++++++ cli/forceArgs.ts | 29 ++++++++ cli/index.ts | 79 +++++++++++++++++----- cli/prompts.ts | 124 ++++++++++++++++++++++++++++++++++ cli/selectMenu.ts | 124 ++++++++++++++++++++++++++++++---- cli/{colorize.ts => style.ts} | 0 tools/checkCode.ts | 1 - tools/fieldRename.ts | 122 ++++++++++++++++++++++++++++----- tools/listFormFields.ts | 56 +++++++++++++++ types.ts | 5 ++ util/dedent.ts | 18 +++++ util/logfile.ts | 15 ++++ 14 files changed, 623 insertions(+), 70 deletions(-) create mode 100644 cli/cursor.ts create mode 100644 cli/forceArgs.ts create mode 100644 cli/prompts.ts rename cli/{colorize.ts => style.ts} (100%) create mode 100644 tools/listFormFields.ts create mode 100644 util/dedent.ts create mode 100644 util/logfile.ts diff --git a/.gitignore b/.gitignore index 13d1799..392e83a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *.exe -.env \ No newline at end of file +.env + +log.txt +log \ No newline at end of file diff --git a/cli/TerminalLayout.ts b/cli/TerminalLayout.ts index 5cf9e34..c44bfa5 100644 --- a/cli/TerminalLayout.ts +++ b/cli/TerminalLayout.ts @@ -1,3 +1,6 @@ +import { log } from "util/logfile.ts"; +import { Cursor } from "./cursor.ts"; + export class TerminalLayout { private static ALT_BUFFER_ENABLE = "\x1b[?1049h"; private static ALT_BUFFER_DISABLE = "\x1b[?1049l"; @@ -12,10 +15,16 @@ export class TerminalLayout { constructor() { Deno.stdout.writeSync( new TextEncoder().encode( - TerminalLayout.ALT_BUFFER_ENABLE + TerminalLayout.CURSOR_HIDE, + TerminalLayout.ALT_BUFFER_ENABLE, ), ); + Cursor.hide(); this.height = Deno.consoleSize().rows; + + Deno.addSignalListener("SIGINT", () => { + this.clearAll(); + Deno.exit(0); + }); } register(name: string, block: TerminalBlock, fixedHeight?: number) { @@ -71,16 +80,25 @@ export class TerminalLayout { } clearAll() { + log("clearAll"); Deno.stdout.writeSync( new TextEncoder().encode( - TerminalLayout.ALT_BUFFER_DISABLE + TerminalLayout.CURSOR_SHOW, + TerminalLayout.ALT_BUFFER_DISABLE, ), ); + Cursor.show(); for (const name of this.layoutOrder) { this.blocks[name].clear(); } } + clear() { + log("clear " + this.height); + for (let i = 0; i < this.height; i++) { + Deno.stdout.writeSync(new TextEncoder().encode("\x1b[2K\x1b[1E")); + } + } + get availableHeight() { return this.height; } @@ -96,14 +114,24 @@ export class TerminalBlock { private renderHeight: number = 0; private lastRenderRow = 1; + private preserveHistory = false; + constructor(private prepend: string = "") {} + setPreserveHistory(preserveHistory: boolean) { + this.preserveHistory = preserveHistory; + } + setLayout(layout: TerminalLayout) { this.layout = layout; } - setLines(lines: string[]) { - this.lines = lines; + setLines(lines: string[], range?: [number, number]) { + if (range && this.preserveHistory) { + this.lines.splice(range[0], range[1], ...lines); + } else { + this.lines = this.preserveHistory ? this.lines.concat(lines) : lines; + } if (this.scrollOffset > lines.length - 1) { this.scrollOffset = Math.max(0, lines.length - 1); } @@ -115,6 +143,19 @@ export class TerminalBlock { ); this.renderInternal(); } + range = [ + range?.[0] ?? this.lines.length - lines.length, + range ? range[0] + lines.length : this.lines.length, + ]; + return range; + } + + append(lines: string[]) { + this.lines.push(...lines); + this.scrollTo(this.lines.length - 1); + if (this.layout) { + this.layout.requestRender(); + } } scrollTo(offset: number) { @@ -144,15 +185,6 @@ export class TerminalBlock { setRenderLines(lines: string[]) { this.renderLines = lines; - this.renderedLineCount = lines.reduce( - (count, line) => - count + - Math.ceil( - (this.prepend.length + line.length) / - (Deno.consoleSize().columns || 80), - ), - 0, - ); } setRenderHeight(height: number) { @@ -165,19 +197,30 @@ export class TerminalBlock { renderInternal(startRow?: number) { this.lastRenderRow = startRow ?? this.lastRenderRow; - this.clear(); - let output = this.renderLines.map((line) => `${this.prepend}${line}\x1b[K`) - .join("\n"); + this.clear(); // uses old renderedLineCount + + const outputLines = this.renderLines.map((line) => + `${this.prepend}${line}\x1b[K` + ); + const output = outputLines.join("\n"); if (startRow !== undefined) { const moveCursor = `\x1b[${startRow};1H`; - output = moveCursor + output; + Deno.stdout.writeSync(new TextEncoder().encode(moveCursor + output)); + } else { + Deno.stdout.writeSync(new TextEncoder().encode(output)); } - Deno.stdout.writeSync( - new TextEncoder().encode(output), + + // update rendered line count *after* rendering + this.renderedLineCount = outputLines.reduce( + (count, line) => + count + + Math.ceil((line.length) / (Deno.consoleSize().columns || 80)), + 0, ); } clear() { + log(this.renderedLineCount); if (this.renderedLineCount === 0) return; const moveCursor = `\x1b[${this.lastRenderRow};1H`; Deno.stdout.writeSync(new TextEncoder().encode(moveCursor)); @@ -187,6 +230,11 @@ export class TerminalBlock { this.renderedLineCount = 0; } + clearAll() { + this.clear(); + this.lines = []; + } + setFixedHeight(height: number) { this.fixedHeight = height; } diff --git a/cli/cursor.ts b/cli/cursor.ts new file mode 100644 index 0000000..dfb6b77 --- /dev/null +++ b/cli/cursor.ts @@ -0,0 +1,29 @@ +export class Cursor { + private static visible = true; + + static show() { + this.visible = true; + Deno.stdout.writeSync(new TextEncoder().encode("\x1b[?25h")); + } + + static hide() { + this.visible = false; + Deno.stdout.writeSync(new TextEncoder().encode("\x1b[?25l")); + } + + static restoreVisibility() { + if (this.visible) { + this.show(); + } else { + this.hide(); + } + } + + static savePosition() { + Deno.stdout.writeSync(new TextEncoder().encode("\x1b7")); + } + + static restorePosition() { + Deno.stdout.writeSync(new TextEncoder().encode("\x1b8")); + } +} diff --git a/cli/forceArgs.ts b/cli/forceArgs.ts new file mode 100644 index 0000000..f901f46 --- /dev/null +++ b/cli/forceArgs.ts @@ -0,0 +1,29 @@ +import { cliPrompt } from "./prompts.ts"; +import type { TerminalBlock } from "./TerminalLayout.ts"; + +type prompt = [string, (v?: string) => boolean] | string; + +export async function forceArgs( + args: string[], + prompts: prompt[], + block?: TerminalBlock, +) { + const newArgs: string[] = []; + for (const [i, arg] of args.entries()) { + if (typeof prompts[i] === "string") { + let val = arg; + while (!val) { + val = await cliPrompt(prompts[i], block) || ""; + } + newArgs.push(val); + } else { + const [promptText, validation] = prompts[i]; + let val = arg; + while (!validation(val)) { + val = await cliPrompt(promptText, block) || ""; + } + newArgs.push(val); + } + } + return newArgs; +} diff --git a/cli/index.ts b/cli/index.ts index b89fc78..1ce38b4 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,9 +1,10 @@ import { getAsciiArt } from "../util/asciiArt.ts"; import { toCase } from "util/caseManagement.ts"; import { ArgParser } from "./argParser.ts"; -import { colorize } from "./colorize.ts"; +import { colorize } from "./style.ts"; import { selectMenuInteractive } from "./selectMenu.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; +import { cliAlert, cliLog } from "./prompts.ts"; export class PdfToolsCli { private tools: Map = new Map(); @@ -30,56 +31,98 @@ export class PdfToolsCli { private async banana() { const asciiArt = await getAsciiArt("banana"); - console.log(colorize(asciiArt, "yellow")); + const body = this.terminalLayout.getBlock("body"); + body.clearAll(); + cliLog(colorize(asciiArt, "yellow"), body); } - private help() { - console.log("BearMetal PDF CLI"); + private async help() { + this.terminalLayout.clear(); + this.ensmallenHeader("Help"); + const bodyBlock = this.terminalLayout.getBlock("body"); + bodyBlock.clearAll(); + await cliAlert("BearMetal PDF CLI\n", bodyBlock); + await this.embiggenHeader(); } public async run() { try { - const lines: string[] = []; - for (const t of ["bearmetal:porple", "pdftools:cyan"]) { - const [name, color] = t.split(":"); - const asciiArt = await getAsciiArt(name); - lines.push(...colorize(asciiArt, color).split("\n")); - } const titleBlock = new TerminalBlock(); this.terminalLayout.register("title", titleBlock); const bodyBlock = new TerminalBlock(); this.terminalLayout.register("body", bodyBlock); - titleBlock.setFixedHeight(lines.length); - titleBlock.setLines(lines); + this.embiggenHeader(); if (Deno.args.length === 0) { // console.log( // colorize("No tool specified. Importing all tools...", "gray"), // ); await this.importTools(); } - this.toolMenu(); + await this.toolMenu(); } finally { this.terminalLayout.clearAll(); + Deno.stdin.setRaw(false); } } private async toolMenu() { const tools = this.tools.keys().toArray(); const bodyBlock = this.terminalLayout.getBlock("body"); - const selected = await selectMenuInteractive("Choose a tool", tools, { - terminalBlock: bodyBlock, - }); - bodyBlock.clear(); + bodyBlock.clearAll(); + bodyBlock.setPreserveHistory(false); + const selected = await selectMenuInteractive( + "Choose a tool", + tools.concat(["Help", "Exit"]), + { + terminalBlock: bodyBlock, + }, + ); if (!selected) return; + if (selected === "Exit") { + return; + } await this.runTool(selected); - this.toolMenu(); + await this.toolMenu(); } private async runTool(toolName: string) { + if (toolName === "Help") { + return await this.help(); + } const tool = this.tools.get(toolName); if (tool) { + this.ensmallenHeader(tool.name + " - " + tool.description); + const bodyBlock = this.terminalLayout.getBlock("body"); + bodyBlock.clearAll(); + tool.setBlock?.(bodyBlock); await tool.run(); await tool.done?.(); + this.embiggenHeader(); } } + + private ensmallenHeader(subtitle: string) { + this.terminalLayout.clear(); + const titleBlock = this.terminalLayout.getBlock("title"); + titleBlock.clear(); + titleBlock.setFixedHeight(3); + titleBlock.setLines([ + colorize("BearMetal PDF Tools", "porple"), + colorize(subtitle, "gray"), + "-=".repeat(Deno.consoleSize().columns / 2), + ]); + } + + private async embiggenHeader() { + const titleBlock = this.terminalLayout.getBlock("title"); + titleBlock.clear(); + const lines: string[] = []; + for (const t of ["bearmetal:porple", "pdftools:cyan"]) { + const [name, color] = t.split(":"); + const asciiArt = await getAsciiArt(name); + lines.push(...colorize(asciiArt, color).split("\n")); + } + titleBlock.setFixedHeight(lines.length); + titleBlock.setLines(lines); + } } diff --git a/cli/prompts.ts b/cli/prompts.ts new file mode 100644 index 0000000..9e0fe1c --- /dev/null +++ b/cli/prompts.ts @@ -0,0 +1,124 @@ +import { Cursor } from "./cursor.ts"; +import { colorize } from "./style.ts"; +import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; + +export async function cliPrompt( + message: string, + block?: TerminalBlock, +): Promise { + const encoder = new TextEncoder(); + const input: string[] = []; + + await Deno.stdin.setRaw(true); + + let cursorVisible = true; + if (!block) { + 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 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 line = message + " " + input.join(""); + if (block) { + range = block.setLines([line], range); + } else { + Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line)); + } + } + + await Deno.stdin.setRaw(false); + if (!block && !cursorVisible) { + Cursor.hide(); + } + Deno.stdout.writeSync(encoder.encode("\n")); + + return input.join(""); +} + +export function cliConfirm(message: string, block?: TerminalBlock) { + return cliPrompt(message + " (y/n)", block).then((v) => + v.toLowerCase() === "y" + ); +} + +export function cliAlert(message: string, block?: TerminalBlock) { + return cliPrompt( + message + colorize(" Press Enter to continue", "gray"), + block, + ).then((v) => { + return v; + }); +} + +export function cliLog(message: string, block?: TerminalBlock) { + if (!block) { + console.log(message); + } else { + block.setLines(message.split("\n")); + } +} + +if (import.meta.main) { + const layout = new TerminalLayout(); + const title = new TerminalBlock(); + const block = new TerminalBlock(); + block.setPreserveHistory(true); + title.setLines(["Hello, World!"]); + title.setFixedHeight(1); + + layout.register("title", title); + layout.register("block", block); + + Deno.addSignalListener("SIGINT", () => { + layout.clearAll(); + // console.clear(); + Deno.exit(0); + }); + const name = await cliPrompt("Enter your name:", block); + cliLog(`Hello, ${name}!`, block); + const single = await cliConfirm("Are you single?", block); + cliLog(single ? "Do you want to go out with me?" : "Okay", block); + const loopingConvo = [ + "No response?", + "I guess that's okay", + "Maybe I'll see you next week?", + "Wow, really not going to say anything to me?", + "Well, if that's how you feel", + ]; + let convo = 0; + setInterval(() => { + cliLog(loopingConvo[convo % loopingConvo.length], block); + convo++; + }, 2000); + // setTimeout(async () => { + // await cliAlert("Well, if that's that...", block); + // Deno.exit(0); + // }, 10000); +} diff --git a/cli/selectMenu.ts b/cli/selectMenu.ts index ec10640..a94ee69 100644 --- a/cli/selectMenu.ts +++ b/cli/selectMenu.ts @@ -1,9 +1,10 @@ -import { colorize } from "./colorize.ts"; -import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; +import { colorize } from "./style.ts"; +import { TerminalBlock } from "./TerminalLayout.ts"; interface ISelectMenuConfig { - multiSelect?: boolean; terminalBlock?: TerminalBlock; + initialSelection?: number; + initialSelections?: number[]; } export function selectMenu(items: string[]) { @@ -21,18 +22,12 @@ export async function selectMenuInteractive( Deno.stdin.setRaw(true); let selected = 0; - if (config?.multiSelect) { - console.warn("Multi-select not implemented yet"); - return null; - } - const terminalBlock = config?.terminalBlock || new TerminalBlock(); if (!config?.terminalBlock) { terminalBlock.setRenderHeight(Deno.consoleSize().rows); } - console.log(terminalBlock.getRenderHeight()); - + let range: [number, number] = [terminalBlock.lineCount, 1]; function renderMenu() { const { rows } = Deno.consoleSize(); const terminalHeight = terminalBlock.getRenderHeight() || rows; @@ -54,7 +49,7 @@ export async function selectMenuInteractive( } } - terminalBlock.setLines(lines); + range = terminalBlock.setLines(lines, range); } function numberAndPadding(i: number, prefix?: string) { @@ -78,7 +73,7 @@ export async function selectMenuInteractive( if (a === 3) { Deno.stdin.setRaw(false); - console.log("\nInterrupted\n"); + terminalBlock?.["layout"]?.clearAll(); Deno.exit(130); } @@ -105,13 +100,112 @@ export async function selectMenuInteractive( } } - terminalBlock.clear(); Deno.stdin.setRaw(false); return options[selected]; } + terminalBlock.setLines(["Selected: " + options[selected]], range); return await handleInput(); } +export async function multiSelectMenuInteractive( + q: string, + options: string[] | [string, callback][], + config?: ISelectMenuConfig, +): Promise { + Deno.stdin.setRaw(true); + let selected = 0; + let selectedOptions: number[] = config?.initialSelections || []; + + const rawValues = new Set( + options.map((i) => typeof i === "string" ? i : i[0]), + ).values().toArray(); + + if (rawValues.length !== options.length) { + throw new Error("Duplicate options in multi-select menu"); + } + + const terminalBlock = config?.terminalBlock || new TerminalBlock(); + if (!config?.terminalBlock) { + terminalBlock.setRenderHeight(Deno.consoleSize().rows); + } + + let range: [number, number] = [terminalBlock.lineCount, 1]; + function renderMenu() { + const { rows } = Deno.consoleSize(); + const terminalHeight = terminalBlock.getRenderHeight() || rows; + const maxHeight = Math.min(terminalHeight - 1, options.length); + let startPoint = Math.max(0, selected - Math.floor(maxHeight / 2)); + const endPoint = Math.min(options.length, startPoint + maxHeight); + if (endPoint - startPoint < maxHeight) { + startPoint = Math.max(0, options.length - maxHeight); + } + + const lines: string[] = []; + lines.push(colorize(q, "green")); + for (let i = startPoint; i < endPoint; i++) { + const option = rawValues[i]; + const checkbox = selectedOptions.includes(i) + ? colorize("◼", "green") + : "◻"; + if (i === selected) { + lines.push(`> ${checkbox} ${colorize(option, "porple")}`); + } else { + lines.push(` ${checkbox} ${option}`); + } + } + + range = terminalBlock.setLines(lines, range); + } + + // Function to handle input + async function handleInput() { + 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; + + if (a === 3) { + Deno.stdin.setRaw(false); + terminalBlock?.["layout"]?.clearAll(); + Deno.exit(130); + } + + if (a === 13) { // Enter key + break; + } else if (a === 27 && b === 91) { // Arrow keys + if (c === 65) { // Up + selected = (selected - 1 + options.length) % options.length; + } else if (c === 66) { // Down + selected = (selected + 1) % options.length; + } + } else if (a === 32) { // Space + Deno.stdout.writeSync(new TextEncoder().encode("\x07")); + if (selectedOptions.includes(selected)) { + selectedOptions = selectedOptions.filter((i) => i !== selected); + } else { + selectedOptions.push(selected); + } + } + } + + Deno.stdin.setRaw(false); + return selectedOptions; + } + const selections = await handleInput(); + for (const optionI of selections) { + const option = options[optionI]; + if (Array.isArray(option)) { + await option[1](option[0]); + } + } + const final = selectedOptions.map((i) => rawValues[i]); + terminalBlock.setLines(["Selected: " + final.join(", ")], range); + return final; +} + if (import.meta.main) { // const layout = new TerminalLayout(); // const block = new TerminalBlock(); @@ -154,7 +248,7 @@ if (import.meta.main) { // layout.clearAll(); // console.log(val); - const val = await selectMenuInteractive("choose a fruit", [ + const val = await multiSelectMenuInteractive("choose a fruit", [ "apple", "banana", "cherry", @@ -183,4 +277,6 @@ if (import.meta.main) { "zucchini", ]); console.log(val); + + // Deno.stdout.writeSync(new TextEncoder().encode("\x07")); } diff --git a/cli/colorize.ts b/cli/style.ts similarity index 100% rename from cli/colorize.ts rename to cli/style.ts diff --git a/tools/checkCode.ts b/tools/checkCode.ts index f6fb062..a9a1a3c 100644 --- a/tools/checkCode.ts +++ b/tools/checkCode.ts @@ -1,4 +1,3 @@ -import { PDFDocument } from "pdf-lib"; import { loadPdfForm } from "../util/saveLoadPdf.ts"; import { callWithArgPrompt } from "util/call.ts"; diff --git a/tools/fieldRename.ts b/tools/fieldRename.ts index 9fbe024..0f5898c 100644 --- a/tools/fieldRename.ts +++ b/tools/fieldRename.ts @@ -1,13 +1,11 @@ -import { - PDFAcroField, - PDFHexString, - PDFName, - PDFString, - toHexString, -} from "pdf-lib"; -import { loadPdfForm, savePdf } from "util/saveLoadPdf.ts"; -import { PDFDocument } from "pdf-lib"; -import { call, callWithArgPrompt } from "util/call.ts"; +import { type PDFAcroField, type PDFField, PDFName, PDFString } from "pdf-lib"; +import { loadPdf, loadPdfForm, savePdf } from "util/saveLoadPdf.ts"; +import { callWithArgPrompt } from "util/call.ts"; +import { TerminalBlock } from "../cli/TerminalLayout.ts"; +import { forceArgs } from "../cli/forceArgs.ts"; +import { colorize } from "../cli/style.ts"; +import { cliLog, cliPrompt } from "../cli/prompts.ts"; +import { multiSelectMenuInteractive } from "../cli/selectMenu.ts"; // const thing = PDFAcroField.prototype.getFullyQualifiedName; // PDFAcroField.prototype.getFullyQualifiedName = function () { @@ -80,18 +78,108 @@ async function renameFields( } } +function applyRename( + field: PDFField, + name: string, + pattern: RegExp, + change: string, +) { + const segments = name.split("."); + const matchingSegments = segments.filter((s) => pattern.test(s)); + let cField: PDFAcroField | undefined = field.acroField; + while (cField) { + if ( + cField.getPartialName() && + matchingSegments.includes(cField.getPartialName()!) + ) { + const mName = cField.getPartialName()?.replace(pattern, change); + if (mName) { + cField.dict.set(PDFName.of("T"), PDFString.of(mName)); + // console.log(cField.getPartialName()) + } + } + cField = cField.getParent(); + // console.log(cField?.getPartialName()) + } +} + +function evaluateChange(change: string, match: RegExpExecArray) { + return change.replace( + /\$(\d+)(i?)/g, + (_, i, indexed) => + indexed + ? (parseInt(match[i]) ? (parseInt(match[i]) - 1).toString() : match[i]) + : match[i], + ); +} + class RenameFields implements ITool { name = "renamefields"; description = "Renames fields in a PDF form"; - help() { - console.log("Usage: renamefields "); + block: TerminalBlock | undefined; + + setBlock(block: TerminalBlock) { + this.block = block; + } + + help(standalone = false) { + cliLog( + "Usage: renamefields ", + standalone ? undefined : this.block, + ); } async run(pdfPath: string = "", pattern: string = "", change: string = "") { - await callWithArgPrompt(renameFields, [ - ["Please provide path to PDF:", (p) => !!p && p.endsWith(".pdf")], - "Please provide search string:", - "Please provide requested change:", - ], [pdfPath, pattern, change]); + if (!this.block) { + this.block = new TerminalBlock(); + } + this.block.setPreserveHistory(true); + + [pdfPath, pattern, change] = await forceArgs( + [pdfPath, pattern, change], + [ + ["Please provide path to PDF:", (p) => !!p && p.endsWith(".pdf")], + "Please provide search string:", + "Please provide requested change:", + ], + this.block, + ); + + const patternRegex = new RegExp(pattern); + + const pdf = await loadPdf(pdfPath); + const form = pdf.getForm(); + const fields = form.getFields(); + + const foundUpdates: [string, callback][] = []; + + for (const field of fields) { + const name = field.getName(); + const match = patternRegex.exec(name); + if (match) { + const toChange = evaluateChange(change, match); + foundUpdates.push([ + `${colorize(name, "red")} -> ${colorize(toChange, "green")}`, + () => { + applyRename(field, name, patternRegex, toChange); + }, + ]); + } + } + + if (foundUpdates.length) { + cliLog("Found updates:", this.block); + await multiSelectMenuInteractive( + "Please select an option to apply", + foundUpdates, + { terminalBlock: this.block }, + ); + } + + const path = await cliPrompt( + "Save to path (or hit enter to keep current):", + this.block, + ); + await savePdf(pdf, path || pdfPath); } } export default new RenameFields(); diff --git a/tools/listFormFields.ts b/tools/listFormFields.ts new file mode 100644 index 0000000..d8c5daf --- /dev/null +++ b/tools/listFormFields.ts @@ -0,0 +1,56 @@ +import { forceArgs } from "../cli/forceArgs.ts"; +import { cliAlert } from "../cli/prompts.ts"; +import { TerminalBlock } from "../cli/TerminalLayout.ts"; +import { loadPdfForm } from "util/saveLoadPdf.ts"; + +export class ListFormFields implements ITool { + name = "listformfields"; + description = "Lists fields in a PDF form"; + block?: TerminalBlock; + async run(pdfPath: string = "") { + if (!this.block) { + this.block = new TerminalBlock(); + } + this.block.setPreserveHistory(true); + [pdfPath] = await forceArgs([pdfPath], [[ + "Please provide path to PDF:", + (p) => !!p && p.endsWith(".pdf"), + ]], this.block); + + const form = await loadPdfForm(pdfPath); + const fields = form.getFields(); + const height = this.block.getRenderHeight() - 1; + 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 = []; + for (let i = 0; i < height; i++) { + let line = ""; + 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]); + await cliAlert("", this.block); + } + setBlock(terminalBlock: TerminalBlock) { + this.block = terminalBlock; + } +} + +export default new ListFormFields(); diff --git a/types.ts b/types.ts index aa54300..de4feeb 100644 --- a/types.ts +++ b/types.ts @@ -1,3 +1,5 @@ +import type { TerminalBlock } from "./cli/TerminalLayout.ts"; + declare global { type ToolFunc = (...args: T) => Promise; interface ITool { @@ -6,5 +8,8 @@ declare global { run: ToolFunc; help?: () => Promise | void; done?: () => Promise | void; + setBlock?: (block: TerminalBlock) => void; } + + type callback = (...args: any[]) => any; } diff --git a/util/dedent.ts b/util/dedent.ts new file mode 100644 index 0000000..860fdc8 --- /dev/null +++ b/util/dedent.ts @@ -0,0 +1,18 @@ +export function dedent(str: string) { + const lines = str.split("\n"); + const indent = lines.reduce((count, line) => { + if (line.trim() === "") return count; + const match = line.match(/^(\s*)/); + return match ? Math.min(count, match[1].length) : count; + }, Infinity); + return lines.map((line) => line.slice(indent)).join("\n"); +} + +if (import.meta.main) { + console.log(dedent(` + Hello, World! + This is a paragraph + that spans multiple lines. + And this is another paragraph. + `)); +} diff --git a/util/logfile.ts b/util/logfile.ts new file mode 100644 index 0000000..ad75a28 --- /dev/null +++ b/util/logfile.ts @@ -0,0 +1,15 @@ +const logFile = Deno.openSync("./log.txt", { + create: true, + write: true, + read: true, + append: true, +}); + +logFile.truncateSync(0); + +export function log(message: any) { + if (typeof message === "object") { + message = JSON.stringify(message); + } + logFile.writeSync(new TextEncoder().encode(message + "\n")); +}