From 45be8b832db68d5facedf252e639ebd20f07066b Mon Sep 17 00:00:00 2001 From: alam00000 Date: Mon, 9 Feb 2026 12:27:52 +0530 Subject: [PATCH] feat: implement PDF password handling and enhance toolbox UI for better accessibility --- src/js/logic/pdf-workflow-page.ts | 135 ++++++++++++++++++++++-- src/js/workflow/engine.ts | 24 +++++ src/js/workflow/nodes/pdf-input-node.ts | 75 ++++++++++++- src/pages/pdf-workflow.html | 91 +++++++++++++--- 4 files changed, 296 insertions(+), 29 deletions(-) diff --git a/src/js/logic/pdf-workflow-page.ts b/src/js/logic/pdf-workflow-page.ts index bce1391..97ffb74 100644 --- a/src/js/logic/pdf-workflow-page.ts +++ b/src/js/logic/pdf-workflow-page.ts @@ -5,7 +5,10 @@ import { executeWorkflow } from '../workflow/engine'; import { nodeRegistry, getNodesByCategory } from '../workflow/nodes/registry'; import type { BaseWorkflowNode } from '../workflow/nodes/base-node'; import type { WorkflowEditor } from '../workflow/editor'; -import { PDFInputNode } from '../workflow/nodes/pdf-input-node'; +import { + PDFInputNode, + EncryptedPDFError, +} from '../workflow/nodes/pdf-input-node'; import { ImageInputNode } from '../workflow/nodes/image-input-node'; import { WordToPdfNode } from '../workflow/nodes/word-to-pdf-node'; import { ExcelToPdfNode } from '../workflow/nodes/excel-to-pdf-node'; @@ -136,6 +139,32 @@ async function initializePage() { updateNodeCount(); }); + // Mobile toolbox sidebar toggle + const toolboxSidebar = document.getElementById('toolbox-sidebar'); + const toolboxBackdrop = document.getElementById('toolbox-backdrop'); + + function closeToolbox() { + toolboxSidebar?.classList.add('hidden'); + toolboxSidebar?.classList.remove('flex'); + toolboxBackdrop?.classList.add('hidden'); + } + + function openToolbox() { + toolboxSidebar?.classList.remove('hidden'); + toolboxSidebar?.classList.add('flex'); + toolboxBackdrop?.classList.remove('hidden'); + } + + document.getElementById('toolbox-toggle')?.addEventListener('click', () => { + if (toolboxSidebar?.classList.contains('hidden')) { + openToolbox(); + } else { + closeToolbox(); + } + }); + + toolboxBackdrop?.addEventListener('click', closeToolbox); + document.getElementById('node-search')?.addEventListener('input', (e) => { const query = (e.target as HTMLInputElement).value.toLowerCase(); const items = document.querySelectorAll('.toolbox-node-item'); @@ -158,16 +187,30 @@ async function initializePage() { }); let justPicked = false; + let dragDistance = 0; + let pickedNodeId: string | null = null; area.addPipe((context) => { if (context.type === 'nodepicked') { const nodeId = context.data.id; selectedNodeId = nodeId; justPicked = true; - const node = editor.getNode(nodeId) as BaseWorkflowNode; - if (node) { - showNodeSettings(node); + pickedNodeId = nodeId; + dragDistance = 0; + } + if (context.type === 'nodetranslated') { + const dx = context.data.position.x - context.data.previous.x; + const dy = context.data.position.y - context.data.previous.y; + dragDistance += Math.abs(dx) + Math.abs(dy); + } + if (context.type === 'nodedragged') { + if (pickedNodeId && dragDistance < 5) { + const node = editor.getNode(pickedNodeId) as BaseWorkflowNode; + if (node) { + showNodeSettings(node); + } } + pickedNodeId = null; } if (context.type === 'translated') { container.classList.add('is-panning'); @@ -443,7 +486,14 @@ function buildToolbox() { labelEl.textContent = entry.label; item.appendChild(labelEl); - item.addEventListener('click', () => addNodeToCanvas(item.dataset.type!)); + item.addEventListener('click', () => { + addNodeToCanvas(item.dataset.type!); + if (window.innerWidth < 768) { + document.getElementById('toolbox-sidebar')?.classList.add('hidden'); + document.getElementById('toolbox-sidebar')?.classList.remove('flex'); + document.getElementById('toolbox-backdrop')?.classList.add('hidden'); + } + }); item.draggable = true; item.addEventListener('dragstart', (e) => { @@ -552,6 +602,57 @@ function buildFileList( container.appendChild(list); } +function promptPdfPassword(filename: string): Promise { + return new Promise((resolve) => { + const modal = document.getElementById('pdf-password-modal')!; + const filenameEl = document.getElementById('pdf-password-filename')!; + const input = document.getElementById( + 'pdf-password-input' + ) as HTMLInputElement; + const errorEl = document.getElementById('pdf-password-error')!; + const skipBtn = document.getElementById('pdf-password-skip')!; + const unlockBtn = document.getElementById('pdf-password-unlock')!; + + filenameEl.textContent = filename; + input.value = ''; + errorEl.classList.add('hidden'); + modal.classList.remove('hidden'); + input.focus(); + + const cleanup = () => { + modal.classList.add('hidden'); + skipBtn.replaceWith(skipBtn.cloneNode(true)); + unlockBtn.replaceWith(unlockBtn.cloneNode(true)); + input.removeEventListener('keydown', onKeydown); + }; + + const onKeydown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + cleanup(); + resolve(input.value || null); + } + if (e.key === 'Escape') { + cleanup(); + resolve(null); + } + }; + + input.addEventListener('keydown', onKeydown); + document + .getElementById('pdf-password-skip')! + .addEventListener('click', () => { + cleanup(); + resolve(null); + }); + document + .getElementById('pdf-password-unlock')! + .addEventListener('click', () => { + cleanup(); + resolve(input.value || null); + }); + }); +} + function showNodeSettings(node: BaseWorkflowNode) { const sidebar = document.getElementById('settings-sidebar'); const title = document.getElementById('settings-title'); @@ -590,14 +691,28 @@ function showNodeSettings(node: BaseWorkflowNode) { fileInput.addEventListener('change', async (e) => { const files = Array.from((e.target as HTMLInputElement).files ?? []); if (files.length === 0) return; - try { - for (const file of files) { + for (const file of files) { + try { await node.addFile(file); + } catch (err) { + if (err instanceof EncryptedPDFError) { + const password = await promptPdfPassword(file.name); + if (password) { + try { + await node.addDecryptedFile(file, password); + } catch { + showAlert( + 'Error', + `Wrong password or failed to decrypt "${file.name}".` + ); + } + } + } else { + showAlert('Error', 'Failed to load PDF: ' + (err as Error).message); + } } - showNodeSettings(node); - } catch (err) { - showAlert('Error', 'Failed to load PDF: ' + (err as Error).message); } + showNodeSettings(node); }); uploadBtn.addEventListener('click', () => fileInput.click()); diff --git a/src/js/workflow/engine.ts b/src/js/workflow/engine.ts index 228cd5d..d7a6ca5 100644 --- a/src/js/workflow/engine.ts +++ b/src/js/workflow/engine.ts @@ -73,6 +73,25 @@ function tick(): Promise { return new Promise((r) => setTimeout(r, 0)); } +function validateEncryptOrdering( + editor: NodeEditor, + pipelineNodes: string[] +): string | null { + for (const nodeId of pipelineNodes) { + const node = editor.getNode(nodeId) as BaseWorkflowNode; + if (node?.label !== 'Encrypt') continue; + + const outConns = editor.getConnections().filter((c) => c.source === nodeId); + for (const conn of outConns) { + const target = editor.getNode(conn.target) as BaseWorkflowNode; + if (target && target.category !== 'Output') { + return `The Encrypt node feeds into "${target.label}", which may fail on encrypted data. Move Encrypt to just before the output node.`; + } + } + } + return null; +} + export async function executeWorkflow( editor: NodeEditor, engine: DataflowEngine, @@ -107,6 +126,11 @@ export async function executeWorkflow( const sorted = topologicalSort(pipelineNodes, editor); + const encryptWarning = validateEncryptOrdering(editor, sorted); + if (encryptWarning) { + throw new WorkflowError(encryptWarning, 'Encrypt'); + } + engine.reset(); for (const nodeId of sorted) { diff --git a/src/js/workflow/nodes/pdf-input-node.ts b/src/js/workflow/nodes/pdf-input-node.ts index df2dab6..d041479 100644 --- a/src/js/workflow/nodes/pdf-input-node.ts +++ b/src/js/workflow/nodes/pdf-input-node.ts @@ -3,7 +3,14 @@ import { BaseWorkflowNode } from './base-node'; import { pdfSocket } from '../sockets'; import type { PDFData, SocketData, MultiPDFData } from '../types'; import { PDFDocument } from 'pdf-lib'; -import { readFileAsArrayBuffer } from '../../utils/helpers.js'; +import { readFileAsArrayBuffer, initializeQpdf } from '../../utils/helpers.js'; + +export class EncryptedPDFError extends Error { + constructor(public readonly filename: string) { + super(`PDF "${filename}" is password-protected`); + this.name = 'EncryptedPDFError'; + } +} export class PDFInputNode extends BaseWorkflowNode { readonly category = 'Input' as const; @@ -20,8 +27,29 @@ export class PDFInputNode extends BaseWorkflowNode { async addFile(file: File): Promise { const arrayBuffer = await readFileAsArrayBuffer(file); const bytes = new Uint8Array(arrayBuffer as ArrayBuffer); + + let isEncrypted = false; + try { + await PDFDocument.load(bytes, { throwOnInvalidObject: false }); + } catch { + isEncrypted = true; + } + + if (isEncrypted) { + try { + await PDFDocument.load(bytes, { + ignoreEncryption: true, + throwOnInvalidObject: false, + }); + } catch { + throw new Error( + `Failed to load "${file.name}" - file may be corrupted` + ); + } + throw new EncryptedPDFError(file.name); + } + const document = await PDFDocument.load(bytes, { - ignoreEncryption: true, throwOnInvalidObject: false, }); this.files.push({ @@ -32,6 +60,49 @@ export class PDFInputNode extends BaseWorkflowNode { }); } + async addDecryptedFile(file: File, password: string): Promise { + const arrayBuffer = await readFileAsArrayBuffer(file); + const bytes = new Uint8Array(arrayBuffer as ArrayBuffer); + const qpdf = await initializeQpdf(); + const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const inputPath = `/tmp/input_decrypt_${uid}.pdf`; + const outputPath = `/tmp/output_decrypt_${uid}.pdf`; + + try { + qpdf.FS.writeFile(inputPath, bytes); + qpdf.callMain([ + inputPath, + '--password=' + password, + '--decrypt', + outputPath, + ]); + const decryptedData = qpdf.FS.readFile(outputPath, { + encoding: 'binary', + }); + const decryptedBytes = new Uint8Array(decryptedData); + const document = await PDFDocument.load(decryptedBytes, { + throwOnInvalidObject: false, + }); + this.files.push({ + type: 'pdf', + document, + bytes: decryptedBytes, + filename: file.name, + }); + } finally { + try { + qpdf.FS.unlink(inputPath); + } catch { + /* cleanup */ + } + try { + qpdf.FS.unlink(outputPath); + } catch { + /* cleanup */ + } + } + } + async setFile(file: File): Promise { this.files = []; await this.addFile(file); diff --git a/src/pages/pdf-workflow.html b/src/pages/pdf-workflow.html index 476dab7..fd323f8 100644 --- a/src/pages/pdf-workflow.html +++ b/src/pages/pdf-workflow.html @@ -281,7 +281,7 @@ +
+
@@ -348,7 +364,7 @@
Ready
@@ -359,7 +375,7 @@