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).
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent f271e27177
commit b83b86b31e
5 changed files with 608 additions and 26 deletions
+216
View File
@@ -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, {
+52
View File
@@ -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 };
+7
View File
@@ -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',
+274 -27
View File
@@ -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() {
<label for="media-operation-select">Operation:</label>
<select id="media-operation-select"></select>
</div>
<div class="export-section">
<label for="media-mode-select">Mode:</label>
<select id="media-mode-select">
<option value="single">Single File</option>
<option value="batch">Batch Folder (apply to every matching file)</option>
</select>
</div>
<small id="media-operation-help" class="hidden"></small>
<div id="media-operation-fields"></div>
<div id="media-status-message" class="info-message hidden" aria-live="polite"></div>
@@ -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() {
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');
// 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 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;
@@ -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([]);
});
});