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) const queueSortState = { key: 'filename', direction: 'asc' }; // History state let historyRowsData = []; let historySortState = { key: 'date', direction: 'desc' }; // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; const recentSortState = { key: 'date', direction: 'desc' }; 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(); // --- 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(); 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); // Upload event listeners — debug log only on state transitions; the 'uploading' // tick fires 4×/sec per active job and an IPC roundtrip per event would // backlog the renderer↔main channel with hundreds of messages/sec. window.api.onUploadProgress((data) => { if (data.status !== 'uploading') { window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || '')); } handleProgress(data); }); window.api.onUploadBatchDone((data) => { window.api.debugLog('RX upload-batch-done'); handleBatchDone(data); }); window.api.onUploadStats((data) => { // Stats fire every second per upload session — skip while uploading. if (data.state !== 'uploading') { window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs); } 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; } return { selectedUploadHosters: getSelectedHosters(), selectedFiles: Array.from(selectedFileMap.values()), queueJobs: queueJobs.map(job => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster, // Save aborted jobs as queued so they survive restart status: job.status === 'aborted' ? 'queued' : job.status, bytesTotal: job.bytesTotal || 0, error: job.status === 'aborted' ? null : (job.error || null), result: job.result || 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). function applyQueueSelectionClasses() { const tbody = document.getElementById('queueBody'); if (!tbody) return; const rows = tbody.querySelectorAll('.queue-row'); for (const tr of rows) { tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId)); } } function applyRecentSelectionClasses() { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; const rows = tbody.querySelectorAll('.recent-file-row'); for (const tr of rows) { 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); } 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. let _queueSortCache = { sig: '', result: [] }; const _STATIC_SORT_KEYS = new Set(['filename', 'host']); 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) return _queueSortCache.result; 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 }; 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; } function getStatusText(job) { switch (job.status) { case 'preview': return 'Bereit'; case 'queued': return 'Wartet'; case 'getting-server': return 'Server...'; case 'uploading': return 'Upload'; case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`; case 'done': return 'Fertig'; case 'aborted': return 'Abgebrochen'; case 'error': return 'Fehlgeschlagen'; case 'skipped': return 'Ü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() { 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 = 'Passwort erforderlich'; 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 = 'Dieses Backup wurde mit einem Passwort verschlüsselt.'; 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(); 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 === '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; 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 immediately if setting is enabled if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { removeJobFromIndex(job); selectedJobIds.delete(job.id); queueJobs = queueJobs.filter(j => j !== job); } // Status changes (done/error/etc) get immediate render; ongoing progress is throttled if (data.status === 'uploading') { scheduleThrottledUIUpdate(); } else { scheduleQueueRender(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); } persistQueueStateSoon(); } function handleBatchDone(summary) { uploading = false; applySummaryResults(summary); _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes // 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(); } 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(); } 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; } } // --- 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()); } 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++; // 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) return; if (!results || results.length === 0) { container.innerHTML = ''; return; } container.innerHTML = results.map(item => { const status = item.status || 'skipped'; return `
${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')} [${status.toUpperCase()}] ${escapeHtml(item.message || '')}
`; }).join(''); } 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 || (uploading && mode === 'manual')) return []; // Build check list: all enabled accounts with creds 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 --- 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
`; container.appendChild(generalPanel); // 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(), sessionLog: !!document.getElementById('sessionLogInput')?.checked, 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 (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 toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; return `
${statusLabel}
`; } // 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); } 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 += `
${escapeHtml(getHosterLabel(name))}
`; accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); }); html += '
'; } container.innerHTML = html; if (!_accountListenersBound) bindAccountListeners(container); } // 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 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); }); 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); } function getCredsFieldsHtml(authType, account) { account = account || {}; if (authType === 'login') { return `
`; } // API key return `
`; } function openAccountModal(editAccountId) { editingAccountId = editAccountId || null; 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'); 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 = 'Speichern & prüfen'; credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account); } 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.'; hosterRow.style.display = 'flex'; saveBtn.textContent = 'Anlegen & prüfen'; hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => `` ).join(''); const firstOpt = HOSTER_ADD_OPTIONS[0]; credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}); } // Toggle visibility buttons credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); modal.style.display = 'flex'; } function closeAccountModal() { document.getElementById('accountModal').style.display = 'none'; _hideOtpField(); editingAccountId = null; } 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]; await window.api.saveConfig({ hosters: config.hosters }); config = await window.api.getConfig(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); closeDeleteModal(); } function readAccountCredsFromModal(authType) { 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 }; } // API const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); return { enabled: !!apiKey, authType: 'api', apiKey }; } async function saveAccount() { let hosterName, authType, accountId; if (editingAccountId) { // Edit existing account const found = findAccountById(editingAccountId); if (!found) return; hosterName = found.name; authType = found.account.authType || 'login'; accountId = editingAccountId; } else { // Add new account const selectValue = document.getElementById('accountHosterSelect')?.value; if (!selectValue) return; const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue); if (!opt) return; hosterName = opt.hoster; authType = opt.authType; accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; } const creds = readAccountCredsFromModal(authType); if (!creds.enabled) { const statusEl = document.getElementById('accountModalStatus'); statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.className = 'account-modal-status error'; return; } // Save credentials if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = []; if (editingAccountId) { // Update existing account in array const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId); if (idx >= 0) { config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds }; } } else { // Add new account config.hosters[hosterName].push({ id: accountId, ...creds }); } await window.api.saveConfig({ hosters: config.hosters }); config = await window.api.getConfig(); // Show checking status const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); statusEl.textContent = 'Prüfe Login...'; statusEl.className = 'account-modal-status checking'; saveBtn.disabled = true; accountStatuses[accountId] = { status: 'checking', message: '' }; syncSelectedUploadHosters(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); // Check if OTP was entered (for retry after OTP prompt) const otpInput = document.getElementById('accField_otp'); const otp = otpInput ? otpInput.value.trim() : ''; // Run health check for this specific account (include OTP if provided) const checkPayload = { hoster: hosterName, accountId }; if (otp) checkPayload.otp = otp; try { const result = await window.api.runHealthCheck({ hosters: [checkPayload] }); const rows = result && Array.isArray(result.results) ? result.results : []; const row = rows.find(r => r.accountId === accountId); if (row && row.status === 'otp_required') { // Show OTP input field if not already visible accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' }; statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; statusEl.className = 'account-modal-status error'; _showOtpField(); saveBtn.textContent = 'OTP bestätigen'; } else if (row && (row.status === 'ok' || row.status === 'warn')) { accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' }; statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; statusEl.className = 'account-modal-status ok'; _hideOtpField(); setTimeout(() => closeAccountModal(), 1200); } else { const msg = (row && row.message) || 'Login fehlgeschlagen'; accountStatuses[accountId] = { status: 'error', message: msg }; statusEl.textContent = msg; statusEl.className = 'account-modal-status error'; } } catch (err) { accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; statusEl.className = 'account-modal-status error'; } finally { saveBtn.disabled = false; ensureAccountStatusEntries(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); } } 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(); 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')} `; rows.forEach(row => { html += ``; }); html += '
${escapeHtml(row.date)} ${escapeHtml(row.filename)} ${escapeHtml(row.host)}
'; container.innerHTML = html; container.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { const key = th.dataset.historySort; if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; } renderHistoryTable(container); }); }); container.querySelectorAll('.history-row').forEach(row => { row.addEventListener('click', () => { if (row.classList.contains('error')) return; 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; 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; if (recentSortState.key === key) { recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; } else { recentSortState.key = key; recentSortState.direction = key === 'date' ? 'desc' : 'asc'; } 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, {}); 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'; }); // 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(); }); } // --- 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; const logKeys = new Set(); for (const entry of entries) { logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`); } let removed = 0; queueJobs = queueJobs.filter(job => { const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`; if (logKeys.has(key)) { if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`); removed++; return false; } return true; }); if (removed > 0) { rebuildJobIndex(); syncSelectedFilesFromQueue(); window.api.debugLog(`auto-dedup: removed ${removed} already-uploaded 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 links = queueJobs .filter(j => j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); if (links.length > 0) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links 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) --- function restoreQueueColumnWidths() { try { const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY); if (!raw) return; const widths = JSON.parse(raw); if (!widths || typeof widths !== 'object') return; for (const [col, px] of Object.entries(widths)) { const th = document.querySelector(`#queueTable th[data-col="${col}"]`); if (th && typeof px === 'number' && px > 20) { th.style.width = px + 'px'; } } } catch {} } function saveQueueColumnWidths() { try { const widths = {}; document.querySelectorAll('#queueTable th[data-col]').forEach(th => { widths[th.dataset.col] = th.getBoundingClientRect().width; }); localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(widths)); } catch {} } 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'); saveQueueColumnWidths(); }; 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();