diff --git a/README.md b/README.md index 0f1bf39..ef8973e 100644 --- a/README.md +++ b/README.md @@ -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). -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 ### Upload workspace - 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. - Review how many selected files were accepted or excluded before choosing upload destinations. - 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. - Validate credentials before a new or edited account is saved. - 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. - Enable, disable, prioritize, and reorder accounts. - 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. - Apply retries, concurrency, bandwidth, file-size, and pacing settings per host. - 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 @@ -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. - Export history as CSV or JSON. - 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. - Use the complete interface in English or German and switch at runtime. - Export settings locally or transfer them with an encrypted online backup key. diff --git a/lib/batch-completion-report.js b/lib/batch-completion-report.js new file mode 100644 index 0000000..abcc2c2 --- /dev/null +++ b/lib/batch-completion-report.js @@ -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 += ''; + index = end; + continue; + } + output += value[index++]; + } + return output; +} + +function errorText(value, secrets) { + return redactPosixPaths(text(value, secrets) + .replace(/https?:\/\/[^\s"'<>]+/gi, '')) + .replace(/\b[A-Za-z0-9_-]{24,}\b/g, ''); +} + +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 }; diff --git a/lib/stats.js b/lib/stats.js index 3408848..11ad24f 100644 --- a/lib/stats.js +++ b/lib/stats.js @@ -196,8 +196,10 @@ }; const existingJobIds = new Set(); const filesByName = new Map(); + const filesByKey = new Map(); for (const file of merged.files) { filesByName.set(String(file.name || file.fileName || ''), file); + if (file.fileKey) filesByKey.set(String(file.fileKey), file); for (const result of file.results) { if (result?.jobId) existingJobIds.add(result.jobId); } @@ -206,11 +208,14 @@ for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) { if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue; 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) { file = { name: fileName, size: Number(skipped.size) || 0, results: [] }; + if (fileKey) file.fileKey = fileKey; merged.files.push(file); filesByName.set(fileName, file); + if (fileKey) filesByKey.set(fileKey, file); } file.results.push({ jobId: skipped.jobId || null, diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 4496ec1..e68e11d 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -441,7 +441,7 @@ class UploadManager extends EventEmitter { for (let j = i; j < end; j++) { const task = tasks[j]; 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); } } @@ -1863,7 +1863,7 @@ class UploadManager extends EventEmitter { if (!results.has(task.file)) { let size = 0; 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._additionalPromises.push(this._runJob(task, results, signal)); diff --git a/lib/upload-recovery.js b/lib/upload-recovery.js index e7f323e..7e568e4 100644 --- a/lib/upload-recovery.js +++ b/lib/upload-recovery.js @@ -39,7 +39,12 @@ const filePath = typeof task.file === 'string' ? task.file : ''; const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`; 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({ jobId: typeof task.jobId === 'string' ? task.jobId : '', hoster: typeof task.hoster === 'string' ? task.hoster : '', diff --git a/main.js b/main.js index da14e41..09fb57a 100644 --- a/main.js +++ b/main.js @@ -21,6 +21,7 @@ const { configureStartupRenderer(app); nativeTheme.themeSource = 'dark'; const fs = require('fs'); +const crypto = require('crypto'); const ConfigStore = require('./lib/config-store'); const UploadManager = require('./lib/upload-manager'); const { createSourceFileCleanup } = require('./lib/source-file-cleanup'); @@ -51,6 +52,7 @@ const stats = require('./lib/stats'); const { createCollectors } = require('./lib/diagnostics-collectors'); const { createAgent } = require('./lib/diagnostics-agent'); const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); +const { buildBatchCompletionReport, buildBatchErrorCsv } = require('./lib/batch-completion-report'); const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/upload-recovery'); const { selectPublicUploadUrl } = require('./lib/upload-confirmation'); const { createBatchMutationGate } = require('./lib/batch-mutation-gate'); @@ -140,7 +142,10 @@ configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); let uploadManager = null; const uploadBatchMutationGates = new WeakMap(); const uploadRecoveryStates = new WeakMap(); +const uploadBatchAdmissionSkips = new WeakMap(); +const batchCompletionReports = new Map(); let lastSessionSummary = null; +let lastBatchCompletionReport = null; let startupRecoveryCoordinator = null; let startupRevealGate = 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. // Reads the LIVE uploadManager.hosterSettings (kept current via // updateSettings) so a mid-batch toggle takes effect immediately. Falls back @@ -1293,6 +1317,12 @@ function buildTaskFromAccount(hoster, account, extra) { 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; function rotationCursors() { if (_rotationCursors === null) { @@ -1323,7 +1353,7 @@ function buildUploadTasks(config, files, hosters, pick) { for (const hoster of hosters) { const account = pick(hoster); 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; @@ -1338,6 +1368,7 @@ function buildUploadTasksFromJobs(config, jobs, pick) { if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); continue; } tasks.push(buildTaskFromAccount(job.hoster, account, { file: job.file, + fileKey: buildBatchFileKey(job.file), jobId: job.id || job.jobId || null, sourceCleanupToken: job.sourceCleanupToken || null })); @@ -2248,6 +2279,27 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => { 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) => { const input = payload && typeof payload === 'object' ? payload : {}; const currentConfig = configStore.load(); @@ -2270,6 +2322,7 @@ ipcMain.handle('start-upload', async (_event, payload) => { async function executeReservedUploadStart(payload, startLease) { const config = configStore.load(); + const batchStartedAt = new Date().toISOString(); const files = payload && Array.isArray(payload.files) ? payload.files : []; const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; @@ -2292,6 +2345,7 @@ async function executeReservedUploadStart(payload, startLease) { 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' @@ -2311,6 +2365,22 @@ async function executeReservedUploadStart(payload, startLease) { if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; 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) { const skippedSummary = stats.mergeSkippedIntoSummary({ 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.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 { started: true, taskCount: 0, @@ -2343,10 +2422,12 @@ async function executeReservedUploadStart(payload, startLease) { uploadBatchMutationGates.set(uploadManager, batchMutationGate); globalThis._mhuUploadManagerRef = uploadManager; const _thisManager = uploadManager; + const batchAdmissionSkippedJobs = [...skippedJobs]; + uploadBatchAdmissionSkips.set(_thisManager, batchAdmissionSkippedJobs); const recovery = { 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) }; @@ -2375,24 +2456,6 @@ async function executeReservedUploadStart(payload, startLease) { // new upload; addJobs during a running batch keeps them). _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 { await configStore.saveUploadRecovery(recovery); } catch (error) { @@ -2404,7 +2467,6 @@ async function executeReservedUploadStart(payload, startLease) { return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }; } uploadRecoveryStates.set(_thisManager, recovery); - for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); _thisManager.sourceFileCleanup = sourceCleanup; const _producerTracker = trackUploadProducer(_thisManager); @@ -2536,7 +2598,7 @@ async function executeReservedUploadStart(payload, startLease) { // orphans (cancel/addJobs see null, the new batch keeps running invisibly). uploadManager.on('batch-done', async (summary) => { const hadActiveBatchMutation = await batchMutationGate.sealAndDrain(); - summary = stats.mergeSkippedIntoSummary(summary, skippedJobs); + summary = stats.mergeSkippedIntoSummary(summary, batchAdmissionSkippedJobs); lastSessionSummary = summary; debugLog(`batch-done: 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 (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization'); - await sourceCleanup.finishBatch({ + const cleanupOutcomes = await sourceCleanup.finishBatch({ historyPersisted, queuePersisted: queuePersisted && terminalRecoveryPersisted && !hadActiveBatchMutation }); + publishBatchCompletionReport(summary, { + startedAt: recovery.startedAt, + completedAt: new Date().toISOString(), + cleanupOutcomes + }); _producerTracker.finish(); const fullyAborted = isAllAborted(summary); @@ -2600,9 +2667,22 @@ async function executeReservedUploadStart(payload, startLease) { primeOverrides: Array.from(_sessionAccountOverrides.entries()) }).catch(async (err) => { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); - await batchMutationGate.sealAndDrain(); - const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'); - await uploadFinalizationBarrier.finalize(errorSummary, recovery); + const hadActiveBatchMutation = await batchMutationGate.sealAndDrain(); + const errorSummary = stats.mergeSkippedIntoSummary( + 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(); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); 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 skippedJobs = jobs .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) { const auditedAdd = await runAfterDurableAudit( () => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'), @@ -2691,6 +2779,7 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { if (batchManager.sourceFileCleanup) { for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId); } + if (skippedJobs.length > 0) uploadBatchAdmissionSkips.get(batchManager)?.push(...skippedJobs); if (tasks.length === 0) { debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); diff --git a/preload.js b/preload.js index ccd7b42..074b49e 100644 --- a/preload.js +++ b/preload.js @@ -9,6 +9,8 @@ contextBridge.exposeInMainWorld('api', { pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }), exportHistory: (format) => ipcRenderer.invoke('export-history', 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), // Hoster settings @@ -118,6 +120,9 @@ contextBridge.exposeInMainWorld('api', { onUploadBatchDone: (callback) => { ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); }, + onUploadBatchReport: (callback) => { + ipcRenderer.on('upload-batch-report', (_event, data) => callback(data)); + }, onUploadStats: (callback) => { ipcRenderer.on('upload-stats', (_event, data) => callback(data)); }, @@ -164,6 +169,7 @@ contextBridge.exposeInMainWorld('api', { removeAllListeners: () => { ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-batch-done'); + ipcRenderer.removeAllListeners('upload-batch-report'); ipcRenderer.removeAllListeners('upload-stats'); ipcRenderer.removeAllListeners('app:update-available'); ipcRenderer.removeAllListeners('app:update-progress'); diff --git a/renderer/app.js b/renderer/app.js index fbeefbf..e438149 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -61,6 +61,7 @@ function refreshLocalizedRuntimeUi() { const activeRecentTab = document.querySelector('.recent-tab.active'); const hint = document.getElementById('recentFilesHint'); 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 @@ -511,6 +512,228 @@ const modalController = (() => { 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) let sessionFilesData = []; let _recentSeqCounter = 0; @@ -566,6 +789,7 @@ async function init() { renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); + await showLastBatchCompletionReport(); 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); @@ -8584,6 +8808,7 @@ function updateStatsPanel() { window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateProgress(handleUpdateProgress); window.api.onPrepareClose(prepareForWindowClose); +setupBatchCompletionReportUi(); setupAppAlertListeners(); init().then(() => { window.api.signalCloseHandshakeReady(); diff --git a/renderer/i18n.js b/renderer/i18n.js index f656434..7edcb83 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -63,6 +63,32 @@ ['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'], ['Hoster', 'Host'], ['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'], ['In Zwischenablage', 'To clipboard'], ['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'], [/^(\d+) Links kopiert$/, '$1 links 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?'], [/^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.'], @@ -865,6 +892,7 @@ [/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'], [/^(\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+) more errors$/, '$1 weitere Fehler'], [/^(\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.'], [/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'], diff --git a/renderer/index.html b/renderer/index.html index 5bedcc7..36818a7 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -667,6 +667,95 @@ + +