diff --git a/src/main.js b/src/main.js index 52cef59..7cf0f2a 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,7 @@ const PDFOperations = require('./main/PDFOperations'); const ImageOperations = require('./main/ImageOperations'); const AudioOperations = require('./main/AudioOperations'); const VideoOperations = require('./main/VideoOperations'); +const { collectFilesByExtension } = require('./main/collectFilesByExtension'); const GitOperations = require('./main/GitOperations'); const PdfFontHeader = require('./main/PdfFontHeader'); const MonospaceFontConfig = require('./main/MonospaceFontConfig'); @@ -4681,6 +4682,221 @@ ipcMain.handle('process-video-operation', async (event, { operation, data }) => } }); +// ======================================== +// BATCH MEDIA OPERATIONS — apply one image/audio/video operation to every matching +// file in a folder, mirroring the ipcMain.on('universal-convert-batch', ...) / +// performBatchConversion() batch-folder pattern above: collect matching files +// (collectFilesByExtension, generalizing that handler's inline collectFiles()), +// loop executeOperation() over them reporting progress per file, then show a +// completion dialog with completed/failed counts. +// ======================================== + +// Per-kind executeOperation callers — Image/Audio/Video Operations modules take +// slightly different call shapes (Image bakes maxFileSize into `data`; Audio/Video +// take a third {ffmpegPath} options object), so each is wrapped identically to how +// the single-file process-*-operation handlers above already call them. +const BATCH_MEDIA_EXECUTORS = { + image: (operation, fileData) => + ImageOperations.executeOperation(operation, { ...fileData, maxFileSize: MAX_FILE_SIZE }), + audio: (operation, fileData) => + AudioOperations.executeOperation(operation, fileData, { ffmpegPath: getFFmpegPath() }), + video: (operation, fileData) => + VideoOperations.executeOperation(operation, fileData, { ffmpegPath: getFFmpegPath() }), +}; + +// How to derive each output file's extension (or, for 'frames', its output directory) +// from the source file. 'fromFormat' means "use data.format" (the operation has a +// format dropdown in the dialog); 'original' keeps the source file's extension; +// 'fixed' always uses a specific extension. Operations not listed here (audio +// 'merge') don't fit the "apply the same operation to every file" batch model — +// merge combines many inputs into a single output — so batch mode is unavailable +// for them (enforced both in the dialog UI and defensively here). +const BATCH_OUTPUT_SPEC = { + image: { + convert: { ext: 'fromFormat' }, + resize: { ext: 'original' }, + compress: { ext: 'original' }, + rotate: { ext: 'original' }, + }, + audio: { + convert: { ext: 'fromFormat' }, + trim: { ext: 'original' }, + extract: { ext: 'fixed', value: 'm4a' }, + }, + video: { + convert: { ext: 'original' }, + compress: { ext: 'original' }, + trim: { ext: 'original' }, + gif: { ext: 'fixed', value: 'gif' }, + frames: { dir: true }, + }, +}; + +async function runMediaBatchOperation({ + mediaKind, + operation, + inputFolder, + outputFolder, + includeSubfolders, + extensions, + data, +}) { + const spec = (BATCH_OUTPUT_SPEC[mediaKind] || {})[operation]; + if (!spec) { + mainWindow.webContents.send('media-batch-complete', { + success: false, + error: `Batch mode is not supported for this operation.`, + }); + return; + } + + if (!inputFolder || !fs.existsSync(inputFolder)) { + mainWindow.webContents.send('media-batch-complete', { + success: false, + error: 'Input folder does not exist.', + }); + return; + } + + try { + fs.mkdirSync(outputFolder, { recursive: true }); + } catch (error) { + mainWindow.webContents.send('media-batch-complete', { + success: false, + error: sanitizeErrorMessage(`Failed to create output folder: ${error.message}`), + }); + return; + } + + const files = collectFilesByExtension(inputFolder, extensions, includeSubfolders !== false); + if (files.length === 0) { + mainWindow.webContents.send('media-batch-complete', { + success: false, + error: 'No matching files found in the selected folder.', + }); + return; + } + + const executor = BATCH_MEDIA_EXECUTORS[mediaKind]; + const total = files.length; + let completed = 0; + let failed = 0; + + for (const filePath of files) { + mainWindow.webContents.send('media-batch-progress', { + completed, + failed, + total, + currentFile: path.basename(filePath), + }); + + 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 { + const ext = + spec.ext === 'fromFormat' + ? data.format + : spec.ext === 'fixed' + ? spec.value + : path.extname(filePath).replace(/^\./, ''); + fileData.outputPath = path.join(targetDir, `${baseName}.${ext}`); + } + + try { + await executor(operation, fileData); + completed++; + } catch { + failed++; + } + } + + mainWindow.webContents.send('media-batch-progress', { + completed, + failed, + total, + currentFile: null, + }); + mainWindow.webContents.send('media-batch-complete', { + success: true, + completed, + failed, + total, + outputFolder, + }); + + const allSucceeded = failed === 0; + dialog.showMessageBox(mainWindow, { + type: allSucceeded ? 'info' : 'warning', + title: allSucceeded ? 'Batch Conversion Complete' : 'Batch Conversion Finished', + message: 'Batch conversion finished!', + detail: `Completed: ${completed}/${total} files${failed > 0 ? ` (${failed} failed)` : ''}\nOutput: ${outputFolder}`, + buttons: ['OK'], + }); +} + +ipcMain.on( + 'batch-image-operation', + async (event, { operation, inputFolder, outputFolder, includeSubfolders, extensions, data }) => { + if (!conversionLimiter()) { + mainWindow.webContents.send('conversion-status', 'Please wait before converting again...'); + return; + } + await runMediaBatchOperation({ + mediaKind: 'image', + operation, + inputFolder, + outputFolder, + includeSubfolders, + extensions, + data, + }); + } +); + +ipcMain.on( + 'batch-audio-operation', + async (event, { operation, inputFolder, outputFolder, includeSubfolders, extensions, data }) => { + if (!conversionLimiter()) { + mainWindow.webContents.send('conversion-status', 'Please wait before converting again...'); + return; + } + await runMediaBatchOperation({ + mediaKind: 'audio', + operation, + inputFolder, + outputFolder, + includeSubfolders, + extensions, + data, + }); + } +); + +ipcMain.on( + 'batch-video-operation', + async (event, { operation, inputFolder, outputFolder, includeSubfolders, extensions, data }) => { + if (!conversionLimiter()) { + mainWindow.webContents.send('conversion-status', 'Please wait before converting again...'); + return; + } + await runMediaBatchOperation({ + mediaKind: 'video', + operation, + inputFolder, + outputFolder, + includeSubfolders, + extensions, + data, + }); + } +); + // 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/collectFilesByExtension.js b/src/main/collectFilesByExtension.js new file mode 100644 index 0000000..9fe8912 --- /dev/null +++ b/src/main/collectFilesByExtension.js @@ -0,0 +1,52 @@ +/** + * collectFilesByExtension + * + * Recursively (optionally) collects files under a directory whose extension matches + * one of a given set of extensions. Generalizes the `collectFiles()` closure defined + * inside `ipcMain.on('universal-convert-batch', ...)` in main.js (which matches a + * single `.${fromFormat}` extension) to match against an arbitrary extension list — + * used by the batch-image/audio/video-operation handlers, which need to match several + * possible input extensions per media kind (e.g. .jpg/.jpeg/.png/... for images). + * + * Pulled out as its own module (rather than an inline closure like the original) so it + * can be unit tested without Electron. + * + * @module collectFilesByExtension + */ + +const fs = require('fs'); +const path = require('path'); + +/** + * @param {string} dir - Directory to scan. + * @param {string[]} extensions - Extensions to match, each including the leading dot + * (e.g. ['.jpg', '.png']). Matching is case-insensitive. + * @param {boolean} [includeSubfolders=true] - Recurse into subdirectories. + * @returns {string[]} Absolute paths of matching files, in directory-walk order. + */ +function collectFilesByExtension(dir, extensions, includeSubfolders = true) { + const normalizedExts = (extensions || []).map((ext) => ext.toLowerCase()); + const results = []; + + function walk(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + if (includeSubfolders) { + walk(fullPath); + } + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (normalizedExts.includes(ext)) { + results.push(fullPath); + } + } + } + } + + walk(dir); + return results; +} + +module.exports = { collectFilesByExtension }; diff --git a/src/preload.js b/src/preload.js index e5bd86f..49d8a17 100644 --- a/src/preload.js +++ b/src/preload.js @@ -47,12 +47,15 @@ const ALLOWED_SEND_CHANNELS = [ // Image converter 'process-image-operation', 'select-image-folder', + 'batch-image-operation', // Audio converter 'process-audio-operation', + 'batch-audio-operation', // Video converter 'process-video-operation', + 'batch-video-operation', // Header/Footer 'get-header-footer-settings', @@ -190,6 +193,10 @@ const ALLOWED_RECEIVE_CHANNELS = [ 'audio-conversion-complete', 'video-conversion-complete', + // Batch media operations (Image/Audio/Video Tools dialog batch mode) + 'media-batch-progress', + 'media-batch-complete', + // Folder selection 'folder-selected', 'pdf-folder-selected', diff --git a/src/renderer/media-operations-dialog.js b/src/renderer/media-operations-dialog.js index 7b49aad..a7a3225 100644 --- a/src/renderer/media-operations-dialog.js +++ b/src/renderer/media-operations-dialog.js @@ -15,10 +15,19 @@ * whose `.path` is read directly (nodeIntegration is enabled for this renderer), the * same approach already used throughout the PDF Editor and Universal Converter * dialogs. No new IPC channel is needed for single-file or save-file pickers. Output - * *folder* selection (used by the video "Extract Frames" operation) reuses the - * existing generic `select-folder` / `folder-selected` IPC channels already wired up - * in main.js for the batch converter — filtered here by a unique `type` string so this - * dialog only reacts to its own request. + * *folder* selection (used by the video "Extract Frames" operation, and by batch + * mode below) reuses the existing generic `select-folder` / `folder-selected` IPC + * channels already wired up in main.js for the batch converter — filtered here by a + * unique `type` string per picker so this dialog only reacts to requests it made. + * + * A "Mode: Single File / Batch Folder" dropdown (disabled for audio "Merge", which + * doesn't fit a per-file batch model) swaps the input/output file fields for an + * Input Folder + "Include subfolders" + Output Folder trio while keeping every other + * parameter field as-is; Process then fires `batch-image-operation` / + * `batch-audio-operation` / `batch-video-operation` (fire-and-forget, like + * `universal-convert-batch`) and progress/completion arrive via the + * `media-batch-progress` / `media-batch-complete` events sent by + * `runMediaBatchOperation()` in main.js. * * @module media-operations-dialog */ @@ -36,6 +45,7 @@ const MEDIA_KIND_CONFIG = { image: { title: 'Image Tools', channel: 'process-image-operation', + batchChannel: 'batch-image-operation', operations: { convert: { label: 'Convert Format', @@ -96,6 +106,7 @@ const MEDIA_KIND_CONFIG = { audio: { title: 'Audio Tools', channel: 'process-audio-operation', + batchChannel: 'batch-audio-operation', operations: { convert: { label: 'Convert Format', @@ -142,6 +153,12 @@ const MEDIA_KIND_CONFIG = { merge: { label: 'Merge', help: 'Select at least 2 audio files to merge, in order.', + // Merge combines several input files into a single output — it does not + // fit the "apply the same operation to every file in a folder" batch model + // (there is no single "one operation per file" mapping), so batch mode is + // unavailable for it. Enforced both here (hides the Batch option in the UI) + // and defensively in main.js's BATCH_OUTPUT_SPEC (no 'merge' entry). + batchable: false, fields: [ { name: 'inputPaths', @@ -157,6 +174,7 @@ const MEDIA_KIND_CONFIG = { video: { title: 'Video Tools', channel: 'process-video-operation', + batchChannel: 'batch-video-operation', operations: { convert: { label: 'Convert Format', @@ -231,11 +249,18 @@ const MEDIA_KIND_CONFIG = { }; const FOLDER_PICK_TYPE = 'media-operations-output-dir'; +const BATCH_INPUT_FOLDER_PICK_TYPE = 'media-operations-batch-input-dir'; +const BATCH_OUTPUT_FOLDER_PICK_TYPE = 'media-operations-batch-output-dir'; + +const BATCH_INPUT_FIELD_NAME = 'batchInputFolder'; +const BATCH_OUTPUT_FIELD_NAME = 'batchOutputFolder'; +const BATCH_SUBFOLDERS_FIELD_NAME = 'batchIncludeSubfolders'; let modalEl = null; let modalManager = null; let els = null; let currentKind = null; +let currentMode = 'single'; let mergeFilePaths = []; function fieldElId(name) { @@ -261,6 +286,13 @@ function buildDialogDom() { +