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:
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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' };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user