mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-24 07:20:16 +05:30
feat(pdf): add bulk PDF operations (watermark/compress/rotate/etc.) to batch converter
This commit is contained in:
+42
@@ -9,6 +9,7 @@ const ImageOperations = require('./main/ImageOperations');
|
|||||||
const AudioOperations = require('./main/AudioOperations');
|
const AudioOperations = require('./main/AudioOperations');
|
||||||
const VideoOperations = require('./main/VideoOperations');
|
const VideoOperations = require('./main/VideoOperations');
|
||||||
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
|
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
|
||||||
|
const { runPDFBatchOperation } = require('./main/PDFBatchOperations');
|
||||||
const GitOperations = require('./main/GitOperations');
|
const GitOperations = require('./main/GitOperations');
|
||||||
const PdfFontHeader = require('./main/PdfFontHeader');
|
const PdfFontHeader = require('./main/PdfFontHeader');
|
||||||
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
|
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)
|
// IPC Handler for folder selection (for batch image operations)
|
||||||
ipcMain.on('select-image-folder', (event, inputId) => {
|
ipcMain.on('select-image-folder', (event, inputId) => {
|
||||||
const folder = dialog.showOpenDialogSync(mainWindow, {
|
const folder = dialog.showOpenDialogSync(mainWindow, {
|
||||||
|
|||||||
@@ -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' } -> <outputFolder>/<relativeDir>/<baseName>.pdf via outputPath
|
||||||
|
// { ext: 'txt' } -> <outputFolder>/<relativeDir>/<baseName>.txt via outputPath
|
||||||
|
// { folder: true } -> split writes its `<baseName>_part_N.pdf` files into
|
||||||
|
// the mirrored output folder via outputFolder
|
||||||
|
// { dir: true } -> extractImages writes images into a per-PDF
|
||||||
|
// <outputFolder>/<relativeDir>/<baseName>/ 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<void>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
@@ -87,6 +87,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'process-pdf-operation',
|
'process-pdf-operation',
|
||||||
'get-pdf-page-count',
|
'get-pdf-page-count',
|
||||||
'select-pdf-folder',
|
'select-pdf-folder',
|
||||||
|
'batch-pdf-operation',
|
||||||
|
|
||||||
// ASCII generator (separate window)
|
// ASCII generator (separate window)
|
||||||
'open-ascii-generator',
|
'open-ascii-generator',
|
||||||
@@ -235,6 +236,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'pdf-page-count',
|
'pdf-page-count',
|
||||||
'pdf-operation-complete',
|
'pdf-operation-complete',
|
||||||
'pdf-operation-error',
|
'pdf-operation-error',
|
||||||
|
'pdf-batch-complete',
|
||||||
|
|
||||||
// ASCII Art Generator
|
// ASCII Art Generator
|
||||||
'show-ascii-generator-window',
|
'show-ascii-generator-window',
|
||||||
|
|||||||
+12
-1
@@ -12,6 +12,7 @@ const hljs = require('highlight.js');
|
|||||||
const { createEditor } = require('./editor/codemirror-setup');
|
const { createEditor } = require('./editor/codemirror-setup');
|
||||||
const { undo, redo } = require('@codemirror/commands');
|
const { undo, redo } = require('@codemirror/commands');
|
||||||
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
||||||
|
const { showPdfBatchDialog } = require('./renderer/pdf-batch-dialog');
|
||||||
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
|
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
|
||||||
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
|
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
|
||||||
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
|
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
|
// Batch converter menu items - open universal converter with batch mode and correct tool
|
||||||
ipcRenderer.on('show-batch-converter', (event, type) => {
|
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();
|
showUniversalConverterDialog();
|
||||||
// Map batch type to the appropriate tool
|
// Map batch type to the appropriate tool
|
||||||
const toolMap = {
|
const toolMap = {
|
||||||
@@ -2792,7 +2803,7 @@ ipcRenderer.on('show-batch-converter', (event, type) => {
|
|||||||
batchToggle.checked = true;
|
batchToggle.checked = true;
|
||||||
batchToggle.dispatchEvent(new Event('change'));
|
batchToggle.dispatchEvent(new Event('change'));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
ipcRenderer.on('conversion-status', (event, status) => {
|
ipcRenderer.on('conversion-status', (event, status) => {
|
||||||
document.getElementById('converter-status').textContent = status;
|
document.getElementById('converter-status').textContent = status;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-content large">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="pdf-batch-title">Batch PDF Tools</h3>
|
||||||
|
<button class="modal-close" id="pdf-batch-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="export-section">
|
||||||
|
<label for="pdf-batch-type">Batch Type:</label>
|
||||||
|
<select id="pdf-batch-type">
|
||||||
|
<option value="convert">Convert Format (existing batch converter)</option>
|
||||||
|
<option value="operation">Bulk PDF Operation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<small id="pdf-batch-convert-hint">
|
||||||
|
Format conversion (PDF to DOCX/HTML/...) uses the existing batch converter.
|
||||||
|
Choose "Bulk PDF Operation" to watermark, compress, or otherwise process many PDFs at once.
|
||||||
|
</small>
|
||||||
|
<div id="pdf-batch-operation-panel" class="hidden">
|
||||||
|
<div class="export-section">
|
||||||
|
<label for="pdf-batch-operation">Operation:</label>
|
||||||
|
<select id="pdf-batch-operation"></select>
|
||||||
|
</div>
|
||||||
|
<div id="pdf-batch-operation-fields"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pdf-batch-status" class="info-message hidden" aria-live="polite"></div>
|
||||||
|
<div id="pdf-batch-progress" class="batch-progress hidden">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="pdf-batch-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">
|
||||||
|
<span id="pdf-batch-progress-text">Processing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="pdf-batch-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
||||||
|
<button id="pdf-batch-process" class="btn btn-primary">Open Batch Converter...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 };
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user