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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 : '',
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'"));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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(\`(() => {
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user