feat: add import preflight summaries

Inspect every manual file, folder, and drag-and-drop import before queue admission. Report exact duplicate, filename-filter, filesystem, destination, configured size-limit, and resulting job counts in the host selection dialog with live bilingual updates.
This commit is contained in:
Sucukdeluxe
2026-08-16 21:31:56 +02:00
parent 16f6f93a72
commit 88a3ee0937
9 changed files with 475 additions and 78 deletions
+144
View File
@@ -0,0 +1,144 @@
(function initImportPreflight(root, factory) {
const api = typeof module === 'object' && module.exports
? factory(require('path'), require('./filename-filter'))
: factory({
normalize: value => String(value).replace(/[\\/]+/g, '/'),
basename: value => String(value).split(/[\\/]/).pop() || ''
}, root.FilenameFilter);
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.ImportPreflight = api;
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path, filenameFilter) {
const { applyFilenameFilter } = filenameFilter;
function normalizePathValue(value) {
const text = String(value ?? '').trim();
return text ? path.normalize(text) : '';
}
function normalizeEntry(value) {
const source = value && typeof value === 'object' ? value : {};
const filePath = normalizePathValue(typeof value === 'string' ? value : source.path);
const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim();
return {
path: filePath,
name: sourceName || path.basename(filePath),
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null
};
}
function createPathKey(value, caseInsensitive) {
const normalized = normalizePathValue(value);
return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized;
}
async function mapWithConcurrency(items, concurrency, operation) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await operation(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
function unavailableReason(result) {
if (!result || result.exists === false) return 'missing';
if (result.readable === false) return 'unreadable';
const size = Number(result.size);
if (!Number.isFinite(size) || size <= 0) return 'empty';
return '';
}
async function inspectImportEntries(entries, options = {}) {
const input = Array.isArray(entries) ? entries : [];
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : [])
.map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive))
.filter(Boolean));
const duplicates = [];
const unavailable = [];
const unique = [];
for (const value of input) {
const entry = normalizeEntry(value);
if (!entry.path) {
unavailable.push({ ...entry, reason: 'missing' });
continue;
}
const key = createPathKey(entry.path, caseInsensitive);
if (existing.has(key)) {
duplicates.push(entry);
continue;
}
existing.add(key);
unique.push(entry);
}
const filtered = applyFilenameFilter(unique, options.filenameFilter);
const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8));
const inspectPath = typeof options.inspectPath === 'function'
? options.inspectPath
: async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size });
const inspected = await mapWithConcurrency(filtered.accepted, concurrency, async entry => {
try {
const result = await inspectPath(entry.path, entry);
const reason = unavailableReason(result);
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason };
return { entry: { ...entry, size: Number(result.size) }, reason: '' };
} catch (error) {
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
}
});
const accepted = [];
for (const result of inspected) {
if (result.reason) unavailable.push({ ...result.entry, reason: result.reason });
else accepted.push(result.entry);
}
return {
candidateCount: input.length,
duplicateCount: duplicates.length,
filteredCount: filtered.excluded.length,
unavailableCount: unavailable.length,
acceptedCount: accepted.length,
accepted,
duplicates,
filtered: filtered.excluded,
unavailable
};
}
function summarizeImportPlan(input = {}) {
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
const selectedHosters = Array.from(new Set((Array.isArray(input.selectedHosters) ? input.selectedHosters : [])
.map(value => String(value ?? '').trim())
.filter(Boolean)));
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
let sizeLimitedJobCount = 0;
for (const file of accepted) {
for (const hoster of selectedHosters) {
const maxSizeMb = Number(settings[hoster]?.maxSizeMb);
if (maxSizeMb > 0 && Number(file.size) > maxSizeMb * 1024 * 1024) sizeLimitedJobCount++;
}
}
return {
candidateCount: Number(inspection.candidateCount) || 0,
duplicateCount: Number(inspection.duplicateCount) || 0,
filteredCount: Number(inspection.filteredCount) || 0,
unavailableCount: Number(inspection.unavailableCount) || 0,
acceptedCount: accepted.length,
targetCount: selectedHosters.length,
jobCount: accepted.length * selectedHosters.length - sizeLimitedJobCount,
sizeLimitedJobCount
};
}
return {
inspectImportEntries,
summarizeImportPlan
};
});
+27
View File
@@ -55,6 +55,7 @@ const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/u
const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
const { createBatchMutationGate } = require('./lib/batch-mutation-gate');
const { createUploadStartReservation } = require('./lib/upload-start-reservation');
const { inspectImportEntries } = require('./lib/import-preflight');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
@@ -2247,6 +2248,32 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
return out;
});
ipcMain.handle('inspect-import-files', async (_event, payload) => {
const input = payload && typeof payload === 'object' ? payload : {};
const currentConfig = configStore.load();
return inspectImportEntries(input.entries, {
existingPaths: input.existingPaths,
filenameFilter: currentConfig.globalSettings?.filenameFilter,
concurrency: 8,
inspectPath: async filePath => {
let fileStat;
try {
fileStat = await fs.promises.stat(filePath);
} catch (error) {
if (error && error.code === 'ENOENT') return { exists: false };
return { exists: true, readable: false };
}
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
try {
await fs.promises.access(filePath, fs.constants.R_OK);
} catch {
return { exists: true, readable: false, size: fileStat.size };
}
return { exists: true, readable: true, size: fileStat.size };
}
});
});
ipcMain.handle('start-upload', async (_event, payload) => {
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
+1
View File
@@ -44,6 +44,7 @@ contextBridge.exposeInMainWorld('api', {
selectFolderWithSizes: () => ipcRenderer.invoke('select-folder-with-sizes'),
resolveFolderFiles: (folderPath) => ipcRenderer.invoke('resolve-folder-files', folderPath),
getFileSizes: (paths) => ipcRenderer.invoke('get-file-sizes', paths),
inspectImportFiles: (entries, existingPaths) => ipcRenderer.invoke('inspect-import-files', { entries, existingPaths }),
// Upload control
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
+112 -73
View File
@@ -673,7 +673,7 @@ async function init() {
}
} else {
// No pre-selected hosters: open modal
addPathsToQueue(files);
await addPathsToQueue(files);
}
});
@@ -1226,6 +1226,7 @@ function renderHosterModal() {
if (available.length === 0) {
list.innerHTML = '';
hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.';
renderImportPlanSummary();
return;
}
@@ -1257,8 +1258,11 @@ function renderHosterModal() {
list.querySelectorAll('input[data-hoster-modal]').forEach(input => {
input.addEventListener('change', () => {
input.closest('.hoster-option')?.classList.toggle('selected', input.checked);
renderImportPlanSummary();
});
});
renderImportPlanSummary();
}
function openHosterModal() {
@@ -1266,9 +1270,7 @@ function openHosterModal() {
renderHosterModal();
const description = document.getElementById('hosterModalDescription');
if (description) {
description.textContent = _pendingImportSummary
? formatFilenameFilterResult(_pendingImportSummary)
: localizeUiText('Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.');
description.textContent = localizeUiText('Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.');
}
modalController.open('hosterModal', {
initialFocus: '#cancelHosterModalBtn',
@@ -1308,12 +1310,12 @@ async function applyHosterSelection() {
updateUploadView();
persistQueueStateSoon(true); // immediate persist after adding files
closeHosterModal();
_pendingImportSummary = null;
_pendingImportInspection = null;
}
function cancelHosterModal() {
_pendingFiles = [];
_pendingImportSummary = null;
_pendingImportInspection = null;
closeHosterModal();
}
@@ -1550,7 +1552,8 @@ function setupDragDrop() {
}
let _pendingFiles = []; // Files waiting for hoster modal confirmation
let _pendingImportSummary = null;
let _pendingImportInspection = null;
let _importCoordination = Promise.resolve();
let _addingDropped = false;
@@ -1558,19 +1561,6 @@ function admitFilenameFilter(files) {
return window.FilenameFilter.applyFilenameFilter(files, config?.globalSettings?.filenameFilter);
}
function mergePendingImportSummary(result) {
if (!result.active) {
if (!_pendingImportSummary) _pendingImportSummary = null;
return;
}
const current = _pendingImportSummary || { total: 0, accepted: 0, excluded: 0 };
_pendingImportSummary = {
total: current.total + result.total,
accepted: current.accepted + result.accepted.length,
excluded: current.excluded + result.excluded.length
};
}
function formatFilenameFilterResult(result) {
const accepted = Array.isArray(result.accepted) ? result.accepted.length : Number(result.accepted) || 0;
const excluded = Array.isArray(result.excluded) ? result.excluded.length : Number(result.excluded) || 0;
@@ -1581,6 +1571,99 @@ function showFilenameFilterResult(result) {
showCopyToast(formatFilenameFilterResult(result), 6500);
}
function mergePendingImportInspection(result) {
const current = _pendingImportInspection || {
candidateCount: 0,
duplicateCount: 0,
filteredCount: 0,
unavailableCount: 0,
accepted: []
};
_pendingImportInspection = {
candidateCount: current.candidateCount + result.candidateCount,
duplicateCount: current.duplicateCount + result.duplicateCount,
filteredCount: current.filteredCount + result.filteredCount,
unavailableCount: current.unavailableCount + result.unavailableCount,
accepted: current.accepted.concat(result.accepted)
};
}
function getImportPlanHosters() {
const inputs = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'));
return inputs.length > 0 || document.getElementById('hosterModal')?.style.display === 'flex'
? inputs.map(input => input.dataset.hosterModal)
: getSelectedHosters();
}
function renderImportPlanSummary() {
if (!_pendingImportInspection || !window.ImportPreflight) return;
const summary = window.ImportPreflight.summarizeImportPlan({
inspection: _pendingImportInspection,
selectedHosters: getImportPlanHosters(),
hosterSettings
});
const values = {
importPlanCandidates: summary.candidateCount,
importPlanDuplicates: summary.duplicateCount,
importPlanFiltered: summary.filteredCount,
importPlanUnavailable: summary.unavailableCount,
importPlanAccepted: summary.acceptedCount,
importPlanTargets: summary.targetCount,
importPlanJobs: summary.jobCount,
importPlanSizeLimited: summary.sizeLimitedJobCount
};
for (const [id, value] of Object.entries(values)) {
const element = document.getElementById(id);
if (element) element.textContent = String(value);
}
}
function existingImportPaths() {
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
}
function coordinateImportEntries(entries) {
const run = async () => {
const candidates = Array.isArray(entries) ? entries : [];
if (candidates.length === 0) return null;
let inspection;
try {
inspection = await window.api.inspectImportFiles(candidates, existingImportPaths());
} catch {
showCopyToast('Vorabprüfung fehlgeschlagen.', 6500);
return null;
}
mergePendingImportInspection(inspection);
if (inspection.accepted.length > 0) {
if (document.getElementById('hosterModal')?.style.display === 'flex') {
selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'))
.map(input => input.dataset.hosterModal);
}
const acceptedPaths = new Set(inspection.accepted.map(file => file.path));
clearDedupKeysForPaths(acceptedPaths);
_pendingFiles.push(...inspection.accepted);
if (document.getElementById('hosterModal')?.style.display === 'flex') {
syncSelectedUploadHosters();
renderHosterModal();
} else {
openHosterModal();
}
} else if (_pendingFiles.length > 0) {
renderImportPlanSummary();
} else if (inspection.candidateCount > 0 && inspection.duplicateCount === inspection.candidateCount) {
showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
_pendingImportInspection = null;
} else {
showCopyToast('Keine Dateien wurden akzeptiert.', 6500);
_pendingImportInspection = null;
}
return inspection;
};
const pending = _importCoordination.then(run, run);
_importCoordination = pending.catch(() => {});
return pending;
}
async function addDropTargetEntries(entries) {
const files = [];
for (const entry of Array.isArray(entries) ? entries : []) {
@@ -1595,7 +1678,7 @@ async function addDropTargetEntries(entries) {
}
files.push(entry);
}
addPathsToQueue(files);
await addPathsToQueue(files);
}
async function addDroppedFiles(fileList) {
@@ -1631,7 +1714,7 @@ async function addDroppedFiles(fileList) {
const fileName = file.name || '';
entries.push({ path: filePath, name: fileName, size: file.size });
}
addPathsToQueue(entries);
await addPathsToQueue(entries);
} finally {
_addingDropped = false;
}
@@ -1640,65 +1723,19 @@ async function addDroppedFiles(fileList) {
async function pickFiles() {
const paths = await window.api.selectFiles();
if (!paths) return;
addPathsToQueue(paths);
await addPathsToQueue(paths);
}
async function pickFolder() {
const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null;
if (richFiles && Array.isArray(richFiles)) { addPathsToQueue(richFiles); return; }
if (richFiles && Array.isArray(richFiles)) { await addPathsToQueue(richFiles); return; }
const paths = await window.api.selectFolder();
if (!paths) return;
addPathsToQueue(paths);
await addPathsToQueue(paths);
}
function addPathsToQueue(paths) {
const existing = new Set();
for (const f of selectedFiles) existing.add(f.path);
for (const f of _pendingFiles) existing.add(f.path);
const newFiles = [];
const pendingSizeFetch = [];
for (const entry of paths) {
const p = typeof entry === 'string' ? entry : (entry && entry.path);
if (!p || existing.has(p)) continue;
existing.add(p);
const name = typeof entry === 'string' ? p.split('\\').pop().split('/').pop() : (entry.name || p.split('\\').pop().split('/').pop());
const size = typeof entry === 'string' ? null : (entry.size || 0);
newFiles.push({ path: p, name, size });
if (size === null || size === undefined || size === 0) pendingSizeFetch.push(p);
}
const admitted = admitFilenameFilter(newFiles);
if (admitted.accepted.length > 0) {
const acceptedPaths = new Set(admitted.accepted.map(file => file.path));
const acceptedSizeFetch = pendingSizeFetch.filter(filePath => acceptedPaths.has(filePath));
_pendingFiles.push(...admitted.accepted);
mergePendingImportSummary(admitted);
openHosterModal();
if (acceptedSizeFetch.length > 0 && window.api.getFileSizes) {
window.api.getFileSizes(acceptedSizeFetch).then((sizeMap) => {
if (!sizeMap || typeof sizeMap !== 'object') return;
let changed = false;
for (const f of _pendingFiles) {
if (sizeMap[f.path] && (!f.size || f.size === 0)) { f.size = sizeMap[f.path]; changed = true; }
}
for (const f of selectedFiles) {
if (sizeMap[f.path] && (!f.size || f.size === 0)) { f.size = sizeMap[f.path]; changed = true; }
}
for (const j of queueJobs) {
if (sizeMap[j.file] && (!j.bytesTotal || j.bytesTotal === 0)) { j.bytesTotal = sizeMap[j.file]; changed = true; }
}
if (changed) {
_queueStatsCache = null;
if (typeof renderQueueTable === 'function') renderQueueTable();
if (typeof updateStatusBar === 'function') updateStatusBar();
}
}).catch(() => {});
}
} else if (admitted.active && admitted.total > 0) {
showFilenameFilterResult(admitted);
} else if (Array.isArray(paths) && paths.length > 0) {
showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
}
async function addPathsToQueue(paths) {
return coordinateImportEntries(paths);
}
function updateUploadView() {
@@ -7500,12 +7537,14 @@ function setupListeners() {
input.checked = true;
input.closest('.hoster-option')?.classList.add('selected');
});
renderImportPlanSummary();
});
document.getElementById('clearHostersBtn').addEventListener('click', () => {
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
input.checked = false;
input.closest('.hoster-option')?.classList.remove('selected');
});
renderImportPlanSummary();
});
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
+12
View File
@@ -339,6 +339,18 @@
['Ausgewählte starten', 'Start selected'],
['Upload-Ziele auswählen', 'Choose upload destinations'],
['Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.', 'Files were added. Now choose the hosts for the upload.'],
['Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.', 'Preflight complete. Now choose the hosts for the upload.'],
['Import-Vorabprüfung', 'Import preflight'],
['Kandidaten', 'Candidates'],
['Bereits vorhanden / dupliziert', 'Already present / duplicated'],
['Durch Dateinamenfilter ausgeschlossen', 'Excluded by filename filter'],
['Fehlend / unlesbar / leer', 'Missing / unreadable / empty'],
['Akzeptierte Dateien', 'Accepted files'],
['Ausgewählte Ziele', 'Selected destinations'],
['Entstehende Jobs', 'Resulting jobs'],
['Durch konfigurierte Größenlimits entfallene Jobs', 'Jobs omitted by configured size limits'],
['Keine Dateien wurden akzeptiert.', 'No files were accepted.'],
['Vorabprüfung fehlgeschlagen.', 'Preflight failed.'],
['Alle', 'All'],
['Keine', 'None'],
['Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.', 'No hosts with credentials are available. Add a login or API key under Accounts first.'],
+11
View File
@@ -663,6 +663,16 @@
<button class="btn btn-xs btn-secondary" id="clearHostersBtn">Keine</button>
</div>
<div class="hoster-modal-list" id="hosterModalList"></div>
<dl class="import-plan-summary" id="importPlanSummary" aria-label="Import-Vorabprüfung">
<div><dt>Kandidaten</dt><dd id="importPlanCandidates">0</dd></div>
<div><dt>Bereits vorhanden / dupliziert</dt><dd id="importPlanDuplicates">0</dd></div>
<div><dt>Durch Dateinamenfilter ausgeschlossen</dt><dd id="importPlanFiltered">0</dd></div>
<div><dt>Fehlend / unlesbar / leer</dt><dd id="importPlanUnavailable">0</dd></div>
<div><dt>Akzeptierte Dateien</dt><dd id="importPlanAccepted">0</dd></div>
<div><dt>Ausgewählte Ziele</dt><dd id="importPlanTargets">0</dd></div>
<div><dt>Entstehende Jobs</dt><dd id="importPlanJobs">0</dd></div>
<div><dt>Durch konfigurierte Größenlimits entfallene Jobs</dt><dd id="importPlanSizeLimited">0</dd></div>
</dl>
<p class="modal-hint" id="hosterModalHint"></p>
</div>
<div class="modal-footer">
@@ -684,6 +694,7 @@
<script src="../lib/speed-history.js"></script>
<script src="../lib/upload-recovery.js"></script>
<script src="../lib/filename-filter.js"></script>
<script src="../lib/import-preflight.js"></script>
<script src="account-submit.js"></script>
<script src="account-status.js"></script>
<script src="history-status.js"></script>
+30
View File
@@ -739,6 +739,36 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
display: grid;
gap: 10px;
}
.import-plan-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 12px 0 0;
}
.import-plan-summary > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.025);
}
.import-plan-summary dt {
min-width: 0;
font-size: 11px;
line-height: 1.3;
color: var(--text-muted);
}
.import-plan-summary dd {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.hoster-option {
display: flex;
align-items: center;
+104
View File
@@ -0,0 +1,104 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
inspectImportEntries,
summarizeImportPlan
} = require('../lib/import-preflight');
describe('import preflight', () => {
it('accounts for candidates, existing and repeated paths, filename filters, unreadable entries and accepted files', async () => {
const sizes = new Map([
['C:\\incoming\\duplicate-new.bin', 2 * 1024 * 1024],
['C:\\incoming\\missing.bin', null],
['C:\\incoming\\unreadable.bin', 'unreadable'],
['C:\\incoming\\empty.bin', 0],
['C:\\incoming\\accepted.xyz', 5 * 1024 * 1024]
]);
const inspection = await inspectImportEntries([
'C:/queue/existing.bin',
'C:/incoming/duplicate-new.bin',
'C:\\incoming\\duplicate-new.bin',
'C:/incoming/skip.sample.bin',
'C:/incoming/missing.bin',
'C:/incoming/unreadable.bin',
'C:/incoming/empty.bin',
'C:/incoming/accepted.xyz'
], {
existingPaths: ['C:\\QUEUE\\existing.bin'],
filenameFilter: {
enabled: true,
action: 'exclude',
conditions: [{ operator: 'contains', value: '.sample.' }]
},
inspectPath: async filePath => {
const value = sizes.get(filePath);
if (value === null) return { exists: false };
if (value === 'unreadable') return { exists: true, readable: false, size: 10 };
return { exists: true, readable: true, size: value };
}
});
assert.equal(inspection.candidateCount, 8);
assert.equal(inspection.duplicateCount, 2);
assert.equal(inspection.filteredCount, 1);
assert.equal(inspection.unavailableCount, 3);
assert.equal(inspection.acceptedCount, 2);
assert.deepEqual(inspection.unavailable.map(entry => entry.reason).sort(), ['empty', 'missing', 'unreadable']);
assert.deepEqual(inspection.accepted.map(entry => entry.name).sort(), ['accepted.xyz', 'duplicate-new.bin']);
assert.equal(inspection.accepted.find(entry => entry.name === 'accepted.xyz').size, 5 * 1024 * 1024);
});
it('counts jobs and only removes jobs blocked by configured host maximum sizes', () => {
const summary = summarizeImportPlan({
inspection: {
candidateCount: 8,
duplicateCount: 2,
filteredCount: 1,
unavailableCount: 3,
accepted: [
{ path: 'C:\\incoming\\small.bin', name: 'small.bin', size: 2 * 1024 * 1024 },
{ path: 'C:\\incoming\\large.custom', name: 'large.custom', size: 5 * 1024 * 1024 }
]
},
selectedHosters: ['unlimited.example', 'limited.example', 'unlimited.example'],
hosterSettings: {
'unlimited.example': { maxSizeMb: 0 },
'limited.example': { maxSizeMb: 3 },
'unknown.example': { maxSizeMb: 1 }
}
});
assert.deepEqual(summary, {
candidateCount: 8,
duplicateCount: 2,
filteredCount: 1,
unavailableCount: 3,
acceptedCount: 2,
targetCount: 2,
jobCount: 3,
sizeLimitedJobCount: 1
});
});
it('limits concurrent file inspections', async () => {
let active = 0;
let maximumActive = 0;
const inspection = await inspectImportEntries(
Array.from({ length: 12 }, (_, index) => `C:/incoming/file-${index}.bin`),
{
concurrency: 3,
inspectPath: async () => {
active++;
maximumActive = Math.max(maximumActive, active);
await new Promise(resolve => setTimeout(resolve, 5));
active--;
return { exists: true, readable: true, size: 1 };
}
}
);
assert.equal(inspection.acceptedCount, 12);
assert.equal(maximumActive, 3);
});
});
+34 -5
View File
@@ -1548,9 +1548,36 @@ setTimeout(async () => {
check('Filename filter follows value, comparison, conditions, then action', filenameFilterGeometry.ruleLabelsAbove && filenameFilterGeometry.inputBeforeOperator && filenameFilterGeometry.compactRule && filenameFilterGeometry.alignedRule && filenameFilterGeometry.policyBelowRule && filenameFilterGeometry.policyOrder && filenameFilterGeometry.policyLabelsAbove && filenameFilterGeometry.equalPolicyWidths);
const filenameFilterPersistence = await wc.executeJavaScript('(async () => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = true; enabled.dispatchEvent(new Event("change", { bubbles: true })); const first = document.querySelector("[data-filename-filter-condition]"); first.querySelector("[data-filename-filter-operator]").value = "contains"; first.querySelector("[data-filename-filter-value]").value = "720p"; first.querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("addFilenameFilterConditionBtn").click(); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[1].querySelector("[data-filename-filter-operator]").value = "notContains"; rows[1].querySelector("[data-filename-filter-value]").value = "sample"; rows[1].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); const dirty = document.getElementById("saveSettingsBtn").disabled === false; await saveSettings({ feedbackText: "Gespeichert" }); const saved = (await window.api.getGlobalSettings()).filenameFilter; return { dirty, saved }; })()');
check('Filename filter conditions participate in dirty tracking and persist canonically', filenameFilterPersistence.dirty === true && filenameFilterPersistence.saved?.enabled === true && filenameFilterPersistence.saved?.action === 'include' && filenameFilterPersistence.saved?.matchMode === 'all' && JSON.stringify(filenameFilterPersistence.saved?.conditions) === JSON.stringify([{ operator: 'contains', value: '720p' }, { operator: 'notContains', value: 'sample' }]));
const filenameFilterImport = await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); addPathsToQueue([{ path: "C:/filter/Episode.720p.mkv", name: "Episode.720p.mkv", size: 10 }, { path: "C:/filter/Episode.720p.Sample.mkv", name: "Episode.720p.Sample.mkv", size: 11 }, { path: "C:/filter/Episode.1080p.mkv", name: "Episode.1080p.mkv", size: 12 }]); return { modal: document.getElementById("hosterModal")?.style.display, description: document.getElementById("hosterModalDescription")?.textContent, pending: _pendingFiles.map(file => file.name) }; })()');
check('Filename filter previews accepted and excluded counts before host selection', filenameFilterImport.modal === 'flex' && filenameFilterImport.pending.length === 1 && filenameFilterImport.pending[0] === 'Episode.720p.mkv' && /1 von 3/.test(filenameFilterImport.description || '') && /2/.test(filenameFilterImport.description || ''));
const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { cancelHosterModal(); await addDropTargetEntries([{ path: "C:/filter/Floating.720p.mkv" }, { path: "C:/filter/Floating.1080p.mkv" }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: "C:/filter/Desktop.720p.mkv", name: "Desktop.720p.mkv", size: 12, type: "video/x-matroska" }, { path: "C:/filter/Desktop.1080p.mkv", name: "Desktop.1080p.mkv", size: 12, type: "video/x-matroska" }]); const desktop = _pendingFiles.map(file => file.name); cancelHosterModal(); return { floating, desktop }; })()');
const importPreflightFolder = fs.mkdtempSync(path.join(app.getPath('temp'), 'mhu-import-preflight-'));
const importPreflightFixtures = {
existing: path.join(importPreflightFolder, 'Existing.720p.bin'),
accepted: path.join(importPreflightFolder, 'Episode.720p.mkv'),
large: path.join(importPreflightFolder, 'Large.720p.custom'),
sample: path.join(importPreflightFolder, 'Episode.720p.Sample.mkv'),
filtered: path.join(importPreflightFolder, 'Episode.1080p.mkv'),
empty: path.join(importPreflightFolder, 'Empty.720p.bin'),
missing: path.join(importPreflightFolder, 'Missing.720p.bin'),
floatingAccepted: path.join(importPreflightFolder, 'Floating.720p.mkv'),
floatingFiltered: path.join(importPreflightFolder, 'Floating.1080p.mkv'),
desktopAccepted: path.join(importPreflightFolder, 'Desktop.720p.mkv'),
desktopFiltered: path.join(importPreflightFolder, 'Desktop.1080p.mkv'),
rejected: path.join(importPreflightFolder, 'Only.1080p.mkv')
};
for (const filePath of Object.values(importPreflightFixtures)) {
if (filePath === importPreflightFixtures.missing) continue;
fs.writeFileSync(filePath, filePath === importPreflightFixtures.empty ? Buffer.alloc(0) : Buffer.from('fixture'));
}
fs.writeFileSync(importPreflightFixtures.large, Buffer.alloc(2 * 1024 * 1024));
const filenameFilterImport = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; selectedFiles = [{ path: fixtures.existing, name: "Existing.720p.bin", size: 7 }]; _pendingFiles = []; _pendingImportInspection = null; queueJobs = []; rebuildJobIndex(); const available = getAvailableHosters().slice(0, 1).map(item => item.name); selectedUploadHosters = available; window.__importPreflightHosterSettings = hosterSettings; hosterSettings = { ...hosterSettings, [available[0]]: { ...(hosterSettings[available[0]] || {}), maxSizeMb: 1 } }; await addPathsToQueue([fixtures.existing, fixtures.accepted, fixtures.large, fixtures.sample, fixtures.filtered, fixtures.empty, fixtures.missing]); const read = () => Object.fromEntries(["Candidates", "Duplicates", "Filtered", "Unavailable", "Accepted", "Targets", "Jobs", "SizeLimited"].map(key => [key, Number(document.getElementById("importPlan" + key)?.textContent)])); const initial = read(); const inputs = [...document.querySelectorAll("input[data-hoster-modal]")]; inputs[0]?.click(); const reduced = read(); inputs[0]?.click(); const restored = read(); setUiLanguage("en"); const englishLabels = [...document.querySelectorAll("#importPlanSummary dt")].map(node => node.textContent.trim()); setUiLanguage("de"); return { modal: document.getElementById("hosterModal")?.style.display, description: document.getElementById("hosterModalDescription")?.textContent, pending: _pendingFiles.map(file => file.name).sort(), available, initial, reduced, restored, englishLabels }; })()');
if (!(filenameFilterImport.modal === 'flex' && filenameFilterImport.available.length === 1 && filenameFilterImport.pending.join('|') === ['Episode.720p.mkv', 'Large.720p.custom'].sort().join('|') && JSON.stringify(filenameFilterImport.initial) === JSON.stringify({ Candidates: 7, Duplicates: 1, Filtered: 2, Unavailable: 2, Accepted: 2, Targets: 1, Jobs: 1, SizeLimited: 1 }) && JSON.stringify(filenameFilterImport.reduced) === JSON.stringify({ Candidates: 7, Duplicates: 1, Filtered: 2, Unavailable: 2, Accepted: 2, Targets: 0, Jobs: 0, SizeLimited: 0 }) && JSON.stringify(filenameFilterImport.restored) === JSON.stringify(filenameFilterImport.initial))) console.log('Import preflight state: ' + JSON.stringify(filenameFilterImport));
check('Import preflight reports every exclusion and configured size-limit job exactly', filenameFilterImport.modal === 'flex' && filenameFilterImport.available.length === 1 && filenameFilterImport.pending.join('|') === ['Episode.720p.mkv', 'Large.720p.custom'].sort().join('|') && JSON.stringify(filenameFilterImport.initial) === JSON.stringify({ Candidates: 7, Duplicates: 1, Filtered: 2, Unavailable: 2, Accepted: 2, Targets: 1, Jobs: 1, SizeLimited: 1 }) && JSON.stringify(filenameFilterImport.reduced) === JSON.stringify({ Candidates: 7, Duplicates: 1, Filtered: 2, Unavailable: 2, Accepted: 2, Targets: 0, Jobs: 0, SizeLimited: 0 }) && JSON.stringify(filenameFilterImport.restored) === JSON.stringify(filenameFilterImport.initial));
check('Import preflight copy switches completely to English without a restart', filenameFilterImport.englishLabels.join('|') === 'Candidates|Already present / duplicated|Excluded by filename filter|Missing / unreadable / empty|Accepted files|Selected destinations|Resulting jobs|Jobs omitted by configured size limits');
const importPreflightBounds = win.getBounds();
await setWindowBounds({ ...importPreflightBounds, width: 800, height: 550 });
const importPreflightMinimumFit = await wc.executeJavaScript('(() => { const card = document.querySelector("#hosterModal .modal-card"); const summary = document.getElementById("importPlanSummary"); const cardRect = card?.getBoundingClientRect(); return Boolean(cardRect && summary && cardRect.left >= 0 && cardRect.right <= innerWidth && cardRect.top >= 0 && cardRect.bottom <= innerHeight && summary.scrollWidth <= summary.clientWidth + 1 && [...summary.querySelectorAll("dd")].every(value => value.getBoundingClientRect().width > 0)); })()');
await setWindowBounds(importPreflightBounds);
check('Import preflight remains contained and readable at the minimum window size', importPreflightMinimumFit === true);
const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); await addDropTargetEntries([{ path: fixtures.floatingAccepted }, { path: fixtures.floatingFiltered }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7, type: "video/x-matroska" }, { path: fixtures.desktopFiltered, name: "Desktop.1080p.mkv", size: 7, type: "video/x-matroska" }]); const desktop = _pendingFiles.map(file => file.name); cancelHosterModal(); return { floating, desktop }; })()');
check('Filename filter applies identically to floating and native desktop drops', JSON.stringify(filenameFilterDropPaths.floating) === JSON.stringify(['Floating.720p.mkv']) && JSON.stringify(filenameFilterDropPaths.desktop) === JSON.stringify(['Desktop.720p.mkv']));
await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); config.globalSettings.folderMonitor = { ...(config.globalSettings.folderMonitor || {}), hosters: ["voe.sx"], autoStart: false }; })()');
wc.send('folder-monitor:new-files', ['C:/filter/Watched.720p.mkv', 'C:/filter/Watched.1080p.mkv']);
@@ -1558,8 +1585,10 @@ setTimeout(async () => {
const filenameFilterFolderMonitor = await wc.executeJavaScript('(() => ({ files: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => job.fileName), modal: document.getElementById("hosterModal")?.style.display, toast: document.getElementById("copyToast")?.textContent }))()');
check('Filename filter also applies to monitored folders with preset destinations', JSON.stringify(filenameFilterFolderMonitor.files) === JSON.stringify(['Watched.720p.mkv']) && filenameFilterFolderMonitor.jobs.every(name => name === 'Watched.720p.mkv') && filenameFilterFolderMonitor.modal !== 'flex' && /1 von 2/.test(filenameFilterFolderMonitor.toast || ''));
await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView()');
const filenameFilterRejectAll = await wc.executeJavaScript('(() => { cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); addPathsToQueue([{ path: "C:/filter/Only.1080p.mkv", name: "Only.1080p.mkv", size: 10 }]); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: toast.textContent, shown: toast.classList.contains("show") }; })()');
check('A fully excluded import stays out of the queue and explains the result', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && /0 von 1/.test(filenameFilterRejectAll.toast || ''));
const filenameFilterRejectAll = await wc.executeJavaScript('(async () => { const rejected = ' + JSON.stringify(importPreflightFixtures.rejected) + '; cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: toast.textContent, shown: toast.classList.contains("show") }; })()');
check('A fully excluded import stays out of the queue and explains the result', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && filenameFilterRejectAll.toast === 'Keine Dateien wurden akzeptiert.');
await wc.executeJavaScript('hosterSettings = window.__importPreflightHosterSettings; delete window.__importPreflightHosterSettings; true');
fs.rmSync(importPreflightFolder, { recursive: true, force: true });
await wc.executeJavaScript('(() => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = false; enabled.dispatchEvent(new Event("change", { bubbles: true })); return saveSettings({ feedbackText: "Gespeichert" }); })()');
const plaintextCredentialOverride = await wc.executeJavaScript('(() => ({ control: document.getElementById("allowPlaintextCredentialStorageInput"), copy: document.body.textContent.includes("Unsichere Klartext-Speicherung"), bridge: typeof window.api.getSecretStoreStatus }))()');
check('Settings expose no plaintext credential storage override', plaintextCredentialOverride.control === null && plaintextCredentialOverride.copy === false && plaintextCredentialOverride.bridge === 'undefined');