From 88a3ee09377a4a75267f84331dcfd6a583194885 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:31:56 +0200 Subject: [PATCH] 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. --- lib/import-preflight.js | 144 +++++++++++++++++++++++++ main.js | 27 +++++ preload.js | 1 + renderer/app.js | 185 ++++++++++++++++++++------------- renderer/i18n.js | 12 +++ renderer/index.html | 11 ++ renderer/styles.css | 30 ++++++ tests/import-preflight.test.js | 104 ++++++++++++++++++ tests/ui-smoke.js | 39 ++++++- 9 files changed, 475 insertions(+), 78 deletions(-) create mode 100644 lib/import-preflight.js create mode 100644 tests/import-preflight.test.js diff --git a/lib/import-preflight.js b/lib/import-preflight.js new file mode 100644 index 0000000..48ca61d --- /dev/null +++ b/lib/import-preflight.js @@ -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 +}; +}); diff --git a/main.js b/main.js index cade948..e291414 100644 --- a/main.js +++ b/main.js @@ -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' }; diff --git a/preload.js b/preload.js index 8a73ad6..ccd7b42 100644 --- a/preload.js +++ b/preload.js @@ -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), diff --git a/renderer/app.js b/renderer/app.js index 7f9e93b..95acca4 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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); diff --git a/renderer/i18n.js b/renderer/i18n.js index f2ed63e..00007b9 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -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.'], diff --git a/renderer/index.html b/renderer/index.html index e5c87a0..a62d9dc 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -663,6 +663,16 @@
+
+
Kandidaten
0
+
Bereits vorhanden / dupliziert
0
+
Durch Dateinamenfilter ausgeschlossen
0
+
Fehlend / unlesbar / leer
0
+
Akzeptierte Dateien
0
+
Ausgewählte Ziele
0
+
Entstehende Jobs
0
+
Durch konfigurierte Größenlimits entfallene Jobs
0
+