From 8a95144bf309ed3e414b8962a280120b1b517f1f Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 15:28:58 +0530 Subject: [PATCH] feat(pdf): add bulk PDF operations (watermark/compress/rotate/etc.) to batch converter --- src/main.js | 42 ++ src/main/PDFBatchOperations.js | 177 +++++++ src/preload.js | 2 + src/renderer.js | 13 +- src/renderer/pdf-batch-dialog.js | 715 ++++++++++++++++++++++++++ tests/main/PDFBatchOperations.test.js | 338 ++++++++++++ tests/pdf-batch-dialog.test.js | 491 ++++++++++++++++++ 7 files changed, 1777 insertions(+), 1 deletion(-) create mode 100644 src/main/PDFBatchOperations.js create mode 100644 src/renderer/pdf-batch-dialog.js create mode 100644 tests/main/PDFBatchOperations.test.js create mode 100644 tests/pdf-batch-dialog.test.js diff --git a/src/main.js b/src/main.js index 9504059..ef54aea 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ const ImageOperations = require('./main/ImageOperations'); const AudioOperations = require('./main/AudioOperations'); const VideoOperations = require('./main/VideoOperations'); const { collectFilesByExtension } = require('./main/collectFilesByExtension'); +const { runPDFBatchOperation } = require('./main/PDFBatchOperations'); const GitOperations = require('./main/GitOperations'); const PdfFontHeader = require('./main/PdfFontHeader'); const MonospaceFontConfig = require('./main/MonospaceFontConfig'); @@ -5054,6 +5055,47 @@ ipcMain.on( } ); +// ======================================== +// BATCH PDF OPERATIONS — apply one PDFOperations.executeOperation() operation +// (watermark, compress, rotate, split, ... — every per-file non-interactive op; +// see PDF_BATCH_OUTPUT_SPEC in src/main/PDFBatchOperations.js for exclusions) to +// every .pdf in a folder, mirroring the runMediaBatchOperation() batch-folder +// pattern above: collect matching files, loop the operation over them with +// per-file progress, then a completion dialog with completed/failed counts. +// ======================================== +ipcMain.on( + 'batch-pdf-operation', + async (event, { operation, data, inputFolder, outputFolder, includeSubfolders }) => { + if (!conversionLimiter()) { + mainWindow.webContents.send('conversion-status', 'Please wait before converting again...'); + return; + } + await runPDFBatchOperation({ + operation, + inputFolder, + outputFolder, + includeSubfolders, + data, + maxFileSize: MAX_FILE_SIZE, + sanitizeError: sanitizeErrorMessage, + onProgress: (progress) => mainWindow.webContents.send('batch-progress', progress), + onComplete: (result) => { + mainWindow.webContents.send('pdf-batch-complete', result); + if (!result.success) return; + + const allSucceeded = result.failed === 0; + dialog.showMessageBox(mainWindow, { + type: allSucceeded ? 'info' : 'warning', + title: allSucceeded ? 'Batch PDF Operation Complete' : 'Batch PDF Operation Finished', + message: 'Batch PDF operation finished!', + detail: `Completed: ${result.completed}/${result.total} files${result.failed > 0 ? ` (${result.failed} failed)` : ''}\nOutput: ${result.outputFolder}`, + buttons: ['OK'], + }); + }, + }); + } +); + // IPC Handler for folder selection (for batch image operations) ipcMain.on('select-image-folder', (event, inputId) => { const folder = dialog.showOpenDialogSync(mainWindow, { diff --git a/src/main/PDFBatchOperations.js b/src/main/PDFBatchOperations.js new file mode 100644 index 0000000..8ae4194 --- /dev/null +++ b/src/main/PDFBatchOperations.js @@ -0,0 +1,177 @@ +/** + * PDFBatchOperations + * + * Applies one PDFOperations.executeOperation() operation (watermark, compress, + * rotate, split, ...) to every .pdf file in an input folder — the PDF sibling of + * runMediaBatchOperation() in main.js (Task 12's image/audio/video batch mode): + * collect matching files via collectFilesByExtension (which generalizes the + * inline collectFiles() of the 'universal-convert-batch' handler), loop the + * operation over them, and report per-file progress plus a final + * completed/failed summary. + * + * Pulled out as its own Electron-free module (same precedent as + * collectFilesByExtension) so the batch loop is unit-testable against real + * pdf-lib fixtures; main.js injects the IPC-facing callbacks: + * onProgress -> mainWindow.webContents.send('batch-progress', ...) + * onComplete -> mainWindow.webContents.send('pdf-batch-complete', ...) + dialog + * + * Not every executeOperation op fits the "apply the same operation to every + * file" batch model. Excluded (enforced by absence from PDF_BATCH_OUTPUT_SPEC, + * which doubles as the defensive backstop for renderer-supplied op names): + * - merge / reorder / fillForm — consume per-file knowledge the batch flow + * cannot supply (merge takes many inputs in one op; reorder needs each + * file's full page order; fillForm's field values differ per file). + * - formFields — a read-only query returning data, not a transform. + * - encrypt / decrypt / permissions — pdf-lib 1.17.1 (bundled) silently + * ignores encryption options in save() and cannot decrypt on load, so a + * batch run would either write unprotected files while reporting success + * or deterministically fail every file. + * + * @module PDFBatchOperations + */ + +const fs = require('fs'); +const path = require('path'); +const PDFOperations = require('./PDFOperations'); +const { collectFilesByExtension } = require('./collectFilesByExtension'); + +// How to derive each output file's path (or directory) from the source file, +// mirroring BATCH_OUTPUT_SPEC in main.js: +// { ext: 'original' } -> //.pdf via outputPath +// { ext: 'txt' } -> //.txt via outputPath +// { folder: true } -> split writes its `_part_N.pdf` files into +// the mirrored output folder via outputFolder +// { dir: true } -> extractImages writes images into a per-PDF +// /// via outputDir +const PDF_BATCH_OUTPUT_SPEC = { + split: { folder: true }, + compress: { ext: 'original' }, + rotate: { ext: 'original' }, + delete: { ext: 'original' }, + watermark: { ext: 'original' }, + extractText: { ext: 'txt' }, + pageNumbers: { ext: 'original' }, + crop: { ext: 'original' }, + extractImages: { dir: true }, +}; + +/** + * Runs a single PDFOperations operation over every .pdf under `inputFolder`. + * + * @param {object} args + * @param {string} args.operation - executeOperation op name (must be batchable). + * @param {string} args.inputFolder - Folder to scan for .pdf files. + * @param {string} args.outputFolder - Destination folder (created if missing); + * the input folder's relative structure is mirrored beneath it. + * @param {boolean} [args.includeSubfolders=true] - Recurse into subdirectories. + * @param {object} [args.data={}] - Shared operation options forwarded to + * executeOperation for every file (same option shapes as the single-file PDF + * editor dialog; inputPath/outputPath are added per file here). + * @param {number} [args.maxFileSize] - Skip (count as failed) files larger than + * this many bytes, mirroring the batch-convert handler's file-size guard. + * @param {Function} [args.onProgress] - Called with { completed, failed, total, + * currentFile } before each file and once (currentFile: null) at the end — + * the existing 'batch-progress' payload shape. + * @param {Function} args.onComplete - Called exactly once with the outcome: + * { success: false, error } for early failures, otherwise + * { success: true, completed, failed, total, outputFolder }. + * @param {Function} [args.sanitizeError] - Sanitizer for error messages that + * could leak absolute paths (main.js passes sanitizeErrorMessage). + * @returns {Promise} + */ +async function runPDFBatchOperation({ + operation, + inputFolder, + outputFolder, + includeSubfolders, + data = {}, + maxFileSize, + onProgress = () => {}, + onComplete, + sanitizeError = (message) => message, +}) { + const spec = PDF_BATCH_OUTPUT_SPEC[operation]; + if (!spec) { + onComplete({ + success: false, + error: `Batch mode is not supported for the "${operation}" operation.`, + }); + return; + } + + if (!inputFolder || !fs.existsSync(inputFolder)) { + onComplete({ success: false, error: 'Input folder does not exist.' }); + return; + } + + try { + fs.mkdirSync(outputFolder, { recursive: true }); + } catch (error) { + onComplete({ + success: false, + error: sanitizeError(`Failed to create output folder: ${error.message}`), + }); + return; + } + + const files = collectFilesByExtension(inputFolder, ['.pdf'], includeSubfolders !== false); + if (files.length === 0) { + onComplete({ success: false, error: 'No matching files found in the selected folder.' }); + return; + } + + const total = files.length; + let completed = 0; + let failed = 0; + + for (const filePath of files) { + onProgress({ + completed, + failed, + total, + currentFile: path.basename(filePath), + }); + + try { + if (maxFileSize && fs.statSync(filePath).size > maxFileSize) { + failed++; + continue; + } + + const baseName = path.basename(filePath, path.extname(filePath)); + const relativeDir = path.dirname(path.relative(inputFolder, filePath)); + const targetDir = relativeDir === '.' ? outputFolder : path.join(outputFolder, relativeDir); + fs.mkdirSync(targetDir, { recursive: true }); + + const fileData = { ...data, inputPath: filePath }; + if (spec.dir) { + fileData.outputDir = path.join(targetDir, baseName); + } else if (spec.folder) { + fileData.outputFolder = targetDir; + } else { + const ext = spec.ext === 'original' ? 'pdf' : spec.ext; + fileData.outputPath = path.join(targetDir, `${baseName}.${ext}`); + } + + // PDFOperations ops report failures via { success: false } rather than by + // throwing (each op catches internally), so the result flag — not just + // the promise — decides the per-file outcome. + const result = await PDFOperations.executeOperation(operation, fileData); + if (result && result.success) { + completed++; + } else { + failed++; + } + } catch { + // stat/mkdir can throw (file vanished mid-scan, output path became a + // file, ...): count the file as failed and keep the batch going, matching + // how executeOperation's own failures are handled. + failed++; + } + } + + onProgress({ completed, failed, total, currentFile: null }); + onComplete({ success: true, completed, failed, total, outputFolder }); +} + +module.exports = { runPDFBatchOperation, PDF_BATCH_OUTPUT_SPEC }; diff --git a/src/preload.js b/src/preload.js index 5060faa..9f510f0 100644 --- a/src/preload.js +++ b/src/preload.js @@ -87,6 +87,7 @@ const ALLOWED_SEND_CHANNELS = [ 'process-pdf-operation', 'get-pdf-page-count', 'select-pdf-folder', + 'batch-pdf-operation', // ASCII generator (separate window) 'open-ascii-generator', @@ -235,6 +236,7 @@ const ALLOWED_RECEIVE_CHANNELS = [ 'pdf-page-count', 'pdf-operation-complete', 'pdf-operation-error', + 'pdf-batch-complete', // ASCII Art Generator 'show-ascii-generator-window', diff --git a/src/renderer.js b/src/renderer.js index 6466bc8..e14b1ad 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -12,6 +12,7 @@ const hljs = require('highlight.js'); const { createEditor } = require('./editor/codemirror-setup'); const { undo, redo } = require('@codemirror/commands'); const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog'); +const { showPdfBatchDialog } = require('./renderer/pdf-batch-dialog'); const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog'); const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets'); const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table'); @@ -2772,6 +2773,16 @@ ipcRenderer.on('show-universal-converter-dialog', () => { // Batch converter menu items - open universal converter with batch mode and correct tool ipcRenderer.on('show-batch-converter', (event, type) => { + if (type === 'pdf') { + // Task 22: the PDF batch menu item now also offers bulk PDF operations + // (watermark/compress/rotate/...). Format conversion stays the default and + // is delegated to the pre-existing universal-converter batch flow. + showPdfBatchDialog({ onConvertFormat: () => openUniversalConverterBatch('pdf') }); + return; + } + openUniversalConverterBatch(type); +}); +function openUniversalConverterBatch(type) { showUniversalConverterDialog(); // Map batch type to the appropriate tool const toolMap = { @@ -2792,7 +2803,7 @@ ipcRenderer.on('show-batch-converter', (event, type) => { batchToggle.checked = true; batchToggle.dispatchEvent(new Event('change')); } -}); +} ipcRenderer.on('conversion-status', (event, status) => { document.getElementById('converter-status').textContent = status; }); diff --git a/src/renderer/pdf-batch-dialog.js b/src/renderer/pdf-batch-dialog.js new file mode 100644 index 0000000..4e11bcc --- /dev/null +++ b/src/renderer/pdf-batch-dialog.js @@ -0,0 +1,715 @@ +/** + * PDF Batch Dialog + * + * Batch wrapper for the single-file PDF operations (Tasks 15-16 backend via + * PDFOperations.executeOperation): pick one operation (watermark, compress, + * rotate, split, ...) plus that operation's shared option fields — the same + * fields and option shapes the single-file PDF editor dialog in + * renderer.js/index.html already sends — and apply it to every .pdf in an + * input folder. Mirrors the batch-folder construction pattern of the Image/ + * Audio/Video Tools dialog (src/renderer/media-operations-dialog.js, Task 12): + * a `.modal`-based dialog driven by ModalManager, folder pickers via the + * existing generic `select-folder` / `folder-selected` IPC channels filtered by + * a unique `type` per picker, and Process firing a fire-and-forget + * `batch-pdf-operation` whose progress/completion arrive via `batch-progress` + * / `pdf-batch-complete` events sent by the `batch-pdf-operation` handler in + * main.js (which delegates to src/main/PDFBatchOperations.js). + * + * The dialog is reached from the Tools > Batch PDF Conversion... menu item + * (`show-batch-converter` with type 'pdf'). Its top selector keeps that menu + * item's existing behavior as the default: "Convert Format" delegates to the + * pre-existing universal-converter batch flow via the `onConvertFormat` + * callback renderer.js passes in; "Bulk PDF Operation" reveals this dialog's + * per-file operation controls. + * + * Only per-file, non-interactive operations are offered (see + * PDF_BATCH_OUTPUT_SPEC in src/main/PDFBatchOperations.js for the exclusions — + * the renderer list and that spec intentionally agree). + * + * @module pdf-batch-dialog + */ + +const { ipcRenderer } = require('electron'); + +// Field names are chosen to exactly match the `data` shape each backend +// operation destructures — see src/main/PDFOperations.js — mirroring the +// single-file PDF editor dialog's collection code in renderer.js. Defaults and +// option lists mirror the corresponding index.html sections. +const BATCH_OPERATIONS = { + watermark: { + label: 'Add Watermark', + fields: [ + { name: 'text', label: 'Watermark Text', type: 'text' }, + { + name: 'pages', + label: 'Apply to', + type: 'select', + options: [ + { value: 'all', label: 'All Pages' }, + { value: 'custom', label: 'Custom Pages' }, + ], + default: 'all', + }, + { + name: 'customPages', + label: 'Custom Pages (e.g. 1-5, 7)', + type: 'text', + showIf: { field: 'pages', equals: 'custom' }, + }, + { + name: 'position', + label: 'Position', + type: 'select', + options: [ + 'center', + 'diagonal', + 'top-left', + 'top-center', + 'top-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ], + default: 'center', + }, + { name: 'fontSize', label: 'Font Size', type: 'number', min: 8, max: 144, default: 48 }, + // 0-100 in the UI; divided by 100 before sending, like the single-file dialog. + { name: 'opacity', label: 'Opacity (0-100)', type: 'number', min: 0, max: 100, default: 30 }, + { name: 'color', label: 'Color', type: 'color', default: '#000000' }, + ], + }, + split: { + label: 'Split', + fields: [ + { + name: 'splitMode', + label: 'Split Mode', + type: 'select', + options: [ + { value: 'pages', label: 'By Page Range' }, + { value: 'interval', label: 'Every N Pages' }, + { value: 'size', label: 'By File Size' }, + ], + default: 'pages', + }, + { + name: 'pageRanges', + label: 'Page Ranges (e.g. 1-5, 6-10)', + type: 'text', + showIf: { field: 'splitMode', equals: 'pages' }, + }, + { + name: 'interval', + label: 'Pages per Split File', + type: 'number', + min: 1, + default: 5, + showIf: { field: 'splitMode', equals: 'interval' }, + }, + ], + }, + compress: { label: 'Compress', fields: [] }, + rotate: { + label: 'Rotate', + fields: [ + { + name: 'angle', + label: 'Rotation Angle', + type: 'select', + options: [ + { value: '90', label: '90° Clockwise' }, + { value: '180', label: '180°' }, + { value: '270', label: '270° Clockwise (90° Counter-clockwise)' }, + ], + default: '90', + }, + { name: 'pages', label: 'Pages (e.g. 1-3, 5; empty = all)', type: 'text', optional: true }, + ], + }, + delete: { + label: 'Delete Pages', + fields: [{ name: 'pages', label: 'Pages to Delete (e.g. 1-3, 5)', type: 'text' }], + }, + extractText: { label: 'Extract Text', fields: [] }, + pageNumbers: { + label: 'Add Page Numbers', + fields: [ + { + name: 'position', + label: 'Position', + type: 'select', + options: [ + 'bottom-center', + 'bottom-left', + 'bottom-right', + 'top-center', + 'top-left', + 'top-right', + ], + default: 'bottom-center', + }, + { name: 'startNumber', label: 'Start Number', type: 'number', min: 1, default: 1 }, + ], + }, + crop: { + label: 'Crop Margins', + fields: [ + { name: 'margins.top', label: 'Top Margin', type: 'number', min: 0, default: 0, float: true }, + { + name: 'margins.bottom', + label: 'Bottom Margin', + type: 'number', + min: 0, + default: 0, + float: true, + }, + { + name: 'margins.left', + label: 'Left Margin', + type: 'number', + min: 0, + default: 0, + float: true, + }, + { + name: 'margins.right', + label: 'Right Margin', + type: 'number', + min: 0, + default: 0, + float: true, + }, + ], + }, + extractImages: { label: 'Extract Images', fields: [] }, +}; + +const INPUT_FOLDER_FIELD = { name: 'inputFolder', label: 'Input Folder' }; +const OUTPUT_FOLDER_FIELD = { name: 'outputFolder', label: 'Output Folder' }; +const SUBFOLDERS_FIELD = { name: 'includeSubfolders', label: 'Include subfolders' }; + +const INPUT_FOLDER_PICK_TYPE = 'pdf-batch-input-dir'; +const OUTPUT_FOLDER_PICK_TYPE = 'pdf-batch-output-dir'; + +let modalEl = null; +let modalManager = null; +let els = null; +let onConvertFormatCallback = null; +// True between sending a batch-pdf-operation and its pdf-batch-complete; guards +// against firing a second overlapping run (the Process button is also disabled, +// but the batch-type switch re-labels it, so the flag is the real guard). +let runInFlight = false; + +function fieldElId(name) { + return `pdf-batch-field-${name}`; +} + +function buildDialogDom() { + modalEl = document.createElement('div'); + modalEl.id = 'pdf-batch-dialog'; + modalEl.className = 'modal hidden'; + modalEl.setAttribute('role', 'dialog'); + modalEl.setAttribute('aria-modal', 'true'); + modalEl.setAttribute('aria-labelledby', 'pdf-batch-title'); + modalEl.innerHTML = ` + + + `; + document.body.appendChild(modalEl); + + els = { + typeSelect: modalEl.querySelector('#pdf-batch-type'), + convertHint: modalEl.querySelector('#pdf-batch-convert-hint'), + operationPanel: modalEl.querySelector('#pdf-batch-operation-panel'), + operationSelect: modalEl.querySelector('#pdf-batch-operation'), + fieldsContainer: modalEl.querySelector('#pdf-batch-operation-fields'), + status: modalEl.querySelector('#pdf-batch-status'), + progress: modalEl.querySelector('#pdf-batch-progress'), + progressFill: modalEl.querySelector('#pdf-batch-progress-fill'), + progressText: modalEl.querySelector('#pdf-batch-progress-text'), + processBtn: modalEl.querySelector('#pdf-batch-process'), + }; + + els.typeSelect.addEventListener('change', updateBatchTypeUI); + els.operationSelect.addEventListener('change', renderOperationFields); + els.processBtn.addEventListener('click', handleProcess); + modalEl.querySelector('#pdf-batch-cancel').addEventListener('click', hideDialog); + + modalManager = new window.ModalManager(modalEl); + + // Generic output-folder picker reply (shared with the batch converter's + // input/output folder pickers) — filter by our own `type` so we only react + // to requests this dialog made. + ipcRenderer.on('folder-selected', (event, { type, path: folderPath }) => { + if (!folderPath) return; + const fieldName = + type === INPUT_FOLDER_PICK_TYPE + ? INPUT_FOLDER_FIELD.name + : type === OUTPUT_FOLDER_PICK_TYPE + ? OUTPUT_FOLDER_FIELD.name + : null; + if (!fieldName) return; + const input = document.getElementById(fieldElId(fieldName)); + if (input) input.value = folderPath; + }); + + // Batch operation progress/completion (main.js: batch-pdf-operation handler, + // delegating to runPDFBatchOperation() in src/main/PDFBatchOperations.js). + ipcRenderer.on('batch-progress', (event, { completed, failed, total, currentFile }) => { + if (!els.progress || els.progress.classList.contains('hidden')) return; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + els.progressFill.style.width = `${pct}%`; + els.progressText.textContent = currentFile + ? `Processing ${completed + 1}/${total}: ${currentFile}${failed ? ` (${failed} failed so far)` : ''}` + : `Processed ${completed}/${total}${failed ? ` (${failed} failed)` : ''}`; + }); + ipcRenderer.on('pdf-batch-complete', (event, { success, completed, failed, total, error }) => { + runInFlight = false; + hideProgress(); + if (success) { + showStatus( + `Batch complete: ${completed}/${total} file(s) processed${failed ? ` (${failed} failed)` : ''}.`, + failed > 0 ? 'warning' : 'success' + ); + } else { + showStatus(`Error: ${error || 'Batch operation failed.'}`, 'warning'); + } + }); +} + +function ensureDialog() { + if (!modalEl) { + buildDialogDom(); + } +} + +function clearStatus() { + els.status.textContent = ''; + els.status.classList.remove('info-message', 'warning-message', 'success-message'); + els.status.classList.add('hidden'); +} + +function showStatus(message, type = 'info') { + els.status.textContent = message; + els.status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message'); + els.status.classList.add(`${type}-message`); +} + +function showProgress() { + els.progress.classList.remove('hidden'); + els.progressText.textContent = 'Scanning folder...'; + els.progressFill.style.width = '0%'; + els.processBtn.disabled = true; +} + +function hideProgress() { + els.progress.classList.add('hidden'); + els.progressFill.style.width = '0%'; + els.processBtn.disabled = false; +} + +function optionValue(option) { + return typeof option === 'string' ? option : option.value; +} + +function optionLabel(option) { + return typeof option === 'string' ? option : option.label; +} + +function createLabeledWrapper(field) { + const wrapper = document.createElement('div'); + wrapper.className = 'export-section'; + wrapper.id = `${fieldElId(field.name)}-wrapper`; + const label = document.createElement('label'); + label.setAttribute('for', fieldElId(field.name)); + label.textContent = `${field.label}:`; + wrapper.appendChild(label); + return wrapper; +} + +function renderTextField(field) { + const wrapper = createLabeledWrapper(field); + const input = document.createElement('input'); + input.type = 'text'; + input.id = fieldElId(field.name); + input.placeholder = field.placeholder || ''; + wrapper.appendChild(input); + return wrapper; +} + +function renderNumberField(field) { + const wrapper = createLabeledWrapper(field); + const input = document.createElement('input'); + input.type = 'number'; + input.id = fieldElId(field.name); + if (field.min !== undefined) input.min = field.min; + if (field.max !== undefined) input.max = field.max; + if (field.default !== undefined) input.value = field.default; + wrapper.appendChild(input); + return wrapper; +} + +function renderSelectField(field) { + const wrapper = createLabeledWrapper(field); + const select = document.createElement('select'); + select.id = fieldElId(field.name); + field.options.forEach((opt) => { + const option = document.createElement('option'); + option.value = optionValue(opt); + option.textContent = optionLabel(opt); + if (optionValue(opt) === field.default) option.selected = true; + select.appendChild(option); + }); + // Attached to the select itself (not via a bubbling container listener) so + // conditional fields update regardless of how the change event was fired. + select.addEventListener('change', updateConditionalFields); + wrapper.appendChild(select); + return wrapper; +} + +function renderColorField(field) { + const wrapper = createLabeledWrapper(field); + const input = document.createElement('input'); + input.type = 'color'; + input.id = fieldElId(field.name); + input.value = field.default || '#000000'; + wrapper.appendChild(input); + return wrapper; +} + +function createFolderPickerField(field, pickType) { + const wrapper = createLabeledWrapper(field); + const group = document.createElement('div'); + group.className = 'folder-input-group'; + + const input = document.createElement('input'); + input.type = 'text'; + input.id = fieldElId(field.name); + input.placeholder = 'Choose...'; + input.readOnly = true; + group.appendChild(input); + + const browseBtn = document.createElement('button'); + browseBtn.type = 'button'; + browseBtn.textContent = 'Browse Folder'; + browseBtn.addEventListener('click', () => { + ipcRenderer.send('select-folder', pickType); + }); + group.appendChild(browseBtn); + + wrapper.appendChild(group); + return wrapper; +} + +function renderCheckboxField(field) { + const wrapper = document.createElement('div'); + wrapper.className = 'export-section'; + wrapper.id = `${fieldElId(field.name)}-wrapper`; + + const label = document.createElement('label'); + label.setAttribute('for', fieldElId(field.name)); + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = fieldElId(field.name); + input.checked = true; + input.style.marginRight = '0.5em'; + + label.appendChild(input); + label.appendChild(document.createTextNode(field.label)); + wrapper.appendChild(label); + return wrapper; +} + +function fieldIsVisible(field, values) { + if (!field.showIf) return true; + return values[field.showIf.field] === field.showIf.equals; +} + +// Toggles the hidden class of conditionally-shown fields (watermark custom +// pages, split ranges/interval) based on the current select values. +function updateConditionalFields() { + const opConfig = BATCH_OPERATIONS[els.operationSelect.value]; + if (!opConfig) return; + const values = {}; + opConfig.fields.forEach((field) => { + const input = document.getElementById(fieldElId(field.name)); + if (input) values[field.name] = input.value; + }); + opConfig.fields.forEach((field) => { + if (!field.showIf) return; + const wrapper = document.getElementById(`${fieldElId(field.name)}-wrapper`); + if (wrapper) wrapper.classList.toggle('hidden', !fieldIsVisible(field, values)); + }); +} + +function renderOperationFields() { + const opConfig = BATCH_OPERATIONS[els.operationSelect.value]; + els.fieldsContainer.innerHTML = ''; + + els.fieldsContainer.appendChild( + createFolderPickerField(INPUT_FOLDER_FIELD, INPUT_FOLDER_PICK_TYPE) + ); + els.fieldsContainer.appendChild(renderCheckboxField(SUBFOLDERS_FIELD)); + + opConfig.fields.forEach((field) => { + let fieldEl; + switch (field.type) { + case 'text': + fieldEl = renderTextField(field); + break; + case 'number': + fieldEl = renderNumberField(field); + break; + case 'select': + fieldEl = renderSelectField(field); + break; + case 'color': + fieldEl = renderColorField(field); + break; + default: + return; + } + els.fieldsContainer.appendChild(fieldEl); + }); + + els.fieldsContainer.appendChild( + createFolderPickerField(OUTPUT_FOLDER_FIELD, OUTPUT_FOLDER_PICK_TYPE) + ); + updateConditionalFields(); +} + +function updateBatchTypeUI() { + const isConvert = els.typeSelect.value === 'convert'; + els.convertHint.classList.toggle('hidden', !isConvert); + els.operationPanel.classList.toggle('hidden', isConvert); + els.processBtn.textContent = isConvert ? 'Open Batch Converter...' : 'Process'; + // Progress state is left alone here: clearing it mid-run would re-enable the + // Process button while a batch is still in flight (runInFlight guards that). + clearStatus(); +} + +// Reads the shared option fields into the exact `data` shape the backend +// operation destructures, dropping conditionally-hidden fields and applying +// per-op conversions (opacity /100, margins grouping) exactly like the +// single-file PDF editor dialog does. +function collectOperationData(opKey) { + const opConfig = BATCH_OPERATIONS[opKey]; + const raw = {}; + for (const field of opConfig.fields) { + const input = document.getElementById(fieldElId(field.name)); + if (!input) continue; + if (field.type === 'number') { + const num = field.float ? parseFloat(input.value) : parseInt(input.value, 10); + raw[field.name] = Number.isFinite(num) ? num : null; + } else { + raw[field.name] = input.value.trim(); + } + } + + const visible = (name) => + fieldIsVisible( + opConfig.fields.find((f) => f.name === name), + raw + ); + + let data; + switch (opKey) { + case 'watermark': + data = { + text: raw.text, + fontSize: raw.fontSize, + opacity: raw.opacity !== null ? raw.opacity / 100 : null, + position: raw.position, + color: raw.color, + pages: raw.pages, + }; + if (raw.pages === 'custom' && visible('customPages')) { + data.customPages = raw.customPages; + } + break; + case 'split': + data = { splitMode: raw.splitMode }; + if (raw.splitMode === 'pages' && visible('pageRanges')) data.pageRanges = raw.pageRanges; + if (raw.splitMode === 'interval' && visible('interval')) data.interval = raw.interval; + break; + case 'rotate': + data = { angle: parseInt(raw.angle, 10), pages: raw.pages }; + break; + case 'delete': + data = { pages: raw.pages }; + break; + case 'pageNumbers': + data = { position: raw.position, startNumber: raw.startNumber }; + break; + case 'crop': + data = { + margins: { + top: raw['margins.top'], + bottom: raw['margins.bottom'], + left: raw['margins.left'], + right: raw['margins.right'], + }, + }; + break; + default: + data = {}; + } + return { data }; +} + +// Per-operation required-field checks, mirroring the single-file dialog's +// validation messages. +function validateOperationData(opKey, data) { + switch (opKey) { + case 'watermark': + if (!data.text) return 'Enter watermark text.'; + if (!data.fontSize) return 'Enter a font size.'; + if (data.pages === 'custom' && !data.customPages) { + return 'Enter the custom pages to watermark.'; + } + return null; + case 'split': + if (data.splitMode === 'pages' && !data.pageRanges) { + return 'Enter page ranges (e.g. 1-5, 6-10).'; + } + if (data.splitMode === 'interval' && !data.interval) { + return 'Enter the number of pages per split file.'; + } + return null; + case 'delete': + if (!data.pages) return 'Enter the pages to delete (e.g. 1-3, 5).'; + return null; + default: + return null; + } +} + +function handleConvertProcess() { + hideDialog(); + if (typeof onConvertFormatCallback === 'function') { + onConvertFormatCallback(); + } +} + +function handleBulkProcess() { + if (runInFlight) return; + + const inputFolder = document.getElementById(fieldElId(INPUT_FOLDER_FIELD.name))?.value; + const outputFolder = document.getElementById(fieldElId(OUTPUT_FOLDER_FIELD.name))?.value; + const includeSubfolders = + document.getElementById(fieldElId(SUBFOLDERS_FIELD.name))?.checked !== false; + + if (!inputFolder) { + showStatus('Select an input folder.', 'warning'); + return; + } + if (!outputFolder) { + showStatus('Select an output folder.', 'warning'); + return; + } + + const opKey = els.operationSelect.value; + const { data } = collectOperationData(opKey); + const error = validateOperationData(opKey, data); + if (error) { + showStatus(error, 'warning'); + return; + } + + clearStatus(); + showProgress(); + runInFlight = true; + + ipcRenderer.send('batch-pdf-operation', { + operation: opKey, + inputFolder, + outputFolder, + includeSubfolders, + data, + }); +} + +function handleProcess() { + if (els.typeSelect.value === 'convert') { + handleConvertProcess(); + } else { + handleBulkProcess(); + } +} + +function hideDialog() { + if (modalManager) modalManager.close(); + clearStatus(); + hideProgress(); +} + +/** + * Opens the Batch PDF Tools dialog. + * + * @param {object} [options] + * @param {Function} [options.onConvertFormat] - Invoked when the user keeps the + * default "Convert Format" batch type; renderer.js passes the pre-existing + * universal-converter batch flow that the Tools > Batch PDF Conversion... + * menu item used to open directly. + */ +function showPdfBatchDialog(options = {}) { + ensureDialog(); + onConvertFormatCallback = options.onConvertFormat || null; + + els.operationSelect.innerHTML = ''; + Object.entries(BATCH_OPERATIONS).forEach(([key, op]) => { + const option = document.createElement('option'); + option.value = key; + option.textContent = op.label; + els.operationSelect.appendChild(option); + }); + + els.typeSelect.value = 'convert'; + runInFlight = false; + clearStatus(); + hideProgress(); + updateBatchTypeUI(); + renderOperationFields(); + modalManager.open(); +} + +module.exports = { showPdfBatchDialog, BATCH_OPERATIONS }; diff --git a/tests/main/PDFBatchOperations.test.js b/tests/main/PDFBatchOperations.test.js new file mode 100644 index 0000000..a97f5f4 --- /dev/null +++ b/tests/main/PDFBatchOperations.test.js @@ -0,0 +1,338 @@ +/** + * @jest-environment node + * + * PDFBatchOperations.js tests for Task 22's batch PDF operations: the folder + * loop that applies one PDFOperations.executeOperation() op to every .pdf in an + * input folder (optionally recursive) and mirrors the folder structure into the + * output folder. Mirrors the real-PDF fixture conventions of + * tests/main/PDFOperations.test.js (pdf-lib-built fixtures in a tmp dir). + * + * The watermark test doubles as the automated stand-in for the brief's manual + * verification step ("batch-watermark a folder of 2-3 test PDFs, confirm each + * output file has the watermark applied") — GUI batch runs are not possible in + * this sandbox, so the assertion extracts the text back out of each output and + * checks the watermark string is present. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const sharp = require('sharp'); +const { PDFDocument, StandardFonts, rgb } = require('pdf-lib'); +const PDFOperations = require('../../src/main/PDFOperations'); +const { + runPDFBatchOperation, + PDF_BATCH_OUTPUT_SPEC, +} = require('../../src/main/PDFBatchOperations'); + +// Builds a small text PDF fixture with `pageCount` pages at the given path. +async function writePdfFixture(filePath, pageCount = 2, label = 'Batch Fixture') { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + for (let i = 1; i <= pageCount; i++) { + const page = doc.addPage([600, 800]); + page.drawText(`${label} Page ${i}`, { x: 50, y: 700, size: 20, font, color: rgb(0, 0, 0) }); + } + fs.writeFileSync(filePath, await doc.save()); +} + +describe('PDFBatchOperations - runPDFBatchOperation', () => { + let tmpDir, inputDir, outputDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfbatch_')); + inputDir = path.join(tmpDir, 'in'); + outputDir = path.join(tmpDir, 'out'); + fs.mkdirSync(inputDir); + fs.mkdirSync(path.join(inputDir, 'sub'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // Runs a batch and returns { progress, completion } recorded from the + // injected callbacks (same wiring main.js performs for the IPC handler). + async function runBatch(args) { + const progress = []; + let completion = null; + await runPDFBatchOperation({ + inputFolder: inputDir, + outputFolder: outputDir, + includeSubfolders: true, + onProgress: (p) => progress.push(p), + onComplete: (c) => { + completion = c; + }, + ...args, + }); + return { progress, completion }; + } + + describe('watermark across a folder (brief manual-verification stand-in)', () => { + beforeEach(async () => { + await writePdfFixture(path.join(inputDir, 'a.pdf'), 2, 'Alpha'); + await writePdfFixture(path.join(inputDir, 'b.pdf'), 3, 'Beta'); + await writePdfFixture(path.join(inputDir, 'sub', 'c.pdf'), 2, 'Gamma'); + fs.writeFileSync(path.join(inputDir, 'notes.txt'), 'not a pdf'); + }); + + it('watermarks every PDF including subfolders, mirroring the folder structure', async () => { + // 'DRAFT' extracts back out cleanly via pdfjs; wider centered strings + // (e.g. 'CONFIDENTIAL') hit a pdfjs-dist text-extraction quirk that + // truncates the returned item even though the full text is drawn. + const { completion } = await runBatch({ + operation: 'watermark', + data: { + text: 'DRAFT', + fontSize: 48, + opacity: 0.5, + position: 'center', + color: '#000000', + pages: 'all', + }, + }); + + expect(completion).toEqual({ + success: true, + completed: 3, + failed: 0, + total: 3, + outputFolder: outputDir, + }); + + const outputs = [ + path.join(outputDir, 'a.pdf'), + path.join(outputDir, 'b.pdf'), + path.join(outputDir, 'sub', 'c.pdf'), + ]; + for (const outPath of outputs) { + expect(fs.existsSync(outPath)).toBe(true); + const saved = await PDFDocument.load(fs.readFileSync(outPath)); + expect(saved.getPageCount()).toBeGreaterThan(0); + const extracted = await PDFOperations.pdfExtractText({ inputPath: outPath }); + expect(extracted.success).toBe(true); + expect(extracted.text).toContain('DRAFT'); + } + }); + + it('ignores non-PDF files', async () => { + const { completion } = await runBatch({ + operation: 'compress', + data: {}, + }); + expect(completion.total).toBe(3); // notes.txt excluded + }); + + it('skips subfolder files when includeSubfolders is false', async () => { + const { completion } = await runBatch({ + operation: 'compress', + includeSubfolders: false, + data: {}, + }); + expect(completion.total).toBe(2); // sub/c.pdf excluded + }); + }); + + describe('per-op output mapping (PDF_BATCH_OUTPUT_SPEC)', () => { + it('exposes exactly the batchable per-file operations', () => { + expect(Object.keys(PDF_BATCH_OUTPUT_SPEC).sort()).toEqual( + [ + 'split', + 'compress', + 'rotate', + 'delete', + 'watermark', + 'extractText', + 'pageNumbers', + 'crop', + 'extractImages', + ].sort() + ); + }); + + it('split writes part files into the mirrored output folder', async () => { + await writePdfFixture(path.join(inputDir, 'a.pdf'), 4, 'Split Me'); + await writePdfFixture(path.join(inputDir, 'sub', 'b.pdf'), 2, 'Split Sub'); + + const { completion } = await runBatch({ + operation: 'split', + data: { splitMode: 'interval', interval: 2 }, + }); + + expect(completion).toMatchObject({ success: true, completed: 2, failed: 0 }); + + const part1 = await PDFDocument.load(fs.readFileSync(path.join(outputDir, 'a_part_1.pdf'))); + expect(part1.getPageCount()).toBe(2); + const subPart = await PDFDocument.load( + fs.readFileSync(path.join(outputDir, 'sub', 'b_part_1.pdf')) + ); + expect(subPart.getPageCount()).toBe(2); + }); + + it('extractText writes one .txt per PDF', async () => { + await writePdfFixture(path.join(inputDir, 'a.pdf'), 2, 'Text Alpha'); + + const { completion } = await runBatch({ operation: 'extractText', data: {} }); + + expect(completion).toMatchObject({ success: true, completed: 1 }); + const txt = fs.readFileSync(path.join(outputDir, 'a.txt'), 'utf8'); + expect(txt).toContain('Text Alpha Page 1'); + expect(txt).toContain('Text Alpha Page 2'); + }); + + it('extractImages writes images into a per-PDF output directory', async () => { + const imgPath = path.join(tmpDir, 'red.png'); + await sharp({ + create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toFile(imgPath); + + const doc = await PDFDocument.create(); + const page = doc.addPage([300, 300]); + const png = await doc.embedPng(fs.readFileSync(imgPath)); + page.drawImage(png, { x: 50, y: 50, width: 100, height: 100 }); + fs.writeFileSync(path.join(inputDir, 'img.pdf'), await doc.save()); + + const { completion } = await runBatch({ operation: 'extractImages', data: {} }); + + expect(completion).toMatchObject({ success: true, completed: 1 }); + const expectedDir = path.join(outputDir, 'img'); + const files = fs.readdirSync(expectedDir); + expect(files.length).toBeGreaterThanOrEqual(1); + expect(files[0]).toMatch(/^img_page1_img1\.png$/); + }); + + it.each([ + ['compress', {}], + ['rotate', { angle: 90 }], + ['delete', { pages: '1' }], + ['pageNumbers', { position: 'bottom-center', startNumber: 1 }], + ['crop', { margins: { top: 10, bottom: 10, left: 10, right: 10 } }], + ])('applies %s to every file via executeOperation', async (operation, data) => { + await writePdfFixture(path.join(inputDir, 'a.pdf'), 2, 'Op Check'); + await writePdfFixture(path.join(inputDir, 'sub', 'b.pdf'), 2, 'Op Check Sub'); + + const { completion } = await runBatch({ operation, data }); + + expect(completion).toMatchObject({ success: true, completed: 2, failed: 0 }); + expect(fs.existsSync(path.join(outputDir, 'a.pdf'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'sub', 'b.pdf'))).toBe(true); + }); + }); + + describe('failure handling', () => { + it('counts a corrupt PDF as failed and continues with the rest', async () => { + await writePdfFixture(path.join(inputDir, 'good.pdf'), 1, 'Good'); + fs.writeFileSync(path.join(inputDir, 'corrupt.pdf'), 'this is not a pdf at all'); + + const { completion } = await runBatch({ operation: 'compress', data: {} }); + + expect(completion).toMatchObject({ success: true, completed: 1, failed: 1, total: 2 }); + expect(fs.existsSync(path.join(outputDir, 'good.pdf'))).toBe(true); + }); + + it('counts files over maxFileSize as failed without processing them', async () => { + await writePdfFixture(path.join(inputDir, 'big.pdf'), 1, 'Big'); + + const { completion } = await runBatch({ + operation: 'compress', + data: {}, + maxFileSize: 10, // fixture is larger than 10 bytes + }); + + expect(completion).toMatchObject({ success: true, completed: 0, failed: 1, total: 1 }); + expect(fs.existsSync(path.join(outputDir, 'big.pdf'))).toBe(false); + }); + + it('rejects an operation that is not batchable', async () => { + const { completion } = await runBatch({ operation: 'merge', data: {} }); + expect(completion.success).toBe(false); + expect(completion.error).toMatch(/not supported/i); + }); + + it('rejects a missing input folder', async () => { + const { completion } = await runBatch({ + operation: 'compress', + inputFolder: path.join(tmpDir, 'does-not-exist'), + data: {}, + }); + expect(completion).toEqual({ success: false, error: 'Input folder does not exist.' }); + }); + + it('rejects when no PDFs are found', async () => { + const { completion } = await runBatch({ operation: 'compress', data: {} }); + expect(completion).toEqual({ + success: false, + error: 'No matching files found in the selected folder.', + }); + }); + + it('creates the output folder when it does not exist', async () => { + await writePdfFixture(path.join(inputDir, 'a.pdf'), 1, 'Mkdir'); + const nestedOutput = path.join(tmpDir, 'deeply', 'nested', 'out'); + + const { completion } = await runBatch({ + operation: 'compress', + outputFolder: nestedOutput, + data: {}, + }); + + expect(completion).toMatchObject({ success: true, completed: 1 }); + expect(fs.existsSync(path.join(nestedOutput, 'a.pdf'))).toBe(true); + }); + + it('sanitizes output-folder creation errors through the injected sanitizer', async () => { + // A regular file in the middle of the output path makes recursive mkdir + // fail with ENOTDIR. + const blocker = path.join(tmpDir, 'blocker'); + fs.writeFileSync(blocker, 'not a directory'); + await writePdfFixture(path.join(inputDir, 'a.pdf'), 1, 'Mkdir Fail'); + + const { completion } = await runBatch({ + operation: 'compress', + outputFolder: path.join(blocker, 'child'), + data: {}, + sanitizeError: (message) => message.replace(new RegExp(path.sep, 'g'), '_SANITIZED_'), + }); + + expect(completion.success).toBe(false); + expect(completion.error).toContain('Failed to create output folder'); + expect(completion.error).toContain('_SANITIZED_'); + }); + }); + + describe('progress reporting', () => { + it('reports one event per file plus a final event, following the batch-progress shape', async () => { + await writePdfFixture(path.join(inputDir, 'a.pdf'), 1, 'A'); + await writePdfFixture(path.join(inputDir, 'b.pdf'), 1, 'B'); + + const { progress } = await runBatch({ operation: 'compress', data: {} }); + + expect(progress).toHaveLength(3); + expect(progress[0]).toEqual({ + completed: 0, + failed: 0, + total: 2, + currentFile: expect.stringMatching(/^[ab]\.pdf$/), + }); + expect(progress[1]).toMatchObject({ completed: 1, failed: 0, total: 2 }); + expect(progress[2]).toEqual({ completed: 2, failed: 0, total: 2, currentFile: null }); + }); + + it('carries the running failed count in progress events', async () => { + await writePdfFixture(path.join(inputDir, 'good.pdf'), 1, 'Good'); + fs.writeFileSync(path.join(inputDir, 'corrupt.pdf'), 'not a pdf'); + + const { progress } = await runBatch({ operation: 'compress', data: {} }); + + // Final event reflects the failure; earlier events carry the running count. + expect(progress[progress.length - 1]).toEqual({ + completed: 1, + failed: 1, + total: 2, + currentFile: null, + }); + }); + }); +}); diff --git a/tests/pdf-batch-dialog.test.js b/tests/pdf-batch-dialog.test.js new file mode 100644 index 0000000..312841a --- /dev/null +++ b/tests/pdf-batch-dialog.test.js @@ -0,0 +1,491 @@ +/** + * Tests for the PDF batch operations dialog (Task 22). Exercises the real + * dialog DOM in jsdom with the electron IPC surface mocked, following the + * jest.mock('electron') pattern in document-compare-dialog.test.js. + * + * These jsdom tests plus tests/main/PDFBatchOperations.test.js substitute for + * the brief's manual GUI verification step (batch-watermarking a folder of + * PDFs), which is not possible in this sandbox. + */ +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + send: jest.fn(), + on: jest.fn(), + once: jest.fn(), + removeAllListeners: jest.fn(), + }, +})); + +require('../src/utils/ModalManager'); // sets window.ModalManager for the dialog +const { ipcRenderer } = require('electron'); +const { showPdfBatchDialog } = require('../src/renderer/pdf-batch-dialog'); + +// Captures the listeners the dialog registers (folder-selected, batch-progress, +// pdf-batch-complete) so tests can fire them like the main process would. +const listeners = {}; +ipcRenderer.on.mockImplementation((channel, callback) => { + listeners[channel] = callback; + return () => delete listeners[channel]; +}); + +const EXPECTED_OPERATIONS = [ + 'watermark', + 'split', + 'compress', + 'rotate', + 'delete', + 'extractText', + 'pageNumbers', + 'crop', + 'extractImages', +]; + +function openDialog(onConvertFormat = jest.fn()) { + showPdfBatchDialog({ onConvertFormat }); + return onConvertFormat; +} + +function selectBatchType(type) { + const select = document.getElementById('pdf-batch-type'); + select.value = type; + select.dispatchEvent(new Event('change')); +} + +function selectOperation(op) { + const select = document.getElementById('pdf-batch-operation'); + select.value = op; + select.dispatchEvent(new Event('change')); +} + +function setField(name, value) { + document.getElementById(`pdf-batch-field-${name}`).value = value; +} + +// Conditional fields are hidden by toggling their wrapper section. +function fieldWrapperHidden(name) { + return document.getElementById(`pdf-batch-field-${name}-wrapper`).classList.contains('hidden'); +} + +function setFolders(input = '/batch/in', output = '/batch/out') { + setField('inputFolder', input); + setField('outputFolder', output); +} + +function clickProcess() { + document.getElementById('pdf-batch-process').click(); +} + +function statusText() { + return document.getElementById('pdf-batch-status').textContent; +} + +function lastSentPayload() { + const calls = ipcRenderer.send.mock.calls.filter((c) => c[0] === 'batch-pdf-operation'); + return calls.length ? calls[calls.length - 1][1] : null; +} + +describe('PDF batch operations dialog', () => { + beforeEach(() => { + ipcRenderer.send.mockReset(); + ipcRenderer.invoke.mockReset(); + }); + + describe('batch type selector', () => { + it('defaults to Convert Format and hides the bulk-operation controls', () => { + const onConvertFormat = openDialog(); + + expect(document.getElementById('pdf-batch-type').value).toBe('convert'); + expect( + document.getElementById('pdf-batch-operation-panel').classList.contains('hidden') + ).toBe(true); + expect(document.getElementById('pdf-batch-process').textContent).toContain('Batch Converter'); + expect(document.getElementById('pdf-batch-operation').children.length).toBe( + EXPECTED_OPERATIONS.length + ); + expect(onConvertFormat).not.toHaveBeenCalled(); + }); + + it('delegates Convert Format to the existing batch converter without sending an operation', () => { + const onConvertFormat = openDialog(); + + clickProcess(); + + expect(onConvertFormat).toHaveBeenCalledTimes(1); + expect(ipcRenderer.send).not.toHaveBeenCalledWith('batch-pdf-operation', expect.anything()); + }); + + it('shows the bulk-operation controls when Bulk PDF Operation is selected', () => { + openDialog(); + selectBatchType('operation'); + + const opSelect = document.getElementById('pdf-batch-operation'); + expect( + document.getElementById('pdf-batch-operation-panel').classList.contains('hidden') + ).toBe(false); + expect(Array.from(opSelect.options).map((o) => o.value)).toEqual(EXPECTED_OPERATIONS); + expect(document.getElementById('pdf-batch-process').textContent).toBe('Process'); + }); + }); + + describe('bulk operation validation', () => { + it('warns when the input folder is missing and sends nothing', () => { + openDialog(); + selectBatchType('operation'); + setField('outputFolder', '/batch/out'); + + clickProcess(); + + expect(statusText()).toBe('Select an input folder.'); + expect(lastSentPayload()).toBeNull(); + }); + + it('warns when the output folder is missing and sends nothing', () => { + openDialog(); + selectBatchType('operation'); + setField('inputFolder', '/batch/in'); + + clickProcess(); + + expect(statusText()).toBe('Select an output folder.'); + expect(lastSentPayload()).toBeNull(); + }); + }); + + describe('watermark operation', () => { + it('sends the batch-pdf-operation payload with the single-file dialog option shapes', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('watermark'); + setFolders(); + setField('text', 'DRAFT'); + + clickProcess(); + + expect(lastSentPayload()).toEqual({ + operation: 'watermark', + inputFolder: '/batch/in', + outputFolder: '/batch/out', + includeSubfolders: true, + data: { + text: 'DRAFT', + fontSize: 48, + opacity: 0.3, + position: 'center', + color: '#000000', + pages: 'all', + }, + }); + }); + + it('warns when the watermark text is empty', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('watermark'); + setFolders(); + + clickProcess(); + + expect(statusText()).toBe('Enter watermark text.'); + expect(lastSentPayload()).toBeNull(); + }); + + it('warns when the font size is cleared', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('watermark'); + setFolders(); + setField('text', 'DRAFT'); + setField('fontSize', ''); + + clickProcess(); + + expect(statusText()).toBe('Enter a font size.'); + expect(lastSentPayload()).toBeNull(); + }); + + it('shows the custom-pages field only for custom pages and sends customPages', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('watermark'); + setFolders(); + setField('text', 'DRAFT'); + + expect(fieldWrapperHidden('customPages')).toBe(true); + + setField('pages', 'custom'); + document.getElementById('pdf-batch-field-pages').dispatchEvent(new Event('change')); + expect(fieldWrapperHidden('customPages')).toBe(false); + + setField('customPages', '1-2'); + clickProcess(); + + expect(lastSentPayload().data).toMatchObject({ pages: 'custom', customPages: '1-2' }); + }); + + it('requires custom pages when Pages is set to custom', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('watermark'); + setFolders(); + setField('text', 'DRAFT'); + setField('pages', 'custom'); + document.getElementById('pdf-batch-field-pages').dispatchEvent(new Event('change')); + + clickProcess(); + + expect(statusText()).toBe('Enter the custom pages to watermark.'); + expect(lastSentPayload()).toBeNull(); + }); + }); + + describe('split operation', () => { + it('shows page-range or interval fields per split mode and validates them', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('split'); + setFolders(); + + // Default mode "pages": ranges required, interval hidden. + expect(fieldWrapperHidden('interval')).toBe(true); + clickProcess(); + expect(statusText()).toBe('Enter page ranges (e.g. 1-5, 6-10).'); + expect(lastSentPayload()).toBeNull(); + + // Interval mode: interval required, ranges hidden. + setField('splitMode', 'interval'); + document.getElementById('pdf-batch-field-splitMode').dispatchEvent(new Event('change')); + expect(fieldWrapperHidden('pageRanges')).toBe(true); + setField('interval', ''); + clickProcess(); + expect(statusText()).toBe('Enter the number of pages per split file.'); + expect(lastSentPayload()).toBeNull(); + + // Valid interval payload. + setField('interval', '2'); + clickProcess(); + expect(lastSentPayload().data).toEqual({ splitMode: 'interval', interval: 2 }); + }); + + it('sends pageRanges in pages mode', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('split'); + setFolders(); + setField('pageRanges', '1-2, 3'); + + clickProcess(); + + expect(lastSentPayload().data).toEqual({ splitMode: 'pages', pageRanges: '1-2, 3' }); + }); + + it('sends only the mode for size splits', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('split'); + setFolders(); + setField('splitMode', 'size'); + document.getElementById('pdf-batch-field-splitMode').dispatchEvent(new Event('change')); + + clickProcess(); + + expect(lastSentPayload().data).toEqual({ splitMode: 'size' }); + }); + }); + + describe('other operations', () => { + it('sends delete pages', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('delete'); + setFolders(); + setField('pages', '2'); + + clickProcess(); + + expect(lastSentPayload().data).toEqual({ pages: '2' }); + }); + + it('warns when delete pages is empty', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('delete'); + setFolders(); + + clickProcess(); + + expect(statusText()).toBe('Enter the pages to delete (e.g. 1-3, 5).'); + expect(lastSentPayload()).toBeNull(); + }); + + it('sends rotate with angle and optional pages', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('rotate'); + setFolders(); + setField('pages', '1'); + + clickProcess(); + + expect(lastSentPayload().data).toEqual({ angle: 90, pages: '1' }); + }); + + it('sends page numbers options', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('pageNumbers'); + setFolders(); + + clickProcess(); + + expect(lastSentPayload().data).toEqual({ position: 'bottom-center', startNumber: 1 }); + }); + + it('sends crop margins', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('crop'); + setFolders(); + setField('margins.top', '10'); + setField('margins.left', '5'); + + clickProcess(); + + expect(lastSentPayload().data).toEqual({ + margins: { top: 10, bottom: 0, left: 5, right: 0 }, + }); + }); + + it('sends an empty data object for parameterless operations', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + setFolders(); + + clickProcess(); + + expect(lastSentPayload()).toEqual({ + operation: 'compress', + inputFolder: '/batch/in', + outputFolder: '/batch/out', + includeSubfolders: true, + data: {}, + }); + }); + }); + + describe('progress and completion events', () => { + it('sends only one operation while a run is in flight', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + setFolders(); + + clickProcess(); + clickProcess(); // second click while the first run is still active + + const calls = ipcRenderer.send.mock.calls.filter((c) => c[0] === 'batch-pdf-operation'); + expect(calls).toHaveLength(1); + + // After completion, a new run can be started. + listeners['pdf-batch-complete']( + {}, + { success: true, completed: 1, failed: 0, total: 1, outputFolder: '/batch/out' } + ); + clickProcess(); + expect( + ipcRenderer.send.mock.calls.filter((c) => c[0] === 'batch-pdf-operation') + ).toHaveLength(2); + }); + + it('updates the progress bar from batch-progress events', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + setFolders(); + clickProcess(); + + listeners['batch-progress']({}, { completed: 1, failed: 0, total: 3, currentFile: 'a.pdf' }); + + const fill = document.getElementById('pdf-batch-progress-fill'); + expect(fill.style.width).toBe('33%'); + expect(document.getElementById('pdf-batch-progress-text').textContent).toContain('a.pdf'); + expect(document.getElementById('pdf-batch-process').disabled).toBe(true); + }); + + it('re-enables Process and reports success on batch completion', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + setFolders(); + clickProcess(); + + listeners['pdf-batch-complete']( + {}, + { success: true, completed: 3, failed: 0, total: 3, outputFolder: '/batch/out' } + ); + + expect(statusText()).toContain('Batch complete: 3/3 file(s) processed'); + expect(document.getElementById('pdf-batch-process').disabled).toBe(false); + expect(document.getElementById('pdf-batch-progress').classList.contains('hidden')).toBe(true); + }); + + it('reports failures in the completion status', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + setFolders(); + clickProcess(); + + listeners['pdf-batch-complete']( + {}, + { success: true, completed: 2, failed: 1, total: 3, outputFolder: '/batch/out' } + ); + + expect(statusText()).toContain('2/3 file(s) processed'); + expect(statusText()).toContain('1 failed'); + }); + + it('surfaces early errors (e.g. no matching files) as a warning', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + setFolders(); + clickProcess(); + + listeners['pdf-batch-complete']( + {}, + { success: false, error: 'No matching files found in the selected folder.' } + ); + + expect(statusText()).toBe('Error: No matching files found in the selected folder.'); + expect(document.getElementById('pdf-batch-process').disabled).toBe(false); + }); + + it('ignores progress events while the dialog has no run in flight', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('compress'); + + listeners['batch-progress']({}, { completed: 1, failed: 0, total: 3, currentFile: 'a.pdf' }); + + // Still the reset value — the event was ignored because no run is active. + expect(document.getElementById('pdf-batch-progress-fill').style.width).toBe('0%'); + }); + }); + + describe('folder picker replies', () => { + it('routes folder-selected events for its own pick types only', () => { + openDialog(); + selectBatchType('operation'); + selectOperation('watermark'); + + listeners['folder-selected']({}, { type: 'pdf-batch-input-dir', path: '/picked/in' }); + listeners['folder-selected']({}, { type: 'pdf-batch-output-dir', path: '/picked/out' }); + listeners['folder-selected']({}, { type: 'unrelated-type', path: '/elsewhere' }); + + expect(document.getElementById('pdf-batch-field-inputFolder').value).toBe('/picked/in'); + expect(document.getElementById('pdf-batch-field-outputFolder').value).toBe('/picked/out'); + }); + }); +});