feat: add secure batch completion reports

Generate immutable post-cleanup summaries only after queue, history, and recovery finalization. Add bilingual accessible report UI, host and cleanup metrics, report-bound JSON and sanitized error CSV exports, renderer reload recovery, duplicate-filename-safe accounting, and public-source verification coverage.
This commit is contained in:
Sucukdeluxe
2026-08-17 00:18:20 +02:00
parent c8170b8556
commit 329d22e5b7
18 changed files with 1282 additions and 36 deletions
+5 -1
View File
@@ -8,13 +8,14 @@ Multi Hoster Uploader is a Windows desktop application for sending file batches
Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
The latest public release is version 2.1.21. Use the release page for the executables and the full English changelog. The latest public release is version 2.1.22. Use the release page for the executables and the full English changelog.
## Features ## Features
### Upload workspace ### Upload workspace
- Add individual files, complete folders, or files by drag and drop. - Add individual files, complete folders, or files by drag and drop.
- Inspect imports before queue creation and review readable, duplicate, filtered, and host-size-limited files in one summary.
- Filter new imports by file name with reusable include or exclude conditions before upload jobs are created. - Filter new imports by file name with reusable include or exclude conditions before upload jobs are created.
- Review how many selected files were accepted or excluded before choosing upload destinations. - Review how many selected files were accepted or excluded before choosing upload destinations.
- Build one job per selected file and destination. - Build one job per selected file and destination.
@@ -34,12 +35,14 @@ The latest public release is version 2.1.21. Use the release page for the execut
- Keep multiple named accounts for each host. - Keep multiple named accounts for each host.
- Validate credentials before a new or edited account is saved. - Validate credentials before a new or edited account is saved.
- Run health checks for one account or all configured accounts. - Run health checks for one account or all configured accounts.
- Review recent host reliability, throughput, last success, and account availability in a dedicated health overview.
- Complete an OTP check in the account view when a host requests it. - Complete an OTP check in the account view when a host requests it.
- Enable, disable, prioritize, and reorder accounts. - Enable, disable, prioritize, and reorder accounts.
- Rotate files across enabled accounts or keep the first enabled account as the primary account. - Rotate files across enabled accounts or keep the first enabled account as the primary account.
- Switch to an available fallback account when an account-specific upload error is detected. - Switch to an available fallback account when an account-specific upload error is detected.
- Apply retries, concurrency, bandwidth, file-size, and pacing settings per host. - Apply retries, concurrency, bandwidth, file-size, and pacing settings per host.
- Monitor a folder for new files and start matching uploads automatically. - Monitor a folder for new files and start matching uploads automatically.
- Restrict new upload starts to configurable weekday and local-time windows while allowing active transfers to finish.
### History, transfer, and updates ### History, transfer, and updates
@@ -48,6 +51,7 @@ The latest public release is version 2.1.21. Use the release page for the execut
- Retain all history, a time window, or the latest 100 or 1,000 uploads. - Retain all history, a time window, or the latest 100 or 1,000 uploads.
- Export history as CSV or JSON. - Export history as CSV or JSON.
- Export a per-session CSV or JSON report with host success rates, duration, bytes, attempts, and errors. - Export a per-session CSV or JSON report with host success rates, duration, bytes, attempts, and errors.
- Review a final post-cleanup batch report with file, job, host, transfer, and source-cleanup totals, then export the complete report as JSON or sanitized errors as CSV.
- Clearly mark interrupted uploads after a restart so they can be resumed deliberately. - Clearly mark interrupted uploads after a restart so they can be resumed deliberately.
- Use the complete interface in English or German and switch at runtime. - Use the complete interface in English or German and switch at runtime.
- Export settings locally or transfer them with an encrypted online backup key. - Export settings locally or transfer them with an encrypted online backup key.
+188
View File
@@ -0,0 +1,188 @@
const { classifyErrorCategory } = require('./stats');
const { redactLogText } = require('./support-bundle');
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function integer(value) {
return Math.max(0, Math.trunc(number(value)));
}
function iso(value, fallback) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
}
function text(value, secrets, limit = 500) {
const source = value instanceof Error ? value.message : String(value ?? '');
return String(redactLogText(source, secrets) || '').slice(0, limit);
}
function redactPosixPaths(value) {
let output = '';
let index = 0;
while (index < value.length) {
const previous = value[index - 1] || '';
if (value[index] !== '/' || (index > 0 && !/[\s=:([{]/.test(previous))) {
output += value[index++];
continue;
}
let end = index + 1;
while (end < value.length && !/[\s"'<>|]/.test(value[end])) end++;
const candidate = value.slice(index, end);
if (candidate.slice(1).includes('/')) {
output += '<redacted-path>';
index = end;
continue;
}
output += value[index++];
}
return output;
}
function errorText(value, secrets) {
return redactPosixPaths(text(value, secrets)
.replace(/https?:\/\/[^\s"'<>]+/gi, '<redacted-url>'))
.replace(/\b[A-Za-z0-9_-]{24,}\b/g, '<redacted>');
}
function fileName(value, secrets) {
const name = String(value ?? '').split(/[\\/]/).pop() || '';
return text(name, secrets, 260);
}
function createJobTotals() {
return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0 };
}
function createHostTotals() {
return { ...createJobTotals(), successfulBytes: 0 };
}
function addStatus(target, status) {
target.total++;
if (status === 'done') target.succeeded++;
else if (status === 'skipped') target.skipped++;
else if (status === 'aborted') target.aborted++;
else target.failed++;
}
function buildCleanupTotals(outcomes) {
const totals = { requested: 0, deleted: 0, blocked: 0, failed: 0 };
for (const value of Array.isArray(outcomes) ? outcomes : []) {
const outcome = String(value || 'failed');
if (outcome === 'setting-disabled') continue;
totals.requested++;
if (outcome === 'deleted') totals.deleted++;
else if (outcome === 'blocked' || outcome === 'source-changed' || outcome === 'source-missing' || outcome === 'unsafe-source-type') totals.blocked++;
else totals.failed++;
}
return totals;
}
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
function buildBatchCompletionReport(input = {}) {
const summary = input.summary && typeof input.summary === 'object' ? input.summary : {};
const secrets = Array.isArray(input.secrets) ? input.secrets : [];
const completedAt = iso(input.completedAt, new Date().toISOString());
const startedAt = iso(input.startedAt ?? summary.timestamp, completedAt);
const durationSec = Math.max(0, (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000);
const files = { total: 0, fullySucceeded: 0, partiallySucceeded: 0, failed: 0 };
const jobs = createJobTotals();
const hostMap = new Map();
const errors = [];
let successfulBytes = 0;
for (const file of Array.isArray(summary.files) ? summary.files : []) {
const results = Array.isArray(file?.results) ? file.results : [];
if (results.length === 0) continue;
files.total++;
const size = number(file?.size);
const successful = results.filter(result => result?.status === 'done').length;
if (successful === results.length) files.fullySucceeded++;
else if (successful > 0) files.partiallySucceeded++;
else files.failed++;
const safeFileName = fileName(file?.name ?? file?.fileName, secrets);
for (const result of results) {
const status = String(result?.status || 'error');
const hoster = text(result?.hoster || 'unknown', secrets, 120) || 'unknown';
if (!hostMap.has(hoster)) hostMap.set(hoster, createHostTotals());
const host = hostMap.get(hoster);
addStatus(jobs, status);
addStatus(host, status);
if (status === 'done') {
successfulBytes += size;
host.successfulBytes += size;
}
if (status === 'error' || result?.remoteCommitUncertain === true) {
const message = errorText(result?.error || 'Unknown error', secrets);
errors.push({
jobId: text(result?.jobId, secrets, 160),
fileName: safeFileName,
hoster,
status,
category: classifyErrorCategory(message),
attempt: integer(result?.attempt),
maxAttempts: integer(result?.maxAttempts),
remoteCommitUncertain: result?.remoteCommitUncertain === true,
message
});
}
}
}
const hosters = Object.fromEntries([...hostMap.entries()].sort(([left], [right]) => left.localeCompare(right)));
const batchId = text(summary.id, secrets, 160);
const report = {
reportId: text(input.reportId || `report-${batchId || completedAt}`, secrets, 200),
batchId,
startedAt,
completedAt,
generatedAt: completedAt,
durationSec,
files,
jobs,
cleanup: buildCleanupTotals(input.cleanupOutcomes),
transfer: {
successfulBytes,
averageBytesPerSecond: durationSec > 0 ? successfulBytes / durationSec : 0
},
hosters,
errors
};
return deepFreeze(report);
}
function csvCell(value) {
let output = value === null || value === undefined ? '' : String(value);
if (/^[\u0000-\u0020]*[=+\-@]/.test(output)) output = `'${output}`;
return /[",\r\n]/.test(output) ? `"${output.replace(/"/g, '""')}"` : output;
}
function buildBatchErrorCsv(report) {
const rows = [['Job ID', 'File name', 'Host', 'Status', 'Category', 'Attempt', 'Max attempts', 'Remote commit uncertain', 'Message']];
for (const error of Array.isArray(report?.errors) ? report.errors : []) {
rows.push([
error.jobId,
error.fileName,
error.hoster,
error.status,
error.category,
integer(error.attempt),
integer(error.maxAttempts),
error.remoteCommitUncertain === true ? 'true' : 'false',
error.message
]);
}
return `${rows.map(row => row.map(csvCell).join(',')).join('\n')}\n`;
}
module.exports = { buildBatchCompletionReport, buildBatchErrorCsv };
+6 -1
View File
@@ -196,8 +196,10 @@
}; };
const existingJobIds = new Set(); const existingJobIds = new Set();
const filesByName = new Map(); const filesByName = new Map();
const filesByKey = new Map();
for (const file of merged.files) { for (const file of merged.files) {
filesByName.set(String(file.name || file.fileName || ''), file); filesByName.set(String(file.name || file.fileName || ''), file);
if (file.fileKey) filesByKey.set(String(file.fileKey), file);
for (const result of file.results) { for (const result of file.results) {
if (result?.jobId) existingJobIds.add(result.jobId); if (result?.jobId) existingJobIds.add(result.jobId);
} }
@@ -206,11 +208,14 @@
for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) { for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) {
if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue; if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue;
const fileName = String(skipped.fileName || skipped.file || '').split(/[\\/]/).pop() || ''; const fileName = String(skipped.fileName || skipped.file || '').split(/[\\/]/).pop() || '';
let file = filesByName.get(fileName); const fileKey = String(skipped.fileKey || '');
let file = fileKey ? filesByKey.get(fileKey) : filesByName.get(fileName);
if (!file) { if (!file) {
file = { name: fileName, size: Number(skipped.size) || 0, results: [] }; file = { name: fileName, size: Number(skipped.size) || 0, results: [] };
if (fileKey) file.fileKey = fileKey;
merged.files.push(file); merged.files.push(file);
filesByName.set(fileName, file); filesByName.set(fileName, file);
if (fileKey) filesByKey.set(fileKey, file);
} }
file.results.push({ file.results.push({
jobId: skipped.jobId || null, jobId: skipped.jobId || null,
+2 -2
View File
@@ -441,7 +441,7 @@ class UploadManager extends EventEmitter {
for (let j = i; j < end; j++) { for (let j = i; j < end; j++) {
const task = tasks[j]; const task = tasks[j];
if (!results.has(task.file)) { if (!results.has(task.file)) {
results.set(task.file, { name: path.basename(task.file), size: 0, results: [] }); results.set(task.file, { name: path.basename(task.file), fileKey: task.fileKey || null, size: 0, results: [] });
toStat.push(task.file); toStat.push(task.file);
} }
} }
@@ -1863,7 +1863,7 @@ class UploadManager extends EventEmitter {
if (!results.has(task.file)) { if (!results.has(task.file)) {
let size = 0; let size = 0;
try { size = fs.statSync(task.file).size; } catch {} try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] }); results.set(task.file, { name: fileName, fileKey: task.fileKey || null, size, results: [] });
} }
this._batchTotal++; this._batchTotal++;
this._additionalPromises.push(this._runJob(task, results, signal)); this._additionalPromises.push(this._runJob(task, results, signal));
+6 -1
View File
@@ -39,7 +39,12 @@
const filePath = typeof task.file === 'string' ? task.file : ''; const filePath = typeof task.file === 'string' ? task.file : '';
const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`; const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`;
const key = filePath || `${fileName}\0${index}`; const key = filePath || `${fileName}\0${index}`;
if (!files.has(key)) files.set(key, { name: fileName, size: 0, results: [] }); if (!files.has(key)) files.set(key, {
name: fileName,
...(typeof task.fileKey === 'string' && task.fileKey ? { fileKey: task.fileKey } : {}),
size: 0,
results: []
});
files.get(key).results.push({ files.get(key).results.push({
jobId: typeof task.jobId === 'string' ? task.jobId : '', jobId: typeof task.jobId === 'string' ? task.jobId : '',
hoster: typeof task.hoster === 'string' ? task.hoster : '', hoster: typeof task.hoster === 'string' ? task.hoster : '',
+116 -27
View File
@@ -21,6 +21,7 @@ const {
configureStartupRenderer(app); configureStartupRenderer(app);
nativeTheme.themeSource = 'dark'; nativeTheme.themeSource = 'dark';
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto');
const ConfigStore = require('./lib/config-store'); const ConfigStore = require('./lib/config-store');
const UploadManager = require('./lib/upload-manager'); const UploadManager = require('./lib/upload-manager');
const { createSourceFileCleanup } = require('./lib/source-file-cleanup'); const { createSourceFileCleanup } = require('./lib/source-file-cleanup');
@@ -51,6 +52,7 @@ const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors'); const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent'); const { createAgent } = require('./lib/diagnostics-agent');
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
const { buildBatchCompletionReport, buildBatchErrorCsv } = require('./lib/batch-completion-report');
const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/upload-recovery'); const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/upload-recovery');
const { selectPublicUploadUrl } = require('./lib/upload-confirmation'); const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
const { createBatchMutationGate } = require('./lib/batch-mutation-gate'); const { createBatchMutationGate } = require('./lib/batch-mutation-gate');
@@ -140,7 +142,10 @@ configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
let uploadManager = null; let uploadManager = null;
const uploadBatchMutationGates = new WeakMap(); const uploadBatchMutationGates = new WeakMap();
const uploadRecoveryStates = new WeakMap(); const uploadRecoveryStates = new WeakMap();
const uploadBatchAdmissionSkips = new WeakMap();
const batchCompletionReports = new Map();
let lastSessionSummary = null; let lastSessionSummary = null;
let lastBatchCompletionReport = null;
let startupRecoveryCoordinator = null; let startupRecoveryCoordinator = null;
let startupRevealGate = null; let startupRevealGate = null;
let startupRendererHandlers = null; let startupRendererHandlers = null;
@@ -1134,6 +1139,25 @@ async function _persistFallbackLogPath(workingPath) {
} }
} }
function publishBatchCompletionReport(summary, options = {}) {
if (isAllAborted(summary)) return null;
let secrets = [];
try { secrets = collectSecretValues(configStore.load()); } catch {}
const report = buildBatchCompletionReport({
reportId: `report-${summary?.id || Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
summary,
startedAt: options.startedAt,
completedAt: options.completedAt,
cleanupOutcomes: options.cleanupOutcomes,
secrets
});
batchCompletionReports.set(report.reportId, report);
while (batchCompletionReports.size > 5) batchCompletionReports.delete(batchCompletionReports.keys().next().value);
lastBatchCompletionReport = report;
safeSend('upload-batch-report', report);
return report;
}
// Whether this hoster's successful links should land in fileuploader.log. // Whether this hoster's successful links should land in fileuploader.log.
// Reads the LIVE uploadManager.hosterSettings (kept current via // Reads the LIVE uploadManager.hosterSettings (kept current via
// updateSettings) so a mid-batch toggle takes effect immediately. Falls back // updateSettings) so a mid-batch toggle takes effect immediately. Falls back
@@ -1293,6 +1317,12 @@ function buildTaskFromAccount(hoster, account, extra) {
return task; return task;
} }
function buildBatchFileKey(file) {
const resolved = path.resolve(String(file || ''));
const canonical = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
return crypto.createHash('sha256').update(canonical).digest('hex');
}
let _rotationCursors = null; let _rotationCursors = null;
function rotationCursors() { function rotationCursors() {
if (_rotationCursors === null) { if (_rotationCursors === null) {
@@ -1323,7 +1353,7 @@ function buildUploadTasks(config, files, hosters, pick) {
for (const hoster of hosters) { for (const hoster of hosters) {
const account = pick(hoster); const account = pick(hoster);
if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; } if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; }
tasks.push(buildTaskFromAccount(hoster, account, { file })); tasks.push(buildTaskFromAccount(hoster, account, { file, fileKey: buildBatchFileKey(file) }));
} }
} }
return tasks; return tasks;
@@ -1338,6 +1368,7 @@ function buildUploadTasksFromJobs(config, jobs, pick) {
if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); continue; } if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); continue; }
tasks.push(buildTaskFromAccount(job.hoster, account, { tasks.push(buildTaskFromAccount(job.hoster, account, {
file: job.file, file: job.file,
fileKey: buildBatchFileKey(job.file),
jobId: job.id || job.jobId || null, jobId: job.id || job.jobId || null,
sourceCleanupToken: job.sourceCleanupToken || null sourceCleanupToken: job.sourceCleanupToken || null
})); }));
@@ -2248,6 +2279,27 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
return out; return out;
}); });
ipcMain.handle('get-last-batch-completion-report', () => lastBatchCompletionReport);
ipcMain.handle('export-batch-completion-report', async (_event, reportId, format) => {
const report = batchCompletionReports.get(String(reportId || ''));
if (!report) return { ok: false, error: shellText('Der Batch-Bericht ist nicht mehr verfügbar', 'The batch report is no longer available') };
const normalizedFormat = String(format || '').toLowerCase();
if (normalizedFormat !== 'json' && normalizedFormat !== 'csv') return { ok: false, error: shellText('Ungültiges Exportformat', 'Invalid export format') };
const datePrefix = report.completedAt.slice(0, 10);
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: shellText('Batch-Bericht exportieren', 'Export batch report'),
defaultPath: `upload-batch-report-${datePrefix}.${normalizedFormat}`,
filters: normalizedFormat === 'json'
? [{ name: shellText('JSON-Datei', 'JSON file'), extensions: ['json'] }]
: [{ name: shellText('CSV-Datei', 'CSV file'), extensions: ['csv'] }]
});
if (canceled || !filePath) return { ok: false, canceled: true };
const content = normalizedFormat === 'json' ? JSON.stringify(report, null, 2) : buildBatchErrorCsv(report);
fs.writeFileSync(filePath, content, 'utf-8');
return { ok: true, path: filePath, format: normalizedFormat, reportId: report.reportId };
});
ipcMain.handle('inspect-import-files', async (_event, payload) => { ipcMain.handle('inspect-import-files', async (_event, payload) => {
const input = payload && typeof payload === 'object' ? payload : {}; const input = payload && typeof payload === 'object' ? payload : {};
const currentConfig = configStore.load(); const currentConfig = configStore.load();
@@ -2270,6 +2322,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
async function executeReservedUploadStart(payload, startLease) { async function executeReservedUploadStart(payload, startLease) {
const config = configStore.load(); const config = configStore.load();
const batchStartedAt = new Date().toISOString();
const files = payload && Array.isArray(payload.files) ? payload.files : []; const files = payload && Array.isArray(payload.files) ? payload.files : [];
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
@@ -2292,6 +2345,7 @@ async function executeReservedUploadStart(payload, startLease) {
jobId: j.id, jobId: j.id,
file: j.file, file: j.file,
fileName: j.fileName || path.basename(j.file || ''), fileName: j.fileName || path.basename(j.file || ''),
fileKey: buildBatchFileKey(j.file),
size: Number(j.bytesTotal) || 0, size: Number(j.bytesTotal) || 0,
hoster: j.hoster, hoster: j.hoster,
reason: 'Kein gültiger Account für diesen Hoster' reason: 'Kein gültiger Account für diesen Hoster'
@@ -2311,6 +2365,22 @@ async function executeReservedUploadStart(payload, startLease) {
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
persistRotation(pick); persistRotation(pick);
const sourceCleanup = createSourceFileCleanup({
fs,
path,
platform: process.platform,
isEnabled: () => configStore.load().globalSettings?.deleteSourceAfterSuccessfulUpload === true,
audit: appendSourceCleanupAudit,
journal: sourceDeleteJournal
});
let sourceCleanupFingerprints;
try {
sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups);
} catch (error) {
return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` };
}
for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId);
if (tasks.length === 0) { if (tasks.length === 0) {
const skippedSummary = stats.mergeSkippedIntoSummary({ const skippedSummary = stats.mergeSkippedIntoSummary({
id: `skipped-${Date.now()}`, id: `skipped-${Date.now()}`,
@@ -2329,6 +2399,15 @@ async function executeReservedUploadStart(payload, startLease) {
}); });
if (!finalization.queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); if (!finalization.queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
if (!finalization.terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); if (!finalization.terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
const cleanupOutcomes = await sourceCleanup.finishBatch({
historyPersisted: finalization.historyPersisted,
queuePersisted: finalization.queuePersisted && finalization.terminalRecoveryPersisted
});
publishBatchCompletionReport(skippedSummary, {
startedAt: batchStartedAt,
completedAt: new Date().toISOString(),
cleanupOutcomes
});
return { return {
started: true, started: true,
taskCount: 0, taskCount: 0,
@@ -2343,10 +2422,12 @@ async function executeReservedUploadStart(payload, startLease) {
uploadBatchMutationGates.set(uploadManager, batchMutationGate); uploadBatchMutationGates.set(uploadManager, batchMutationGate);
globalThis._mhuUploadManagerRef = uploadManager; globalThis._mhuUploadManagerRef = uploadManager;
const _thisManager = uploadManager; const _thisManager = uploadManager;
const batchAdmissionSkippedJobs = [...skippedJobs];
uploadBatchAdmissionSkips.set(_thisManager, batchAdmissionSkippedJobs);
const recovery = { const recovery = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
startedAt: new Date().toISOString(), startedAt: batchStartedAt,
jobIds: tasks.map(task => task.jobId).filter(Boolean) jobIds: tasks.map(task => task.jobId).filter(Boolean)
}; };
@@ -2375,24 +2456,6 @@ async function executeReservedUploadStart(payload, startLease) {
// new upload; addJobs during a running batch keeps them). // new upload; addJobs during a running batch keeps them).
_jobLogCollector.clear(); _jobLogCollector.clear();
const sourceCleanup = createSourceFileCleanup({
fs,
path,
platform: process.platform,
isEnabled: () => configStore.load().globalSettings?.deleteSourceAfterSuccessfulUpload === true,
audit: appendSourceCleanupAudit,
journal: sourceDeleteJournal
});
let sourceCleanupFingerprints;
try {
sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups);
} catch (error) {
if (uploadManager === _thisManager) {
uploadManager = null;
globalThis._mhuUploadManagerRef = null;
}
return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` };
}
try { try {
await configStore.saveUploadRecovery(recovery); await configStore.saveUploadRecovery(recovery);
} catch (error) { } catch (error) {
@@ -2404,7 +2467,6 @@ async function executeReservedUploadStart(payload, startLease) {
return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }; return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' };
} }
uploadRecoveryStates.set(_thisManager, recovery); uploadRecoveryStates.set(_thisManager, recovery);
for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId);
_thisManager.sourceFileCleanup = sourceCleanup; _thisManager.sourceFileCleanup = sourceCleanup;
const _producerTracker = trackUploadProducer(_thisManager); const _producerTracker = trackUploadProducer(_thisManager);
@@ -2536,7 +2598,7 @@ async function executeReservedUploadStart(payload, startLease) {
// orphans (cancel/addJobs see null, the new batch keeps running invisibly). // orphans (cancel/addJobs see null, the new batch keeps running invisibly).
uploadManager.on('batch-done', async (summary) => { uploadManager.on('batch-done', async (summary) => {
const hadActiveBatchMutation = await batchMutationGate.sealAndDrain(); const hadActiveBatchMutation = await batchMutationGate.sealAndDrain();
summary = stats.mergeSkippedIntoSummary(summary, skippedJobs); summary = stats.mergeSkippedIntoSummary(summary, batchAdmissionSkippedJobs);
lastSessionSummary = summary; lastSessionSummary = summary;
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed }); logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed });
@@ -2557,10 +2619,15 @@ async function executeReservedUploadStart(payload, startLease) {
if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization'); if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization');
await sourceCleanup.finishBatch({ const cleanupOutcomes = await sourceCleanup.finishBatch({
historyPersisted, historyPersisted,
queuePersisted: queuePersisted && terminalRecoveryPersisted && !hadActiveBatchMutation queuePersisted: queuePersisted && terminalRecoveryPersisted && !hadActiveBatchMutation
}); });
publishBatchCompletionReport(summary, {
startedAt: recovery.startedAt,
completedAt: new Date().toISOString(),
cleanupOutcomes
});
_producerTracker.finish(); _producerTracker.finish();
const fullyAborted = isAllAborted(summary); const fullyAborted = isAllAborted(summary);
@@ -2600,9 +2667,22 @@ async function executeReservedUploadStart(payload, startLease) {
primeOverrides: Array.from(_sessionAccountOverrides.entries()) primeOverrides: Array.from(_sessionAccountOverrides.entries())
}).catch(async (err) => { }).catch(async (err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
await batchMutationGate.sealAndDrain(); const hadActiveBatchMutation = await batchMutationGate.sealAndDrain();
const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'); const errorSummary = stats.mergeSkippedIntoSummary(
await uploadFinalizationBarrier.finalize(errorSummary, recovery); buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'),
batchAdmissionSkippedJobs
);
lastSessionSummary = errorSummary;
const finalization = await uploadFinalizationBarrier.finalize(errorSummary, recovery);
const cleanupOutcomes = await sourceCleanup.finishBatch({
historyPersisted: finalization.historyPersisted,
queuePersisted: finalization.queuePersisted && finalization.terminalRecoveryPersisted && !hadActiveBatchMutation
});
publishBatchCompletionReport(errorSummary, {
startedAt: recovery.startedAt,
completedAt: new Date().toISOString(),
cleanupOutcomes
});
_producerTracker.finish(); _producerTracker.finish();
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
@@ -2658,7 +2738,15 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
const skippedJobs = jobs const skippedJobs = jobs
.filter(j => j && j.id && !taskJobIds.has(j.id)) .filter(j => j && j.id && !taskJobIds.has(j.id))
.map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); .map(j => ({
jobId: j.id,
file: j.file,
fileName: j.fileName || path.basename(j.file || ''),
fileKey: buildBatchFileKey(j.file),
size: Number(j.bytesTotal) || 0,
hoster: j.hoster,
reason: 'Kein gültiger Account für diesen Hoster'
}));
if (jobs.length > 0) { if (jobs.length > 0) {
const auditedAdd = await runAfterDurableAudit( const auditedAdd = await runAfterDurableAudit(
() => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'), () => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'),
@@ -2691,6 +2779,7 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
if (batchManager.sourceFileCleanup) { if (batchManager.sourceFileCleanup) {
for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId); for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId);
} }
if (skippedJobs.length > 0) uploadBatchAdmissionSkips.get(batchManager)?.push(...skippedJobs);
if (tasks.length === 0) { if (tasks.length === 0) {
debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`);
+6
View File
@@ -9,6 +9,8 @@ contextBridge.exposeInMainWorld('api', {
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }), pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
exportHistory: (format) => ipcRenderer.invoke('export-history', format), exportHistory: (format) => ipcRenderer.invoke('export-history', format),
exportSessionReport: (format) => ipcRenderer.invoke('export-session-report', format), exportSessionReport: (format) => ipcRenderer.invoke('export-session-report', format),
getLastBatchCompletionReport: () => ipcRenderer.invoke('get-last-batch-completion-report'),
exportBatchCompletionReport: (reportId, format) => ipcRenderer.invoke('export-batch-completion-report', reportId, format),
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters), saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
// Hoster settings // Hoster settings
@@ -118,6 +120,9 @@ contextBridge.exposeInMainWorld('api', {
onUploadBatchDone: (callback) => { onUploadBatchDone: (callback) => {
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
}, },
onUploadBatchReport: (callback) => {
ipcRenderer.on('upload-batch-report', (_event, data) => callback(data));
},
onUploadStats: (callback) => { onUploadStats: (callback) => {
ipcRenderer.on('upload-stats', (_event, data) => callback(data)); ipcRenderer.on('upload-stats', (_event, data) => callback(data));
}, },
@@ -164,6 +169,7 @@ contextBridge.exposeInMainWorld('api', {
removeAllListeners: () => { removeAllListeners: () => {
ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-progress');
ipcRenderer.removeAllListeners('upload-batch-done'); ipcRenderer.removeAllListeners('upload-batch-done');
ipcRenderer.removeAllListeners('upload-batch-report');
ipcRenderer.removeAllListeners('upload-stats'); ipcRenderer.removeAllListeners('upload-stats');
ipcRenderer.removeAllListeners('app:update-available'); ipcRenderer.removeAllListeners('app:update-available');
ipcRenderer.removeAllListeners('app:update-progress'); ipcRenderer.removeAllListeners('app:update-progress');
+225
View File
@@ -61,6 +61,7 @@ function refreshLocalizedRuntimeUi() {
const activeRecentTab = document.querySelector('.recent-tab.active'); const activeRecentTab = document.querySelector('.recent-tab.active');
const hint = document.getElementById('recentFilesHint'); const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'); if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
if (_activeBatchCompletionReport) renderBatchCompletionReport(_activeBatchCompletionReport);
} }
// Dropdown options for "Add Account" modal: value -> label // Dropdown options for "Add Account" modal: value -> label
@@ -511,6 +512,228 @@ const modalController = (() => {
return { open, close, isOpen }; return { open, close, isOpen };
})(); })();
const _shownBatchCompletionReportIds = new Set();
let _activeBatchCompletionReport = null;
let _batchCompletionReportUiReady = false;
function batchReportNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function batchReportInteger(value) {
return Math.max(0, Math.trunc(batchReportNumber(value)));
}
function setBatchCompletionValue(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = batchReportInteger(value).toLocaleString(getUiLocale());
}
function getBatchCompletionOutcome(report) {
const files = report?.files || {};
const jobs = report?.jobs || {};
const cleanup = report?.cleanup || {};
return batchReportInteger(files.partiallySucceeded) > 0
|| batchReportInteger(files.failed) > 0
|| batchReportInteger(jobs.failed) > 0
|| batchReportInteger(jobs.skipped) > 0
|| batchReportInteger(jobs.aborted) > 0
|| batchReportInteger(cleanup.blocked) > 0
|| batchReportInteger(cleanup.failed) > 0
|| (Array.isArray(report?.errors) && report.errors.length > 0)
? 'mixed'
: 'success';
}
function getBatchErrorCategoryLabel(category) {
const labels = {
network: 'Netzwerk',
'hoster-transient': 'Temporärer Hosterfehler',
'file-rejected': 'Datei abgelehnt',
'account-error': 'Account-Fehler',
aborted: 'Abgebrochen',
unknown: 'Unbekannt'
};
return localizeUiText(labels[String(category || '')] || 'Unbekannt');
}
function getBatchErrorStatusLabel(status) {
const labels = {
done: 'Erfolgreich',
error: 'Fehlgeschlagen',
skipped: 'Übersprungen',
aborted: 'Abgebrochen'
};
return localizeUiText(labels[String(status || '')] || 'Fehlgeschlagen');
}
function renderBatchCompletionHosters(report) {
const body = document.getElementById('batchCompletionHostersBody');
if (!body) return;
const rows = Object.entries(report?.hosters && typeof report.hosters === 'object' ? report.hosters : {}).map(([hoster, values]) => {
const row = document.createElement('tr');
row.dataset.hoster = hoster;
const host = document.createElement('th');
host.scope = 'row';
host.textContent = getHosterLabel(hoster);
row.appendChild(host);
[
batchReportInteger(values?.total).toLocaleString(getUiLocale()),
batchReportInteger(values?.succeeded).toLocaleString(getUiLocale()),
batchReportInteger(values?.failed).toLocaleString(getUiLocale()),
batchReportInteger(values?.skipped).toLocaleString(getUiLocale()),
batchReportInteger(values?.aborted).toLocaleString(getUiLocale()),
formatBytes(batchReportNumber(values?.successfulBytes))
].forEach(value => {
const cell = document.createElement('td');
cell.textContent = value;
row.appendChild(cell);
});
return row;
});
body.replaceChildren(...rows);
}
function renderBatchCompletionErrors(report) {
const section = document.getElementById('batchCompletionErrorsSection');
const list = document.getElementById('batchCompletionErrorsList');
const count = document.getElementById('batchCompletionErrorsCount');
const more = document.getElementById('batchCompletionErrorsMore');
if (!section || !list || !count || !more) return;
const errors = Array.isArray(report?.errors) ? report.errors : [];
section.hidden = errors.length === 0;
count.textContent = errors.length.toLocaleString(getUiLocale());
const items = errors.slice(0, 5).map(error => {
const item = document.createElement('li');
const head = document.createElement('div');
head.className = 'batch-completion-error-head';
const file = document.createElement('strong');
file.className = 'batch-completion-error-file';
file.textContent = String(error?.fileName || localizeUiText('Unbekannt'));
const hoster = document.createElement('span');
hoster.textContent = getHosterLabel(String(error?.hoster || ''));
head.append(file, hoster);
const meta = document.createElement('div');
meta.className = 'batch-completion-error-meta';
const status = document.createElement('span');
status.textContent = getBatchErrorStatusLabel(error?.status);
const category = document.createElement('span');
category.textContent = getBatchErrorCategoryLabel(error?.category);
meta.append(status, category);
const attempt = batchReportInteger(error?.attempt);
const maxAttempts = batchReportInteger(error?.maxAttempts);
if (attempt > 0 || maxAttempts > 0) {
const attemptLabel = document.createElement('span');
attemptLabel.textContent = `${localizeUiText('Versuch')} ${attempt}${maxAttempts > 0 ? `/${maxAttempts}` : ''}`;
meta.appendChild(attemptLabel);
}
if (error?.remoteCommitUncertain === true) {
const uncertain = document.createElement('span');
uncertain.className = 'batch-completion-error-uncertain';
uncertain.textContent = localizeUiText('Remote-Abschluss unklar');
meta.appendChild(uncertain);
}
const message = document.createElement('p');
message.className = 'batch-completion-error-message';
message.textContent = String(error?.message || localizeUiText('Unbekannter Fehler'));
item.append(head, meta, message);
return item;
});
list.replaceChildren(...items);
const remaining = Math.max(0, errors.length - items.length);
more.hidden = remaining === 0;
more.textContent = remaining === 1 ? localizeUiText('1 weiterer Fehler') : localizeUiText(`${remaining} weitere Fehler`);
}
function renderBatchCompletionReport(report) {
const modal = document.getElementById('batchCompletionModal');
if (!modal || !report) return false;
_activeBatchCompletionReport = report;
const outcome = getBatchCompletionOutcome(report);
modal.dataset.reportId = String(report.reportId);
modal.dataset.outcome = outcome;
const outcomeLabel = document.getElementById('batchCompletionOutcome');
if (outcomeLabel) outcomeLabel.textContent = localizeUiText(outcome === 'success' ? 'Erfolgreich' : 'Mit Problemen');
const fileCount = batchReportInteger(report.files?.total);
const jobCount = batchReportInteger(report.jobs?.total);
const summary = document.getElementById('batchCompletionSummary');
if (summary) summary.textContent = `${fileCount.toLocaleString(getUiLocale())} ${localizeUiText(fileCount === 1 ? 'Datei' : 'Dateien')} · ${jobCount.toLocaleString(getUiLocale())} ${localizeUiText(jobCount === 1 ? 'Auftrag' : 'Aufträge')} · ${formatDateTime(report.completedAt).text}`;
setBatchCompletionValue('batchCompletionFilesTotal', report.files?.total);
setBatchCompletionValue('batchCompletionFilesFullySucceeded', report.files?.fullySucceeded);
setBatchCompletionValue('batchCompletionFilesPartiallySucceeded', report.files?.partiallySucceeded);
setBatchCompletionValue('batchCompletionFilesFailed', report.files?.failed);
setBatchCompletionValue('batchCompletionJobsTotal', report.jobs?.total);
setBatchCompletionValue('batchCompletionJobsSucceeded', report.jobs?.succeeded);
setBatchCompletionValue('batchCompletionJobsFailed', report.jobs?.failed);
setBatchCompletionValue('batchCompletionJobsSkipped', report.jobs?.skipped);
setBatchCompletionValue('batchCompletionJobsAborted', report.jobs?.aborted);
setBatchCompletionValue('batchCompletionCleanupRequested', report.cleanup?.requested);
setBatchCompletionValue('batchCompletionCleanupDeleted', report.cleanup?.deleted);
setBatchCompletionValue('batchCompletionCleanupBlocked', report.cleanup?.blocked);
setBatchCompletionValue('batchCompletionCleanupFailed', report.cleanup?.failed);
const duration = document.getElementById('batchCompletionDuration');
const bytes = document.getElementById('batchCompletionSuccessfulBytes');
const speed = document.getElementById('batchCompletionAverageSpeed');
if (duration) duration.textContent = formatDuration(Math.round(batchReportNumber(report.durationSec)));
if (bytes) bytes.textContent = formatBytes(batchReportNumber(report.transfer?.successfulBytes));
if (speed) speed.textContent = `${formatBytes(batchReportNumber(report.transfer?.averageBytesPerSecond))}/s`;
renderBatchCompletionHosters(report);
renderBatchCompletionErrors(report);
uiLocalizer.translate(modal);
return true;
}
function closeBatchCompletionReport() {
modalController.close('batchCompletionModal', { fallbackFocus: '#addFilesBtn' });
}
function showBatchCompletionReport(report) {
const reportId = typeof report?.reportId === 'string' ? report.reportId.trim() : '';
if (!reportId || _shownBatchCompletionReportIds.has(reportId)) return false;
_shownBatchCompletionReportIds.add(reportId);
if (!renderBatchCompletionReport({ ...report, reportId })) return false;
return modalController.open('batchCompletionModal', {
initialFocus: '#batchCompletionHeaderCloseBtn',
fallbackFocus: '#addFilesBtn',
onEscape: closeBatchCompletionReport
});
}
async function exportVisibleBatchCompletionReport(format, button) {
const reportId = _activeBatchCompletionReport?.reportId;
if (!reportId || !window.api?.exportBatchCompletionReport) return;
button.disabled = true;
try {
const result = await window.api.exportBatchCompletionReport(reportId, format);
if (result?.ok) showCopyToast(format === 'json' ? 'JSON-Bericht exportiert' : 'Fehler-CSV exportiert');
else if (!result?.canceled) await showAppAlert(result?.error || 'Batch-Bericht konnte nicht exportiert werden.', 'Export fehlgeschlagen');
} catch (error) {
await showAppAlert(getLocalizedErrorDetail(error), 'Export fehlgeschlagen');
} finally {
button.disabled = false;
}
}
function setupBatchCompletionReportUi() {
if (_batchCompletionReportUiReady) return;
_batchCompletionReportUiReady = true;
document.getElementById('batchCompletionHeaderCloseBtn')?.addEventListener('click', closeBatchCompletionReport);
document.getElementById('batchCompletionCloseBtn')?.addEventListener('click', closeBatchCompletionReport);
const jsonButton = document.getElementById('batchCompletionExportJsonBtn');
const csvButton = document.getElementById('batchCompletionExportCsvBtn');
jsonButton?.addEventListener('click', () => exportVisibleBatchCompletionReport('json', jsonButton));
csvButton?.addEventListener('click', () => exportVisibleBatchCompletionReport('csv', csvButton));
window.api?.onUploadBatchReport?.(showBatchCompletionReport);
}
async function showLastBatchCompletionReport() {
if (!window.api?.getLastBatchCompletionReport) return;
try {
showBatchCompletionReport(await window.api.getLastBatchCompletionReport());
} catch {}
}
// Session-specific files for the "Files" panel (resets each session) // Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = []; let sessionFilesData = [];
let _recentSeqCounter = 0; let _recentSeqCounter = 0;
@@ -566,6 +789,7 @@ async function init() {
renderRecentUploadsPanel(); renderRecentUploadsPanel();
updateUploadView(); updateUploadView();
updateStatusBar(); updateStatusBar();
await showLastBatchCompletionReport();
const interruptedCount = queueJobs.filter(job => job.interrupted).length; const interruptedCount = queueJobs.filter(job => job.interrupted).length;
if (interruptedCount > 0) showCopyToast(interruptedCount === 1 ? '1 unterbrochener Upload kann fortgesetzt werden.' : `${interruptedCount} unterbrochene Uploads können fortgesetzt werden.`, 7000); if (interruptedCount > 0) showCopyToast(interruptedCount === 1 ? '1 unterbrochener Upload kann fortgesetzt werden.' : `${interruptedCount} unterbrochene Uploads können fortgesetzt werden.`, 7000);
@@ -8584,6 +8808,7 @@ function updateStatsPanel() {
window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress); window.api.onUpdateProgress(handleUpdateProgress);
window.api.onPrepareClose(prepareForWindowClose); window.api.onPrepareClose(prepareForWindowClose);
setupBatchCompletionReportUi();
setupAppAlertListeners(); setupAppAlertListeners();
init().then(() => { init().then(() => {
window.api.signalCloseHandshakeReady(); window.api.signalCloseHandshakeReady();
+28
View File
@@ -63,6 +63,32 @@
['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'], ['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'],
['Hoster', 'Host'], ['Hoster', 'Host'],
['Versuch', 'Attempt'], ['Versuch', 'Attempt'],
['Batch abgeschlossen', 'Batch complete'],
['Der Upload-Batch wurde abgeschlossen.', 'The upload batch has completed.'],
['Mit Problemen', 'Completed with issues'],
['Übertragung', 'Transfer'],
['Dauer', 'Duration'],
['Erfolgreich übertragen', 'Successfully transferred'],
['Durchschnitt', 'Average'],
['Vollständig erfolgreich', 'Fully successful'],
['Teilweise erfolgreich', 'Partially successful'],
['Auftrag', 'Job'],
['Aufträge', 'Jobs'],
['Angefordert', 'Requested'],
['Gelöscht', 'Deleted'],
['Blockiert', 'Blocked'],
['Hosterübersicht', 'Host overview'],
['Übertragen', 'Transferred'],
['Fehlerbeispiele', 'Error examples'],
['Fehler-CSV exportieren', 'Export error CSV'],
['Temporärer Hosterfehler', 'Temporary host error'],
['Datei abgelehnt', 'File rejected'],
['Account-Fehler', 'Account error'],
['Remote-Abschluss unklar', 'Remote completion uncertain'],
['1 weiterer Fehler', '1 more error'],
['JSON-Bericht exportiert', 'JSON report exported'],
['Fehler-CSV exportiert', 'Error CSV exported'],
['Batch-Bericht konnte nicht exportiert werden.', 'The batch report could not be exported.'],
['Importieren', 'Import'], ['Importieren', 'Import'],
['In Zwischenablage', 'To clipboard'], ['In Zwischenablage', 'To clipboard'],
['In diesem Lauf hochgeladen:', 'Uploaded during this run:'], ['In diesem Lauf hochgeladen:', 'Uploaded during this run:'],
@@ -772,6 +798,7 @@
[/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'], [/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'],
[/^(\d+) Links kopiert$/, '$1 links copied'], [/^(\d+) Links kopiert$/, '$1 links copied'],
[/^(\d+) Link kopiert$/, '$1 link copied'], [/^(\d+) Link kopiert$/, '$1 link copied'],
[/^(\d+) weitere Fehler$/, '$1 more errors'],
[/^Wirklich alle (\d+) Links aus diesem Panel entfernen\?$/, 'Remove all $1 links from this panel?'], [/^Wirklich alle (\d+) Links aus diesem Panel entfernen\?$/, 'Remove all $1 links from this panel?'],
[/^Ein ausgewählter Eintrag wird aus diesem Panel entfernt\.$/, 'One selected entry will be removed from this panel.'], [/^Ein ausgewählter Eintrag wird aus diesem Panel entfernt\.$/, 'One selected entry will be removed from this panel.'],
[/^(\d+) ausgewählte Einträge werden aus diesem Panel entfernt\.$/, '$1 selected entries will be removed from this panel.'], [/^(\d+) ausgewählte Einträge werden aus diesem Panel entfernt\.$/, '$1 selected entries will be removed from this panel.'],
@@ -865,6 +892,7 @@
[/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'], [/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'],
[/^(\d+) job reset for upload$/, '$1 Job zum erneuten Upload zurückgesetzt'], [/^(\d+) job reset for upload$/, '$1 Job zum erneuten Upload zurückgesetzt'],
[/^(\d+) jobs reset for upload$/, '$1 Jobs zum erneuten Upload zurückgesetzt'], [/^(\d+) jobs reset for upload$/, '$1 Jobs zum erneuten Upload zurückgesetzt'],
[/^(\d+) more errors$/, '$1 weitere Fehler'],
[/^(\d+) history entry will be permanently removed\.$/, '$1 Verlaufseintrag wird dauerhaft entfernt.'], [/^(\d+) history entry will be permanently removed\.$/, '$1 Verlaufseintrag wird dauerhaft entfernt.'],
[/^(\d+) history entries will be permanently removed\.$/, '$1 Verlaufseinträge werden dauerhaft entfernt.'], [/^(\d+) history entries will be permanently removed\.$/, '$1 Verlaufseinträge werden dauerhaft entfernt.'],
[/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'], [/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'],
+89
View File
@@ -667,6 +667,95 @@
<button class="btn btn-xs btn-secondary" id="cancelStartupResumeBtn">Abbrechen</button> <button class="btn btn-xs btn-secondary" id="cancelStartupResumeBtn">Abbrechen</button>
</div> </div>
<div class="modal-overlay" id="batchCompletionModal" style="display:none" aria-hidden="true">
<div class="modal-card batch-completion-card" role="dialog" aria-modal="true" aria-labelledby="batchCompletionTitle" aria-describedby="batchCompletionSummary" tabindex="-1">
<div class="modal-header batch-completion-header">
<div class="batch-completion-heading">
<span class="batch-completion-outcome" id="batchCompletionOutcome">Erfolgreich</span>
<h3 id="batchCompletionTitle">Batch abgeschlossen</h3>
<p id="batchCompletionSummary">Der Upload-Batch wurde abgeschlossen.</p>
</div>
<button class="icon-btn" id="batchCompletionHeaderCloseBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body batch-completion-body">
<dl class="batch-completion-transfer" aria-label="Übertragung">
<div><dt>Dauer</dt><dd id="batchCompletionDuration">00:00:00</dd></div>
<div><dt>Erfolgreich übertragen</dt><dd id="batchCompletionSuccessfulBytes">0 B</dd></div>
<div><dt>Durchschnitt</dt><dd id="batchCompletionAverageSpeed">0 B/s</dd></div>
</dl>
<div class="batch-completion-metric-sections">
<section class="batch-completion-section" aria-labelledby="batchCompletionFilesTitle">
<h4 id="batchCompletionFilesTitle">Dateien</h4>
<dl class="batch-completion-metrics">
<div><dt>Gesamt</dt><dd id="batchCompletionFilesTotal">0</dd></div>
<div><dt>Vollständig erfolgreich</dt><dd id="batchCompletionFilesFullySucceeded">0</dd></div>
<div><dt>Teilweise erfolgreich</dt><dd id="batchCompletionFilesPartiallySucceeded">0</dd></div>
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionFilesFailed">0</dd></div>
</dl>
</section>
<section class="batch-completion-section" aria-labelledby="batchCompletionJobsTitle">
<h4 id="batchCompletionJobsTitle">Aufträge</h4>
<dl class="batch-completion-metrics batch-completion-job-metrics">
<div><dt>Gesamt</dt><dd id="batchCompletionJobsTotal">0</dd></div>
<div><dt>Erfolgreich</dt><dd id="batchCompletionJobsSucceeded">0</dd></div>
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionJobsFailed">0</dd></div>
<div><dt>Übersprungen</dt><dd id="batchCompletionJobsSkipped">0</dd></div>
<div><dt>Abgebrochen</dt><dd id="batchCompletionJobsAborted">0</dd></div>
</dl>
</section>
<section class="batch-completion-section" aria-labelledby="batchCompletionCleanupTitle">
<h4 id="batchCompletionCleanupTitle">Quelldateien</h4>
<dl class="batch-completion-metrics">
<div><dt>Angefordert</dt><dd id="batchCompletionCleanupRequested">0</dd></div>
<div><dt>Gelöscht</dt><dd id="batchCompletionCleanupDeleted">0</dd></div>
<div><dt>Blockiert</dt><dd id="batchCompletionCleanupBlocked">0</dd></div>
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionCleanupFailed">0</dd></div>
</dl>
</section>
</div>
<section class="batch-completion-section batch-completion-hosters" aria-labelledby="batchCompletionHostersTitle">
<h4 id="batchCompletionHostersTitle">Hosterübersicht</h4>
<div class="batch-completion-table-scroll" tabindex="0">
<table>
<thead>
<tr>
<th scope="col">Hoster</th>
<th scope="col">Aufträge</th>
<th scope="col">Erfolgreich</th>
<th scope="col">Fehlgeschlagen</th>
<th scope="col">Übersprungen</th>
<th scope="col">Abgebrochen</th>
<th scope="col">Übertragen</th>
</tr>
</thead>
<tbody id="batchCompletionHostersBody"></tbody>
</table>
</div>
</section>
<section class="batch-completion-section batch-completion-errors" id="batchCompletionErrorsSection" aria-labelledby="batchCompletionErrorsTitle" hidden>
<div class="batch-completion-section-heading">
<h4 id="batchCompletionErrorsTitle">Fehlerbeispiele</h4>
<span id="batchCompletionErrorsCount">0</span>
</div>
<ol id="batchCompletionErrorsList"></ol>
<p id="batchCompletionErrorsMore" hidden></p>
</section>
</div>
<div class="modal-footer batch-completion-footer">
<div class="batch-completion-export-actions">
<button class="btn btn-secondary" id="batchCompletionExportJsonBtn">JSON exportieren</button>
<button class="btn btn-secondary" id="batchCompletionExportCsvBtn">Fehler-CSV exportieren</button>
</div>
<button class="btn btn-primary" id="batchCompletionCloseBtn">Schließen</button>
</div>
</div>
</div>
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true"> <div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true">
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage" tabindex="-1"> <div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage" tabindex="-1">
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p> <p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
+233
View File
@@ -735,6 +735,239 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
gap: 8px; gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.batch-completion-card {
width: min(920px, 100%);
max-height: min(calc(100vh - 48px), 820px);
}
.batch-completion-header {
align-items: flex-start;
}
.batch-completion-heading {
min-width: 0;
}
.batch-completion-outcome {
display: inline-flex;
align-items: center;
min-height: 22px;
margin-bottom: 7px;
padding: 3px 8px;
border: 1px solid rgba(52, 211, 153, .38);
border-radius: 999px;
background: rgba(52, 211, 153, .12);
color: var(--success);
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
#batchCompletionModal[data-outcome="mixed"] .batch-completion-outcome {
border-color: rgba(245, 158, 11, .42);
background: rgba(245, 158, 11, .12);
color: var(--warning);
}
.batch-completion-body {
display: grid;
gap: 12px;
min-width: 0;
overflow-x: hidden;
}
.batch-completion-transfer,
.batch-completion-metrics {
margin: 0;
}
.batch-completion-transfer {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.batch-completion-transfer > div,
.batch-completion-metrics > div {
min-width: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, .025);
}
.batch-completion-transfer > div {
padding: 10px 12px;
}
.batch-completion-transfer dt,
.batch-completion-metrics dt {
color: var(--text-muted);
font-size: 11px;
}
.batch-completion-transfer dd,
.batch-completion-metrics dd {
margin: 4px 0 0;
color: var(--text-primary);
font-size: 16px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.batch-completion-metric-sections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.batch-completion-section {
min-width: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, .018);
}
.batch-completion-section h4 {
margin: 0 0 9px;
font-size: 13px;
}
.batch-completion-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.batch-completion-metrics > div {
padding: 8px 9px;
}
.batch-completion-job-metrics > div:first-child {
grid-column: 1 / -1;
}
.batch-completion-table-scroll {
max-width: 100%;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.batch-completion-table-scroll:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.batch-completion-table-scroll table {
width: 100%;
min-width: 650px;
border-collapse: collapse;
}
.batch-completion-table-scroll th,
.batch-completion-table-scroll td {
padding: 7px 9px;
border-bottom: 1px solid var(--border);
text-align: right;
white-space: nowrap;
}
.batch-completion-table-scroll th {
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.batch-completion-table-scroll th:first-child,
.batch-completion-table-scroll td:first-child {
text-align: left;
}
.batch-completion-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.batch-completion-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.batch-completion-section-heading h4 {
margin-bottom: 0;
}
.batch-completion-section-heading span {
min-width: 24px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(239, 68, 68, .14);
color: var(--danger);
text-align: center;
font-size: 11px;
font-weight: 700;
}
.batch-completion-errors ol {
display: grid;
gap: 8px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.batch-completion-errors li {
min-width: 0;
padding: 9px 10px;
border: 1px solid rgba(239, 68, 68, .22);
border-radius: 8px;
background: rgba(239, 68, 68, .055);
}
.batch-completion-error-head,
.batch-completion-error-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
}
.batch-completion-error-file {
overflow-wrap: anywhere;
}
.batch-completion-error-meta {
margin-top: 4px;
color: var(--text-muted);
font-size: 11px;
}
.batch-completion-error-message {
margin: 7px 0 0;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.batch-completion-error-uncertain {
color: var(--warning);
font-weight: 700;
}
#batchCompletionErrorsMore {
margin: 9px 0 0;
color: var(--text-muted);
font-size: 12px;
}
.batch-completion-footer,
.batch-completion-export-actions {
display: flex;
align-items: center;
gap: 8px;
}
.batch-completion-footer {
flex-wrap: wrap;
}
.batch-completion-export-actions {
flex-wrap: wrap;
}
@media (max-width: 820px) {
.batch-completion-card {
max-height: calc(100vh - 24px);
}
.batch-completion-metric-sections {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.batch-completion-metric-sections > :last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 620px) {
.batch-completion-transfer,
.batch-completion-metric-sections {
grid-template-columns: 1fr;
}
.batch-completion-metric-sections > :last-child {
grid-column: auto;
}
.batch-completion-footer,
.batch-completion-export-actions {
align-items: stretch;
width: 100%;
}
.batch-completion-footer > .btn,
.batch-completion-export-actions .btn {
flex: 1 1 auto;
}
}
.hoster-modal-list { .hoster-modal-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
+7
View File
@@ -23,6 +23,7 @@ const sourceFiles = [
'lib/account-auth.js', 'lib/account-auth.js',
'lib/account-rotation.js', 'lib/account-rotation.js',
'lib/backup-crypto.js', 'lib/backup-crypto.js',
'lib/batch-completion-report.js',
'lib/batch-mutation-gate.js', 'lib/batch-mutation-gate.js',
'lib/clouddrop-upload.js', 'lib/clouddrop-upload.js',
'lib/coalesced-set.js', 'lib/coalesced-set.js',
@@ -36,6 +37,7 @@ const sourceFiles = [
'lib/folder-monitor.js', 'lib/folder-monitor.js',
'lib/hosters.js', 'lib/hosters.js',
'lib/hoster-transport-error.js', 'lib/hoster-transport-error.js',
'lib/import-preflight.js',
'lib/ip-allowlist.js', 'lib/ip-allowlist.js',
'lib/log-mode.js', 'lib/log-mode.js',
'lib/log-policy.js', 'lib/log-policy.js',
@@ -70,6 +72,7 @@ const sourceFiles = [
'lib/upload-diagnostics.js', 'lib/upload-diagnostics.js',
'lib/upload-manager.js', 'lib/upload-manager.js',
'lib/upload-recovery.js', 'lib/upload-recovery.js',
'lib/upload-schedule.js',
'lib/upload-start-reservation.js', 'lib/upload-start-reservation.js',
'lib/vidmoly-upload.js', 'lib/vidmoly-upload.js',
'lib/voe-upload.js', 'lib/voe-upload.js',
@@ -103,6 +106,8 @@ const sourceFiles = [
'tests/account-status.test.js', 'tests/account-status.test.js',
'tests/auto-resume.test.js', 'tests/auto-resume.test.js',
'tests/backup-crypto.test.js', 'tests/backup-crypto.test.js',
'tests/batch-completion-main.test.js',
'tests/batch-completion-report.test.js',
'tests/batch-mutation-gate.test.js', 'tests/batch-mutation-gate.test.js',
'tests/byse-reject-recovery.test.js', 'tests/byse-reject-recovery.test.js',
'tests/coalesced-set.test.js', 'tests/coalesced-set.test.js',
@@ -119,6 +124,7 @@ const sourceFiles = [
'tests/folder-monitor.test.js', 'tests/folder-monitor.test.js',
'tests/history-status.test.js', 'tests/history-status.test.js',
'tests/history-retention.test.js', 'tests/history-retention.test.js',
'tests/import-preflight.test.js',
'tests/hidden-electron-window.test.js', 'tests/hidden-electron-window.test.js',
'tests/hosters.test.js', 'tests/hosters.test.js',
'tests/hoster-recovery-provenance.test.js', 'tests/hoster-recovery-provenance.test.js',
@@ -171,6 +177,7 @@ const sourceFiles = [
'tests/upload-manager.test.js', 'tests/upload-manager.test.js',
'tests/upload-manager-recovery-claims.test.js', 'tests/upload-manager-recovery-claims.test.js',
'tests/upload-recovery.test.js', 'tests/upload-recovery.test.js',
'tests/upload-schedule.test.js',
'tests/upload-start-reservation.test.js', 'tests/upload-start-reservation.test.js',
'tests/session-report.test.js', 'tests/session-report.test.js',
'tests/validate-credentials.test.js', 'tests/validate-credentials.test.js',
+58
View File
@@ -0,0 +1,58 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const preloadSource = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8');
test('publishes the authoritative batch report only after finalization and source cleanup', () => {
const handler = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf('// Shutdown after finish'));
const finalization = handler.indexOf('uploadFinalizationBarrier.finalize');
const cleanup = handler.indexOf('sourceCleanup.finishBatch');
const report = handler.indexOf('publishBatchCompletionReport');
assert.ok(finalization >= 0);
assert.ok(cleanup > finalization);
assert.ok(report > cleanup);
});
test('keeps initial and live admission skips in the final batch summary', () => {
assert.match(mainSource, /const uploadBatchAdmissionSkips = new WeakMap\(\)/);
assert.match(mainSource, /uploadBatchAdmissionSkips\.set\(_thisManager, batchAdmissionSkippedJobs\)/);
assert.match(mainSource, /uploadBatchAdmissionSkips\.get\(batchManager\).*push\(\.\.\.skippedJobs\)/s);
assert.match(mainSource, /stats\.mergeSkippedIntoSummary\(summary, batchAdmissionSkippedJobs\)/);
assert.match(mainSource, /fileName: j\.fileName \|\| path\.basename\(j\.file \|\| ''\)/);
assert.match(mainSource, /fileKey: buildBatchFileKey\(j\.file\)/);
assert.match(mainSource, /size: Number\(j\.bytesTotal\) \|\| 0/);
});
test('finalizes cleanup and reports skipped-only and rejected-start batches', () => {
const skippedOnly = mainSource.slice(mainSource.indexOf('if (tasks.length === 0)'), mainSource.indexOf('uploadManager = new UploadManager'));
const rejectedStart = mainSource.slice(mainSource.indexOf('}).catch(async (err) =>'), mainSource.indexOf('logMemorySnapshot(\'batch-start\')'));
assert.match(skippedOnly, /sourceCleanup\.finishBatch/);
assert.match(skippedOnly, /publishBatchCompletionReport/);
assert.match(rejectedStart, /sourceCleanup\.finishBatch/);
assert.match(rejectedStart, /publishBatchCompletionReport/);
});
test('preload exposes report recovery and report-bound exports', () => {
assert.match(preloadSource, /onUploadBatchReport/);
assert.match(preloadSource, /getLastBatchCompletionReport/);
assert.match(preloadSource, /exportBatchCompletionReport/);
assert.match(preloadSource, /removeAllListeners\('upload-batch-report'\)/);
assert.match(mainSource, /shellText\('Der Batch-Bericht ist nicht mehr verfügbar', 'The batch report is no longer available'\)/);
assert.match(mainSource, /shellText\('Ungültiges Exportformat', 'Invalid export format'\)/);
});
test('does not cache or publish a fully aborted batch report', () => {
const publisher = mainSource.slice(
mainSource.indexOf('function publishBatchCompletionReport'),
mainSource.indexOf('function shouldLogHosterToFile')
);
assert.match(publisher, /if \(isAllAborted\(summary\)\) return null/);
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf('batchCompletionReports.set'));
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf("safeSend('upload-batch-report'"));
});
+130
View File
@@ -0,0 +1,130 @@
const test = require('node:test');
const assert = require('node:assert/strict');
test('builds immutable file, job, transfer, cleanup, host and error totals', () => {
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
const report = buildBatchCompletionReport({
reportId: 'report-1',
startedAt: '2026-08-16T10:00:00.000Z',
completedAt: '2026-08-16T10:00:10.000Z',
cleanupOutcomes: ['deleted', 'blocked', 'source-changed', 'source-missing', 'unsafe-source-type', 'failed', 'setting-disabled'],
summary: {
id: 'batch-1',
files: [
{
name: 'complete.mkv',
size: 100,
results: [
{ jobId: 'done-a', hoster: 'doodstream.com', status: 'done', attempt: 1, maxAttempts: 3 },
{ jobId: 'done-b', hoster: 'voe.sx', status: 'done', attempt: 1, maxAttempts: 2 }
]
},
{
name: 'partial.mkv',
size: 200,
results: [
{ jobId: 'done-c', hoster: 'doodstream.com', status: 'done', attempt: 2, maxAttempts: 3 },
{ jobId: 'error-a', hoster: 'voe.sx', status: 'error', error: 'network timeout', attempt: 2, maxAttempts: 2 }
]
},
{
name: 'failed.mkv',
size: 300,
results: [
{ jobId: 'skip-a', hoster: 'doodstream.com', status: 'skipped', error: 'No account', attempt: 0, maxAttempts: 0 },
{ jobId: 'abort-a', hoster: 'voe.sx', status: 'aborted', error: 'Aborted', attempt: 0, maxAttempts: 2 },
{ jobId: 'error-b', hoster: 'byse.sx', status: 'error', error: 'account full', attempt: 1, maxAttempts: 1, remoteCommitUncertain: true }
]
}
]
}
});
assert.deepEqual(report.files, { total: 3, fullySucceeded: 1, partiallySucceeded: 1, failed: 1 });
assert.deepEqual(report.jobs, { total: 7, succeeded: 3, failed: 2, skipped: 1, aborted: 1 });
assert.deepEqual(report.cleanup, { requested: 6, deleted: 1, blocked: 4, failed: 1 });
assert.deepEqual(report.transfer, { successfulBytes: 400, averageBytesPerSecond: 40 });
assert.deepEqual(report.hosters['doodstream.com'], { total: 3, succeeded: 2, failed: 0, skipped: 1, aborted: 0, successfulBytes: 300 });
assert.equal(report.errors.length, 2);
assert.deepEqual(report.errors.map(error => error.category), ['network', 'account-error']);
assert.equal(report.errors[1].remoteCommitUncertain, true);
assert.equal(report.batchId, 'batch-1');
assert.equal(report.durationSec, 10);
assert.equal(Object.isFrozen(report), true);
assert.equal(Object.isFrozen(report.errors), true);
assert.equal(Object.isFrozen(report.errors[0]), true);
});
test('counts duplicate basenames as separate summary files without exposing local paths', () => {
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
const report = buildBatchCompletionReport({
summary: {
files: [
{ name: 'C:\\private\\one\\same.mkv', size: 10, results: [{ jobId: 'one', hoster: 'voe.sx', status: 'done' }] },
{ name: '/private/two/same.mkv', size: 10, results: [{ jobId: 'two', hoster: 'voe.sx', status: 'error', error: 'failed at C:\\private\\two\\same.mkv' }] }
]
}
});
assert.equal(report.files.total, 2);
assert.equal(report.files.fullySucceeded, 1);
assert.equal(report.files.failed, 1);
assert.equal(report.errors[0].fileName, 'same.mkv');
assert.doesNotMatch(JSON.stringify(report), /private[\\/](?:one|two)/i);
});
test('redacts configured secrets, opaque tokens, URLs and local paths from errors', () => {
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
const secret = 'private-api-value';
const opaqueToken = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4';
const report = buildBatchCompletionReport({
secrets: [secret],
summary: {
files: [{
name: 'secret.mkv',
size: 1,
results: [{
jobId: 'error-secret',
hoster: 'doodstream.com',
status: 'error',
error: `token=${secret} path=D:\\private\\secret.mkv /home/private/customer/file.mkv https://private.example.test/upload/${opaqueToken}`
}]
}]
}
});
const serialized = JSON.stringify(report);
assert.doesNotMatch(serialized, new RegExp(secret));
assert.doesNotMatch(serialized, /D:\\\\private/);
assert.doesNotMatch(serialized, /\/home\/private/);
assert.doesNotMatch(serialized, /private\.example\.test/);
assert.doesNotMatch(serialized, new RegExp(opaqueToken));
assert.match(report.errors[0].message, /<redacted>/);
assert.match(report.errors[0].message, /<redacted-path>/);
});
test('builds a formula-safe English error CSV and handles zero duration', () => {
const { buildBatchCompletionReport, buildBatchErrorCsv } = require('../lib/batch-completion-report');
const report = buildBatchCompletionReport({
startedAt: '2026-08-16T10:00:00.000Z',
completedAt: '2026-08-16T10:00:00.000Z',
summary: {
files: [{
name: '=danger.csv',
size: 8,
results: [{ jobId: '+job', hoster: '@host', status: 'error', error: '-CMD()', attempt: 1, maxAttempts: 1 }]
}]
}
});
const csv = buildBatchErrorCsv(report);
assert.equal(report.transfer.averageBytesPerSecond, 0);
assert.match(csv, /^Job ID,File name,Host,Status,Category,Attempt,Max attempts,Remote commit uncertain,Message\n/);
assert.match(csv, /'\+job/);
assert.match(csv, /'=danger\.csv/);
assert.match(csv, /'@host/);
assert.match(csv, /'-CMD\(\)/);
const prefixed = buildBatchErrorCsv({ errors: [{ message: '\t=HYPERLINK("https://example.test")' }] });
assert.match(prefixed, /'\t=HYPERLINK/);
assert.equal(csv.endsWith('\n'), true);
});
+21
View File
@@ -422,6 +422,27 @@ test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', ()
}); });
}); });
test('mergeSkippedIntoSummary keeps duplicate basenames separated by file key', () => {
const summary = {
total: 2,
succeeded: 2,
failed: 0,
skipped: 0,
files: [
{ name: 'same.mkv', fileKey: 'file-one', size: 10, results: [{ jobId: 'done-one', hoster: 'voe.sx', status: 'done' }] },
{ name: 'same.mkv', fileKey: 'file-two', size: 20, results: [{ jobId: 'done-two', hoster: 'voe.sx', status: 'done' }] }
]
};
const merged = mergeSkippedIntoSummary(summary, [
{ jobId: 'skip-one', fileName: 'same.mkv', fileKey: 'file-one', hoster: 'byse.sx', reason: 'Kein Account' },
{ jobId: 'skip-two', fileName: 'same.mkv', fileKey: 'file-two', hoster: 'doodstream.com', reason: 'Kein Account' }
]);
assert.strictEqual(merged.files.length, 2);
assert.deepStrictEqual(merged.files[0].results.map(result => result.jobId), ['done-one', 'skip-one']);
assert.deepStrictEqual(merged.files[1].results.map(result => result.jobId), ['done-two', 'skip-two']);
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => { test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true); assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true); assert.strictEqual(isRetryableCategory('network'), true);
+150 -2
View File
@@ -118,6 +118,48 @@ let releaseBlockedWrite = null;
let blockedHistoryWriteMarker = ''; let blockedHistoryWriteMarker = '';
let blockedHistoryWriteStarted = false; let blockedHistoryWriteStarted = false;
let releaseBlockedHistoryWrite = null; let releaseBlockedHistoryWrite = null;
const successfulBatchCompletionReport = {
reportId: 'ui-report-success',
batchId: 'ui-batch-success',
startedAt: '2026-08-16T10:00:00.000Z',
completedAt: '2026-08-16T10:00:12.000Z',
durationSec: 12,
files: { total: 2, fullySucceeded: 2, partiallySucceeded: 0, failed: 0 },
jobs: { total: 4, succeeded: 4, failed: 0, skipped: 0, aborted: 0 },
cleanup: { requested: 2, deleted: 2, blocked: 0, failed: 0 },
transfer: { successfulBytes: 3145728, averageBytesPerSecond: 262144 },
hosters: {
'doodstream.com': { total: 2, succeeded: 2, failed: 0, skipped: 0, aborted: 0, successfulBytes: 1572864 },
'voe.sx': { total: 2, succeeded: 2, failed: 0, skipped: 0, aborted: 0, successfulBytes: 1572864 }
},
errors: []
};
const mixedBatchCompletionReport = {
reportId: 'ui-report-mixed',
batchId: 'ui-batch-mixed',
startedAt: '2026-08-16T11:00:00.000Z',
completedAt: '2026-08-16T11:01:40.000Z',
durationSec: 100,
files: { total: 4, fullySucceeded: 1, partiallySucceeded: 1, failed: 2 },
jobs: { total: 10, succeeded: 3, failed: 5, skipped: 1, aborted: 1 },
cleanup: { requested: 6, deleted: 2, blocked: 3, failed: 1 },
transfer: { successfulBytes: 5242880, averageBytesPerSecond: 52428.8 },
hosters: {
'doodstream.com': { total: 4, succeeded: 2, failed: 2, skipped: 0, aborted: 0, successfulBytes: 4194304 },
'voe.sx': { total: 3, succeeded: 1, failed: 1, skipped: 1, aborted: 0, successfulBytes: 1048576 },
'byse.sx': { total: 3, succeeded: 0, failed: 2, skipped: 0, aborted: 1, successfulBytes: 0 }
},
errors: [
{ jobId: 'mixed-1', fileName: 'alpha.mkv', hoster: 'doodstream.com', status: 'error', category: 'network', attempt: 2, maxAttempts: 3, remoteCommitUncertain: false, message: 'Connection timed out' },
{ jobId: 'mixed-2', fileName: 'beta.mkv', hoster: 'voe.sx', status: 'error', category: 'account-error', attempt: 1, maxAttempts: 1, remoteCommitUncertain: false, message: 'Account rejected' },
{ jobId: 'mixed-3', fileName: 'gamma.mkv', hoster: 'byse.sx', status: 'error', category: 'file-rejected', attempt: 1, maxAttempts: 2, remoteCommitUncertain: false, message: 'File rejected' },
{ jobId: 'mixed-4', fileName: 'delta.mkv', hoster: 'doodstream.com', status: 'error', category: 'hoster-transient', attempt: 3, maxAttempts: 3, remoteCommitUncertain: true, message: 'Remote completion is uncertain' },
{ jobId: 'mixed-5', fileName: 'epsilon.mkv', hoster: 'byse.sx', status: 'error', category: 'unknown', attempt: 1, maxAttempts: 1, remoteCommitUncertain: false, message: 'Unknown response' },
{ jobId: 'mixed-6', fileName: 'zeta.mkv', hoster: 'voe.sx', status: 'done', category: 'unknown', attempt: 1, maxAttempts: 2, remoteCommitUncertain: true, message: 'Confirmation missing' }
]
};
let batchCompletionReportReads = 0;
const batchCompletionExportCalls = [];
ConfigStore.prototype._atomicWrite = function (data) { ConfigStore.prototype._atomicWrite = function (data) {
activeConfigStore = this; activeConfigStore = this;
if (blockedWriteMarker && !blockedWriteStarted && String(data).includes(blockedWriteMarker)) { if (blockedWriteMarker && !blockedWriteStarted && String(data).includes(blockedWriteMarker)) {
@@ -294,6 +336,11 @@ setTimeout(async () => {
await saveSettings({ feedbackText: 'Saved' }); await saveSettings({ feedbackText: 'Saved' });
return new URL(location.href).searchParams.get('language'); return new URL(location.href).searchParams.get('language');
})()\`); })()\`);
ipcMain.removeHandler('get-last-batch-completion-report');
registerIpcHandler('get-last-batch-completion-report', () => {
batchCompletionReportReads++;
return successfulBatchCompletionReport;
});
const languageReloadFinished = new Promise(resolve => wc.once('did-finish-load', resolve)); const languageReloadFinished = new Promise(resolve => wc.once('did-finish-load', resolve));
wc.reload(); wc.reload();
await languageReloadFinished; await languageReloadFinished;
@@ -301,6 +348,22 @@ setTimeout(async () => {
if (typeof config !== 'object' || config.globalSettings?.language !== 'en') return ''; if (typeof config !== 'object' || config.globalSettings?.language !== 'en') return '';
return [document.documentElement.lang, new URL(location.href).searchParams.get('language'), [...document.querySelectorAll('.tab')].map(tab => tab.textContent.trim()).join(',')].join('|'); return [document.documentElement.lang, new URL(location.href).searchParams.get('language'), [...document.querySelectorAll('.tab')].map(tab => tab.textContent.trim()).join(',')].join('|');
})()\`)); })()\`));
const startupBatchReportState = await waitUntil(() => wc.executeJavaScript(\`(() => {
const modal = document.getElementById('batchCompletionModal');
if (!modal || modal.style.display === 'none') return null;
return {
title: document.getElementById('batchCompletionTitle')?.textContent.trim(),
outcome: modal.dataset.outcome,
files: ['Total', 'FullySucceeded', 'PartiallySucceeded', 'Failed'].map(key => document.getElementById('batchCompletionFiles' + key)?.textContent.trim()).join('|'),
jobs: ['Total', 'Succeeded', 'Failed', 'Skipped', 'Aborted'].map(key => document.getElementById('batchCompletionJobs' + key)?.textContent.trim()).join('|'),
hosters: [...document.querySelectorAll('#batchCompletionHostersBody tr')].map(row => row.dataset.hoster).join('|'),
errorsHidden: document.getElementById('batchCompletionErrorsSection')?.hidden,
focused: document.activeElement?.id,
isolated: [...document.body.children].filter(element => element !== modal).every(element => element.inert)
};
})()\`));
check('Startup fetch shows the latest successful batch report exactly once', batchCompletionReportReads === 1 && startupBatchReportState?.title === 'Batch complete' && startupBatchReportState?.outcome === 'success' && startupBatchReportState?.files === '2|2|0|0' && startupBatchReportState?.jobs === '4|4|0|0|0');
check('Successful batch report renders hosts, hides empty errors and isolates the background', startupBatchReportState?.hosters === 'doodstream.com|voe.sx' && startupBatchReportState?.errorsHidden === true && startupBatchReportState?.focused === 'batchCompletionHeaderCloseBtn' && startupBatchReportState?.isolated === true);
const germanLanguageQuery = await wc.executeJavaScript(\`(async () => { const germanLanguageQuery = await wc.executeJavaScript(\`(async () => {
const input = document.getElementById('languageInput'); const input = document.getElementById('languageInput');
input.value = 'de'; input.value = 'de';
@@ -310,6 +373,89 @@ setTimeout(async () => {
})()\`); })()\`);
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === 'en|en|Upload,Accounts,Settings,History' && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de'); check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === 'en|en|Upload,Accounts,Settings,History' && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
const germanBatchReportState = await wc.executeJavaScript(\`(() => ({
title: document.getElementById('batchCompletionTitle')?.textContent.trim(),
filesHeading: document.getElementById('batchCompletionFilesTitle')?.textContent.trim(),
exportJson: document.getElementById('batchCompletionExportJsonBtn')?.textContent.trim(),
exportCsv: document.getElementById('batchCompletionExportCsvBtn')?.textContent.trim()
}))()\`);
check('Open batch report switches completely from English to German without restart', germanBatchReportState.title === 'Batch abgeschlossen' && germanBatchReportState.filesHeading === 'Dateien' && germanBatchReportState.exportJson === 'JSON exportieren' && germanBatchReportState.exportCsv === 'Fehler-CSV exportieren');
await wc.executeJavaScript('document.getElementById("batchCompletionCloseBtn")?.click()');
win.webContents.send('upload-batch-report', successfulBatchCompletionReport);
await new Promise(resolve => setTimeout(resolve, 80));
const duplicateBatchReportHidden = await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display === "none"');
check('The same batch report event is ignored after its reportId was already shown', duplicateBatchReportHidden === true);
await wc.executeJavaScript('document.getElementById("addFilesBtn")?.focus()');
win.webContents.send('upload-batch-report', mixedBatchCompletionReport);
const mixedBatchReportState = await waitUntil(() => wc.executeJavaScript(\`(() => {
const modal = document.getElementById('batchCompletionModal');
if (!modal || modal.style.display === 'none' || modal.dataset.reportId !== 'ui-report-mixed') return null;
return {
outcome: modal.dataset.outcome,
files: ['Total', 'FullySucceeded', 'PartiallySucceeded', 'Failed'].map(key => document.getElementById('batchCompletionFiles' + key)?.textContent.trim()).join('|'),
jobs: ['Total', 'Succeeded', 'Failed', 'Skipped', 'Aborted'].map(key => document.getElementById('batchCompletionJobs' + key)?.textContent.trim()).join('|'),
cleanup: ['Requested', 'Deleted', 'Blocked', 'Failed'].map(key => document.getElementById('batchCompletionCleanup' + key)?.textContent.trim()).join('|'),
errorCount: document.querySelectorAll('#batchCompletionErrorsList > li').length,
more: document.getElementById('batchCompletionErrorsMore')?.textContent.trim(),
uncertain: document.querySelectorAll('#batchCompletionErrorsList .batch-completion-error-uncertain').length,
hosters: [...document.querySelectorAll('#batchCompletionHostersBody tr')].map(row => row.dataset.hoster).join('|')
};
})()\`));
check('Mixed batch report renders all file, job, cleanup and host metrics', mixedBatchReportState?.outcome === 'mixed' && mixedBatchReportState?.files === '4|1|1|2' && mixedBatchReportState?.jobs === '10|3|5|1|1' && mixedBatchReportState?.cleanup === '6|2|3|1' && mixedBatchReportState?.hosters === 'doodstream.com|voe.sx|byse.sx');
check('Mixed batch report limits visible error examples to five and marks uncertain completion', mixedBatchReportState?.errorCount === 5 && mixedBatchReportState?.more === '1 weiterer Fehler' && mixedBatchReportState?.uncertain === 1);
const standardBatchReportFit = await wc.executeJavaScript(\`(() => {
const card = document.querySelector('#batchCompletionModal .batch-completion-card')?.getBoundingClientRect();
const body = document.querySelector('#batchCompletionModal .batch-completion-body');
return Boolean(card && body && card.left >= 0 && card.right <= innerWidth && card.top >= 0 && card.bottom <= innerHeight && body.scrollWidth <= body.clientWidth + 1);
})()\`);
check('Batch report fits the standard window without horizontal overflow', standardBatchReportFit === true);
await setWindowBounds({ ...originalBounds, width: 800, height: 550 });
const minimumBatchReportFit = await wc.executeJavaScript(\`(() => {
const card = document.querySelector('#batchCompletionModal .batch-completion-card')?.getBoundingClientRect();
const body = document.querySelector('#batchCompletionModal .batch-completion-body');
const footer = document.querySelector('#batchCompletionModal .modal-footer')?.getBoundingClientRect();
return Boolean(card && body && footer && card.left >= 0 && card.right <= innerWidth && card.top >= 0 && card.bottom <= innerHeight && body.scrollWidth <= body.clientWidth + 1 && footer.bottom <= card.bottom + 1);
})()\`);
check('Batch report remains readable and contained at the minimum window size', minimumBatchReportFit === true);
await setWindowBounds(originalBounds);
ipcMain.removeHandler('export-batch-completion-report');
registerIpcHandler('export-batch-completion-report', (_event, reportId, format) => {
batchCompletionExportCalls.push({ reportId, format });
return { ok: true, reportId, format, path: 'C:/ui/report.' + format };
});
await wc.executeJavaScript('document.getElementById("batchCompletionExportJsonBtn")?.click()');
await waitUntil(() => batchCompletionExportCalls.length === 1);
await wc.executeJavaScript('document.getElementById("batchCompletionExportCsvBtn")?.click()');
await waitUntil(() => batchCompletionExportCalls.length === 2);
check('Batch report exports bind JSON and error CSV to the exact visible reportId', batchCompletionExportCalls.length === 2 && batchCompletionExportCalls[0].reportId === 'ui-report-mixed' && batchCompletionExportCalls[0].format === 'json' && batchCompletionExportCalls[1].reportId === 'ui-report-mixed' && batchCompletionExportCalls[1].format === 'csv');
restoreInitialIpcHandler('export-batch-completion-report');
restoreInitialIpcHandler('get-last-batch-completion-report');
const batchReportFocusTrap = await wc.executeJavaScript(\`(() => {
const first = document.getElementById('batchCompletionHeaderCloseBtn');
const last = document.getElementById('batchCompletionCloseBtn');
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }));
const backward = document.activeElement?.id;
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
return { backward, forward: document.activeElement?.id };
})()\`);
check('Batch report traps forward and backward keyboard focus', batchReportFocusTrap.backward === 'batchCompletionCloseBtn' && batchReportFocusTrap.forward === 'batchCompletionHeaderCloseBtn');
await wc.executeJavaScript('document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }))');
const escapedBatchReportState = await wc.executeJavaScript(\`(() => {
const modal = document.getElementById('batchCompletionModal');
return {
hidden: modal?.style.display === 'none' && modal?.getAttribute('aria-hidden') === 'true',
focus: document.activeElement?.id,
isolated: [...document.body.children].some(element => element !== modal && element.inert)
};
})()\`);
check('Escape closes the batch report, restores focus and removes background isolation', escapedBatchReportState.hidden && escapedBatchReportState.focus === 'addFilesBtn' && escapedBatchReportState.isolated === false);
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();'); await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
console.log('\\n=== Upload View ==='); console.log('\\n=== Upload View ===');
@@ -2764,6 +2910,7 @@ setTimeout(async () => {
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
console.log('\\n=== History View ==='); console.log('\\n=== History View ===');
await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display !== "none" && document.getElementById("batchCompletionCloseBtn")?.click()');
let historyFixture = [{ let historyFixture = [{
timestamp: '2026-08-10T10:00:00.000Z', timestamp: '2026-08-10T10:00:00.000Z',
@@ -3359,7 +3506,7 @@ setTimeout(async () => {
[...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.getAttribute('aria-modal') === 'true' && dialog.tabIndex === -1), [...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.getAttribute('aria-modal') === 'true' && dialog.tabIndex === -1),
[...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.parentElement?.getAttribute('aria-hidden') === 'true') [...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.parentElement?.getAttribute('aria-hidden') === 'true')
].join('|'))()\`); ].join('|'))()\`);
check('Every renderer dialog has complete hidden modal semantics', modalSemantics === '8|true|true'); check('Every renderer dialog has complete hidden modal semantics', modalSemantics === '9|true|true');
const rapidViewStability = await wc.executeJavaScript(\`(async () => { const rapidViewStability = await wc.executeJavaScript(\`(async () => {
const sequence = ['upload', 'accounts', 'settings', 'history', 'settings', 'accounts', 'upload', 'history', 'upload', 'accounts', 'history', 'settings']; const sequence = ['upload', 'accounts', 'settings', 'history', 'settings', 'accounts', 'upload', 'history', 'upload', 'accounts', 'history', 'settings'];
@@ -3752,6 +3899,7 @@ setTimeout(async () => {
check('Renderer initialization failures notify main with serializable details', typeof initializationFailureSignal?.message === 'string' && initializationFailureSignal.message.includes('Injected renderer initialization failure') && typeof initializationFailureSignal.stack === 'string'); check('Renderer initialization failures notify main with serializable details', typeof initializationFailureSignal?.message === 'string' && initializationFailureSignal.message.includes('Injected renderer initialization failure') && typeof initializationFailureSignal.stack === 'string');
check('Renderer initialization failure recovery restores the real interface', initializationRecovery === true); check('Renderer initialization failure recovery restores the real interface', initializationRecovery === true);
await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display !== "none" && document.getElementById("batchCompletionCloseBtn")?.click()');
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: { de: "\\\\n\\\\n\\\\n## Neu in dieser Version\\\\n\\\\n\\\\n### Menüs und Navigation\\\\n\\\\n- Direkter Sprachwechsel hinzugefügt.\\\\n- Einstellungsdarstellung verbessert.\\\\n\\\\n\\\\n", en: "## New in this version\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout." } }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()'); const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: { de: "\\\\n\\\\n\\\\n## Neu in dieser Version\\\\n\\\\n\\\\n### Menüs und Navigation\\\\n\\\\n- Direkter Sprachwechsel hinzugefügt.\\\\n- Einstellungsdarstellung verbessert.\\\\n\\\\n\\\\n", en: "## New in this version\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout." } }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex'); check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
@@ -3785,7 +3933,7 @@ setTimeout(async () => {
const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()'); const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()');
check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.'); check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.');
const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert].join("|"); })()'); const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display !== "none" && document.getElementById("batchCompletionCloseBtn")?.click(); document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert].join("|"); })()');
check('Update dialog closes and restores focus and background', updateDialogDismissed === 'none|true|headerUpdateBtn|false|false'); check('Update dialog closes and restores focus and background', updateDialogDismissed === 'none|true|headerUpdateBtn|false|false');
const busyUpdateState = await wc.executeJavaScript(\`(() => { const busyUpdateState = await wc.executeJavaScript(\`(() => {
+3 -2
View File
@@ -264,8 +264,8 @@ describe('UploadManager', () => {
mgr.on('batch-done', (s) => { summary = s; }); mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([ await mgr.startBatch([
{ file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' }, { file: '/test/video1.mp4', fileKey: 'video-one', hoster: 'doodstream.com', apiKey: 'key1' },
{ file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' } { file: '/test/video2.mp4', fileKey: 'video-two', hoster: 'doodstream.com', apiKey: 'key1' }
]); ]);
assert.ok(summary); assert.ok(summary);
@@ -273,6 +273,7 @@ describe('UploadManager', () => {
assert.equal(summary.succeeded, 2); assert.equal(summary.succeeded, 2);
assert.equal(summary.failed, 0); assert.equal(summary.failed, 0);
assert.equal(summary.files.length, 2); assert.equal(summary.files.length, 2);
assert.deepEqual(summary.files.map(file => file.fileKey), ['video-one', 'video-two']);
assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0)); assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0));
}); });
+9
View File
@@ -17,6 +17,15 @@ test('catastrophic batch starts retain exact terminal outcomes for every job', (
]); ]);
}); });
test('catastrophic batch summaries preserve safe file keys for later skipped-job merging', () => {
const { buildFailedUploadSummary } = require('../lib/upload-recovery');
const summary = buildFailedUploadSummary([
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', fileKey: 'safe-file-key', hoster: 'doodstream.com' }
], 'Upload konnte nicht gestartet werden');
assert.equal(summary.files[0].fileKey, 'safe-file-key');
});
test('terminal recovery snapshots retain exact job outcomes and canonical links', () => { test('terminal recovery snapshots retain exact job outcomes and canonical links', () => {
const { buildTerminalJobSnapshots } = require('../lib/upload-recovery'); const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
const snapshots = buildTerminalJobSnapshots({ const snapshots = buildTerminalJobSnapshots({