mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+216
@@ -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, {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user