const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; // Dropdown options for "Add Account" modal: value -> label const HOSTER_ADD_OPTIONS = [ { value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' }, { value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' }, { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' }, { value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' } ]; // --- State --- let selectedFiles = []; // { path, name, size } let selectedUploadHosters = []; let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let editingAccountId = null; // null = adding, string = editing account by ID let autoHealthCheckEnabled = true; let queuePersistTimer = null; let settingsSaveTimer = null; let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx'; const STARTABLE_QUEUE_STATUSES = new Set(['preview', 'queued', 'error', 'aborted', 'skipped']); function isStartableQueueStatus(status) { return STARTABLE_QUEUE_STATUSES.has(status); } function isStartableQueueJob(job) { return !!job && isStartableQueueStatus(job.status); } // Queue state let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link } const _jobIndexById = new Map(); // id -> job (O(1) lookup) const _jobIndexByUploadId = new Map(); // uploadId -> job const selectedJobIds = new Set(); let _sessionTotalBytes = 0; // Total bytes ever added to queue this session let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs) const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone) const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks) // Coalesce removeFromQueueOnDone removals into one filter pass per microtask // to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now // lives in lib/coalesced-set.js so it can be unit-tested with a manual // scheduler. Optional-chained so the renderer still works if the script // failed to load — falls back to immediate per-event filter (legacy slow // path), better than crashing. const _doneRemovalCoalescer = window.CoalescedSet ? window.CoalescedSet.makeCoalescedSet({ apply: (drop) => { queueJobs = queueJobs.filter(j => !drop.has(j.id)); } }) : null; const queueSortState = { key: 'filename', direction: 'asc' }; // History state let historyRowsData = []; let historySortState = { key: 'date', direction: 'desc' }; let _historySortClicked = false; // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; const recentSortState = { key: 'date', direction: 'desc' }; let _recentSortClicked = false; const selectedRecentIds = new Set(); // Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar. let _sessionDoneCount = 0; let _sessionErrorCount = 0; // O(1) dedup for maybeAddSessionFile (was O(n) sessionFilesData.some). // Huge with thousands of rows × thousands of incoming results. const _sessionFileKeys = new Set(); window.addEventListener('error', (e) => { try { const msg = `RENDERER ERROR: ${e.message} at ${e.filename}:${e.lineno}:${e.colno}${e.error && e.error.stack ? '\n' + e.error.stack : ''}`; if (window.api && window.api.debugLog) window.api.debugLog(msg); } catch {} }); window.addEventListener('unhandledrejection', (e) => { try { const reason = e.reason && e.reason.stack ? e.reason.stack : (e.reason && e.reason.message) || String(e.reason); if (window.api && window.api.debugLog) window.api.debugLog(`RENDERER UNHANDLED REJECTION: ${reason}`); } catch {} }); // --- Init --- async function init() { config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); restoreQueueStateFromConfig(); await _autoDeduplicateFromLog(); renderHosterSummary(); renderHosterModal(); renderSettings(); renderAccounts(); setupListeners(); setupDragDrop(); restoreQueueColumnWidths(); loadHistory(); _refreshSessionFailedSnapshot(); renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); // Version display try { const version = await window.api.getVersion(); const versionLabel = document.getElementById('versionLabel'); if (versionLabel) versionLabel.textContent = `v${version}`; } catch {} // Update listeners window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateProgress(handleUpdateProgress); window.api.onUploadProgress((data) => { handleProgress(data); }); if (window.api.onUploadProgressBatch) { window.api.onUploadProgressBatch((batch) => { if (!Array.isArray(batch)) return; for (let i = 0; i < batch.length; i++) handleProgress(batch[i]); }); } window.api.onUploadBatchDone((data) => { handleBatchDone(data); }); window.api.onUploadStats((data) => { handleStats(data); }); window.api.onShutdownCountdown(handleShutdownCountdown); window.api.onUploadLogFallback((data) => { const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)'; showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000); }); window.api.onLogPathAutoUpdated((data) => { if (!data || !data.logFilePath) return; // Keep the in-memory config and the visible Settings input in sync so // the user sees the path that's actually being written to, and the // next save from the UI doesn't revert it. if (config && config.globalSettings) config.globalSettings.logFilePath = data.logFilePath; const input = document.getElementById('logFilePathInput'); if (input) input.value = data.logFilePath; showCopyToast(`Log-Pfad automatisch auf funktionierenden Ordner gesetzt`, 5000); }); window.api.onAccountRotationLog((entry) => { // Surface only the user-visible rotation events as toasts; full detail // goes to account-rotation.log. Keep it quiet otherwise. if (!entry || !entry.event) return; const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : ''; if (entry.event === 'rotate') { showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`); } else if (entry.event === 'rotation-end') { showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`); } else if (entry.event === 'final-error') { showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`); } }); // Folder monitor: auto-queue new files window.api.onFolderMonitorNewFiles((files) => { window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); const fm = config.globalSettings && config.globalSettings.folderMonitor; const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : []; if (fmHosters.length > 0) { // Pre-selected hosters: set them as active selection and add directly to queue selectedUploadHosters = fmHosters.slice(); const existing = new Set(); for (const f of selectedFiles) existing.add(f.path); for (const f of _pendingFiles) existing.add(f.path); const newFiles = []; for (const p of files) { if (existing.has(p)) continue; existing.add(p); const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } if (newFiles.length > 0) { const newPaths = new Set(newFiles.map(f => f.path)); selectedFiles.push(...newFiles); buildQueuePreview(); updateUploadView(); if (fm.autoStart && !uploading && !healthCheckRunning) { startUpload(); } else if (uploading) { // Inject new preview jobs into the running batch const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file)); if (newJobs.length > 0) { newJobs.forEach(j => { j.status = 'queued'; }); renderQueueTable(); window.api.addJobsToBatch({ jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) }).then(result => { _markSkippedJobs(result); }).catch(() => {}); persistQueueStateSoon(true); } } } } else { // No pre-selected hosters: open modal addPathsToQueue(files); } }); // Account switched notification window.api.onAccountSwitched((data) => { window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`); }); // Drop target window: files dropped on the small floating window window.api.onDropTargetFiles((paths) => { addPathsToQueue(paths); }); // Remote client count updates (registered once, not per renderSettings call) window.api.onRemoteClientCount(() => { const el = document.getElementById('remoteConnectionStatus'); if (el && el.style.color === 'rgb(16, 185, 129)') { window.api.remoteStatus().then(status => { if (status.running) { el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; } }).catch(() => {}); } }); window.api.debugLog('init complete, all listeners registered'); // Restore always-on-top state try { const onTop = await window.api.getAlwaysOnTop(); alwaysOnTopState = !!onTop; } catch {} scheduleStartupAccountCheck(); } // --- Tab switching --- let _historyDirty = false; function _isHistoryTabActive() { const tab = document.querySelector('.tab.active'); return !!(tab && tab.dataset.view === 'history'); } // Cache the tab/view collections once and use event delegation on the parent // so tab switches don't trigger three querySelectorAll walks per click. (() => { const tabs = Array.from(document.querySelectorAll('.tab')); const views = Array.from(document.querySelectorAll('.view')); const tabsByView = {}; const viewsById = {}; for (const t of tabs) tabsByView[t.dataset.view] = t; for (const v of views) viewsById[v.id] = v; let activeTab = tabs.find(t => t.classList.contains('active')) || tabs[0]; const handle = (target) => { const tab = target.closest('.tab'); if (!tab || tab === activeTab) return; if (activeTab) { activeTab.classList.remove('active'); const prevView = viewsById[`${activeTab.dataset.view}-view`]; if (prevView) prevView.classList.remove('active'); } tab.classList.add('active'); const nextView = viewsById[`${tab.dataset.view}-view`]; if (nextView) nextView.classList.add('active'); activeTab = tab; if (tab.dataset.view === 'history') { _historyDirty = false; loadHistory(); } }; const tabBar = tabs[0] && tabs[0].parentElement; if (tabBar) { tabBar.addEventListener('click', (e) => handle(e.target)); } else { // Fallback: bind per-tab if somehow no common parent tabs.forEach(t => t.addEventListener('click', () => handle(t))); } })(); // --- Hoster selection --- function accountHasCreds(name, account) { if (!account) return false; if (account.authType === 'api') return !!account.apiKey; if (account.authType === 'login') return !!(account.username && account.password); // Fallback if (name === 'vidmoly.me') return !!(account.username && account.password); if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; return !!account.apiKey; } // Returns hosters that have at least one enabled account with credentials function getAvailableHosters() { const result = []; for (const name of HOSTERS) { const accounts = config.hosters[name]; if (!Array.isArray(accounts)) continue; const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); if (hasEnabledAccount) result.push({ name }); } return result; } function syncSelectedUploadHosters() { const available = new Set(getAvailableHosters().map(item => item.name)); selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); if (selectedUploadHosters.length === 0) { selectedUploadHosters = HOSTERS.filter(name => { const accounts = config.hosters[name]; return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); }); } } function getSelectedHosters() { return selectedUploadHosters.slice(); } function getHosterLabel(name) { const labels = { 'doodstream.com': 'Doodstream', 'voe.sx': 'VOE', 'vidmoly.me': 'Vidmoly', 'byse.sx': 'Byse', 'clouddrop.cc': 'Clouddrop' }; return labels[name] || name; } function getAccountAuthLabel(account) { if (!account) return ''; if (account.authType === 'api') return 'API'; if (account.authType === 'login') return 'Web Login'; return ''; } function getAccountDisplayName(name, account) { const authLabel = getAccountAuthLabel(account); return authLabel ? `${getHosterLabel(name)} (${authLabel})` : getHosterLabel(name); } function maskCredential(value, keep = 4) { const text = String(value || '').trim(); if (!text) return ''; if (text.length <= keep) return text; return `${text.slice(0, keep)}…${text.slice(-2)}`; } function ensureAccountStatusEntries() { const nextStatuses = {}; for (const { account } of getAllAccountsFlat()) { if (account.id) { nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' }; } } accountStatuses = nextStatuses; } // Returns flat array of all accounts: [{ name, account, index }] function getAllAccountsFlat() { const result = []; for (const name of HOSTERS) { const accounts = config.hosters[name]; if (!Array.isArray(accounts)) continue; accounts.forEach((account, index) => result.push({ name, account, index })); } return result; } // Returns flat array of accounts with credentials function getAccountsWithCredsFlat() { return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account)); } // Find account by ID across all hosters function findAccountById(accountId) { for (const name of HOSTERS) { const accounts = config.hosters[name]; if (!Array.isArray(accounts)) continue; const account = accounts.find(a => a.id === accountId); if (account) return { name, account }; } return null; } function scheduleStartupAccountCheck() { const accounts = getAccountsWithCredsFlat(); if (!accounts.length) return; setTimeout(() => { runHealthCheck('startup').catch(() => {}); }, 500); } function renderHosterSummary() { const summary = document.getElementById('hosterSummary'); if (!summary) return; const hosters = getSelectedHosters(); if (hosters.length === 0) { summary.textContent = 'Keine Upload-Ziele ausgewählt'; } else if (hosters.length === 1) { summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`; } else { summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; } } function renderHosterModal() { const list = document.getElementById('hosterModalList'); const hint = document.getElementById('hosterModalHint'); if (!list || !hint) return; const available = getAvailableHosters(); if (available.length === 0) { list.innerHTML = ''; hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.'; return; } list.innerHTML = available.map(item => { const checked = selectedUploadHosters.includes(item.name); // Get first enabled account's status for subtitle const accounts = config.hosters[item.name] || []; const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a)); const accountCount = enabledAccounts.length; let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`; // Check if any account has ok status const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok'); const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error'); if (hasOk) subtitle += ' • Bereit'; else if (hasError) subtitle += ' • Fehler'; return ` `; }).join(''); hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.'; list.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.addEventListener('change', () => { input.closest('.hoster-option')?.classList.toggle('selected', input.checked); }); }); } function openHosterModal() { syncSelectedUploadHosters(); renderHosterModal(); document.getElementById('hosterModal').style.display = 'flex'; } function closeHosterModal() { const modal = document.getElementById('hosterModal'); if (modal) modal.style.display = 'none'; } function applyHosterSelection() { selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); // Move pending files to selectedFiles on confirm const pendingPaths = new Set(_pendingFiles.map(f => f.path)); if (_pendingFiles.length > 0) { selectedFiles.push(..._pendingFiles); _pendingFiles = []; } renderHosterSummary(); // During an active upload, build preview jobs for the new files and inject // them into the running batch immediately (otherwise they'd be lost on // handleBatchDone via syncSelectedFilesFromQueue) if (uploading && pendingPaths.size > 0) { buildQueuePreview(); // creates 'preview' jobs for new files const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file)); if (newJobs.length > 0) { newJobs.forEach(j => { j.status = 'queued'; }); renderQueueTable(); window.api.addJobsToBatch({ jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) }).then(result => { _markSkippedJobs(result); }).catch(() => {}); persistQueueStateSoon(true); } } updateUploadView(); persistQueueStateSoon(true); // immediate persist after adding files document.getElementById('hosterModal').style.display = 'none'; } function cancelHosterModal() { _pendingFiles = []; closeHosterModal(); } function normalizeRestoredJobStatus(status) { if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status; return 'queued'; } function restoreQueueStateFromConfig() { if (config?.globalSettings?.resumeQueueOnLaunch === false) return; const pending = config?.globalSettings?.pendingQueue; if (!pending || typeof pending !== 'object') return; selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters.filter(Boolean) : selectedUploadHosters; selectedFiles = Array.isArray(pending.selectedFiles) ? pending.selectedFiles .filter(file => file && file.path) .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) : []; const rawJobs = Array.isArray(pending.queueJobs) ? pending.queueJobs .filter(job => job && job.fileName && job.hoster) .map(job => ({ id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: null, file: job.file || '', fileName: job.fileName, hoster: job.hoster, status: normalizeRestoredJobStatus(job.status), bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, bytesTotal: job.bytesTotal || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: job.error || null, result: job.result || null, attempt: 0, maxAttempts: job.maxAttempts || 0, link: '', progress: job.status === 'done' ? 1 : 0 })) : []; // Deduplicate: keep the job with the best status for each file+hoster pair const seen = new Map(); const statusPriority = { done: 0, uploading: 1, queued: 2, preview: 3, error: 4, aborted: 5, skipped: 6 }; for (const job of rawJobs) { const key = `${job.file}|${job.hoster}`; const existing = seen.get(key); if (!existing || (statusPriority[job.status] ?? 9) < (statusPriority[existing.status] ?? 9)) { seen.set(key, job); } } queueJobs = Array.from(seen.values()); rebuildJobIndex(); } function buildPersistedQueueState() { const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status)); const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of persistableJobs) { if (job.file && !selectedFileMap.has(job.file)) { selectedFileMap.set(job.file, { path: job.file, name: job.fileName, size: job.bytesTotal || 0 }); } } if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { return null; } // After a restart no upload manager is running, so any in-flight state // (queued / getting-server / uploading / retrying / aborted) is // meaningless. Collapse them all to 'preview' so the queue shows a // consistent "Bereit" for everything that didn't actually terminate. // Only true terminal states (done / error / skipped) survive as-is. const TERMINAL = new Set(['done', 'error', 'skipped']); return { selectedUploadHosters: getSelectedHosters(), selectedFiles: Array.from(selectedFileMap.values()), queueJobs: queueJobs.map(job => { const isTerminal = TERMINAL.has(job.status); return { id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster, status: isTerminal ? job.status : 'preview', bytesTotal: job.bytesTotal || 0, error: isTerminal ? (job.error || null) : null, result: isTerminal ? (job.result || null) : null, maxAttempts: job.maxAttempts || 0 }; }) }; } async function persistQueueStateNow() { const globalSettings = { ...(config.globalSettings || {}), pendingQueue: buildPersistedQueueState() }; config.globalSettings = globalSettings; await window.api.saveGlobalSettings(globalSettings); } function persistQueueStateSoon(immediate) { clearTimeout(queuePersistTimer); if (immediate) { persistQueueStateNow().catch(() => {}); return; } // Use longer debounce during uploads to reduce disk I/O const delay = uploading ? 10000 : 500; queuePersistTimer = setTimeout(() => { persistQueueStateNow().catch(() => {}); }, delay); } function clearPersistedQueueStateSoon() { clearTimeout(queuePersistTimer); queuePersistTimer = setTimeout(() => { const globalSettings = { ...(config.globalSettings || {}), pendingQueue: null }; config.globalSettings = globalSettings; window.api.saveGlobalSettings(globalSettings).catch(() => {}); }, 0); } // --- File selection --- function setupDragDrop() { const dropZone = document.getElementById('dropZone'); // Allow drop on the entire upload view const uploadView = document.getElementById('upload-view'); let _dragCounter = 0; dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }); dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over'); addDroppedFiles(e.dataTransfer.files).catch(console.error); }); dropZone.addEventListener('click', () => pickFiles()); // Also handle drops on queue container uploadView.addEventListener('dragover', (e) => { e.preventDefault(); }); uploadView.addEventListener('drop', (e) => { e.preventDefault(); if (e.target.closest('.drop-zone')) return; // handled above addDroppedFiles(e.dataTransfer.files).catch(console.error); }); } let _pendingFiles = []; // Files waiting for hoster modal confirmation let _addingDropped = false; async function addDroppedFiles(fileList) { if (_addingDropped) return; _addingDropped = true; try { const files = Array.from(fileList); const existingPaths = new Set([ ...selectedFiles.map(f => f.path), ..._pendingFiles.map(f => f.path) ]); const newFiles = []; for (const file of files) { let filePath = ''; try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } if (!filePath) continue; // Detect folders: directories report size 0 and empty type in Electron drag-and-drop if (file.type === '' && file.size === 0) { try { const folderFiles = await window.api.resolveFolderFiles(filePath); if (folderFiles && folderFiles.length > 0) { for (const fp of folderFiles) { if (!existingPaths.has(fp)) { const name = fp.split('\\').pop().split('/').pop(); newFiles.push({ path: fp, name, size: null }); existingPaths.add(fp); } } continue; } } catch {} } // Regular file const fileName = file.name || ''; if (!existingPaths.has(filePath)) { newFiles.push({ path: filePath, name: fileName, size: file.size }); existingPaths.add(filePath); } } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); } } finally { _addingDropped = false; } } async function pickFiles() { const paths = await window.api.selectFiles(); if (!paths) return; addPathsToQueue(paths); } async function pickFolder() { const paths = await window.api.selectFolder(); if (!paths) return; addPathsToQueue(paths); } function addPathsToQueue(paths) { // Build path-Set once so dedup is O(1) per candidate instead of O(n+m). // Matters when the user picks a folder with thousands of files. const existing = new Set(); for (const f of selectedFiles) existing.add(f.path); for (const f of _pendingFiles) existing.add(f.path); const newFiles = []; for (const p of paths) { if (existing.has(p)) continue; existing.add(p); const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); } } function updateUploadView() { const dropZone = document.getElementById('dropZone'); const queueShell = document.getElementById('queueShell'); const queueActions = document.getElementById('queueActions'); if (selectedFiles.length === 0 && queueJobs.length === 0) { dropZone.style.display = 'flex'; queueShell.style.display = 'none'; queueActions.style.display = 'none'; } else { dropZone.style.display = 'none'; queueShell.style.display = 'flex'; queueActions.style.display = 'flex'; if (!uploading && selectedFiles.length > 0) { buildQueuePreview(); } } updateQueueActionButtons(); } function updateStartButton() { const btn = document.getElementById('startUploadBtn'); const hosters = getSelectedHosters(); const hasQueuedJobs = queueJobs.some(isStartableQueueJob); const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0; btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection); } const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']); const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']); function updateQueueActionButtons() { updateStartButton(); const hasSelection = selectedJobIds.size > 0; // Single pass over the (usually small) selection set instead of three O(n) // scans over the entire queue. For 1000 jobs × 3 scans this drops the // selection-change cost from ~3000 checks to |selection|. let hasUploadSelection = false, hasAbortSelection = false, hasStartableSelection = false; for (const id of selectedJobIds) { const job = _jobIndexById.get(id); if (!job) continue; const s = job.status; if (!hasUploadSelection && _UPLOAD_SELECTION_STATUSES.has(s)) hasUploadSelection = true; if (!hasAbortSelection && _ABORT_SELECTION_STATUSES.has(s)) hasAbortSelection = true; if (!hasStartableSelection && isStartableQueueStatus(s)) hasStartableSelection = true; if (hasUploadSelection && hasAbortSelection && hasStartableSelection) break; } const hasMovableSelection = hasSelection && !uploading; const startSelectedBtn = document.getElementById('startSelectedBtn'); const reuploadBtn = document.getElementById('reuploadSelectedBtn'); const abortSelectedBtn = document.getElementById('abortSelectedBtn'); const finishStopBtn = document.getElementById('finishStopBtn'); const abortAllBtn = document.getElementById('abortAllBtn'); const moveTopBtn = document.getElementById('moveTopBtn'); const moveUpBtn = document.getElementById('moveUpBtn'); const moveDownBtn = document.getElementById('moveDownBtn'); const moveBottomBtn = document.getElementById('moveBottomBtn'); if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection; if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; if (finishStopBtn) finishStopBtn.disabled = !uploading; if (abortAllBtn) abortAllBtn.disabled = !uploading; if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection; if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection; if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection; if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; } // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); if (hosters.length === 0) { queueJobs = queueJobs.filter(j => j.status !== 'preview'); rebuildJobIndex(); renderQueueTable(); persistQueueStateSoon(); return; } // Remove old preview jobs queueJobs = queueJobs.filter(j => j.status !== 'preview'); // Build a Set for fast existence checks const existingKeys = new Set(); for (const j of queueJobs) { if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`); } for (const file of selectedFiles) { for (const hoster of hosters) { const key = `${file.path}|${hoster}`; if (!existingKeys.has(key) && !_completedUploadKeys.has(key)) { const job = { id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: file.path, fileName: file.name, hoster, status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: null, result: null, attempt: 0, maxAttempts: 0, link: '' }; queueJobs.push(job); existingKeys.add(key); } } } rebuildJobIndex(); renderQueueTable(); persistQueueStateSoon(); } // --- Job Index Management --- function rebuildJobIndex() { _jobIndexById.clear(); _jobIndexByUploadId.clear(); for (const job of queueJobs) { _jobIndexById.set(job.id, job); if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); } } function indexJob(job) { _jobIndexById.set(job.id, job); if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); } function removeJobFromIndex(job) { _jobIndexById.delete(job.id); if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId); // Track deletion so handleProgress() won't re-create this job from stale callbacks _deletedJobIds.add(job.id); if (job.uploadId) _deletedJobIds.add(job.uploadId); // Allow re-uploading same file+hoster after deletion if (job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`); } // --- Queue Table Rendering (debounced with virtual scrolling) --- let _renderQueued = false; let _sortedJobsCache = []; const VIRTUAL_ROW_HEIGHT = 28; const VIRTUAL_OVERSCAN = 10; let _lastVisibleRange = { start: -1, end: -1 }; let _queueListenersBound = false; // Throttled UI update scheduling – max one render per 200ms during uploads let _uiUpdateTimer = null; const UI_UPDATE_INTERVAL = 200; // ms function scheduleQueueRender() { if (_renderQueued) return; _renderQueued = true; requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); } let _recentRenderQueued = false; function scheduleRecentRender() { if (_recentRenderQueued) return; _recentRenderQueued = true; requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); }); } // Toggle the .selected class on existing rows without rebuilding the table. // Used on click/selection changes — O(rendered rows) instead of O(total rows × sort). // Uses getElementsByClassName for the live HTMLCollection (DOM-cached after the // first call, auto-tracks insertions/removals on tbody) instead of running a // fresh querySelectorAll on every click. At 200 visible rows that's the // difference between paying for a tree walk per click vs reading a memoized // list that the engine already maintains. function applyQueueSelectionClasses() { const tbody = document.getElementById('queueBody'); if (!tbody) return; const rows = tbody.getElementsByClassName('queue-row'); for (let i = 0; i < rows.length; i++) { const tr = rows[i]; tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId)); } } function applyRecentSelectionClasses() { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; const rows = tbody.getElementsByClassName('recent-file-row'); for (let i = 0; i < rows.length; i++) { const tr = rows[i]; const order = parseInt(tr.dataset.order, 10); tr.classList.toggle('selected', selectedRecentIds.has(order)); } } function scheduleThrottledUIUpdate() { if (_uiUpdateTimer) return; _uiUpdateTimer = setTimeout(() => { _uiUpdateTimer = null; scheduleQueueRender(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); }, UI_UPDATE_INTERVAL); } // Coalesces status-change updates (done/error/retrying/queued/…) into one // frame. Without this, a batch of 500 jobs flipping queued→getting-server // →uploading synchronously fires 1500+ updateStatusBar/Buttons/Stats calls // and janks the renderer. rAF caps it to ~60 Hz. let _statusChangeUpdateQueued = false; function scheduleStatusChangeUpdate() { if (_statusChangeUpdateQueued) return; _statusChangeUpdateQueued = true; requestAnimationFrame(() => { _statusChangeUpdateQueued = false; renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); }); } function buildRowHtml(job) { const statusClass = `status-${job.status}`; const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; const uploadedSize = job.status === 'preview' ? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') : `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; const statusText = getStatusText(job); const elapsed = formatTime(job.elapsed); const remaining = formatTime(job.remaining); const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; return ` ${escapeHtml(job.fileName)} ${uploadedSize} ${escapeHtml(job.hoster)} ${escapeHtml(statusText)} ${elapsed} ${remaining} ${speed}
${job.status === 'preview' ? '' : pct + '%'}
`; } // In-place update of a single row's cells (avoids full innerHTML rebuild) function _updateRowInPlace(tr, job) { const statusClass = `status-${job.status}`; const uploadedSize = job.status === 'preview' ? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') : `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; const statusText = getStatusText(job); const elapsed = formatTime(job.elapsed); const remaining = formatTime(job.remaining); const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; const pct = Math.min(100, Math.round((job.progress || 0) * 100)); const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; // Write DOM only when the target value actually changes — a no-op progress // tick (same pct, same speed) then performs zero DOM work. Massive saver // when most of the visible jobs are idle/queued/done and only a few are // actively uploading. const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; if (tr.className !== newClass) tr.className = newClass; if (tr.dataset.link !== link) tr.dataset.link = link; const cells = tr.children; if (cells.length < 8) return false; // structure mismatch, needs full rebuild if (cells[1].textContent !== uploadedSize) cells[1].textContent = uploadedSize; // cells[0] (filename) and cells[2] (hoster) don't change during upload const badge = cells[3].querySelector('.status-badge'); if (badge) { const badgeClass = `status-badge ${statusClass}`; if (badge.className !== badgeClass) badge.className = badgeClass; if (badge.textContent !== statusText) badge.textContent = statusText; } if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed; if (cells[5].textContent !== remaining) cells[5].textContent = remaining; if (cells[6].textContent !== speed) cells[6].textContent = speed; const fill = cells[7].querySelector('.progress-bar-fill'); if (fill) { const pctStr = pct + '%'; if (fill.style.width !== pctStr) fill.style.width = pctStr; const fillClass = `progress-bar-fill ${statusClass}`; if (fill.className !== fillClass) fill.className = fillClass; } const pctSpan = cells[7].querySelector('.progress-pct'); if (pctSpan) { const pctText = job.status === 'preview' ? '' : pct + '%'; if (pctSpan.textContent !== pctText) pctSpan.textContent = pctText; } return true; } function renderQueueTable() { const tbody = document.getElementById('queueBody'); if (!tbody) return; _sortedJobsCache = sortQueueJobs(queueJobs); const totalRows = _sortedJobsCache.length; if (totalRows < 200) { // Try in-place update if row count matches (fast path) const existingRows = tbody.querySelectorAll('.queue-row'); if (existingRows.length === totalRows && totalRows > 0) { // In-place update – no DOM destruction for (let i = 0; i < totalRows; i++) { const tr = existingRows[i]; const job = _sortedJobsCache[i]; // If row identity changed (different job), fall back to full rebuild if (tr.dataset.jobId !== job.id) { tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); _lastVisibleRange = { start: -1, end: -1 }; break; } _updateRowInPlace(tr, job); } } else { // Full rebuild needed (row count changed) tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); _lastVisibleRange = { start: -1, end: -1 }; } } else { // Virtual scrolling for large queues — in-place update when range unchanged _renderVirtualRows(tbody); } // Bind event delegation once if (!_queueListenersBound) { _queueListenersBound = true; tbody.addEventListener('click', (e) => { const row = e.target.closest('.queue-row'); if (row) handleRowClick(e, row); }); tbody.addEventListener('contextmenu', (e) => { const row = e.target.closest('.queue-row'); if (row) handleRowContextMenu(e, row); }); } // Update retry button visibility const hasFailedJobs = queueJobs.some(j => j.status === 'error'); document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; updateQueueActionButtons(); } function _renderVirtualRows(tbody) { const scrollContainer = document.getElementById('queueContainer'); if (!scrollContainer) return; const totalRows = _sortedJobsCache.length; const scrollTop = scrollContainer.scrollTop; // Use a minimum viewport height to avoid rendering nothing when container is being laid out const viewportHeight = Math.max(scrollContainer.clientHeight, 200); const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN); const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN); // Same range — try in-place update to avoid hover flicker if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) { const rows = tbody.querySelectorAll('.queue-row'); if (rows.length === endIdx - startIdx) { let allMatch = true; for (let i = 0; i < rows.length; i++) { const job = _sortedJobsCache[startIdx + i]; if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; } _updateRowInPlace(rows[i], job); } if (allMatch) return; // all rows updated in-place, no rebuild needed } } _lastVisibleRange = { start: startIdx, end: endIdx }; const topPad = startIdx * VIRTUAL_ROW_HEIGHT; const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT); let html = ''; if (topPad > 0) html += ``; for (let i = startIdx; i < endIdx; i++) { html += buildRowHtml(_sortedJobsCache[i]); } if (bottomPad > 0) html += ``; tbody.innerHTML = html; } // Coalesce rapid scroll events (a fast trackpad fling fires dozens) into one // render per frame. rAF keeps the scroll thread cheap. let _queueScrollQueued = false; function _onQueueScroll() { if (_queueScrollQueued) return; if (_sortedJobsCache.length < 200) return; _queueScrollQueued = true; requestAnimationFrame(() => { _queueScrollQueued = false; const tbody = document.getElementById('queueBody'); if (tbody) _renderVirtualRows(tbody); }); } const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); const _collatorSimple = new Intl.Collator('de'); // Queue sort memoization. Keys that don't change after a job enters the queue // (filename, host) reuse the cached result across progress-driven re-renders. // Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when // previews resolve / upload starts) are recomputed each call — otherwise a // queue sorted by size during previews would be stuck in all-zeros order. // // CRITICAL: the cache also tracks jobsRef (identity of the queueJobs array) so // that a full replacement (e.g. backup import, queue restore) invalidates the // cache. Length alone can match across a replace and would otherwise pin the // renderer to stale job references — the UI freezes showing old statuses even // though queueJobs itself has fresh objects. Observed as "upload runs in // status bar but all rows stay 'Bereit'" after importing a backup. let _queueSortCache = { sig: '', result: [], jobsRef: null }; const _STATIC_SORT_KEYS = new Set(['filename', 'host']); // Dynamic-key sort throttle: status/speed/progress/size change on every // progress tick, so a strict per-call sort is O(N log N) per render. Within // one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's // slightly out of order — the user can't perceive sub-200ms reorder lag, and // at 5000 queued jobs this is the difference between smooth and stuttering. // Uses lib/throttled-cache.js (see tests/throttled-cache.test.js). const DYNAMIC_SORT_REFRESH_MS = 200; const _dynamicSortCache = window.ThrottledCache ? window.ThrottledCache.makeThrottledCache(DYNAMIC_SORT_REFRESH_MS) : { get: () => undefined, set: (s, i, v) => v, clear: () => {} }; function sortQueueJobs(jobs) { const { key, direction } = queueSortState; const factor = direction === 'asc' ? 1 : -1; const canCache = _STATIC_SORT_KEYS.has(key); const sig = canCache ? `${key}|${direction}|${jobs.length}` : ''; if (sig && _queueSortCache.sig === sig && _queueSortCache.jobsRef === jobs) { return _queueSortCache.result; } // Dynamic-key throttle: same key+direction+array, sorted within the last // 200ms → reuse. The cache is keyed by `key|direction` and uses the jobs // array identity as the input ref, so a fresh queueJobs (e.g. after // backup import) misses correctly. if (!canCache) { const dynSig = `${key}|${direction}`; const cached = _dynamicSortCache.get(dynSig, jobs); if (cached) return cached; } const sorted = jobs.slice().sort((a, b) => { let cmp = 0; if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName); else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0); else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, b.hoster); else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status); else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0); else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0); return cmp * factor; }); if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs }; else _dynamicSortCache.set(`${key}|${direction}`, jobs, sorted); return sorted; } function getStatusOrder(status) { const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 }; return order[status] ?? 4; } // "Primär" / "Fallback #1" / "Fallback #2"… derived from the job's current // accountId position in the configured hoster account list. Returns '' if we // can't resolve it (e.g. account was removed mid-session). function getAccountLabel(job) { if (!job || !job.accountId || !job.hoster) return ''; const accounts = config && config.hosters && config.hosters[job.hoster]; if (!Array.isArray(accounts)) return ''; const idx = accounts.findIndex(a => a && a.id === job.accountId); if (idx < 0) return ''; return idx === 0 ? 'Primär' : `Fallback #${idx}`; } function getStatusText(job) { const shortErr = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; const acc = getAccountLabel(job); const accSuffix = acc ? ` · ${acc}` : ''; switch (job.status) { case 'preview': return 'Bereit'; case 'queued': return 'Wartet'; case 'getting-server': return `Server...${accSuffix}`; case 'uploading': return `Upload${accSuffix}`; case 'retrying': { const base = `Retry ${job.attempt}/${job.maxAttempts}${accSuffix}`; return shortErr ? `${base}: ${shortErr}` : base; } case 'done': return 'Fertig'; case 'aborted': return 'Abgebrochen'; case 'error': return shortErr ? `Fehlgeschlagen: ${shortErr}` : 'Fehlgeschlagen'; case 'skipped': return shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen'; default: return job.status; } } // --- Queue interactions --- function handleRowClick(e, row) { const jobId = row.dataset.jobId; // Clear recent panel selection when clicking in queue — class-toggle only. if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); } if (e.ctrlKey || e.metaKey) { if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); else selectedJobIds.add(jobId); } else if (e.shiftKey && selectedJobIds.size > 0) { // Use sorted jobs cache for correct shift-click with virtual scrolling const sortedIds = _sortedJobsCache.map(j => j.id); const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id)); const curIdx = sortedIds.indexOf(jobId); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); const to = Math.max(lastIdx, curIdx); for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]); } } else { selectedJobIds.clear(); selectedJobIds.add(jobId); // Single click on done job -> copy link const job = _jobIndexById.get(jobId); if (job && job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } } } // Selection changes don't change sort order / row content — just toggle classes. applyQueueSelectionClasses(); updateQueueActionButtons(); } // --- Context menu --- let alwaysOnTopState = false; // Cache hoster-counts for the context menu. Invalidated on structural changes // to queueJobs (the length-based signature is good enough — a job's hoster // never changes after it's created). let _hosterCountsCache = { sig: '', result: new Map() }; function _getHosterCounts() { const sig = `${queueJobs.length}`; if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result; const m = new Map(); for (let i = 0; i < queueJobs.length; i++) { const h = queueJobs[i].hoster; m.set(h, (m.get(h) || 0) + 1); } _hosterCountsCache = { sig, result: m }; return m; } function handleRowContextMenu(e, row) { e.preventDefault(); const jobId = row.dataset.jobId; if (!selectedJobIds.has(jobId)) { selectedJobIds.clear(); selectedJobIds.add(jobId); applyQueueSelectionClasses(); updateQueueActionButtons(); } showContextMenu(e.clientX, e.clientY); } function showContextMenu(x, y) { const menu = document.getElementById('contextMenu'); // 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'; // Update labels with selection count const n = selectedJobIds.size; const delItem = menu.querySelector('[data-action="delete-selected"]'); if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen'; const copyItem = menu.querySelector('[data-action="copy-links"]'); if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren'; menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => { el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen'; }); const startItem = menu.querySelector('[data-action="start-selected"]'); if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten'; // Dynamic "delete by hoster" submenu — cached count keyed by queue length // so a right-click on a 5000-job queue doesn't rescan everything. const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu'); const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items'); const hosterCounts = _getHosterCounts(); deleteHosterContainer.innerHTML = ''; if (hosterCounts.size > 0) { deleteHosterSubmenu.style.display = ''; hosterCounts.forEach((count, hoster) => { const item = document.createElement('div'); item.className = 'ctx-item ctx-item-danger'; item.dataset.action = `delete-hoster:${hoster}`; item.textContent = `${getHosterLabel(hoster)} (${count})`; deleteHosterContainer.appendChild(item); }); } else { deleteHosterSubmenu.style.display = 'none'; } menu.style.display = 'block'; const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5); menu.style.left = menuX + 'px'; menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; // Flip submenus if they would overflow the viewport right edge menu.querySelectorAll('.ctx-submenu-items').forEach(sub => { // Temporarily show to measure actual width (display:none → offsetWidth=0) sub.style.visibility = 'hidden'; sub.style.display = 'block'; sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth); sub.style.display = ''; sub.style.visibility = ''; }); } function hideContextMenu() { document.getElementById('contextMenu').style.display = 'none'; document.getElementById('recentContextMenu').style.display = 'none'; } function deleteSelectedRecentFiles() { if (selectedRecentIds.size === 0) return; let removedDone = 0, removedErr = 0; sessionFilesData = sessionFilesData.filter(r => { if (!selectedRecentIds.has(r.order)) return true; if (r.isError) removedErr++; else removedDone++; _sessionFileKeys.delete(`${r.link}\u0001${r.filename}\u0001${r.host}`); return false; }); _sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone); _sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr); selectedRecentIds.clear(); renderRecentUploadsPanel(); } function clearAllRecentFiles() { if (sessionFilesData.length === 0) return; if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return; sessionFilesData = []; _sessionFileKeys.clear(); _sessionDoneCount = 0; _sessionErrorCount = 0; selectedRecentIds.clear(); renderRecentUploadsPanel(); } async function exportAllRecentFiles() { if (sessionFilesData.length === 0) { alert('Keine Einträge zum Exportieren.'); return; } const rows = sortRecentFiles(sessionFilesData); const header = 'timestamp|hoster|link|filename|status'; const lines = rows.map(r => { const ts = r.timestamp || r.time || ''; const host = r.host || r.hoster || ''; const link = r.link || ''; const name = r.filename || ''; const status = r.isError ? 'error' : 'ok'; return [ts, host, link, name, status].map(v => String(v).replace(/[\r\n|]/g, ' ')).join('|'); }); const content = [header, ...lines].join('\n') + '\n'; const defaultName = `uploads-${new Date().toISOString().slice(0, 10)}.log`; try { const result = await window.api.saveTextFile(defaultName, content, [ { name: 'Log-Datei', extensions: ['log', 'txt', 'csv'] } ]); if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`); } catch (err) { alert('Export fehlgeschlagen: ' + (err.message || err)); } } function copySelectedRecentLinks() { const links = sessionFilesData .filter(r => selectedRecentIds.has(r.order) && !r.isError) .map(r => r.link) .filter(Boolean); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } // --- Backup export / import --- async function doBackupExport() { try { const result = await window.api.exportBackup(); if (result && result.ok) showCopyToast('Backup exportiert'); } catch (err) { alert('Export fehlgeschlagen: ' + (err.message || err)); } } function askLegacyBackupPassword(hint) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; const card = document.createElement('div'); card.className = 'modal-card'; card.style.width = 'min(380px,100%)'; const header = document.createElement('div'); header.className = 'modal-header'; const h3 = document.createElement('h3'); h3.textContent = 'Backup nicht entschlüsselbar'; header.appendChild(h3); const body = document.createElement('div'); body.className = 'modal-body'; const p = document.createElement('p'); p.style.margin = '0 0 10px'; p.style.fontSize = '13px'; p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.'; if (hint) { const p2 = document.createElement('p'); p2.style.margin = '0 0 10px'; p2.style.fontSize = '12px'; p2.style.color = 'var(--text-dim)'; p2.textContent = hint; body.appendChild(p2); } const input = document.createElement('input'); input.type = 'password'; input.className = 'key-input'; input.placeholder = 'Passwort'; input.autocomplete = 'off'; input.style.width = '100%'; body.appendChild(p); body.appendChild(input); const footer = document.createElement('div'); footer.className = 'modal-footer'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-secondary'; cancelBtn.textContent = 'Abbrechen'; const okBtn = document.createElement('button'); okBtn.className = 'btn btn-primary'; okBtn.textContent = 'Importieren'; footer.appendChild(cancelBtn); footer.appendChild(okBtn); card.appendChild(header); card.appendChild(body); card.appendChild(footer); overlay.appendChild(card); document.body.appendChild(overlay); const done = (val) => { overlay.remove(); resolve(val); }; okBtn.onclick = () => done(input.value || null); cancelBtn.onclick = () => done(null); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(input.value || null); if (e.key === 'Escape') done(null); }); input.focus(); }); } async function doBackupImport(legacyPassword) { const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined; try { const result = await window.api.importBackup(pw); if (!result || result.canceled) return; if (result.needsPassword) { const entered = await askLegacyBackupPassword(result.hint); if (entered) doBackupImport(entered); return; } if (result.ok) { config = result.config; hosterSettings = config.hosterSettings || {}; alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); window.api.setAlwaysOnTop(alwaysOnTopState); renderSettings(); renderAccounts(); renderHosterSummary(); renderHosterModal(); loadHistory(); showCopyToast('Backup importiert'); } else if (result.error) { alert('Import fehlgeschlagen: ' + result.error); } } catch (err) { alert('Import fehlgeschlagen: ' + (err.message || err)); } } document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { hideContextMenu(); cancelHosterModal(); } if (e.target.closest('input, textarea, select')) return; const activeView = document.querySelector('.view.active'); // Ctrl+A if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); // Select recent files only if user's last interaction was in the recent panel if (selectedRecentIds.size > 0 && selectedJobIds.size === 0) { sessionFilesData.forEach(r => selectedRecentIds.add(r.order)); renderRecentUploadsPanel(); } else if (queueJobs.length > 0) { queueJobs.forEach(j => selectedJobIds.add(j.id)); renderQueueTable(); } } } // Delete if (e.key === 'Delete') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); if (selectedRecentIds.size > 0) { deleteSelectedRecentFiles(); } else if (selectedJobIds.size > 0) { const deletedIds = [...selectedJobIds]; // Cancel active uploads for deleted jobs const activeIds = deletedIds.filter(id => { const j = _jobIndexById.get(id); return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); persistQueueStateSoon(true); } } } }); document.getElementById('contextMenu').addEventListener('click', (e) => { const item = e.target.closest('.ctx-item'); if (!item) return; const action = item.dataset.action; if (!action) return; hideContextMenu(); handleContextAction(action); }); async function handleContextAction(action) { if (action === 'start-selected') { startSelectedUpload(); } else if (action === 'copy-links') { const links = getSelectedJobLinks(); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } else if (action === 'retry-selected') { retrySelectedJobs(); } else if (action === 'show-log') { showJobLogModal(); } else if (action === 'delete-selected') { // Cancel active uploads for deleted jobs const activeIds = [...selectedJobIds].filter(id => { const j = _jobIndexById.get(id); return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); persistQueueStateSoon(true); } else if (action === 'copy-all-links') { copyAllLinks(); } else if (action === 'delete-all') { // Cancel all active uploads const activeIds = queueJobs .filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server') .map(j => j.id); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs = []; selectedJobIds.clear(); selectedFiles = []; syncSelectedFilesFromQueue(); renderQueueTable(); updateUploadView(); updateStatusBar(); persistQueueStateSoon(true); } else if (action === 'always-on-top') { alwaysOnTopState = !alwaysOnTopState; await window.api.setAlwaysOnTop(alwaysOnTopState); } else if (action.startsWith('delete-hoster:')) { const hoster = action.replace('delete-hoster:', ''); // Cancel active uploads for this hoster const activeIds = queueJobs .filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview')) .map(j => j.id); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); // Remove ALL jobs for this hoster queueJobs = queueJobs.filter(j => { if (j.hoster === hoster) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); updateQueueActionButtons(); persistQueueStateSoon(true); } else if (action.startsWith('shutdown-')) { const mode = action.replace('shutdown-', ''); await window.api.setShutdownAfterFinish(mode); } } function getSelectedJobLinks() { return queueJobs .filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); } // --- Upload --- async function startUpload() { if (uploading) return; uploading = true; // set immediately to prevent double-click race updateQueueActionButtons(); const hosters = getSelectedHosters(); if (queueJobs.length === 0 && selectedFiles.length > 0) { if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); uploading = false; updateQueueActionButtons(); return; } buildQueuePreview(); } const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } try { jobsToStart.forEach(j => { j.status = 'queued'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.elapsed = 0; j.remaining = 0; j.progress = 0; j.uploadId = null; }); updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); const uploadPayload = { hosters, jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster })) }; const result = await window.api.startUpload(uploadPayload); _markSkippedJobs(result); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; updateQueueActionButtons(); updateStatusBar(); } } catch (err) { uploading = false; updateQueueActionButtons(); updateStatusBar(); alert(`Upload-Start fehlgeschlagen: ${err.message}`); } } function _markSkippedJobs(result) { if (!result || !Array.isArray(result.skippedJobs) || result.skippedJobs.length === 0) return; for (const skipped of result.skippedJobs) { const job = _jobIndexById.get(skipped.jobId); if (job) { job.status = 'error'; job.error = skipped.reason || 'Kein gültiger Account'; } } renderQueueTable(); } async function startSelectedUpload() { if (uploading) { // Batch already running — add selected jobs (queued/error/aborted/skipped) to running batch // Upload-manager has duplicate protection (skips jobs already tracked) const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && ['queued', 'error', 'aborted', 'skipped'].includes(j.status)); if (addable.length > 0) { addable.forEach(j => { j.status = 'queued'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; }); renderQueueTable(); let result = null; try { result = await window.api.addJobsToBatch({ jobs: addable.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) }); } catch (err) { showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${err.message}`); return; } // If the batch ended between UI-state and IPC call, start a fresh batch immediately if (result && result.error === 'Kein Upload aktiv') { uploading = false; updateQueueActionButtons(); updateStatusBar(); await startSelectedUpload(); return; } _markSkippedJobs(result); persistQueueStateSoon(); const added = Number(result && result.added) || 0; // Use ASCII-only toast text here to avoid encoding artifacts on some systems. const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0; const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds) ? result.alreadyInBatchJobIds.length : Math.max(0, addable.length - added - skipped); const toastParts = []; if (added > 0) toastParts.push(`${added} hinzugefuegt`); if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`); if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`); if (result && result.error) { showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${result.error}`); } else if (toastParts.length > 0) { showCopyToast(`Jobs: ${toastParts.join(', ')}`); } else { showCopyToast('Keine Jobs hinzugefuegt'); } return; } return; } uploading = true; // set immediately to prevent double-click race updateQueueActionButtons(); const hosters = getSelectedHosters(); const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } try { jobsToStart.forEach(j => { j.status = 'queued'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; }); updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); const uploadPayload = { hosters, jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster })) }; const result = await window.api.startUpload(uploadPayload); _markSkippedJobs(result); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; updateQueueActionButtons(); updateStatusBar(); } } catch (err) { uploading = false; updateQueueActionButtons(); updateStatusBar(); alert(`Upload-Start fehlgeschlagen: ${err.message}`); } } async function cancelUpload() { await window.api.cancelUpload(); uploading = false; // Reset all non-finished jobs back to queued state for (const job of queueJobs) { if (!['done', 'error', 'skipped'].includes(job.status)) { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.speedKbs = 0; job.elapsed = 0; job.remaining = 0; job.error = null; } } renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(); } // --- Progress handling --- function handleProgress(data) { let job = data.jobId ? _jobIndexById.get(data.jobId) : null; if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job) { job = queueJobs.find(j => j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued' ) || queueJobs.find(j => j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview' ); if (job && data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); } } if (!job) { // Don't re-create jobs that were explicitly deleted by the user if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) { return; } job = { id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: data.uploadId, file: '', fileName: data.fileName, hoster: data.hoster, status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: null, result: null, attempt: 0, maxAttempts: 0, link: '' }; queueJobs.push(job); indexJob(job); } // Don't regress from terminal states (stale callbacks can arrive after completion) if (job.status === 'done' || job.status === 'skipped') return; // Update job state job.status = data.status; job.bytesUploaded = data.bytesUploaded || 0; job.bytesTotal = data.bytesTotal || job.bytesTotal; // Track session total bytes (survives removeFromQueueOnDone) if (job.bytesTotal > 0 && !_sessionTrackedJobs.has(job.id)) { _sessionTotalBytes += job.bytesTotal; _sessionTrackedJobs.add(job.id); } job.speedKbs = data.speedKbs || 0; job.elapsed = data.elapsed || 0; job.remaining = data.remaining || 0; job.error = data.error || null; job.result = data.result || job.result; job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; job.progress = data.progress || 0; // Track which account the backend is currently using so the status cell // can display "Primär" vs "Fallback #N" during rotation. if (data.accountId) job.accountId = data.accountId; if (data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); } maybeAddSessionFile(job); // Track session uploaded bytes (survives removeFromQueueOnDone) if (job.status === 'done' && !_sessionDoneJobs.has(job.id)) { _sessionUploadedBytes += job.bytesTotal || 0; _sessionDoneJobs.add(job.id); } // Track completed uploads so they don't get re-queued after removal if (job.status === 'done') { _completedUploadKeys.add(`${job.file}|${job.hoster}`); } // Remove finished jobs from queue if setting is enabled. Coalesce the // actual array filter into one microtask: a burst of 500 done events // would otherwise fire 500 individual O(N) filters = O(N²) work, visible // as a brief UI freeze when a big batch finishes. Index/selection are // updated synchronously so subsequent lookups see the right state — only // the array rewrite is deferred. if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { removeJobFromIndex(job); selectedJobIds.delete(job.id); if (_doneRemovalCoalescer) { _doneRemovalCoalescer.add(job.id); } else { // Legacy slow path: immediate filter when the lib script didn't load. queueJobs = queueJobs.filter(j => j !== job); } } // Status changes (done/error/etc) get one coalesced update per frame so a // burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM // updates. Ongoing uploading progress is throttled at 200ms. if (data.status === 'uploading') { scheduleThrottledUIUpdate(); } else { scheduleStatusChangeUpdate(); } persistQueueStateSoon(); } function handleBatchDone(summary) { uploading = false; applySummaryResults(summary); _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes // Prune session-stats sets to current queue contents. Without this, IDs // of jobs that were removed from queueJobs (via removeFromQueueOnDone // or the cap-prune below) live forever in these sets — small leak per // entry, real over weeks of use. _completedUploadKeys is intentionally // kept (it's the dedup against re-queueing the same file). if (_sessionTrackedJobs.size > 0 || _sessionDoneJobs.size > 0) { const aliveIds = new Set(); for (const j of queueJobs) aliveIds.add(j.id); for (const id of _sessionTrackedJobs) if (!aliveIds.has(id)) _sessionTrackedJobs.delete(id); for (const id of _sessionDoneJobs) if (!aliveIds.has(id)) _sessionDoneJobs.delete(id); } // Reset aborted jobs back to queued so they can be restarted for (const job of queueJobs) { if (job.status === 'aborted') { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.speedKbs = 0; job.elapsed = 0; job.remaining = 0; job.error = null; } } syncSelectedFilesFromQueue(); updateQueueActionButtons(); renderQueueTable(); renderRecentUploadsPanel(); // History is only visible on the Verlauf tab. Mark it dirty and refresh when // the user actually switches to it — skips an IPC + full table rebuild per // batch-done when the user is watching the upload view. _historyDirty = true; if (_isHistoryTabActive()) loadHistory(); const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; if (removeOnDone) { // Single pass: build the keep-list and clean up the index for removed jobs. const nextJobs = []; for (const job of queueJobs) { if (job.status === 'done') { removeJobFromIndex(job); selectedJobIds.delete(job.id); } else { nextJobs.push(job); } } queueJobs = nextJobs; renderQueueTable(); } else { // Auto-prune for the default (removeOnDone=false) too: cap terminal // jobs (done/skipped/error/aborted) at the most recent N so the queue // can't grow unbounded across long sessions. The algorithm lives in // lib/queue-prune.js (same impl Node-tested, see tests/queue-prune.test.js) // and the result tells us which jobs to drop so we can clean up the // index + selection in one pass. const TERMINAL_KEEP_LIMIT = 500; // Optional-chain so the renderer still works if the prune script fails // to load (e.g. file:// path issues during dev) — falls back to no-prune // rather than crashing on every batch-done. const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT); if (result) { for (const j of result.dropped) { removeJobFromIndex(j); selectedJobIds.delete(j.id); } queueJobs = result.kept; renderQueueTable(); } } if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true); else clearPersistedQueueStateSoon(); lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; updateStatusBar(); _maybeShowBatchSummary(summary); _refreshSessionFailedSnapshot(); } let _sessionFailedKeys = new Set(); async function _refreshSessionFailedSnapshot() { if (!window.api || !window.api.getSessionFailedAccounts) return; try { const keys = await window.api.getSessionFailedAccounts(); _sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []); renderAccounts(); } catch { /* ignore */ } } function _maybeShowBatchSummary(summary) { if (!window.Stats || !summary) return; const buckets = window.Stats.summarizeBatchErrors(summary); const total = Object.values(buckets).reduce((n, arr) => n + arr.length, 0); if (total === 0) return; const modal = document.getElementById('batchSummaryModal'); if (!modal) return; const list = modal.querySelector('#batchSummaryList'); const retryAllBtn = modal.querySelector('#batchSummaryRetryAll'); const retryTransientBtn = modal.querySelector('#batchSummaryRetryTransient'); const closeBtn = modal.querySelector('#batchSummaryClose'); const order = ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error', 'aborted']; list.innerHTML = order .filter(cat => buckets[cat].length > 0) .map(cat => { const items = buckets[cat]; const sample = items.slice(0, 3).map(i => `
  • ${escapeHtml(i.fileName)} → ${escapeHtml(i.hoster)}: ${escapeHtml(i.error)}
  • `).join(''); const more = items.length > 3 ? `
  • … +${items.length - 3} weitere
  • ` : ''; const retryable = window.Stats.isRetryableCategory(cat); const tag = retryable ? 'erneut versuchbar' : 'manuell'; return `
    ${escapeHtml(window.Stats.CATEGORY_LABELS[cat] || cat)} ${items.length} ${tag}
    `; }).join(''); const transientCount = ['hoster-transient', 'network', 'unknown'].reduce((n, c) => n + buckets[c].length, 0); retryTransientBtn.textContent = transientCount > 0 ? `Transiente erneut hochladen (${transientCount})` : 'Keine transienten Fehler'; retryTransientBtn.disabled = transientCount === 0; const allRetryable = total - buckets['aborted'].length; retryAllBtn.textContent = `Alle Fehler erneut versuchen (${allRetryable})`; retryAllBtn.disabled = allRetryable === 0; const close = () => { modal.style.display = 'none'; }; closeBtn.onclick = close; retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); }; retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); }; modal.style.display = 'flex'; } function _retryFailedFromBuckets(buckets, transientOnly) { const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error']; const toRetry = []; for (const cat of cats) { for (const item of (buckets[cat] || [])) toRetry.push(item); } if (toRetry.length === 0) return; const jobsToRetry = []; for (const item of toRetry) { const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped')); if (job) { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.error = null; job.result = null; jobsToRetry.push(job); } } if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; } renderQueueTable(); showCopyToast(`${jobsToRetry.length} Job(s) zum erneuten Upload zurückgesetzt`); if (typeof startUpload === 'function') startUpload(); } function handleStats(data) { lastUploadStats = { state: data.state || 'idle', globalSpeedKbs: data.globalSpeedKbs || 0, totalBytes: data.totalBytes || 0, elapsed: data.elapsed || 0, activeJobs: data.activeJobs || 0 }; updateStatusBar(); updateStatsPanel(); // Track run time if (data.state === 'uploading' || data.state === 'stopping') { if (!statsStartTime) { statsStartTime = Date.now(); statsRunTimer = setInterval(() => { const el = document.getElementById('statRunTime'); if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000)); }, 1000); } } else if (data.state === 'idle' && statsRunTimer) { clearInterval(statsRunTimer); statsRunTimer = null; } } // --- Per-job log modal --- async function showJobLogModal() { if (selectedJobIds.size === 0) return; // Use the first selected job — log view is per-file, multi-select doesn't // make sense here. const jobId = [...selectedJobIds][0]; const job = _jobIndexById.get(jobId); const modal = document.getElementById('jobLogModal'); const titleEl = document.getElementById('jobLogTitle'); const bodyEl = document.getElementById('jobLogBody'); if (!modal || !titleEl || !bodyEl) return; titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log'; bodyEl.textContent = 'Lade…'; modal.style.display = 'flex'; let entries = []; try { entries = await window.api.getJobLog(jobId); } catch {} if (!Array.isArray(entries) || entries.length === 0) { bodyEl.textContent = 'Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).'; return; } const fmt = (e) => { const t = new Date(e.ts || Date.now()).toLocaleTimeString('de-DE', { hour12: false }) + '.' + String((e.ts || 0) % 1000).padStart(3, '0'); if (e.kind === 'progress') { const attempt = e.attempt ? ` (${e.attempt}/${e.maxAttempts || '?'})` : ''; const acc = e.accountId ? ` acc=${e.accountId.slice(0, 32)}` : ''; const err = e.error ? `\n → ${e.error}` : ''; return `[${t}] status=${e.status}${attempt}${acc}${err}`; } // rot-log const rest = Object.entries(e) .filter(([k]) => !['ts', 'kind', 'event', 'jobId'].includes(k)) .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) .join(' '); return `[${t}] [${e.event}] ${rest}`; }; bodyEl.textContent = entries.map(fmt).join('\n'); } function hideJobLogModal() { const m = document.getElementById('jobLogModal'); if (m) m.style.display = 'none'; } async function copyJobLogToClipboard() { const body = document.getElementById('jobLogBody'); if (!body || !body.textContent) return; try { await window.api.copyToClipboard(body.textContent); showCopyToast('Log in Zwischenablage'); } catch {} } // --- Retry --- async function retrySelectedJobs() { const retryJobs = []; // Build a Set for O(1) selectedFiles dedup below. const existingFilePaths = new Set(); for (const f of selectedFiles) existingFilePaths.add(f.path); queueJobs.forEach(j => { if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { // Invalidate the old uploadId: retire the index entry and mark it so // any late progress event from the previous (cancelled/completed) // upload can't overwrite the freshly-reset state. if (j.uploadId) { _jobIndexByUploadId.delete(j.uploadId); _deletedJobIds.add(j.uploadId); } j.status = uploading ? 'queued' : 'preview'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.elapsed = 0; j.remaining = 0; j.progress = 0; j.uploadId = null; retryJobs.push(j); if (!existingFilePaths.has(j.file)) { selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); existingFilePaths.add(j.file); } } }); if (retryJobs.length === 0) return; // Select the retry jobs and start them immediately. // No renderQueueTable / updateQueueActionButtons / updateStatusBar here: // startSelectedUpload() runs the exact same trio right after, and at 500+ // jobs the double render freezes the UI for multiple seconds. selectedJobIds.clear(); retryJobs.forEach(j => selectedJobIds.add(j.id)); persistQueueStateSoon(); await startSelectedUpload(); } async function abortSelectedJobs() { const activeJobIds = []; queueJobs.forEach((job) => { if (!selectedJobIds.has(job.id)) return; if (['preview', 'queued'].includes(job.status)) { job.status = 'aborted'; job.error = 'Abgebrochen'; job.progress = 0; job.uploadId = null; } else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) { activeJobIds.push(job.id); } }); if (activeJobIds.length > 0) { await window.api.cancelSelectedJobs(activeJobIds); } selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(true); } async function finishUploadsInProgress() { if (!uploading) return; await window.api.finishAfterActive(); lastUploadStats.state = 'stopping'; updateStatusBar(); } async function abortAllUploads() { await cancelUpload(); } function moveSelectedJobs(direction) { if (uploading || selectedJobIds.size === 0) return; const jobs = queueJobs.slice(); if (direction === 'top') { queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id))); } else if (direction === 'bottom') { queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id))); } else if (direction === 'up') { for (let i = 1; i < jobs.length; i++) { if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) { [jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]]; } } queueJobs = jobs; } else if (direction === 'down') { for (let i = jobs.length - 2; i >= 0; i--) { if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) { [jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]]; } } queueJobs = jobs; } rebuildJobIndex(); renderQueueTable(); updateStatusBar(); persistQueueStateSoon(true); } function syncSelectedFilesFromQueue() { const fileMap = new Map(); queueJobs .filter((job) => !['done', 'skipped', 'aborted'].includes(job.status)) .forEach((job) => { if (!job.file || fileMap.has(job.file)) return; fileMap.set(job.file, { path: job.file, name: job.fileName, size: job.bytesTotal || 0 }); }); selectedFiles = Array.from(fileMap.values()); } // Cap recent-files panel growth so a multi-thousand-job session doesn't // turn every renderRecentUploadsPanel call into a multi-MB innerHTML write. const SESSION_FILES_CAP = 2000; function maybeAddSessionFile(job) { if (!job) return; const dt = formatDateTime(new Date()); if (job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (!link) return; const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`; if (!_sessionFileKeys.has(dedupKey)) { _sessionFileKeys.add(dedupKey); sessionFilesData.push({ date: dt.text, dateTs: dt.ts, filename: job.fileName || '', host: job.hoster || '', link, isError: false, order: sessionFilesData.length }); _sessionDoneCount++; // Drop oldest entries past the cap to keep render cost bounded. // Without this, sessionFilesData grows unbounded across the session // and every renderRecentUploadsPanel call becomes a megabyte-sized // innerHTML write — visible as scroll/click lag in the lower panel. if (sessionFilesData.length > SESSION_FILES_CAP) { const drop = sessionFilesData.length - SESSION_FILES_CAP; for (let i = 0; i < drop; i++) { const r = sessionFilesData[i]; _sessionFileKeys.delete(`${r.link}${r.filename}${r.host}`); } sessionFilesData = sessionFilesData.slice(drop); } // Coalesce rapid successive adds into one render per frame. scheduleRecentRender(); } } } function applySummaryResults(summary) { const files = Array.isArray(summary?.files) ? summary.files : []; // Build a (fileName + hoster) → job map once so the per-result lookup is O(1) // instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters) // otherwise become O(n²). const jobByKey = new Map(); for (const j of queueJobs) { jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j); } for (const file of files) { for (const result of file.results || []) { const job = jobByKey.get(`${file.name}\u0001${result.hoster}`); if (!job) continue; if (result.status === 'done') { job.status = 'done'; job.result = { download_url: result.download_url || null, embed_url: result.embed_url || null, file_code: result.file_code || null }; job.error = null; job.progress = 1; job.bytesUploaded = job.bytesTotal || file.size || 0; } else if (result.status === 'aborted') { job.status = 'aborted'; job.error = result.error || 'Abgebrochen'; } else if (result.status === 'error') { job.status = 'error'; job.error = result.error || 'Fehlgeschlagen'; } maybeAddSessionFile(job); } } } // Single-pass queue stats computation (shared by status bar + stats panel). // Also tracks inProgressBytes so the status bar doesn't need a second scan. // // Memoized within a single tick: back-to-back calls (updateStatusBar + // updateStatsPanel fire together 4×/sec during upload) share one scan. The // cache is cleared on microtask so the next tick picks up fresh state. let _queueStatsCache = null; function _computeQueueStats() { if (_queueStatsCache) return _queueStatsCache; let remaining = 0, inProgress = 0, done = 0, errors = 0; let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0; const total = queueJobs.length; for (let i = 0; i < total; i++) { const job = queueJobs[i]; const s = job.status; const bt = job.bytesTotal || 0; const bu = job.bytesUploaded || 0; totalSize += bt; if (s === 'uploading' || s === 'getting-server' || s === 'retrying') { inProgress++; remaining++; inProgressBytes += bu; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'preview' || s === 'queued') { remaining++; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'done') { done++; } else if (s === 'error') { errors++; } else if (s !== 'skipped') { remainingSize += Math.max(0, bt - bu); } } _queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes }; queueMicrotask(() => { _queueStatsCache = null; }); return _queueStatsCache; } function updateStatusBar() { const stats = _computeQueueStats(); const etaSeconds = lastUploadStats.globalSpeedKbs > 0 ? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024)) : 0; const stateText = lastUploadStats.state === 'uploading' ? 'Upload läuft...' : lastUploadStats.state === 'stopping' ? 'Stoppt nach aktiven Uploads...' : uploading ? 'Upload vorbereitet...' : 'Bereit'; document.getElementById('sbState').textContent = stateText; document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes; const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`; document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`; document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`; document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`; document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`; document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`; } // --- Health Check --- function renderHealthCheckResults(_results) { const container = document.getElementById('healthCheckResults'); if (container) container.innerHTML = ''; } async function executeHealthCheck(hosters, _mode) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); const rows = result && Array.isArray(result.results) ? result.results : []; rows.forEach((row) => { if (!row) return; const key = row.accountId || row.hoster; if (key) { accountStatuses[key] = { status: row.status || 'unchecked', message: row.message || '' }; } }); renderHealthCheckResults(rows); renderAccounts(); renderHosterModal(); return rows; } async function runHealthCheck(mode = 'manual', requestedHosters = null) { if (healthCheckRunning) { if (mode === 'manual') showCopyToast('Account-Check läuft bereits.'); return []; } let hosters; if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { hosters = requestedHosters; } else { hosters = getAccountsWithCredsFlat() .filter(({ account }) => account.enabled !== false) .map(({ name, account }) => ({ hoster: name, accountId: account.id })); } if (hosters.length === 0) { if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); return []; } healthCheckRunning = true; // Mark all accounts as checking for (const h of hosters) { const key = typeof h === 'string' ? h : (h.accountId || h.hoster); accountStatuses[key] = { status: 'checking', message: '' }; } renderAccounts(); try { return await executeHealthCheck(hosters, mode); } catch (err) { renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); return []; } finally { healthCheckRunning = false; renderAccounts(); } } // --- Settings --- async function _renderLogPathsList(el) { if (!el || !window.api || !window.api.getLogPaths) return; try { const paths = await window.api.getLogPaths(); if (!paths || typeof paths !== 'object') { el.innerHTML = 'Pfade nicht verfügbar.'; return; } const entries = [ ['fileuploader', 'fileuploader.log'], ['debug', 'debug.log'], ['accountRotation', 'account-rotation.log'], ['doodstreamDebug', 'doodstream-debug.log'] ]; el.innerHTML = entries.map(([key, label]) => { const p = paths[key] || ''; return `
    ${escapeHtml(label)} ${escapeHtml(p) || ''}
    `; }).join(''); el.querySelectorAll('[data-reveal-log]').forEach(btn => { btn.addEventListener('click', () => { const target = btn.getAttribute('data-reveal-log'); if (window.api && window.api.revealLogFile) window.api.revealLogFile(target).catch(() => {}); }); }); } catch (err) { el.innerHTML = `Fehler: ${escapeHtml(err.message || String(err))}`; } } function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; const globalSettings = config.globalSettings || {}; const configuredAccounts = getAvailableHosters(); const generalPanel = document.createElement('div'); generalPanel.className = 'hoster-settings-panel'; generalPanel.innerHTML = `
    Allgemein System
    Uploads
    0 = nur pro Hoster
    0 = unbegrenzt
    Verhalten
    Updates
    Log
    Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.
    Diagnose
    Wird geladen…
    Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.
    `; container.appendChild(generalPanel); _renderLogPathsList(generalPanel.querySelector('#logPathsList')); const verboseInput = generalPanel.querySelector('#logVerboseInput'); if (verboseInput) { verboseInput.addEventListener('change', () => { if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {}); }); } const sbBtn = generalPanel.querySelector('#createSupportBundleBtn'); if (sbBtn) { sbBtn.addEventListener('click', async () => { const hint = generalPanel.querySelector('#supportBundleHint'); sbBtn.disabled = true; const prevText = sbBtn.textContent; sbBtn.textContent = 'Exportiere…'; try { const res = await window.api.createSupportBundle(); if (res && res.ok) { if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`; } else if (res && res.canceled) { if (hint) hint.textContent = 'Abgebrochen.'; } else { if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`; } } catch (err) { if (hint) hint.textContent = `Fehler: ${err.message || err}`; } finally { sbBtn.disabled = false; sbBtn.textContent = prevText; } }); } // Toggle general panel generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = generalPanel.querySelector('.hoster-panel-body'); const arrow = generalPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // --- Folder Monitor Panel --- const fm = globalSettings.folderMonitor || {}; const folderMonitorPanel = document.createElement('div'); folderMonitorPanel.className = 'hoster-settings-panel'; folderMonitorPanel.innerHTML = `
    Ordnerüberwachung ${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}
    `; container.appendChild(folderMonitorPanel); // Toggle folder monitor panel folderMonitorPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = folderMonitorPanel.querySelector('.hoster-panel-body'); const arrow = folderMonitorPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // Update badge immediately on checkbox/path change const updateFmBadge = () => { const b = document.getElementById('folderMonitorStatusBadge'); if (!b) return; const enabled = document.getElementById('fmEnabledInput')?.checked; const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim(); if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; } else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; } }; document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge); document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge); document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => { const folder = await window.api.folderMonitorSelectFolder(); if (folder) { document.getElementById('fmFolderPathInput').value = folder; updateFmBadge(); scheduleSettingsSave(); } }); // --- Remote Control Panel --- const remoteSettings = globalSettings.remote || {}; const remotePanel = document.createElement('div'); remotePanel.className = 'hoster-settings-panel'; remotePanel.innerHTML = `
    Fernsteuerung ${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}
    `; container.appendChild(remotePanel); // Toggle remote panel remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = remotePanel.querySelector('.hoster-panel-body'); const arrow = remotePanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // Copy token document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => { const token = document.getElementById('remoteTokenInput').value; if (token) { await window.api.copyToClipboard(token); document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!'; setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500); } }); // Regenerate token document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => { const newToken = await window.api.remoteGenerateToken(); document.getElementById('remoteTokenInput').value = newToken; scheduleSettingsSave(); }); // Update status window.api.remoteStatus().then(status => { const el = document.getElementById('remoteConnectionStatus'); if (!el) return; if (status.running) { el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; el.style.color = '#10b981'; } else { el.textContent = 'Nicht aktiv'; el.style.color = '#94a3b8'; } }).catch(() => {}); // Live client count updates (listener registered once in init, not here) // --- Backup Panel --- const backupPanel = document.createElement('div'); backupPanel.className = 'hoster-settings-panel'; backupPanel.innerHTML = `
    Backup System
    `; container.appendChild(backupPanel); backupPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = backupPanel.querySelector('.hoster-panel-body'); const arrow = backupPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport()); document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport()); // --- Separator before hoster panels --- const separator = document.createElement('div'); separator.style.cssText = 'height:16px'; container.appendChild(separator); if (configuredAccounts.length === 0) { const empty = document.createElement('div'); empty.className = 'settings-empty'; empty.innerHTML = '

    Noch keine Account-Einstellungen vorhanden.

    Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.'; container.appendChild(empty); } for (const { name } of configuredAccounts) { const hs = hosterSettings[name] || {}; const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; const panel = document.createElement('div'); panel.className = 'hoster-settings-panel'; panel.innerHTML = `
    ${escapeHtml(getHosterLabel(name))} Aktiv
    `; container.appendChild(panel); // Toggle panel panel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = panel.querySelector('.hoster-panel-body'); const arrow = panel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); } document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => { const btn = e.target; btn.disabled = true; btn.textContent = 'Prüfe...'; try { const result = await window.api.checkForUpdate(); if (result && result.available) { showUpdateBanner(result); btn.textContent = 'Update gefunden!'; } else { btn.textContent = 'Kein Update verfügbar'; } } catch { btn.textContent = 'Fehler beim Prüfen'; } setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000); }); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' ? 'change' : 'input'; input.addEventListener(eventName, scheduleSettingsSave); }); } async function chooseLogFilePath() { const folders = await window.api.selectFolder(); if (!folders || !folders[0]) return; const normalized = folders[0].replace(/[\\\/]+$/, ''); document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; scheduleSettingsSave(); } function scheduleSettingsSave() { const feedback = document.getElementById('saveFeedback'); if (feedback) feedback.textContent = 'Speichert...'; clearTimeout(settingsSaveTimer); settingsSaveTimer = setTimeout(() => { saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => { if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`; }); }, 350); } async function saveSettings(options = {}) { const { feedbackText = 'Gespeichert!' } = options; const newHosterSettings = { ...(config.hosterSettings || {}) }; const globalSettings = { ...(config.globalSettings || {}), logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), logMode: (() => { const v = document.getElementById('logModeInput')?.value; return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single'; })(), resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked, showDropTarget: !!document.getElementById('showDropTargetInput')?.checked, globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)), folderMonitor: { enabled: !!document.getElementById('fmEnabledInput')?.checked, folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(), recursive: !!document.getElementById('fmRecursiveInput')?.checked, filterMode: document.getElementById('fmFilterModeInput')?.value || 'include', extensions: (document.getElementById('fmExtensionsInput')?.value || '').trim(), skipDuplicates: !!document.getElementById('fmSkipDuplicatesInput')?.checked, delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3), autoStart: !!document.getElementById('fmAutoStartInput')?.checked, hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster) }, remote: { enabled: !!document.getElementById('remoteEnabledInput')?.checked, port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)), token: (document.getElementById('remoteTokenInput')?.value || '').trim(), allowInput: !!document.getElementById('remoteAllowInputInput')?.checked } }; // Always on top setting const aotCheckbox = document.getElementById('alwaysOnTopInput'); if (aotCheckbox) { const newAot = !!aotCheckbox.checked; if (newAot !== alwaysOnTopState) { alwaysOnTopState = newAot; await window.api.setAlwaysOnTop(alwaysOnTopState); } } // Drop target window const dtCheckbox = document.getElementById('showDropTargetInput'); if (dtCheckbox) { if (dtCheckbox.checked) await window.api.showDropTarget(); else await window.api.hideDropTarget(); } for (const name of HOSTERS) { const hs = { ...(hosterSettings[name] || {}) }; document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { const field = input.dataset.hs; if (input.type === 'checkbox') hs[field] = input.checked; else if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024)); else hs[field] = parseInt(input.value, 10) || 0; }); newHosterSettings[name] = hs; } // Fire both saves in parallel instead of serializing the two IPC round-trips. // Skip the getConfig refetch — we just wrote it, we know the new state, and // the round-trip added 100–200ms of UI stall per keystroke (autosave fires // on every input change). await Promise.all([ window.api.saveHosterSettings(newHosterSettings), window.api.saveGlobalSettings(globalSettings) ]); config.hosterSettings = newHosterSettings; config.globalSettings = globalSettings; hosterSettings = newHosterSettings; clearTimeout(settingsSaveTimer); // Start/stop folder monitor based on settings const fmSettings = globalSettings.folderMonitor; const badge = document.getElementById('folderMonitorStatusBadge'); if (fmSettings && fmSettings.enabled && fmSettings.folderPath) { try { await window.api.folderMonitorStart(fmSettings); if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; } } catch { if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; } } } else { await window.api.folderMonitorStop(); if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; } } // Start/stop remote server based on settings const remoteSettings = globalSettings.remote; const remoteBadge = document.getElementById('remoteStatusBadge'); if (remoteSettings) { try { await window.api.remoteSaveSettings(remoteSettings); if (remoteBadge) { remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'; remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`; } // Update status display const status = await window.api.remoteStatus(); const statusEl = document.getElementById('remoteConnectionStatus'); if (statusEl) { if (status.running) { statusEl.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; statusEl.style.color = '#10b981'; } else { statusEl.textContent = 'Nicht aktiv'; statusEl.style.color = '#94a3b8'; } } } catch {} } const feedback = document.getElementById('saveFeedback'); feedback.textContent = feedbackText; setTimeout(() => { if (feedback.textContent === feedbackText) { feedback.textContent = 'Änderungen werden automatisch gespeichert.'; } }, 1800); } // --- Accounts --- function getCredentialLabel(name, account) { if (!account) return 'Keine Zugangsdaten'; if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`; if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`; // Fallback if (account.username && account.password) return `Login: ${account.username}`; if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`; return 'Keine Zugangsdaten'; } const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; function _buildAccountCardHtml(name, account, idx) { const isDisabled = account.enabled === false; const st = accountStatuses[account.id] || { status: 'unchecked', message: '' }; const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft'); const statusClass = isDisabled ? 'disabled' : st.status; const credLabel = getCredentialLabel(name, account); const userLabel = account.label && String(account.label).trim(); // Subtitle: "Label: XYZ • API: ABC… • " — the user-set label is the // disambiguator for accounts that otherwise look identical (e.g. two byse // API-key accounts where you can't tell what's what from the masked key). const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; const isSessionPaused = _sessionFailedKeys.has(`${name}:${account.id}`); const sessionPausedBadge = isSessionPaused ? `Pausiert (Session) ` : ''; return `
    `; } // Replace only the one card for `accountId` instead of re-rendering the whole // container. Runs on enable/disable, single health check, priority-badge bumps // after a reorder — anywhere we only change one card's state. function updateAccountCard(accountId) { const container = document.getElementById('accountsList'); if (!container) return; const found = findAccountById(accountId); if (!found) return; const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`); if (!card) return; const accounts = config.hosters[found.name] || []; const idx = accounts.findIndex(a => a.id === accountId); if (idx < 0) return; const tmp = document.createElement('div'); tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx); card.replaceWith(tmp.firstElementChild); _refreshHosterGroupHeader(found.name); } function _refreshHosterGroupHeader(name) { const container = document.getElementById('accountsList'); if (!container) return; const group = container.querySelector(`.account-hoster-group[data-hoster-group="${name}"]`); if (!group) return; const accounts = config.hosters[name] || []; const summary = _summarizeHosterGroup(accounts); let dot = 'unchecked'; if (summary.error > 0) dot = 'error'; else if (summary.checking > 0) dot = 'checking'; else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok'; const dotEl = group.querySelector('.account-hoster-group-header .account-status-dot'); if (dotEl) dotEl.className = `account-status-dot status-${dot}`; const countEl = group.querySelector('.account-hoster-group-count'); if (countEl) countEl.textContent = `${summary.ok}/${summary.total}`; group.querySelectorAll('.account-hoster-group-meta').forEach(el => el.remove()); const header = group.querySelector('.account-hoster-group-header'); if (header) { if (summary.disabled) { const meta = document.createElement('span'); meta.className = 'account-hoster-group-meta'; meta.textContent = `${summary.disabled} deaktiviert`; header.appendChild(meta); } if (summary.error) { const meta = document.createElement('span'); meta.className = 'account-hoster-group-meta error'; meta.textContent = `${summary.error} Fehler`; header.appendChild(meta); } } } let _accountListenersBound = false; function renderAccounts() { const container = document.getElementById('accountsList'); if (!container) return; ensureAccountStatusEntries(); const allAccounts = getAllAccountsFlat(); const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; if (allAccounts.length === 0) { container.innerHTML = `

    Keine Accounts vorhanden

    Klicke auf "Account hinzufügen", um einen Hoster einzurichten.
    `; if (!_accountListenersBound) bindAccountListeners(container); return; } const byHoster = {}; for (const { name, account } of allAccounts) { if (!byHoster[name]) byHoster[name] = []; byHoster[name].push(account); } let html = ''; for (const name of HOSTERS) { const accounts = byHoster[name]; if (!accounts || accounts.length === 0) continue; html += _buildAccountHosterGroupHtml(name, accounts); } container.innerHTML = html; if (!_accountListenersBound) bindAccountListeners(container); } function _summarizeHosterGroup(accounts) { let ok = 0, error = 0, checking = 0, unchecked = 0, disabled = 0; for (const a of accounts) { if (a.enabled === false) { disabled++; continue; } const s = (accountStatuses[a.id] && accountStatuses[a.id].status) || 'unchecked'; if (s === 'ok' || s === 'warn') ok++; else if (s === 'error') error++; else if (s === 'checking') checking++; else unchecked++; } return { ok, error, checking, unchecked, disabled, total: accounts.length }; } function _hosterGroupOpenState(name, summary) { const prev = _hosterGroupOpenMemory.get(name); if (prev && typeof prev === 'object') { if (summary.error > (prev.errorsAtClose || 0)) { _hosterGroupOpenMemory.delete(name); return true; } return prev.state === 'open'; } return summary.error > 0; } const _hosterGroupOpenMemory = new Map(); function _buildAccountHosterGroupHtml(name, accounts) { const summary = _summarizeHosterGroup(accounts); const isOpen = _hosterGroupOpenState(name, summary); let dot = 'unchecked'; if (summary.error > 0) dot = 'error'; else if (summary.checking > 0) dot = 'checking'; else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok'; const countLabel = `${summary.ok}/${summary.total}`; const arrow = isOpen ? '▼' : '▶'; let cardsHtml = ''; accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); }); const bodyStyle = isOpen ? '' : 'style="display:none"'; const lifeStat = _hosterLifetimeStat(name); const lifeMeta = lifeStat && lifeStat.total > 0 ? `${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})` : ''; return `
    `; } let _hosterLifetimeCache = null; function _hosterLifetimeStat(name) { if (!_hosterLifetimeCache && window.Stats && Array.isArray(window._historyForStats)) { _hosterLifetimeCache = window.Stats.summarizePerHoster(window._historyForStats, { lastNBatches: 50 }); } return _hosterLifetimeCache ? _hosterLifetimeCache[name] : null; } function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; } // Single set of delegated listeners on the accounts container. Bound once on // the first render and reused for every subsequent in-place update / card // swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners // per render — with 20 accounts that's 180 listener create/destroy cycles on // every enable/disable click. function bindAccountListeners(container) { _accountListenersBound = true; container.addEventListener('click', (e) => { const header = e.target.closest('[data-hoster-toggle]'); if (header && !e.target.closest('button')) { const name = header.dataset.hosterToggle; const group = header.closest('.account-hoster-group'); const body = group && group.querySelector('.account-hoster-group-body'); const arrow = header.querySelector('.panel-arrow'); if (body) { const willOpen = body.style.display === 'none'; body.style.display = willOpen ? '' : 'none'; if (arrow) arrow.innerHTML = willOpen ? '▼' : '▶'; const summary = _summarizeHosterGroup(config.hosters[name] || []); _hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error }); } return; } const btn = e.target.closest('button'); if (!btn) return; if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck); if (btn.dataset.accountReactivate) { const accountId = btn.dataset.accountReactivate; const hoster = btn.dataset.accountReactivateHoster; if (!hoster || !accountId) return; e.stopPropagation(); window.api.resetSessionFailedAccount({ hoster, accountId }).then(() => { _sessionFailedKeys.delete(`${hoster}:${accountId}`); renderAccounts(); showCopyToast(`${getHosterLabel(hoster)} Account wieder aktiv — nächste Batch verwendet ihn`); }).catch(() => {}); return; } }); let draggedCard = null; container.addEventListener('dragstart', (e) => { const card = e.target.closest('.account-card[draggable]'); if (!card) return; draggedCard = card; card.classList.add('dragging'); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; }); container.addEventListener('dragend', () => { if (draggedCard) draggedCard.classList.remove('dragging'); draggedCard = null; container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); }); container.addEventListener('dragover', (e) => { const card = e.target.closest('.account-card[draggable]'); if (!card || !draggedCard || draggedCard === card) return; if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; const rect = card.getBoundingClientRect(); const midY = rect.top + rect.height / 2; card.classList.toggle('drag-over-above', e.clientY < midY); card.classList.toggle('drag-over-below', e.clientY >= midY); }); container.addEventListener('dragleave', (e) => { const card = e.target.closest('.account-card[draggable]'); if (card) card.classList.remove('drag-over-above', 'drag-over-below'); }); container.addEventListener('drop', (e) => { const card = e.target.closest('.account-card[draggable]'); if (!card || !draggedCard || draggedCard === card) return; e.preventDefault(); card.classList.remove('drag-over-above', 'drag-over-below'); const hosterName = card.dataset.accountHoster; if (draggedCard.dataset.accountHoster !== hosterName) return; const draggedId = draggedCard.dataset.accountId; const targetId = card.dataset.accountId; const accounts = config.hosters[hosterName]; if (!Array.isArray(accounts)) return; const fromIdx = accounts.findIndex(a => a.id === draggedId); if (fromIdx < 0) return; const [moved] = accounts.splice(fromIdx, 1); const rect = card.getBoundingClientRect(); const insertBefore = e.clientY < rect.top + rect.height / 2; const newToIdx = accounts.findIndex(a => a.id === targetId); accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved); // Move the DOM node in place — no full re-render. if (insertBefore) card.before(draggedCard); else card.after(draggedCard); // The Primär / Fallback #N badges just changed for the whole group. for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id); // Persist in the background. saveConfig is idempotent; we don't need to // await here or re-fetch — our in-memory config is already the truth. window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); }); } async function toggleAccount(accountId) { const found = findAccountById(accountId); if (!found) return; found.account.enabled = !found.account.enabled; syncSelectedUploadHosters(); // In-place: swap only the one affected card. No full re-render, no IPC // refetch, no flicker. Rapid click-toggles now feel instant even with 50 // accounts in the list. updateAccountCard(accountId); renderHosterSummary(); window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); } async function checkSingleAccount(accountId) { if (!accountId || healthCheckRunning) return; const found = findAccountById(accountId); if (!found) return; healthCheckRunning = true; accountStatuses[accountId] = { status: 'checking', message: '' }; updateAccountCard(accountId); try { const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const rows = result && Array.isArray(result.results) ? result.results : []; const row = rows.find(r => r.accountId === accountId); if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' }; } catch (err) { accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; } finally { healthCheckRunning = false; } updateAccountCard(accountId); } // Per-hoster overrides for the login form. VOE only accepts emails — the // generic "Username / E-Mail" label sent users down a confusing rabbit hole // (login fails → upload fetches login redirect → "CSRF token nicht gefunden"). // Other hosters that genuinely accept either keep the generic wording. const LOGIN_FIELD_LABELS = { 'voe.sx': { label: 'E-Mail', placeholder: 'E-Mail-Adresse', inputType: 'email' } }; function getCredsFieldsHtml(authType, account, hoster) { account = account || {}; if (authType === 'login') { const fld = (hoster && LOGIN_FIELD_LABELS[hoster]) || { label: 'Username / E-Mail', placeholder: 'Username oder E-Mail', inputType: 'text' }; return `
    `; } // API key return `
    `; } function openAccountModal(editAccountId) { editingAccountId = editAccountId || null; // Reset the two-step state — any previously validated snapshot from a prior // modal session is stale and must not allow a no-recheck commit. _resetAccountModalState(); const modal = document.getElementById('accountModal'); const title = document.getElementById('accountModalTitle'); const subtitle = document.getElementById('accountModalSubtitle'); const hosterRow = document.getElementById('accountHosterRow'); const hosterSelect = document.getElementById('accountHosterSelect'); const credsContainer = document.getElementById('accountCredsFields'); const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); const labelInput = document.getElementById('accField_label'); statusEl.textContent = ''; statusEl.className = 'account-modal-status'; if (editingAccountId) { // Edit mode const found = findAccountById(editingAccountId); if (!found) return; title.textContent = 'Account bearbeiten'; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`; hosterRow.style.display = 'none'; saveBtn.textContent = 'Prüfen'; if (labelInput) labelInput.value = found.account.label || ''; credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name); } else { // Add mode — always show all options (multiple accounts per hoster allowed) title.textContent = 'Account hinzufügen'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".'; hosterRow.style.display = 'flex'; saveBtn.textContent = 'Prüfen'; hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => `` ).join(''); const firstOpt = HOSTER_ADD_OPTIONS[0]; if (labelInput) labelInput.value = ''; credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value); } // Toggle visibility buttons credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); // Wire field invalidation: any change to a cred field after a green check // drops the validated snapshot so the next click is a re-check, not a commit // of unverified creds. Re-wired here every open because credsContainer's HTML // was replaced. _wireCredFieldInvalidation(); modal.style.display = 'flex'; } function closeAccountModal() { document.getElementById('accountModal').style.display = 'none'; _hideOtpField(); editingAccountId = null; // Cancel any pending auto-close so a stale timer can't close a future modal // the user reopens within the auto-close window. if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } _validatedCreds = null; _accountModalBusy = false; } function openDeleteAccountModal(accountId) { const found = findAccountById(accountId); if (!found) return; const modal = document.getElementById('deleteAccountModal'); const msg = document.getElementById('deleteAccountMessage'); msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`; modal.dataset.accountId = accountId; modal.style.display = 'flex'; } function closeDeleteModal() { document.getElementById('deleteAccountModal').style.display = 'none'; } async function deleteAccount(accountId) { const found = findAccountById(accountId); if (!found) return; // Remove account from the array const accounts = config.hosters[found.name]; if (Array.isArray(accounts)) { config.hosters[found.name] = accounts.filter(a => a.id !== accountId); } delete accountStatuses[accountId]; // saveConfig is async — close the modal immediately so the UI feels // responsive instead of waiting for the atomic write + safeStorage encrypt. // The in-memory config already reflects the delete; the IPC just persists it. closeDeleteModal(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); // Fire-and-forget the persist. The earlier `await getConfig()` round-trip // was redundant (we already have the truth in memory) and was the main // source of perceived lag on add/delete. window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); } function readAccountCredsFromModal(authType) { const label = (document.getElementById('accField_label')?.value || '').trim(); if (authType === 'login') { const username = (document.getElementById('accField_username')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim(); return { enabled: !!(username && password), authType: 'login', username, password, label }; } // API const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); return { enabled: !!apiKey, authType: 'api', apiKey, label }; } // --- Two-step account-modal state machine --- // // Goal: never persist invalid/unverified credentials to config.hosters. The // user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green // the button label flips to "Anlegen" / "Speichern" → the next click commits // to config. Editing any cred field between the two clicks drops the validated // snapshot so the user can't sneak unverified creds through by editing // post-green. // // Invariants enforced here: // 1. Nothing reaches config.hosters until _validatedCreds matches a green // result for the currently-typed creds. // 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler // before any await — guards against double-clicks producing duplicates. // 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with // the new OTP, no config writes until green. // 4. Edit mode hits the same path → bad edits never overwrite known-good // creds on disk. let _accountModalBusy = false; let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green let _autoCloseTimer = null; // Session token used to ignore stale validate-credentials responses: if the // user closes the modal mid-flight and reopens it, the late .then must NOT // stomp the new session's state. Bumped on every modal reset. let _accountModalSession = 0; function _resetAccountModalState() { _accountModalBusy = false; _validatedCreds = null; _accountModalSession++; if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } } function _credsSnapshotKey(authType, creds) { // Identity key for the typed creds — used to detect post-validation edits. // Label changes do NOT invalidate (label is metadata, not a credential). if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`; return `api:${creds.apiKey || ''}`; } function _wireCredFieldInvalidation() { // Any change to a cred IDENTITY field (username/password/apiKey) clears the // validated snapshot and reverts the button to "Prüfen". Label edits don't // invalidate (label is metadata, not a credential). OTP edits don't either: // OTP is an ephemeral auth challenge — once doodstream returned "ok" for // these username+password+OTP, the resulting trust is on the creds; the user // clearing or fixing the OTP field afterward shouldn't force a re-prompt. const ids = ['accField_username', 'accField_password', 'accField_apiKey']; for (const id of ids) { const el = document.getElementById(id); if (!el || el.dataset.invalidateBound === '1') continue; el.addEventListener('input', () => { if (_validatedCreds) { _validatedCreds = null; const saveBtn = document.getElementById('saveAccountBtn'); if (saveBtn) saveBtn.textContent = 'Prüfen'; const statusEl = document.getElementById('accountModalStatus'); if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; } } }); el.dataset.invalidateBound = '1'; } } function _determineHosterContext() { if (editingAccountId) { const found = findAccountById(editingAccountId); if (!found) return null; return { hosterName: found.name, authType: found.account.authType || 'login', accountId: editingAccountId, isEdit: true }; } const selectValue = document.getElementById('accountHosterSelect')?.value; if (!selectValue) return null; const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue); if (!opt) return null; return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false }; } async function saveAccount() { // SYNCHRONOUS re-entry guard — must come before any await. Without this a // double-click before the first IPC returns triggers two saveAccount() calls // and (in the old code) two pushes/two IPCs. _accountModalBusy is checked // synchronously and set synchronously, so the second click no-ops cleanly. if (_accountModalBusy) return; const ctx = _determineHosterContext(); if (!ctx) return; const creds = readAccountCredsFromModal(ctx.authType); const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); if (!creds.enabled) { statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.className = 'account-modal-status error'; return; } // STEP 2: commit. Only fires if a previous "Prüfen" already validated the // EXACT same creds (label changes don't break this — label isn't part of the // credential identity). const snapshotKey = _credsSnapshotKey(ctx.authType, creds); if (_validatedCreds && _validatedCreds.hosterName === ctx.hosterName && _validatedCreds.authType === ctx.authType && _validatedCreds.snapshot === snapshotKey) { // Set busy INSIDE the try so a sync throw on the saveBtn deref above can't // leak _accountModalBusy=true and lock the user out for the session. try { _accountModalBusy = true; saveBtn.disabled = true; saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…'; await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message); } finally { _accountModalBusy = false; if (saveBtn) saveBtn.disabled = false; } return; } // STEP 1: validate ephemerally. NOTHING is written to config.hosters here. // Snapshot the session token so a stale late-arriving response from a // closed-and-reopened modal can't stomp the new session's state. const mySession = _accountModalSession; _accountModalBusy = true; saveBtn.disabled = true; statusEl.textContent = 'Prüfe Login…'; statusEl.className = 'account-modal-status checking'; const otpInput = document.getElementById('accField_otp'); const otp = otpInput ? otpInput.value.trim() : ''; const payload = { hoster: ctx.hosterName, authType: ctx.authType, username: creds.username || '', password: creds.password || '', apiKey: creds.apiKey || '', otp }; let row; try { row = await window.api.validateCredentials(payload); } catch (err) { row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' }; } finally { if (mySession === _accountModalSession) { _accountModalBusy = false; if (saveBtn) saveBtn.disabled = false; } } // Stale response — modal was closed/reopened while we awaited. Drop it. if (mySession !== _accountModalSession) return; if (row && row.status === 'otp_required') { statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; statusEl.className = 'account-modal-status error'; _showOtpField(); _wireCredFieldInvalidation(); // OTP input now exists — wire its listener too saveBtn.textContent = 'Mit OTP prüfen'; return; } if (row && (row.status === 'ok' || row.status === 'warn')) { statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.'; statusEl.className = 'account-modal-status ok'; _hideOtpField(); _validatedCreds = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot: snapshotKey, status: row.status, message: row.message || '' }; saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen'; return; } // error const msg = (row && row.message) || 'Login fehlgeschlagen'; statusEl.textContent = msg; statusEl.className = 'account-modal-status error'; } async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) { // Persist the validated creds to config.hosters and close the modal. By the // time we reach this function the validate-credentials IPC has already // returned ok/warn for these exact creds, so we skip a redundant re-check. let accountId; if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = []; if (ctx.isEdit) { accountId = ctx.accountId; const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId); if (idx >= 0) { config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds }; } } else { accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; config.hosters[ctx.hosterName].push({ id: accountId, ...creds }); } await window.api.saveConfig({ hosters: config.hosters }); // Skip the redundant await getConfig() — the in-memory state is the source // of truth for what we just wrote, decrypted creds didn't change, and the // round-trip was the main lag source on add/delete. accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' }; ensureAccountStatusEntries(); syncSelectedUploadHosters(); // Targeted updates instead of the 4-panel cascade. For add we need a full // accounts-list re-render (new card) and the hoster summary count; for edit // we can update the single card. Settings panel only needs re-render if its // hoster-summary section is visible — that's covered by renderHosterSummary. if (ctx.isEdit) { updateAccountCard(accountId); } else { renderAccounts(); } renderHosterSummary(); // Auto-close after a short pause so the user sees the success state. if (_autoCloseTimer) clearTimeout(_autoCloseTimer); _autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600); } function _showOtpField() { if (document.getElementById('accField_otp')) return; // already visible const container = document.getElementById('accountCredsFields'); const otpHtml = `
    `; container.insertAdjacentHTML('beforeend', otpHtml); // Auto-focus the OTP field setTimeout(() => document.getElementById('accField_otp')?.focus(), 50); } function _hideOtpField() { const row = document.getElementById('otpFieldRow'); if (row) row.remove(); } // --- History --- async function loadHistory() { const history = await window.api.getHistory(); window._historyForStats = history || []; _invalidateHosterLifetimeCache(); const container = document.getElementById('historyContainer'); if (!history || history.length === 0) { historyRowsData = []; container.innerHTML = '

    Noch keine Uploads.

    '; return; } historySortState = { key: 'date', direction: 'desc' }; historyRowsData = []; let order = 0; for (const batch of history) { const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { if (result.status === 'aborted' || result.status === 'error') continue; historyRowsData.push({ date: dt.text, dateTs: dt.ts, filename: file.name || '', host: result.hoster || '', link: result.download_url || result.embed_url || '', isError: false, order: order++ }); } } } renderHistoryTable(container); } async function exportHistory() { const history = await window.api.getHistory(); if (!history || history.length === 0) { alert('Kein Verlauf zum Exportieren vorhanden.'); return; } const asCsv = confirm('Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON'); const format = asCsv ? 'csv' : 'json'; const result = await window.api.exportHistory(format); if (!result || result.canceled) return; if (!result.ok) { alert(result.error || 'Export fehlgeschlagen.'); return; } showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`); } // Memoize sort result: invalidated only when data length changes or sort state changes. // Selection changes and re-renders reuse the cached sorted array — a big win when // the panel has thousands of rows and the sort is stable. let _recentSortCache = { sig: '', result: [] }; function sortRecentFiles(data) { const { key, direction } = recentSortState; const sig = `${key}|${direction}|${data.length}`; if (_recentSortCache.sig === sig) return _recentSortCache.result; const sorted = data.slice(); const dir = direction === 'asc' ? 1 : -1; sorted.sort((a, b) => { if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order)); if (key === 'filename') return dir * _collatorDE.compare(a.filename, b.filename); if (key === 'host') return dir * _collatorDE.compare(a.host, b.host); if (key === 'link') return dir * _collatorDE.compare(a.link, b.link); return 0; }); _recentSortCache = { sig, result: sorted }; return sorted; } function updateRecentSortHeaders() { const head = document.getElementById('recentFilesHead'); if (!head) return; head.querySelectorAll('th[data-recent-sort]').forEach(th => { const key = th.dataset.recentSort; const active = recentSortState.key === key; const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕'; th.classList.toggle('active', active); const indicator = th.querySelector('.sort-indicator'); if (indicator) indicator.textContent = arrow; }); } let _recentListenersBound = false; function _buildRecentRowHtml(row) { const cls = `recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}`; return `` + `${escapeHtml(row.date)}` + `${escapeHtml(row.filename)}` + `${escapeHtml(row.host)}` + `${escapeHtml(row.link)}` + ``; } // Tracks the last rendered dataset so we can append-only when the user is just // accumulating new uploads (the default case: sort=date desc, rows only grow). let _recentLastRenderedSig = ''; let _recentLastRenderedLen = 0; function renderRecentUploadsPanel() { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; if (!sessionFilesData.length) { tbody.innerHTML = 'Noch keine Uploads in dieser Session.'; _recentLastRenderedSig = ''; _recentLastRenderedLen = 0; return; } const rows = sortRecentFiles(sessionFilesData); const sig = `${recentSortState.key}|${recentSortState.direction}`; const dateDescAppendOnly = sig === 'date|desc' && _recentLastRenderedSig === sig && rows.length > _recentLastRenderedLen && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; let wasAppendOnly = false; if (dateDescAppendOnly) { // Fast path: only new rows (date desc puts newest on top) — insert them // at the top without rebuilding the 5000-row tbody below. const added = rows.length - _recentLastRenderedLen; let html = ''; for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]); tbody.insertAdjacentHTML('afterbegin', html); wasAppendOnly = true; } else { tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); } _recentLastRenderedSig = sig; _recentLastRenderedLen = rows.length; // Event delegation – bind once, not per-row if (!_recentListenersBound) { _recentListenersBound = true; tbody.addEventListener('click', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; // Clear queue selection when clicking in recent panel — class-toggle only. if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); } const id = parseInt(tr.dataset.order, 10); if (e.ctrlKey || e.metaKey) { if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); else selectedRecentIds.add(id); } else if (e.shiftKey && selectedRecentIds.size > 0) { // Reuse the already-sorted array from the sort cache instead of // querying every .recent-file-row in the DOM (O(visible) vs O(N) // on large panels). const sortedOrders = (_recentSortCache.result || sortRecentFiles(sessionFilesData)) .map(r => r.order); const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o)); const curIdx = sortedOrders.indexOf(id); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); const to = Math.max(lastIdx, curIdx); for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]); } } else { selectedRecentIds.clear(); selectedRecentIds.add(id); } // Selection change — toggle classes, no tbody rebuild. applyRecentSelectionClasses(); }); tbody.addEventListener('dblclick', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr || tr.classList.contains('error')) return; const link = tr.dataset.link; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } }); } // Sort headers only change when the sort state changes — skip on appends. if (!wasAppendOnly) updateRecentSortHeaders(); } function renderHistoryTable(container) { if (!container || !historyRowsData.length) { if (container) container.innerHTML = '

    Noch keine Uploads.

    '; return; } const rows = sortHistoryRows(historyRowsData); const headerCell = (key, label) => { const active = historySortState.key === key; const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; return `${label}${dir}`; }; let html = ` ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} `; const parts = [html]; const len = rows.length; for (let i = 0; i < len; i++) { const row = rows[i]; const link = row.link || ''; const date = escapeHtml(row.date); const filename = escapeHtml(row.filename); const host = escapeHtml(row.host); const linkHtml = escapeHtml(link); const linkAttr = escapeAttr(link); parts.push(''); } parts.push('
    '); parts.push(date); parts.push(''); parts.push(filename); parts.push(''); parts.push(host); parts.push('
    '); container.innerHTML = parts.join(''); // Delegated listeners: bind once per render-target instead of once per // row/header. With a 5000-row history the per-row bind path was a // 5000-iteration synchronous loop on every Verlauf-tab switch — the // dominant cause of "tab switching lags" in the user report. if (!container.dataset.historyListenersBound) { container.dataset.historyListenersBound = '1'; container.addEventListener('click', (e) => { const th = e.target.closest('th.sortable'); if (th && container.contains(th)) { const key = th.dataset.historySort; const defaultDir = key === 'date' ? 'desc' : 'asc'; if (!_historySortClicked || historySortState.key !== key) { _historySortClicked = true; historySortState.key = key; historySortState.direction = defaultDir; } else { historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; } renderHistoryTable(container); return; } const row = e.target.closest('.history-row'); if (row && !row.classList.contains('error')) { const link = row.dataset.link; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } } }); } } function sortHistoryRows(rows) { const { key, direction } = historySortState; const factor = direction === 'asc' ? 1 : -1; return rows.slice().sort((a, b) => { const cmp = key === 'date' ? a.dateTs - b.dateTs : _collatorDE.compare(String(a[key] || ''), String(b[key] || '')); return (cmp || a.order - b.order) * factor; }); } // Flush pending queue state on window close (sync IPC — blocks until save completes) window.addEventListener('beforeunload', () => { // Flush pending settings save if user changed settings right before closing if (settingsSaveTimer) { clearTimeout(settingsSaveTimer); settingsSaveTimer = null; try { saveSettings(); } catch {} } clearTimeout(queuePersistTimer); queuePersistTimer = null; // Drain pending done-removals synchronously before persisting so jobs the // user expected to disappear (removeFromQueueOnDone=true) don't reappear // on next launch. Microtask wouldn't run before the sync IPC below. if (_doneRemovalCoalescer) _doneRemovalCoalescer.drainSync(); const globalSettings = { ...(config.globalSettings || {}), pendingQueue: buildPersistedQueueState() }; config.globalSettings = globalSettings; window.api.saveGlobalSettingsSync(globalSettings); }); // --- Setup Listeners --- function setupListeners() { document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('addFolderBtn').addEventListener('click', pickFolder); document.getElementById('startUploadBtn').addEventListener('click', startUpload); document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload); // Recent files sort headers document.getElementById('recentFilesHead').addEventListener('click', (e) => { const th = e.target.closest('th[data-recent-sort]'); if (!th) return; const key = th.dataset.recentSort; const defaultDir = key === 'date' ? 'desc' : 'asc'; if (!_recentSortClicked || recentSortState.key !== key) { _recentSortClicked = true; recentSortState.key = key; recentSortState.direction = defaultDir; } else { recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; } renderRecentUploadsPanel(); }); // Recent files context menu document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; e.preventDefault(); e.stopPropagation(); const id = parseInt(tr.dataset.order, 10); if (!selectedRecentIds.has(id)) { selectedRecentIds.clear(); selectedRecentIds.add(id); renderRecentUploadsPanel(); } const menu = document.getElementById('recentContextMenu'); menu.style.display = 'block'; menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px'; }); document.getElementById('recentContextMenu').addEventListener('click', (e) => { const item = e.target.closest('.ctx-item'); if (!item) return; hideContextMenu(); const action = item.dataset.action; if (action === 'recent-copy-links') copySelectedRecentLinks(); else if (action === 'recent-delete') deleteSelectedRecentFiles(); }); document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs); document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs); document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress); document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads); document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top')); document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up')); document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down')); document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom')); document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual')); document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); document.getElementById('clearRecentFilesBtn').addEventListener('click', clearAllRecentFiles); document.getElementById('exportRecentFilesBtn').addEventListener('click', exportAllRecentFiles); document.getElementById('retryFailedBtn').addEventListener('click', () => { queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); }); retrySelectedJobs(); }); document.getElementById('importLogBtn').addEventListener('click', importUploadLog); document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection); document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal); document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal); document.getElementById('selectAllHostersBtn').addEventListener('click', () => { document.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.checked = true; input.closest('.hoster-option')?.classList.add('selected'); }); }); document.getElementById('clearHostersBtn').addEventListener('click', () => { document.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.checked = false; input.closest('.hoster-option')?.classList.remove('selected'); }); }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm('Verlauf wirklich löschen?')) return; await window.api.clearHistory(); loadHistory(); }); document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); // Auto health check toggle const autoToggle = document.getElementById('autoHealthCheckToggle'); if (autoToggle) { autoToggle.checked = autoHealthCheckEnabled; autoToggle.addEventListener('change', (e) => { autoHealthCheckEnabled = !!e.target.checked; try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {} }); } // Virtual scroll for large queues const queueContainer = document.getElementById('queueContainer'); if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true }); // Queue table sorting document.querySelectorAll('#queueTable th.sortable').forEach(th => { th.addEventListener('click', (e) => { // Don't sort if click was on the resizer handle if (e.target.classList.contains('col-resizer')) return; const key = th.dataset.sort; if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc'; else { queueSortState.key = key; queueSortState.direction = 'asc'; } _lastVisibleRange = { start: -1, end: -1 }; // force full rebuild after re-sort renderQueueTable(); }); }); // Queue table column resizing (JDownloader-style) setupColumnResizing(); // Shutdown cancel document.getElementById('cancelShutdownBtn').addEventListener('click', async () => { await window.api.cancelShutdown(); if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; } document.getElementById('shutdownOverlay').style.display = 'none'; }); // Click on empty area in queue → deselect all document.getElementById('upload-view').addEventListener('click', (e) => { if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); } } }); // Right-click on upload view background document.getElementById('upload-view').addEventListener('contextmenu', (e) => { if (e.target.closest('.queue-row')) return; // handled per row if (queueJobs.length === 0 && selectedFiles.length === 0) return; // nothing in queue e.preventDefault(); showContextMenu(e.clientX, e.clientY); }); document.getElementById('hosterModal').addEventListener('click', (e) => { if (e.target.id === 'hosterModal') cancelHosterModal(); }); // Account management document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null)); document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal); document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal); document.getElementById('saveAccountBtn').addEventListener('click', saveAccount); document.getElementById('accountModal').addEventListener('click', (e) => { if (e.target.id === 'accountModal') closeAccountModal(); }); // Account hoster select change → update credential fields document.getElementById('accountHosterSelect').addEventListener('change', (e) => { const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); const authType = opt ? opt.authType : 'login'; const credsContainer = document.getElementById('accountCredsFields'); credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value); credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); document.getElementById('accountModalStatus').textContent = ''; document.getElementById('accountModalStatus').className = 'account-modal-status'; // Hoster changed → any prior validation is stale by construction. Drop the // snapshot and revert the button so the user has to re-Prüfen. _validatedCreds = null; const sb = document.getElementById('saveAccountBtn'); if (sb) sb.textContent = 'Prüfen'; // The cred inputs were just replaced — rewire invalidation listeners on // the fresh elements so post-validation edits still revert the button. _wireCredFieldInvalidation(); }); // Delete account modal document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal); document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal); document.getElementById('confirmDeleteBtn').addEventListener('click', () => { const modal = document.getElementById('deleteAccountModal'); const accountId = modal.dataset.accountId; if (accountId) deleteAccount(accountId); }); document.getElementById('deleteAccountModal').addEventListener('click', (e) => { if (e.target.id === 'deleteAccountModal') closeDeleteModal(); }); // Job log modal document.getElementById('closeJobLogBtn')?.addEventListener('click', hideJobLogModal); document.getElementById('closeJobLogBtn2')?.addEventListener('click', hideJobLogModal); document.getElementById('copyJobLogBtn')?.addEventListener('click', copyJobLogToClipboard); document.getElementById('jobLogModal')?.addEventListener('click', (e) => { if (e.target.id === 'jobLogModal') hideJobLogModal(); }); } // --- Update UI --- function showUpdateBanner(info) { const banner = document.getElementById('updateBanner'); const msg = document.getElementById('updateMessage'); if (!banner || !msg) return; msg.textContent = `Update v${info.remoteVersion} verfügbar`; banner.style.display = 'flex'; document.getElementById('installUpdateBtn').onclick = async () => { msg.textContent = 'Update wird heruntergeladen...'; document.getElementById('installUpdateBtn').disabled = true; await persistQueueStateNow().catch(() => {}); // Save queue before update restart await window.api.installUpdate(); }; document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; }; } function handleUpdateProgress(data) { const msg = document.getElementById('updateMessage'); if (!msg) return; if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`; else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; else if (data.stage === 'error') { msg.textContent = `Update fehlgeschlagen: ${data.error}`; const btn = document.getElementById('installUpdateBtn'); if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; } } } // --- Shutdown --- let shutdownCountdownInterval = null; function handleShutdownCountdown(data) { const overlay = document.getElementById('shutdownOverlay'); const msgEl = document.getElementById('shutdownMessage'); const secEl = document.getElementById('shutdownSeconds'); overlay.style.display = 'flex'; const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' }; let remaining = data.seconds || 60; secEl.textContent = remaining; msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = setInterval(() => { remaining--; secEl.textContent = remaining; msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; if (remaining <= 0) { clearInterval(shutdownCountdownInterval); } }, 1000); } // --- Auto-deduplicate restored queue against own upload log on startup --- async function _autoDeduplicateFromLog() { if (queueJobs.length === 0) return; try { const entries = await window.api.readOwnUploadLog(); if (!entries || entries.length === 0) return; // Only 'done' jobs are dropped here (declutter completed uploads). Pending // and failed jobs survive even if their name+hoster is in the log — they're // intentional queued work. Decision lives in lib/queue-dedup.js (Node-tested, // see tests/queue-dedup.test.js) so it can't silently regress to nuking the // whole restored queue on restart/update. const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries); if (removed.length > 0) { queueJobs = kept; for (const job of removed) { if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`); } rebuildJobIndex(); syncSelectedFilesFromQueue(); window.api.debugLog(`auto-dedup: removed ${removed.length} already-uploaded (done) jobs from restored queue (${entries.length} log entries)`); } } catch {} } // --- Log import: remove already-uploaded file+hoster combos from queue --- async function importUploadLog() { const result = await window.api.importUploadLog(); if (!result || result.canceled) return; const entries = result.entries || []; if (entries.length === 0) { showCopyToast('Keine Einträge im Log gefunden'); return; } // Build lookup Set: "filename_lower|hoster" const logKeys = new Set(); for (const entry of entries) { logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`); } // Find queue jobs that match (already uploaded) let removed = 0; queueJobs = queueJobs.filter(job => { const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`; if (logKeys.has(key) && job.status !== 'done') { removeJobFromIndex(job); // Mark as completed so buildQueuePreview won't re-create them if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`); removed++; return false; } return true; }); if (removed > 0) { selectedJobIds.clear(); syncSelectedFilesFromQueue(); rebuildJobIndex(); renderQueueTable(); updateUploadView(); updateStatusBar(); persistQueueStateSoon(true); } showCopyToast(`${removed} bereits hochgeladene Jobs aus Queue entfernt (${entries.length} Log-Einträge gelesen)`); } // --- Link operations --- function copyAllLinks() { const rows = queueJobs .filter(j => j.status === 'done' && j.result) .map(j => ({ fileName: j.fileName || '', hoster: j.hoster || '', url: j.result.download_url || j.result.embed_url || '' })) .filter(r => r.url); if (rows.length === 0) return; const formatEl = document.getElementById('linkExportFormat'); const fmt = (formatEl && formatEl.value) || 'plain'; const text = window.Stats ? window.Stats.formatLinks(rows, fmt) : rows.map(r => r.url).join('\n'); window.api.copyToClipboard(text); showCopyToast(`${rows.length} Link${rows.length === 1 ? '' : 's'} als ${fmt.toUpperCase()} kopiert`); } // --- Utilities --- function formatSize(bytes) { if (!bytes || bytes <= 0) return '0 B'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function formatSpeed(kbs) { if (!kbs || kbs <= 0) return '0 kB/s'; if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s'; return Math.round(kbs) + ' kB/s'; } function formatTime(seconds) { if (!seconds || seconds <= 0) return '00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`; return `${pad(m)}:${pad(s)}`; } function pad(n) { return String(Math.floor(n)).padStart(2, '0'); } function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); const safeDate = Number.isNaN(date.getTime()) ? new Date() : date; return { ts: safeDate.getTime(), text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }; } function loadAutoCheckPreference() { try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; } catch { return true; } } // --- Queue table column resizing (JDownloader-style) --- // Two-tier widths: _idealColumnWidths is what the user set (persisted); the // displayed widths are scaled proportionally if the window is too narrow to fit // all ideals (fullscreen → windowed). We never overwrite ideals just because // the window shrunk — only an explicit drag updates the ideal for that column. const _idealColumnWidths = {}; function restoreQueueColumnWidths() { try { const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY); if (raw) { const widths = JSON.parse(raw); if (widths && typeof widths === 'object') { for (const [col, px] of Object.entries(widths)) { if (typeof px === 'number' && px > 20) _idealColumnWidths[col] = px; } } } _applyFittedColumnWidths(); } catch {} } function saveDraggedColumnWidth(col, width) { // Called from the resizer onUp: the dragged column's new width becomes its // new ideal. Other columns keep their saved ideals untouched (so a drag // while the window is small doesn't bake the scaled values in). if (!col || typeof width !== 'number' || width < 40) return; _idealColumnWidths[col] = width; try { localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(_idealColumnWidths)); } catch {} _applyFittedColumnWidths(); } function _applyFittedColumnWidths() { const container = document.getElementById('queueContainer'); if (!container) return; const ths = document.querySelectorAll('#queueTable th[data-col]'); if (!ths.length) return; const entries = []; let total = 0; ths.forEach(th => { // Fall back to the column's currently-measured width if no ideal exists // yet (first render before the user ever dragged). const ideal = _idealColumnWidths[th.dataset.col] || th.getBoundingClientRect().width || 0; entries.push({ th, ideal }); total += ideal; }); if (total <= 0) return; const available = container.clientWidth; if (available <= 0) return; const MIN = 40; if (total <= available) { entries.forEach(({ th, ideal }) => { th.style.width = ideal + 'px'; }); return; } // Scale all columns proportionally so they exactly fit the available width. const scale = available / total; entries.forEach(({ th, ideal }) => { th.style.width = Math.max(MIN, Math.round(ideal * scale)) + 'px'; }); } // Debounced window-resize refit. Fires on every window size change — fullscreen // → windowed, dragging the window edge, monitor unplug — and reshapes columns // to the new viewport so the user never has to drag the window wider just to // see a hidden column. let _columnRefitTimer = null; window.addEventListener('resize', () => { clearTimeout(_columnRefitTimer); _columnRefitTimer = setTimeout(_applyFittedColumnWidths, 60); }); function setupColumnResizing() { const headers = document.querySelectorAll('#queueTable th[data-col]'); headers.forEach(th => { const resizer = th.querySelector('.col-resizer'); if (!resizer) return; resizer.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startWidth = th.getBoundingClientRect().width; resizer.classList.add('dragging'); document.body.classList.add('col-resizing'); const onMove = (ev) => { const delta = ev.clientX - startX; const newWidth = Math.max(40, startWidth + delta); th.style.width = newWidth + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); resizer.classList.remove('dragging'); document.body.classList.remove('col-resizing'); // Only the dragged column's new width becomes its new ideal; other // columns keep their saved ideals (so dragging while the window is // narrow doesn't permanently shrink everything else). saveDraggedColumnWidth(th.dataset.col, th.getBoundingClientRect().width); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); } // Single-pass escape instead of 4 chained .replace(/x/g, ...) calls. // Hot path on large table rebuilds — every text cell runs through one of these. const _HTML_ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' }; const _HTML_ESC_RE = /[&<>"]/g; const _ATTR_ESC_MAP = { '&': '&', '"': '"', "'": ''' }; const _ATTR_ESC_RE = /[&"']/g; function escapeHtml(str) { if (!str) return ''; return String(str).replace(_HTML_ESC_RE, (c) => _HTML_ESC_MAP[c]); } function escapeAttr(str) { if (!str) return ''; return String(str).replace(_ATTR_ESC_RE, (c) => _ATTR_ESC_MAP[c]); } function showCopyToast(msg, durationMs) { const toast = document.getElementById('copyToast'); toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('show'), durationMs || 1500); } // --- Resize handle for recent-files panel --- { const resizer = document.getElementById('recentFilesResizer'); const panel = document.getElementById('recentFilesPanel'); if (resizer && panel) { let startY = 0; let startH = 0; resizer.addEventListener('mousedown', (e) => { e.preventDefault(); startY = e.clientY; startH = panel.getBoundingClientRect().height; resizer.classList.add('dragging'); document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; const onMove = (e2) => { const delta = startY - e2.clientY; const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta)); panel.style.flex = `0 0 ${newH}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); resizer.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } } // --- Recent panel tabs --- document.querySelectorAll('.recent-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active')); tab.classList.add('active'); const panel = document.getElementById(tab.dataset.panel); if (panel) panel.classList.add('active'); const hint = document.getElementById('recentFilesHint'); if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'; }); }); // --- Stats panel update --- let statsStartTime = 0; let statsRunTimer = null; function formatBytes(bytes) { if (bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i]; } function formatDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function updateStatsPanel() { const stats = _computeQueueStats(); const remaining = stats.total - stats.done - stats.errors; const el = (id) => document.getElementById(id); if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total; if (el('statQueueDone')) el('statQueueDone').textContent = stats.done; if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining; if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress; if (el('statQueueError')) el('statQueueError').textContent = stats.errors; if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize); if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize); const speed = lastUploadStats.globalSpeedKbs || 0; if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s'; if (el('statEta')) { if (speed > 0 && stats.remainingSize > 0) { el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024))); } else { el('statEta').textContent = '--:--'; } } if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0); } // --- Start --- init();