diff --git a/renderer/app.js b/renderer/app.js index 0de478f..4388f18 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2367,6 +2367,7 @@ let alwaysOnTopState = false; // never changes after it's created). let _hosterCountsCache = { sig: '', result: new Map() }; let contextMenuReturnFocus = null; +let contextMenuTargetJobId = null; function _getHosterCounts() { const sig = `${queueJobs.length}`; if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result; @@ -2382,6 +2383,7 @@ function _getHosterCounts() { function handleRowContextMenu(e, row) { e.preventDefault(); const jobId = row.dataset.jobId; + contextMenuTargetJobId = jobId; if (!selectedJobIds.has(jobId)) { selectedJobIds.clear(); selectedJobIds.add(jobId); @@ -2394,6 +2396,9 @@ function handleRowContextMenu(e, row) { function showContextMenu(x, y) { const menu = document.getElementById('contextMenu'); + const targetJob = _jobIndexById.get(contextMenuTargetJobId); + const copyFailureItem = menu.querySelector('[data-action="copy-failure-details"]'); + if (copyFailureItem) copyFailureItem.style.display = targetJob?.status === 'error' && (targetJob.error || targetJob.failureDetails) ? '' : 'none'; // Update "Always on top" text const aotItem = menu.querySelector('[data-action="always-on-top"]'); if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund'; @@ -2882,11 +2887,12 @@ document.getElementById('contextMenu').addEventListener('click', (e) => { if (!item) return; const action = item.dataset.action; if (!action) return; + const targetJobId = contextMenuTargetJobId; hideContextMenu(); - handleContextAction(action); + handleContextAction(action, targetJobId); }); -async function handleContextAction(action) { +async function handleContextAction(action, targetJobId = null) { _normalizeQueueSelectionToVisible(); if (action === 'start-selected') { startSelectedUpload(); @@ -2897,6 +2903,8 @@ async function handleContextAction(action) { retrySelectedJobs(); } else if (action === 'show-log') { showJobLogModal(); + } else if (action === 'copy-failure-details') { + await copyFailureDetails(targetJobId); } else if (action === 'delete-selected') { const count = selectedJobIds.size; if (!count || !await showAppConfirm({ title: 'Uploads entfernen?', message: count === 1 ? 'Ein ausgewählter Upload wird aus der Liste entfernt.' : `${count} ausgewählte Uploads werden aus der Liste entfernt.`, confirmText: 'Entfernen', danger: true })) return; @@ -3561,6 +3569,41 @@ function _handleStatsImpl(data) { } // --- Per-job log modal --- +function _sanitizeFailureClipboardValue(value, limit = 320) { + let text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); + text = text.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]'); + text = text.replace(/((?:api[_-]?key|token|password|cookie|authorization|session)\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]'); + return text.slice(0, limit); +} + +function formatFailureDetailsForClipboard(job) { + if (!job || job.status !== 'error') return ''; + const english = getUiLocale() === 'en-US'; + const details = job.failureDetails && typeof job.failureDetails === 'object' ? job.failureDetails : {}; + const labels = english + ? { file: 'File', host: 'Host', account: 'Account', attempt: 'Attempt', error: 'Error', http: 'HTTP status', contentType: 'Content type', response: 'Response excerpt' } + : { file: 'Datei', host: 'Hoster', account: 'Account', attempt: 'Versuch', error: 'Fehler', http: 'HTTP-Status', contentType: 'Inhaltstyp', response: 'Antwortauszug' }; + const account = getAccountLabel(job) || _sanitizeFailureClipboardValue(job.accountId, 80) || '–'; + return [ + `${labels.file}: ${_sanitizeFailureClipboardValue(job.fileName || job.file, 260) || '–'}`, + `${labels.host}: ${_sanitizeFailureClipboardValue(job.hoster, 120) || '–'}`, + `${labels.account}: ${account}`, + `${labels.attempt}: ${Math.max(0, Number(job.attempt) || 0)} / ${Math.max(0, Number(job.maxAttempts) || 0)}`, + `${labels.error}: ${_sanitizeFailureClipboardValue(job.error, 400) || '–'}`, + details.httpStatus ? `${labels.http}: ${details.httpStatus}` : '', + details.contentType ? `${labels.contentType}: ${_sanitizeFailureClipboardValue(details.contentType, 120)}` : '', + details.responseSnippet ? `${labels.response}: ${_sanitizeFailureClipboardValue(details.responseSnippet, 320)}` : '' + ].filter(Boolean).join('\n'); +} + +async function copyFailureDetails(jobId) { + const job = _jobIndexById.get(jobId); + const text = formatFailureDetailsForClipboard(job); + if (!text) return; + await window.api.copyToClipboard(text); + showCopyToast(localizeUiText('Fehlerdetails kopiert')); +} + async function showJobLogModal() { const selectedJobs = _getVisibleSelectedQueueJobs(); if (selectedJobs.length === 0) return; diff --git a/renderer/i18n.js b/renderer/i18n.js index b8b6815..929bbd5 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -129,6 +129,12 @@ ['Fertig', 'Completed'], ['Fehler', 'Failed'], ['geprüft', 'checked'], + ['Fehlerdetails kopieren', 'Copy failure details'], + ['Fehlerdetails kopiert', 'Failure details copied'], + ['Versuch', 'Attempt'], + ['HTTP-Status', 'HTTP status'], + ['Inhaltstyp', 'Content type'], + ['Antwortauszug', 'Response excerpt'], ['Verfügbarkeit', 'Availability'], ['Bereite Accounts', 'Ready accounts'], ['Primär', 'Primary'], diff --git a/renderer/index.html b/renderer/index.html index 7b5f5d7..f7da179 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -637,6 +637,7 @@ +
diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 2d4f432..c9d5e37 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -21,6 +21,12 @@ test('translates the account check timestamp label', () => { assert.equal(translateText('checked', 'de'), 'geprüft'); }); +test('translates the failure detail clipboard action', () => { + assert.equal(translateText('Fehlerdetails kopieren', 'en'), 'Copy failure details'); + assert.equal(translateText('Failure details copied', 'de'), 'Fehlerdetails kopiert'); + assert.equal(translateText('Antwortauszug', 'en'), 'Response excerpt'); +}); + test('English is the fallback language and German remains selectable', () => { assert.equal(normalizeLanguage(), 'en'); assert.equal(normalizeLanguage('fr'), 'en'); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index aa577f4..9eac778 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -179,6 +179,7 @@ test('header occupies its final geometry before asynchronous initialization', () assert.match(html, /class="update-progress-footer"[\s\S]*id="updateProgressDetails"[\s\S]*id="updateProgressSize"[\s\S]*id="updateProgressSpeed"[\s\S]*id="updateProgressEta"[\s\S]*id="updateProgressText"/u); assert.match(html, /id="queueFilterResetBtn"[^>]*disabled[^>]*>Filter zurücksetzen]*>0]*style="display:none"[^>]*>Fehlerdetails kopieren\s*
{ if (!(recentCapScrollState.before > 0 && Math.abs(recentCapScrollState.delta - 28) <= 1)) console.log('Recent cap scroll state: ' + JSON.stringify(recentCapScrollState)); check('A capped recent-upload list preserves the visible rows when a new item arrives', recentCapScrollState.before > 0 && Math.abs(recentCapScrollState.delta - 28) <= 1); + const failureClipboardContract = await wc.executeJavaScript(\`(() => { + setUiLanguage('en'); + queueJobs = [ + { id: 'failure-a', file: 'C:/ui/failure-a.mkv', fileName: 'failure-a.mkv', hoster: 'byse.sx', accountId: 'account-a', status: 'error', error: 'Wrong failure', attempt: 1, maxAttempts: 3, bytesUploaded: 0, bytesTotal: 100, progress: 0 }, + { id: 'failure-b', file: 'C:/ui/failure-b.mkv', fileName: 'failure-b.mkv', hoster: 'doodstream.com', accountId: 'account-b', status: 'error', error: 'Request failed at https://secret.invalid/path?token=secret', attempt: 2, maxAttempts: 4, failureDetails: { httpStatus: 503, contentType: 'text/html', responseSnippet: 'apiKey=very-secret temporary gateway response' }, bytesUploaded: 0, bytesTotal: 100, progress: 0 } + ]; + selectedJobIds.clear(); + selectedJobIds.add('failure-a'); + selectedJobIds.add('failure-b'); + rebuildJobIndex(); + setUploadSidebarFilter('all'); + updateUploadView(); + renderQueueTable(); + const row = document.querySelector('[data-job-id="failure-b"]'); + handleRowContextMenu({ preventDefault() {}, clientX: 50, clientY: 50 }, row); + const item = document.querySelector('[data-action="copy-failure-details"]'); + const text = formatFailureDetailsForClipboard(_jobIndexById.get(contextMenuTargetJobId)); + hideContextMenu(); + setUiLanguage('de'); + queueJobs = []; + selectedJobIds.clear(); + rebuildJobIndex(); + updateUploadView(); + renderQueueTable(); + return { target: contextMenuTargetJobId, visible: getComputedStyle(item).display !== 'none', label: item.textContent.trim(), text }; + })()\`); + check('Failure detail copying targets the right-clicked job and redacts sensitive values', failureClipboardContract.target === 'failure-b' && failureClipboardContract.visible === true && failureClipboardContract.label === 'Copy failure details' && failureClipboardContract.text.includes('File: failure-b.mkv') && failureClipboardContract.text.includes('HTTP status: 503') && failureClipboardContract.text.includes('Content type: text/html') && failureClipboardContract.text.includes('[URL]') && failureClipboardContract.text.includes('apiKey=[redacted]') && !failureClipboardContract.text.includes('secret.invalid') && !failureClipboardContract.text.includes('very-secret')); + const keyboardInteractionContract = await wc.executeJavaScript(\`(() => { HOSTERS.forEach(name => { config.hosters[name] = []; }); config.hosters['byse.sx'] = [{ id: 'ui-keyboard-account', enabled: true, authType: 'api', apiKey: 'keyboard-key' }];