From b83b86b31e4f88a635678ebb3917a58bd8c1c9d6 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 09:46:58 +0530 Subject: [PATCH] feat(media): add batch folder mode to Image/Audio/Video Tools dialog Reviewer follow-up on Task 12: the task's own title/brief called for batch support and no later task in the plan picks it up, so this closes that gap. Adds a "Single File" / "Batch Folder" mode toggle to the existing media-operations-dialog.js; batch mode swaps the per-file input/output fields for an Input Folder + "Include subfolders" + Output Folder trio while keeping every other parameter (width/height/ quality/angle/startTime/duration/crf/fps/format/fit) applied uniformly to every matching file. Disabled for audio "Merge", which combines many inputs into one output and doesn't fit a per-file batch model. main.js: adds collectFilesByExtension() (src/main/collectFilesByExtension.js, unit tested), a generalization of the inline collectFiles() closure inside ipcMain.on('universal-convert-batch', ...) to match a set of extensions instead of one format. runMediaBatchOperation() loops ImageOperations/AudioOperations/VideoOperations.executeOperation() over the matched files, reporting per-file progress via new 'media-batch-progress' events and a final 'media-batch-complete' event, then shows a "Batch Conversion Complete" dialog.showMessageBox with completed/failed counts, mirroring performBatchConversion()'s pattern. Wired via three new ipcMain.on handlers: batch-image-operation, batch-audio-operation, batch-video-operation. preload.js: whitelists the three new send channels and the two new receive channels (media-batch-progress, media-batch-complete). --- src/main.js | 216 +++++++++++++++ src/main/collectFilesByExtension.js | 52 ++++ src/preload.js | 7 + src/renderer/media-operations-dialog.js | 299 +++++++++++++++++++-- tests/main/collectFilesByExtension.test.js | 60 +++++ 5 files changed, 608 insertions(+), 26 deletions(-) create mode 100644 src/main/collectFilesByExtension.js create mode 100644 tests/main/collectFilesByExtension.test.js 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() { +
+ + +
@@ -284,6 +316,7 @@ function buildDialogDom() { els = { title: modalEl.querySelector('#media-operations-title'), operationSelect: modalEl.querySelector('#media-operation-select'), + modeSelect: modalEl.querySelector('#media-mode-select'), help: modalEl.querySelector('#media-operation-help'), fieldsContainer: modalEl.querySelector('#media-operation-fields'), status: modalEl.querySelector('#media-status-message'), @@ -294,7 +327,15 @@ function buildDialogDom() { cancelBtn: modalEl.querySelector('#media-operations-cancel'), }; - els.operationSelect.addEventListener('change', renderFields); + els.operationSelect.addEventListener('change', () => { + currentMode = 'single'; + updateModeOptions(); + renderFields(); + }); + els.modeSelect.addEventListener('change', () => { + currentMode = els.modeSelect.value; + renderFields(); + }); els.processBtn.addEventListener('click', handleProcess); els.cancelBtn.addEventListener('click', hideDialog); @@ -304,10 +345,36 @@ function buildDialogDom() { // 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 (type !== FOLDER_PICK_TYPE || !folderPath) return; - const input = document.getElementById(fieldElId('outputDir')); + if (!folderPath) return; + let targetFieldName = null; + if (type === FOLDER_PICK_TYPE) targetFieldName = 'outputDir'; + else if (type === BATCH_INPUT_FOLDER_PICK_TYPE) targetFieldName = BATCH_INPUT_FIELD_NAME; + else if (type === BATCH_OUTPUT_FOLDER_PICK_TYPE) targetFieldName = BATCH_OUTPUT_FIELD_NAME; + if (!targetFieldName) return; + const input = document.getElementById(fieldElId(targetFieldName)); if (input) input.value = folderPath; }); + + // Batch operation progress/completion (main.js: runMediaBatchOperation()). + ipcRenderer.on('media-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('media-batch-complete', (event, { success, completed, failed, total, error }) => { + 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() { @@ -402,14 +469,34 @@ function renderSaveField(field) { }); } -function renderFolderField(field) { +function renderFolderField(field, pickType = FOLDER_PICK_TYPE) { return createFolderInputGroup(field, { onBrowse: () => { - ipcRenderer.send('select-folder', FOLDER_PICK_TYPE); + ipcRenderer.send('select-folder', pickType); }, }); } +function renderCheckboxField(field) { + const wrapper = document.createElement('div'); + wrapper.className = 'export-section'; + + 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 = field.default !== false; + input.style.marginRight = '0.5em'; + + label.appendChild(input); + label.appendChild(document.createTextNode(field.label)); + wrapper.appendChild(label); + + return wrapper; +} + function renderNumberField(field) { const wrapper = document.createElement('div'); wrapper.className = 'export-section'; @@ -514,21 +601,33 @@ function renderFilesField(field) { return wrapper; } -function renderFields() { +// The field carrying the input-file picker for a batchable operation is always a +// 'file' field (single input) — 'files' (merge) operations are excluded from batch +// mode via `batchable: false` before this is ever consulted. +function getInputFileField(opConfig) { + return opConfig.fields.find((field) => field.type === 'file'); +} + +function isBatchable(opConfig) { + return opConfig.batchable !== false; +} + +// Keep the Mode dropdown in sync with whether the currently selected operation +// supports batch mode (everything except audio "Merge"). +function updateModeOptions() { const kindConfig = MEDIA_KIND_CONFIG[currentKind]; - const opKey = els.operationSelect.value; - const opConfig = kindConfig.operations[opKey]; - - els.fieldsContainer.innerHTML = ''; - - if (opConfig.help) { - els.help.textContent = opConfig.help; - els.help.classList.remove('hidden'); - } else { - els.help.textContent = ''; - els.help.classList.add('hidden'); + const opConfig = kindConfig.operations[els.operationSelect.value]; + const batchOption = els.modeSelect.querySelector('option[value="batch"]'); + if (batchOption) { + batchOption.disabled = !isBatchable(opConfig); } + if (!isBatchable(opConfig)) { + currentMode = 'single'; + } + els.modeSelect.value = currentMode; +} +function renderSingleFields(opConfig) { opConfig.fields.forEach((field) => { let fieldEl; switch (field.type) { @@ -557,6 +656,68 @@ function renderFields() { }); } +// Batch mode swaps the single input/output file (or folder) fields for one +// "Input Folder" + "Include Subfolders" + "Output Folder" trio, while keeping every +// other parameter field (width/height/quality/angle/startTime/duration/crf/fps/ +// format/fit/...) exactly as in single mode — those values apply to every matching +// file. The actual per-file output path/dir is computed by main.js's +// runMediaBatchOperation()/BATCH_OUTPUT_SPEC. +function renderBatchFields(opConfig) { + els.fieldsContainer.appendChild( + renderFolderField( + { name: BATCH_INPUT_FIELD_NAME, label: 'Input Folder', type: 'folder' }, + BATCH_INPUT_FOLDER_PICK_TYPE + ) + ); + els.fieldsContainer.appendChild( + renderCheckboxField({ + name: BATCH_SUBFOLDERS_FIELD_NAME, + label: 'Include subfolders', + default: true, + }) + ); + + opConfig.fields.forEach((field) => { + if (field.type === 'number') { + els.fieldsContainer.appendChild(renderNumberField(field)); + } else if (field.type === 'select') { + els.fieldsContainer.appendChild(renderSelectField(field)); + } + // 'file' / 'save' / 'folder' single-file fields are intentionally skipped — + // replaced by the Input/Output Folder fields below/above. + }); + + els.fieldsContainer.appendChild( + renderFolderField( + { name: BATCH_OUTPUT_FIELD_NAME, label: 'Output Folder', type: 'folder' }, + BATCH_OUTPUT_FOLDER_PICK_TYPE + ) + ); +} + +function renderFields() { + const kindConfig = MEDIA_KIND_CONFIG[currentKind]; + const opKey = els.operationSelect.value; + const opConfig = kindConfig.operations[opKey]; + + updateModeOptions(); + els.fieldsContainer.innerHTML = ''; + + if (opConfig.help) { + els.help.textContent = opConfig.help; + els.help.classList.remove('hidden'); + } else { + els.help.textContent = ''; + els.help.classList.add('hidden'); + } + + if (currentMode === 'batch') { + renderBatchFields(opConfig); + } else { + renderSingleFields(opConfig); + } +} + function collectOperationData(opConfig) { const data = {}; @@ -607,11 +768,7 @@ function collectOperationData(opConfig) { return { data }; } -async function handleProcess() { - const kindConfig = MEDIA_KIND_CONFIG[currentKind]; - const opKey = els.operationSelect.value; - const opConfig = kindConfig.operations[opKey]; - +async function handleSingleProcess(kindConfig, opKey, opConfig) { const { data, error } = collectOperationData(opConfig); if (error) { showStatus(error, 'warning'); @@ -638,6 +795,95 @@ async function handleProcess() { } } +// Collects the shared parameter fields (number/select only — no file/folder/files +// fields) that apply identically to every file in a batch run. +function collectBatchParamData(opConfig) { + const data = {}; + for (const field of opConfig.fields) { + if (field.type !== 'number' && field.type !== 'select') continue; + const input = document.getElementById(fieldElId(field.name)); + if (!input) continue; + + if (field.type === 'number') { + const raw = String(input.value).trim(); + if (raw === '') { + if (field.optional) { + data[field.name] = null; + continue; + } + return { error: `${field.label} is required.` }; + } + const num = Number(raw); + if (!Number.isFinite(num)) { + return { error: `${field.label} must be a valid number.` }; + } + data[field.name] = num; + } else { + data[field.name] = input.value; + } + } + + if ('width' in data && 'height' in data && data.width === null && data.height === null) { + return { error: 'Provide at least one of Width or Height.' }; + } + + return { data }; +} + +function handleBatchProcess(kindConfig, opKey, opConfig) { + const inputFolder = document.getElementById(fieldElId(BATCH_INPUT_FIELD_NAME))?.value; + const outputFolder = document.getElementById(fieldElId(BATCH_OUTPUT_FIELD_NAME))?.value; + const includeSubfolders = + document.getElementById(fieldElId(BATCH_SUBFOLDERS_FIELD_NAME))?.checked !== false; + + if (!inputFolder) { + showStatus('Select an input folder.', 'warning'); + return; + } + if (!outputFolder) { + showStatus('Select an output folder.', 'warning'); + return; + } + + const { data, error } = collectBatchParamData(opConfig); + if (error) { + showStatus(error, 'warning'); + return; + } + + const inputField = getInputFileField(opConfig); + const extensions = (inputField?.accept || '').split(',').filter(Boolean); + if (extensions.length === 0) { + showStatus('This operation does not support batch mode.', 'warning'); + return; + } + + clearStatus(); + showProgress(); + els.progressText.textContent = 'Scanning folder...'; + + ipcRenderer.send(kindConfig.batchChannel, { + operation: opKey, + inputFolder, + outputFolder, + includeSubfolders, + extensions, + data, + }); +} + +async function handleProcess() { + const kindConfig = MEDIA_KIND_CONFIG[currentKind]; + const opKey = els.operationSelect.value; + const opConfig = kindConfig.operations[opKey]; + + if (currentMode === 'batch' && isBatchable(opConfig)) { + handleBatchProcess(kindConfig, opKey, opConfig); + } else { + await handleSingleProcess(kindConfig, opKey, opConfig); + } +} + function hideDialog() { if (modalManager) modalManager.close(); clearStatus(); @@ -651,6 +897,7 @@ function showMediaOperationsDialog(kind) { ensureDialog(); currentKind = kind; + currentMode = 'single'; const kindConfig = MEDIA_KIND_CONFIG[kind]; els.title.textContent = kindConfig.title; diff --git a/tests/main/collectFilesByExtension.test.js b/tests/main/collectFilesByExtension.test.js new file mode 100644 index 0000000..49e3c56 --- /dev/null +++ b/tests/main/collectFilesByExtension.test.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { collectFilesByExtension } = require('../../src/main/collectFilesByExtension'); + +describe('collectFilesByExtension', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'collectext_')); + fs.writeFileSync(path.join(tmpDir, 'a.jpg'), 'x'); + fs.writeFileSync(path.join(tmpDir, 'b.PNG'), 'x'); // uppercase extension + fs.writeFileSync(path.join(tmpDir, 'c.txt'), 'x'); + fs.mkdirSync(path.join(tmpDir, 'sub')); + fs.writeFileSync(path.join(tmpDir, 'sub', 'd.jpeg'), 'x'); + fs.mkdirSync(path.join(tmpDir, 'sub', 'nested')); + fs.writeFileSync(path.join(tmpDir, 'sub', 'nested', 'e.jpg'), 'x'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('matches only files with a listed extension at the top level when includeSubfolders is false', () => { + const results = collectFilesByExtension(tmpDir, ['.jpg', '.png'], false); + const names = results.map((p) => path.basename(p)).sort(); + expect(names).toEqual(['a.jpg', 'b.PNG']); + }); + + test('matches extensions case-insensitively', () => { + const results = collectFilesByExtension(tmpDir, ['.png'], false); + expect(results.map((p) => path.basename(p))).toEqual(['b.PNG']); + }); + + test('recurses into subfolders when includeSubfolders is true (default)', () => { + const results = collectFilesByExtension(tmpDir, ['.jpg', '.jpeg']); + const names = results.map((p) => path.basename(p)).sort(); + expect(names).toEqual(['a.jpg', 'd.jpeg', 'e.jpg']); + }); + + test('does not recurse when includeSubfolders is false', () => { + const results = collectFilesByExtension(tmpDir, ['.jpg', '.jpeg'], false); + expect(results.map((p) => path.basename(p))).toEqual(['a.jpg']); + }); + + test('excludes non-matching extensions', () => { + const results = collectFilesByExtension(tmpDir, ['.jpg']); + expect(results.some((p) => p.endsWith('.txt'))).toBe(false); + }); + + test('returns an empty array when nothing matches', () => { + const results = collectFilesByExtension(tmpDir, ['.mp4']); + expect(results).toEqual([]); + }); + + test('defaults extensions to an empty list gracefully when omitted', () => { + expect(() => collectFilesByExtension(tmpDir, undefined, false)).not.toThrow(); + expect(collectFilesByExtension(tmpDir, undefined, false)).toEqual([]); + }); +});