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) => {
alert('Der konfigurierte Log-Pfad konnte nicht beschrieben werden.\n\nNeue Einträge werden zwischenzeitlich hier gespeichert:\n' + (data && data.fallbackPath ? data.fallbackPath : '(Fallback)') + '\n\nBitte in den Einstellungen einen gültigen Pfad setzen.');
});
// 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');
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`${tab.dataset.view}-view`).classList.add('active');
if (tab.dataset.view === 'history') {
_historyDirty = false;
loadHistory();
}
});
});
// --- 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 `
${escapeHtml(getHosterLabel(item.name))}
${subtitle}
`;
}).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;
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
const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu');
const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items');
const hosterCounts = new Map();
queueJobs.forEach(j => hosterCounts.set(j.hoster, (hosterCounts.get(j.hoster) || 0) + 1));
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
selectedJobIds.clear();
retryJobs.forEach(j => selectedJobIds.add(j.id));
renderQueueTable();
updateQueueActionButtons();
updateStatusBar();
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 = `
`;
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
Ordnerpfad
Wählen
Dateierweiterungen
Nur diese
Alle außer
Verzögerung (Sekunden)
Warten bis Datei fertig geschrieben
Verhalten
Hoster-Vorauswahl
${configuredAccounts.map(({ name }) => `
${escapeHtml(name)}
`).join('')}
${configuredAccounts.length === 0 ? '
Erst Accounts anlegen, dann hier auswählen.
' : '
Keine Auswahl = Hoster-Modal bei jeder Datei.
'}
`;
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 = `
`;
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 = `
Alle Accounts, Einstellungen und den Upload-Verlauf verschlüsselt exportieren oder importieren.
Backup exportieren
Backup importieren
`;
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 = `
`;
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('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;
}
await window.api.saveHosterSettings(newHosterSettings);
await window.api.saveGlobalSettings(globalSettings);
config = await window.api.getConfig();
hosterSettings = config.hosterSettings || {};
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';
}
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.
`;
return;
}
// Group by hoster for drag reorder sections
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) => {
const isDisabled = account.enabled === false;
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[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}`;
html += `
☰
${escapeHtml(getAccountDisplayName(name, account))} ${priorityLabel}
${escapeHtml(credLabel)}${st.message && !isDisabled ? ` • ${escapeHtml(st.message)}` : ''}
${statusLabel}
${toggleLabel}
Prüfen
Bearbeiten
Löschen
`;
});
html += '
';
}
container.innerHTML = html;
// Wire up buttons
container.querySelectorAll('[data-account-toggle]').forEach(btn => {
btn.addEventListener('click', () => toggleAccount(btn.dataset.accountToggle));
});
container.querySelectorAll('[data-account-edit]').forEach(btn => {
btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit));
});
container.querySelectorAll('[data-account-delete]').forEach(btn => {
btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete));
});
container.querySelectorAll('[data-account-check]').forEach(btn => {
btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck));
});
// Drag-and-drop reorder within each hoster group
setupAccountDragReorder(container);
}
function setupAccountDragReorder(container) {
let draggedCard = null;
container.querySelectorAll('.account-card[draggable]').forEach(card => {
card.addEventListener('dragstart', (e) => {
draggedCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => {
if (draggedCard) draggedCard.classList.remove('dragging');
draggedCard = null;
container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedCard || draggedCard === card) return;
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
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);
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over-above', 'drag-over-below');
});
card.addEventListener('drop', async (e) => {
e.preventDefault();
card.classList.remove('drag-over-above', 'drag-over-below');
if (!draggedCard || draggedCard === card) return;
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);
const toIdx = accounts.findIndex(a => a.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
// Move account in array
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);
// Save and re-render
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
renderAccounts();
});
});
}
async function toggleAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
// Flip in place; re-render immediately so the UI responds before the
// disk write completes. The saveConfig is fire-and-forget-ish here because
// we already know the new state locally — a full getConfig round-trip
// after every toggle made rapid enable/disable clicks feel laggy.
found.account.enabled = !found.account.enabled;
syncSelectedUploadHosters();
renderAccounts();
renderHosterSummary();
try { await 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: '' };
renderAccounts();
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;
}
renderAccounts();
}
function getCredsFieldsHtml(authType, account) {
account = account || {};
if (authType === 'login') {
return `
Username / E-Mail
Passwort
👁
`;
}
// API key
return `
API Key
👁
`;
}
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 =>
`${escapeHtml(opt.label)} `
).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 = `
OTP Code
`;
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) {
// Use already-sorted DOM order (cheap) instead of re-sorting the full array.
const sortedOrders = Array.from(tbody.querySelectorAll('.recent-file-row'))
.map(r => parseInt(r.dataset.order, 10));
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 += `
${escapeHtml(row.date)}
${escapeHtml(row.filename)}
${escapeHtml(row.host)}
${escapeHtml(row.link)}
`;
});
html += '
';
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) {
const toast = document.getElementById('copyToast');
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove('show'), 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();