const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
const uiLocalizer = window.I18n.createDomLocalizer(document);
uiLocalizer.start(new URLSearchParams(window.location.search).get('language'));
function setUiLanguage(value) {
const language = uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value));
if (document.body && typeof queueJobs !== 'undefined') refreshLocalizedRuntimeUi();
return language;
}
function getUiLocale() {
return uiLocalizer.getLanguage() === 'de' ? 'de-DE' : 'en-US';
}
function localizeUiText(value) {
return window.I18n.translateText(value, uiLocalizer.getLanguage());
}
function getLocalizedErrorDetail(error) {
const raw = String(error?.message || error || '').trim();
if (raw) {
const translated = localizeUiText(raw);
if (translated !== raw) return translated;
const oppositeLanguage = uiLocalizer.getLanguage() === 'de' ? 'en' : 'de';
if (window.I18n.translateText(raw, oppositeLanguage) !== raw) return raw;
}
return localizeUiText('Unbekannter Fehler');
}
function formatLocalizedError(prefix, error) {
return `${localizeUiText(prefix)}: ${getLocalizedErrorDetail(error)}`;
}
function getCopiedLinksMessage(count) {
return count === 1 ? '1 Link kopiert' : `${count} Links kopiert`;
}
function formatRemoteClientStatus(port, count) {
const clients = count === 1 ? '1 Client' : `${count} Clients`;
return localizeUiText(`Aktiv auf Port ${port} — ${clients} verbunden`);
}
function refreshLocalizedRuntimeUi() {
renderQueueTable();
renderAccounts();
sessionFilesData.forEach(row => {
if (Number.isFinite(row.dateTs)) row.date = formatDateTime(row.dateTs).text;
});
renderRecentUploadsPanel();
historyRowsData.forEach(row => {
if (row.rawTimestamp !== undefined) {
const formatted = formatDateTime(row.rawTimestamp);
row.date = formatted.text;
row.dateTs = formatted.ts;
}
});
const historyContainer = document.getElementById('historyContainer');
if (historyContainer && historyRowsData.length) renderHistoryTable(historyContainer);
updateStatusBar();
const activeRecentTab = document.querySelector('.recent-tab.active');
const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
}
// 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 sourceCleanupFinalizationPending = false;
let sourceCleanupRevocationPending = false;
let healthCheckRunning = false;
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
function _rElLabel(el) {
try {
if (!el || !el.tagName) return '?';
let s = el.tagName.toLowerCase();
if (el.id) s += '#' + el.id;
else if (el.className && typeof el.className === 'string') { const c = el.className.trim().split(/\s+/)[0]; if (c) s += '.' + c; }
const a = el.getAttribute && (el.getAttribute('data-action') || el.getAttribute('data-tab') || el.getAttribute('aria-label') || el.getAttribute('title'));
if (a) s += `[${String(a).slice(0, 24)}]`;
return s;
} catch { return '?'; }
}
try {
if (window.PerformanceObserver) {
new window.PerformanceObserver((list) => {
for (const e of list.getEntries()) {
_rLongTasks++;
if (e.duration > _rLongTaskMax) _rLongTaskMax = e.duration;
if (e.duration >= 100 && window.api && window.api.debugLog) window.api.debugLog(`renderer-longtask dur=${Math.round(e.duration)}ms`);
}
}).observe({ entryTypes: ['longtask'] });
}
} catch {}
try {
if (window.PerformanceObserver) {
new window.PerformanceObserver((list) => {
for (const e of list.getEntries()) {
const proc = Math.round((e.processingEnd || 0) - (e.processingStart || 0));
if (window.api && window.api.debugLog) window.api.debugLog(`renderer-interaction ${e.name} dur=${Math.round(e.duration)}ms proc=${proc}ms target=${_rElLabel(e.target)}`);
}
}).observe({ type: 'event', durationThreshold: 50, buffered: true });
}
} catch {}
function _rFrameTick(ts) {
if (_rFrameLast) { const d = ts - _rFrameLast; _rFrameCount++; if (d > _rFrameWorst) _rFrameWorst = d; if (d > 33) _rFrameJank++; }
_rFrameLast = ts;
requestAnimationFrame(_rFrameTick);
}
requestAnimationFrame(_rFrameTick);
function _resetRendererPerf() {
_rPerfWindowStart = Date.now();
_rFrameCount = 0; _rFrameJank = 0; _rFrameWorst = 0; _rLongTasks = 0; _rLongTaskMax = 0;
}
function _maybeLogRendererPerf(activeJobs) {
const now = Date.now();
if (!_rPerfWindowStart) _rPerfWindowStart = now;
if (now - _rPerfLastLog < 5000) return;
const winSec = (now - _rPerfWindowStart) / 1000;
const fps = winSec > 0 ? Math.round(_rFrameCount / winSec) : 0;
if (window.api && window.api.debugLog) {
window.api.debugLog(`renderer-perf active=${activeJobs} fps=${fps} jankFrames=${_rFrameJank} worstFrame=${Math.round(_rFrameWorst)}ms longtasks=${_rLongTasks} maxTask=${Math.round(_rLongTaskMax)}ms`);
}
_rPerfLastLog = now;
_resetRendererPerf();
}
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let accountStatusGenerationSequence = 0;
const accountStatusGenerations = new Map();
let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true;
const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeThrottleTimer)
? window.ThrottleTimer.makeThrottleTimer()
: (function () {
let h = null;
let fn = null;
let burstStart = null;
function go() { h = null; burstStart = null; const g = fn; fn = null; if (g) g(); }
return {
request(f, d, maxWait) {
if (typeof f === 'function') fn = f;
const t = Date.now();
if (burstStart === null) burstStart = t;
let wait = typeof d === 'number' && d >= 0 ? d : 0;
if (typeof maxWait === 'number' && maxWait >= 0) {
const remaining = maxWait - (t - burstStart);
wait = Math.min(wait, remaining < 0 ? 0 : remaining);
}
clearTimeout(h);
h = setTimeout(go, wait);
},
flushSync() { clearTimeout(h); h = null; burstStart = null; const g = fn; fn = null; if (g) g(); },
cancel() { clearTimeout(h); h = null; burstStart = null; fn = null; },
isPending() { return h !== null; }
};
})();
let configWriteQueue = Promise.resolve();
const failedConfigWriteOperations = [];
let configFlushPromise = null;
let configWriteEpoch = 0;
let configImportInProgress = false;
let closePreparationState = 'open';
let closeWriteAccessDepth = 0;
function createSupersededConfigWriteError() {
const error = new Error('Einstellungen wurden durch einen Import ersetzt');
error.code = 'CONFIG_WRITE_SUPERSEDED';
return error;
}
function createClosedConfigWriteError() {
const error = new Error('Die Anwendung wird gerade beendet');
error.code = 'CONFIG_WRITE_CLOSED';
return error;
}
function beginConfigImport() {
if (configImportInProgress) throw new Error('Ein Einstellungsimport läuft bereits');
configImportInProgress = true;
configWriteEpoch++;
}
function endConfigImport() {
configImportInProgress = false;
}
function isConfigWriteImportGateError(error) {
return String(error && error.message ? error.message : error).includes('Einstellungen werden gerade importiert');
}
function retainFailedConfigWrite(operation, epoch, preserveAcrossImport) {
if (!failedConfigWriteOperations.some(entry => entry.operation === operation)) {
failedConfigWriteOperations.push({ operation, epoch, preserveAcrossImport });
}
}
async function retryFailedConfigWrites() {
while (failedConfigWriteOperations.length > 0) {
const entry = failedConfigWriteOperations[0];
if (entry.epoch !== configWriteEpoch && !entry.preserveAcrossImport) {
failedConfigWriteOperations.shift();
continue;
}
try {
await entry.operation();
failedConfigWriteOperations.shift();
} catch (error) {
if (isConfigWriteImportGateError(error)) {
failedConfigWriteOperations.shift();
continue;
}
throw error;
}
}
}
function enqueueConfigWriteOperation(operation) {
const promise = configWriteQueue.then(operation, operation);
const tail = promise.then(() => undefined, () => undefined);
configWriteQueue = tail;
return { promise, tail };
}
function runConfigWrite(operation, options = {}) {
const epoch = configWriteEpoch;
const preserveAcrossImport = options.preserveAcrossImport === true;
const allowedDuringClose = options.allowDuringClose === true || closeWriteAccessDepth > 0;
const blockedByImport = configImportInProgress && !preserveAcrossImport;
const blockedByClose = closePreparationState !== 'open' && !allowedDuringClose;
return enqueueConfigWriteOperation(async () => {
if (blockedByClose || (closePreparationState === 'sealed' && !allowedDuringClose)) throw createClosedConfigWriteError();
if (!preserveAcrossImport && (blockedByImport || configImportInProgress || epoch !== configWriteEpoch)) throw createSupersededConfigWriteError();
try {
await retryFailedConfigWrites();
} catch (error) {
retainFailedConfigWrite(operation, epoch, preserveAcrossImport);
throw error;
}
try {
return await operation();
} catch (error) {
if (!isConfigWriteImportGateError(error)) retainFailedConfigWrite(operation, epoch, preserveAcrossImport);
throw error;
}
}).promise;
}
async function withCloseWriteAccess(operation) {
closeWriteAccessDepth++;
try {
return await operation();
} finally {
closeWriteAccessDepth--;
}
}
function snapshotConfigWritePayload(payload) {
return structuredClone(payload);
}
function saveConfigTracked(configPatch) {
const payload = snapshotConfigWritePayload(configPatch);
return runConfigWrite(() => window.api.saveConfig(payload));
}
function saveHosterSettingsTracked(settings) {
const payload = snapshotConfigWritePayload(settings);
return runConfigWrite(() => window.api.saveHosterSettings(payload));
}
function saveGlobalSettingsTracked(settings) {
const payload = snapshotConfigWritePayload(settings);
return runConfigWrite(() => window.api.saveGlobalSettings(payload));
}
function savePendingQueueTracked(pendingQueue) {
const payload = snapshotConfigWritePayload(pendingQueue);
return runConfigWrite(() => window.api.savePendingQueue(payload), { preserveAcrossImport: true });
}
function setAlwaysOnTopTracked(value) {
const enabled = !!value;
return runConfigWrite(() => window.api.setAlwaysOnTop(enabled));
}
function saveDiagnosticsSettingsTracked(settings) {
const payload = snapshotConfigWritePayload(settings);
return runConfigWrite(() => window.api.diagnosticsSaveSettings(payload));
}
function saveRemoteSettingsTracked(settings) {
const payload = snapshotConfigWritePayload(settings);
return runConfigWrite(() => window.api.remoteSaveSettings(payload));
}
function flushConfigWrites() {
if (configFlushPromise) return configFlushPromise;
configFlushPromise = (async () => {
while (true) {
const { promise, tail } = enqueueConfigWriteOperation(retryFailedConfigWrites);
await promise;
if (configWriteQueue === tail && failedConfigWriteOperations.length === 0) return;
}
})();
configFlushPromise.then(
() => { configFlushPromise = null; },
() => { configFlushPromise = null; }
);
return configFlushPromise;
}
let _restoredSnapshotSavedAt = null;
let settingsSaveTimer = null;
const settingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performSaveSettings);
let settingsBaseline = '';
let settingsDirty = false;
let settingsSaving = false;
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
const uploadSpeedState = { display: 0, history: [] };
let uploadSpeedTimer = null;
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 selectionAnchorJobId = null;
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 _suppressedPreviewKeys = new Set();
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
const queueSortState = { key: 'filename', direction: 'asc' };
let uploadSidebarFilter = 'all';
let queueSearchQuery = '';
let queueHosterFilter = '';
let queueStatusFilter = '';
let _queueFilterCache = { filter: '', source: null, result: [] };
// History state
let historyRowsData = [];
let historySortState = { key: 'date', direction: 'desc' };
let _historySortClicked = false;
let historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
let accountSidebarFilter = 'all';
let historySidebarFilter = 'all';
let _knownUpdateInfo = null;
let _updateCheckBusy = false;
let _updateInstallBusy = false;
let _startupAutoResumeController = null;
let _startupAutoResumeCanceled = false;
const modalController = (() => {
const stack = [];
const baselineInert = new Map();
const focusSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const resolveOverlay = value => typeof value === 'string' ? document.getElementById(value) : value;
const getDialog = overlay => overlay?.querySelector('[role="dialog"]') || overlay;
const isFocusable = element => {
if (!(element instanceof HTMLElement) || element.hidden || element.matches(':disabled')) return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
};
const getFocusable = overlay => Array.from(getDialog(overlay)?.querySelectorAll(focusSelector) || []).filter(isFocusable);
const resolveFocusTarget = (entry, value) => {
const candidate = typeof value === 'function'
? value()
: typeof value === 'string'
? entry.overlay.querySelector(value) || document.querySelector(value)
: value;
return isFocusable(candidate) ? candidate : null;
};
const syncIsolation = () => {
const top = stack.at(-1);
if (!top) {
baselineInert.forEach((inert, element) => {
if (element.isConnected) element.inert = inert;
});
baselineInert.clear();
return;
}
Array.from(document.body.children).forEach(element => {
if (!baselineInert.has(element)) baselineInert.set(element, element.inert);
element.inert = element !== top.overlay;
});
};
const focusEntry = entry => {
if (!entry || stack.at(-1) !== entry) return;
const dialog = getDialog(entry.overlay);
const target = resolveFocusTarget(entry, entry.options.initialFocus) || getFocusable(entry.overlay)[0] || dialog;
if (target instanceof HTMLElement && !target.matches(':disabled')) target.focus();
};
const open = (value, options = {}) => {
const overlay = resolveOverlay(value);
if (!overlay) return false;
let entry = stack.find(item => item.overlay === overlay);
if (entry) {
entry.options = { ...entry.options, ...options };
stack.splice(stack.indexOf(entry), 1);
stack.push(entry);
} else {
entry = {
overlay,
options,
returnFocus: options.returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
};
stack.push(entry);
}
overlay.style.display = options.display || 'flex';
overlay.setAttribute('aria-hidden', 'false');
overlay.inert = false;
syncIsolation();
if (!getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry);
requestAnimationFrame(() => {
if (stack.at(-1) === entry && !getDialog(overlay)?.contains(document.activeElement)) focusEntry(entry);
});
return true;
};
const close = (value, options = {}) => {
const overlay = resolveOverlay(value);
const index = stack.findIndex(item => item.overlay === overlay);
if (index < 0) {
if (overlay) {
overlay.style.display = 'none';
overlay.setAttribute('aria-hidden', 'true');
}
return false;
}
const wasTop = index === stack.length - 1;
const [entry] = stack.splice(index, 1);
overlay.style.display = 'none';
overlay.setAttribute('aria-hidden', 'true');
syncIsolation();
if (wasTop && options.restoreFocus !== false) {
const fallback = resolveFocusTarget(entry, options.fallbackFocus ?? entry.options.fallbackFocus);
const target = resolveFocusTarget(entry, entry.returnFocus) || fallback;
if (target) target.focus();
else if (stack.length > 0) focusEntry(stack.at(-1));
}
return true;
};
const isOpen = value => {
const overlay = resolveOverlay(value);
return Boolean(overlay && stack.some(item => item.overlay === overlay));
};
const handleKeydown = event => {
const entry = stack.at(-1);
if (!entry) return;
const dialog = getDialog(entry.overlay);
if (!dialog) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
entry.options.onEscape?.();
return;
}
if (event.key !== 'Tab') return;
const focusable = getFocusable(entry.overlay);
event.stopImmediatePropagation();
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1);
if (!dialog.contains(document.activeElement) || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
};
const handleClick = event => {
const entry = stack.at(-1);
if (!entry || event.target !== entry.overlay || typeof entry.options.onBackdrop !== 'function') return;
event.preventDefault();
event.stopImmediatePropagation();
entry.options.onBackdrop();
};
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('click', handleClick, true);
return { open, close, isOpen };
})();
// Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = [];
let _recentSeqCounter = 0;
let _recentDataVersion = 0;
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();
window.addEventListener('error', (e) => {
try {
const msg = `RENDERER ERROR: ${e.message} at ${e.filename}:${e.lineno}:${e.colno}${e.error && e.error.stack ? '\n' + e.error.stack : ''}`;
if (window.api && window.api.debugLog) window.api.debugLog(msg);
} catch {}
});
window.addEventListener('unhandledrejection', (e) => {
try {
const reason = e.reason && e.reason.stack ? e.reason.stack : (e.reason && e.reason.message) || String(e.reason);
if (window.api && window.api.debugLog) window.api.debugLog(`RENDERER UNHANDLED REJECTION: ${reason}`);
} catch {}
});
// --- Init ---
async function init() {
try {
config = await window.api.getConfig();
} catch (error) {
await showAppAlert(getLocalizedErrorDetail(error), 'Zugangsdaten gesperrt');
throw error;
}
setUiLanguage(config.globalSettings?.language);
hosterSettings = config.hosterSettings || {};
autoHealthCheckEnabled = loadAutoCheckPreference();
ensureAccountStatusEntries();
syncSelectedUploadHosters();
restoreQueueStateFromConfig();
await _autoDeduplicateFromLog();
_hydrateMissingJobSizes();
renderHosterSummary();
renderHosterModal();
renderSettings();
renderAccounts();
setupListeners();
setupDragDrop();
initUploadSpeedSparkline();
restoreQueueColumnWidths();
loadHistory();
_refreshSessionFailedSnapshot();
renderRecentUploadsPanel();
updateUploadView();
updateStatusBar();
const interruptedCount = queueJobs.filter(job => job.interrupted).length;
if (interruptedCount > 0) showCopyToast(interruptedCount === 1 ? '1 unterbrochener Upload kann fortgesetzt werden.' : `${interruptedCount} unterbrochene Uploads können fortgesetzt werden.`, 7000);
// Version display
try {
const version = await window.api.getVersion();
const versionLabel = document.getElementById('versionLabel');
if (versionLabel) versionLabel.textContent = `v${version}`;
} catch {}
window.api.onUploadProgress((data) => {
handleProgress(data);
});
if (window.api.onUploadProgressBatch) {
window.api.onUploadProgressBatch((batch) => {
if (!Array.isArray(batch)) return;
for (let i = 0; i < batch.length; i++) handleProgress(batch[i]);
});
}
window.api.onUploadBatchDone(async (data) => {
const summary = data && data.summary ? data.summary : data;
const requiresFinalization = Boolean(data && data.finalizationId && window.api.completeUploadFinalization);
if (!requiresFinalization) {
handleBatchDone(summary);
return;
}
sourceCleanupFinalizationPending = true;
try {
handleBatchDone(summary, { deferPersistence: true, historyPersisted: data.historyPersisted === true });
await completeSourceCleanupFinalization(data);
} finally {
sourceCleanupFinalizationPending = false;
updateQueueActionButtons();
}
});
window.api.onUploadStats((data) => {
handleStats(data);
});
window.api.onShutdownCountdown(handleShutdownCountdown);
window.api.onUploadLogFallback((data) => {
const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)';
showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000);
});
window.api.onLogPathAutoUpdated((data) => {
if (!data || !data.logFilePath) return;
// Keep the in-memory config and the visible Settings input in sync so
// the user sees the path that's actually being written to, and the
// next save from the UI doesn't revert it.
if (config && config.globalSettings) config.globalSettings.logFilePath = data.logFilePath;
const input = document.getElementById('logFilePathInput');
if (input) input.value = data.logFilePath;
showCopyToast(`Log-Pfad automatisch auf funktionierenden Ordner gesetzt`, 5000);
});
window.api.onAccountRotationLog((entry) => {
// Surface only the user-visible rotation events as toasts; full detail
// goes to account-rotation.log. Keep it quiet otherwise.
if (!entry || !entry.event) return;
const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : '';
if (entry.event === 'rotate') {
showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`);
} else if (entry.event === 'rotation-end') {
showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`);
} else if (entry.event === 'final-error') {
showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`);
}
});
// Folder monitor: auto-queue new files
window.api.onFolderMonitorNewFiles(async (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 });
}
const admitted = admitFilenameFilter(newFiles);
if (admitted.accepted.length > 0) {
const newPaths = new Set(admitted.accepted.map(f => f.path));
clearDedupKeysForPaths(newPaths);
selectedFiles.push(...admitted.accepted);
buildQueuePreview();
updateUploadView();
if (admitted.active && admitted.excluded.length > 0) showFilenameFilterResult(admitted);
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) {
await startSelectedUpload(newJobs);
}
}
} else if (admitted.active && admitted.total > 0) {
showFilenameFilterResult(admitted);
}
} else {
// No pre-selected hosters: open modal
await 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((entries) => {
addDropTargetEntries(entries).catch(console.error);
});
// 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 = formatRemoteClientStatus(status.port, status.clientCount);
}
}).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();
scheduleRestoredQueueAutoStart();
}
// --- Tab switching ---
let _historyDirty = false;
let _historyEverLoaded = false;
let clampRecentPanelHeight = () => {};
function _isHistoryTabActive() {
const tab = document.querySelector('.tab.active');
return !!(tab && tab.dataset.view === 'history');
}
// Cache the tab/view collections once and use event delegation on the parent
// so tab switches don't trigger three querySelectorAll walks per click.
(() => {
const tabs = Array.from(document.querySelectorAll('.tab'));
const views = Array.from(document.querySelectorAll('.view'));
const tabsByView = {};
const viewsById = {};
for (const t of tabs) tabsByView[t.dataset.view] = t;
for (const v of views) viewsById[v.id] = v;
let activeTab = tabs.find(t => t.classList.contains('active')) || tabs[0];
const handle = (target) => {
const tab = target.closest('.tab');
if (!tab || tab === activeTab) return;
if (activeTab) {
activeTab.classList.remove('active');
activeTab.setAttribute('aria-selected', 'false');
activeTab.tabIndex = -1;
const prevView = viewsById[`${activeTab.dataset.view}-view`];
if (prevView) prevView.classList.remove('active');
}
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active');
if (tab.dataset.view === 'upload') requestAnimationFrame(clampRecentPanelHeight);
activeTab = tab;
syncTabIndicator(tab);
const activeSidebarButton = nextView?.querySelector('.view-sidebar-navigation > .view-sidebar-item.active, .settings-navigation > .settings-nav-button.active');
_syncSidebarIndicator(activeSidebarButton, true);
if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) {
loadHistory();
}
};
const tabBar = tabs[0] && tabs[0].parentElement;
const tabIndicator = tabBar?.querySelector('.tab-indicator');
function syncTabIndicator(tab) {
if (!tabIndicator || !tab) return;
tabIndicator.style.width = `${tab.offsetWidth}px`;
tabIndicator.style.transform = `translateX(${tab.offsetLeft}px)`;
}
if (tabBar) {
if (tabIndicator) {
tabIndicator.style.transition = 'none';
syncTabIndicator(activeTab);
requestAnimationFrame(() => { tabIndicator.style.transition = ''; });
window.addEventListener('resize', () => syncTabIndicator(activeTab));
}
tabBar.addEventListener('click', (e) => handle(e.target));
tabBar.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
const current = Math.max(0, tabs.indexOf(activeTab));
const next = e.key === 'Home'
? 0
: e.key === 'End'
? tabs.length - 1
: (current + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
handle(tabs[next]);
});
} else {
// Fallback: bind per-tab if somehow no common parent
tabs.forEach(t => t.addEventListener('click', () => handle(t)));
}
})();
// --- Top menu bar (Datei / Einstellungen / Hilfe) ---
function initMenuBar() {
const menuBar = document.getElementById('menuBar');
if (!menuBar) return;
let openMenu = null;
const dropdowns = {};
menuBar.querySelectorAll('[data-menu-dropdown]').forEach(d => { dropdowns[d.dataset.menuDropdown] = d; });
const triggers = {};
menuBar.querySelectorAll('[data-menu-trigger]').forEach(t => { triggers[t.dataset.menuTrigger] = t; });
const panelTokens = new WeakMap();
function openPanel(panel) {
if (!panel) return;
panelTokens.set(panel, (panelTokens.get(panel) || 0) + 1);
panel.classList.remove('menu-closing', 'menu-opening');
panel.style.display = '';
void panel.offsetHeight;
panel.classList.add('menu-opening');
}
function closePanel(panel) {
if (!panel || window.getComputedStyle(panel).display === 'none') return;
const token = (panelTokens.get(panel) || 0) + 1;
panelTokens.set(panel, token);
panel.classList.remove('menu-opening', 'menu-closing');
void panel.offsetHeight;
panel.classList.add('menu-closing');
const finish = () => {
if (!Object.is(panelTokens.get(panel), token)) return;
panel.style.display = 'none';
panel.classList.remove('menu-closing');
};
panel.addEventListener('animationend', finish, { once: true });
setTimeout(finish, 220);
}
function closeMenus() {
openMenu = null;
for (const k in dropdowns) closePanel(dropdowns[k]);
for (const k in triggers) triggers[k].classList.remove('open');
menuBar.querySelectorAll('.menu-submenu-dropdown').forEach(closePanel);
menuBar.querySelectorAll('.menu-submenu-trigger').forEach(trigger => trigger.setAttribute('aria-expanded', 'false'));
}
function openMenuNamed(name) {
if (openMenu === name) return;
closeMenus();
openMenu = name;
openPanel(dropdowns[name]);
if (triggers[name]) triggers[name].classList.add('open');
if (name === 'einstellungen') _syncMenuSettings();
}
menuBar.querySelectorAll('[data-menu-trigger]').forEach(trigger => {
const name = trigger.dataset.menuTrigger;
trigger.addEventListener('click', (e) => {
e.stopPropagation();
if (openMenu === name) closeMenus(); else openMenuNamed(name);
});
trigger.addEventListener('mouseenter', () => { if (openMenu && openMenu !== name) openMenuNamed(name); });
});
menuBar.querySelectorAll('.menu-submenu').forEach(sm => {
const sub = sm.querySelector('.menu-submenu-dropdown');
const trigger = sm.querySelector('.menu-submenu-trigger');
const openSubmenu = (focusFirst = false) => {
openPanel(sub);
trigger?.setAttribute('aria-expanded', 'true');
if (focusFirst) requestAnimationFrame(() => sub.querySelector('[data-menu-action]')?.focus());
};
const closeSubmenu = () => {
closePanel(sub);
trigger?.setAttribute('aria-expanded', 'false');
};
sm.addEventListener('mouseenter', () => openSubmenu());
sm.addEventListener('mouseleave', closeSubmenu);
trigger?.addEventListener('click', (event) => {
event.stopPropagation();
if (trigger.getAttribute('aria-expanded') === 'true') closeSubmenu();
else openSubmenu();
});
trigger?.addEventListener('keydown', (event) => {
if (!['Enter', ' ', 'ArrowRight', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
openSubmenu(true);
});
sub.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return;
event.preventDefault();
closeSubmenu();
trigger?.focus();
});
});
menuBar.querySelectorAll('[data-menu-action]').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const action = item.dataset.menuAction;
closeMenus();
_handleMenuAction(action);
});
});
document.addEventListener('mousedown', (e) => { if (openMenu && !e.target.closest('.menu-bar')) closeMenus(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openMenu) closeMenus(); });
_initMenuSettingsControls();
}
async function _handleMenuAction(action) {
switch (action) {
case 'add-files': document.getElementById('addFilesBtn')?.click(); break;
case 'add-folder': document.getElementById('addFolderBtn')?.click(); break;
case 'backup-export': doBackupExport(); break;
case 'backup-import': doBackupImport(); break;
case 'online-backup-create': doOnlineBackupCreate(); break;
case 'online-backup-restore': openOnlineBackupRestore(); break;
case 'restart': if (await showAppConfirm({ title: 'Anwendung neu starten?', message: 'Nicht gespeicherte laufende Aktionen werden beendet.', confirmText: 'Neustart', danger: true })) window.api.restartApp(); break;
case 'quit': window.api.quitApp(); break;
case 'open-settings': document.querySelector('.tab[data-view="settings"]')?.click(); break;
case 'open-log-folder': window.api.openLogFolder(); break;
case 'support-bundle': {
showCopyToast('Diagnose-Paket wird erstellt…');
try {
const res = await window.api.createSupportBundle();
if (res && res.ok) showCopyToast(`Diagnose-Paket gespeichert (${(res.bytes / 1024).toFixed(1)} KB)`);
else if (res && res.canceled) showCopyToast('Abgebrochen');
else showCopyToast(formatLocalizedError('Fehler', res?.error));
} catch (err) { showCopyToast(formatLocalizedError('Fehler', err)); }
break;
}
case 'check-updates': {
await requestUpdateCheck();
break;
}
}
}
function _setHeaderUpdateLabel(text) {
const button = document.getElementById('headerUpdateBtn');
if (!button) return;
const label = button.querySelector('[data-update-label], .header-update-label');
if (label) label.textContent = text;
else button.textContent = text;
}
function _syncHeaderUpdateState() {
const button = document.getElementById('headerUpdateBtn');
const available = !!(_knownUpdateInfo && _knownUpdateInfo.available);
const version = available ? String(_knownUpdateInfo.remoteVersion || '').replace(/^v/i, '') : '';
const hint = _updateCheckBusy
? 'Suche nach Aktualisierungen…'
: available
? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.`
: 'Nach Aktualisierungen suchen';
if (button) {
button.hidden = !available;
button.classList.toggle('update-available', available);
button.classList.toggle('is-checking', _updateCheckBusy);
button.disabled = _updateCheckBusy;
button.setAttribute('aria-busy', _updateCheckBusy ? 'true' : 'false');
button.setAttribute('aria-label', hint);
button.title = hint;
button.dataset.tooltip = hint;
_setHeaderUpdateLabel(_updateCheckBusy ? 'Prüfen…' : (available ? 'Update verfügbar' : 'Update'));
}
const manualButton = document.getElementById('manualUpdateCheckBtn');
if (manualButton) {
manualButton.disabled = _updateCheckBusy;
manualButton.setAttribute('aria-busy', _updateCheckBusy ? 'true' : 'false');
manualButton.textContent = _updateCheckBusy ? 'Prüfe…' : (available ? 'Update verfügbar' : 'Nach Updates suchen');
}
}
async function requestUpdateCheck() {
if (_knownUpdateInfo && _knownUpdateInfo.available) {
showUpdateBanner(_knownUpdateInfo);
return _knownUpdateInfo;
}
if (_updateCheckBusy) return null;
_updateCheckBusy = true;
_syncHeaderUpdateState();
showCopyToast('Suche nach Updates…');
try {
const result = await window.api.checkForUpdate();
if (result && result.available) {
showUpdateBanner(result);
showCopyToast('Update gefunden!');
} else if (result && result.error) {
showCopyToast('Updateprüfung fehlgeschlagen');
} else {
showCopyToast('Kein Update verfügbar');
}
return result;
} catch {
showCopyToast('Updateprüfung fehlgeschlagen');
return null;
} finally {
_updateCheckBusy = false;
_syncHeaderUpdateState();
}
}
function _syncMenuSettings() {
const gs = config.globalSettings || {};
const pInput = document.getElementById('menuParallelInput');
if (pInput) pInput.value = String(gs.parallelUploadCount ?? 0);
const speedKbs = gs.globalMaxSpeedKbs || 0;
const enabled = speedKbs > 0;
const sCheck = document.getElementById('menuSpeedLimitCheck');
const sInput = document.getElementById('menuSpeedInput');
const sSpinner = document.getElementById('menuSpeedSpinner');
if (sCheck) sCheck.checked = enabled;
if (sInput) sInput.value = enabled ? String(+(speedKbs / 1024).toFixed(2)) : '0';
if (sSpinner) sSpinner.classList.toggle('disabled', !enabled);
}
function _menuSaveParallel(v) {
const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
const gs = { ...(config.globalSettings || {}), parallelUploadCount: n };
config.globalSettings = gs;
saveGlobalSettingsTracked(gs).catch(() => {});
const mirror = document.getElementById('parallelUploadCountInput');
if (mirror) mirror.value = String(n);
return n;
}
function _menuSaveSpeedMbs(mbs, enabled) {
const kbs = enabled ? Math.max(0, Math.round((parseFloat(mbs) || 0) * 1024)) : 0;
const gs = { ...(config.globalSettings || {}), globalMaxSpeedKbs: kbs };
config.globalSettings = gs;
saveGlobalSettingsTracked(gs).catch(() => {});
const mirror = document.getElementById('globalMaxSpeedMbsInput');
if (mirror) mirror.value = kbs > 0 ? String(+(kbs / 1024).toFixed(2)) : '0';
}
function _initMenuSettingsControls() {
const grid = document.getElementById('menuSettingsGrid');
if (grid) grid.addEventListener('click', (e) => e.stopPropagation());
const pInput = document.getElementById('menuParallelInput');
if (pInput) {
const save = () => { pInput.value = String(_menuSaveParallel(pInput.value)); };
pInput.addEventListener('change', save);
pInput.addEventListener('blur', save);
}
const sInput = document.getElementById('menuSpeedInput');
const sCheck = document.getElementById('menuSpeedLimitCheck');
const sSpinner = document.getElementById('menuSpeedSpinner');
if (sCheck) sCheck.addEventListener('change', () => {
if (sSpinner) sSpinner.classList.toggle('disabled', !sCheck.checked);
_menuSaveSpeedMbs(sInput ? sInput.value : 0, sCheck.checked);
});
if (sInput) {
const save = () => _menuSaveSpeedMbs(sInput.value, sCheck ? sCheck.checked : true);
sInput.addEventListener('change', save);
sInput.addEventListener('blur', save);
}
document.querySelectorAll('#menuSettingsGrid [data-spin]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const kind = btn.dataset.spin;
if (kind === 'parallel-up' || kind === 'parallel-down') {
const cur = parseInt(pInput.value, 10) || 0;
pInput.value = String(_menuSaveParallel(cur + (kind === 'parallel-up' ? 1 : -1)));
} else if (kind === 'speed-up' || kind === 'speed-down') {
const cur = parseFloat(sInput.value) || 0;
const next = Math.max(0, cur + (kind === 'speed-up' ? 1 : -1));
sInput.value = String(next);
_menuSaveSpeedMbs(next, sCheck ? sCheck.checked : true);
}
});
});
}
// --- 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 = {};
const activeIds = new Set();
for (const { account } of getAllAccountsFlat()) {
if (account.id) {
activeIds.add(account.id);
nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' };
}
}
for (const accountId of accountStatusGenerations.keys()) {
if (!activeIds.has(accountId)) accountStatusGenerations.delete(accountId);
}
accountStatuses = nextStatuses;
}
function _nextAccountStatusGeneration(accountId) {
const generation = ++accountStatusGenerationSequence;
accountStatusGenerations.set(accountId, generation);
return generation;
}
function _isCurrentAccountStatusGeneration(accountId, generation) {
return accountStatusGenerations.get(accountId) === generation;
}
function _finishAccountStatusGeneration(accountId, generation) {
if (_isCurrentAccountStatusGeneration(accountId, generation)) accountStatusGenerations.delete(accountId);
}
function _invalidateAccountStatusGeneration(accountId) {
accountStatusGenerations.delete(accountId);
}
// 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.';
renderImportPlanSummary();
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);
renderImportPlanSummary();
});
});
renderImportPlanSummary();
}
function openHosterModal() {
syncSelectedUploadHosters();
renderHosterModal();
const description = document.getElementById('hosterModalDescription');
if (description) {
description.textContent = localizeUiText('Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.');
}
modalController.open('hosterModal', {
initialFocus: '#cancelHosterModalBtn',
fallbackFocus: '#addFilesBtn',
onEscape: cancelHosterModal,
onBackdrop: cancelHosterModal
});
}
function closeHosterModal() {
modalController.close('hosterModal', { fallbackFocus: '#addFilesBtn' });
}
async 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 = [];
}
clearDedupKeysForPaths(pendingPaths);
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) {
await startSelectedUpload(newJobs);
}
}
updateUploadView();
persistQueueStateSoon(true); // immediate persist after adding files
closeHosterModal();
_pendingImportInspection = null;
}
function cancelHosterModal() {
_pendingFiles = [];
_pendingImportInspection = null;
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;
_restoredSnapshotSavedAt = (typeof pending.savedAt === 'number' && isFinite(pending.savedAt))
? pending.savedAt
: null;
if (Array.isArray(pending.completedKeys)) {
for (const k of pending.completedKeys) {
if (typeof k === 'string' && k) _completedUploadKeys.add(k);
}
}
if (Array.isArray(pending.suppressedKeys)) {
for (const k of pending.suppressedKeys) {
if (typeof k === 'string' && k) _suppressedPreviewKeys.add(k);
}
}
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 uploadRecovery = config?.globalSettings?.uploadRecovery || null;
const rawJobs = Array.isArray(pending.queueJobs)
? pending.queueJobs
.filter(job => job && job.fileName && job.hoster)
.map(job => {
const id = job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const recoveryOutcome = window.UploadRecovery.getRecoveryOutcome({ ...job, id }, uploadRecovery);
const status = normalizeRestoredJobStatus(recoveryOutcome.status);
return {
id,
uploadId: null,
file: job.file || '',
fileName: job.fileName,
hoster: job.hoster,
status,
bytesUploaded: status === 'done' ? (job.bytesTotal || 0) : 0,
bytesTotal: job.bytesTotal || 0,
speedKbs: 0,
elapsed: 0,
remaining: 0,
error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null),
failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null),
remoteCommitUncertain: Object.hasOwn(recoveryOutcome, 'remoteCommitUncertain')
? recoveryOutcome.remoteCommitUncertain === true
: job.remoteCommitUncertain === true,
historyPending: Object.hasOwn(recoveryOutcome, 'historyPending')
? recoveryOutcome.historyPending === true
: job.historyPending === true,
interrupted: recoveryOutcome.interrupted === true,
result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null),
sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null,
sourceCleanupToken: job.sourceCleanupToken || null,
sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
? [...job.sourceCleanupConfirmedHosters]
: [],
sourceCleanupProvisionalHosters: [],
sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters)
? [...job.sourceCleanupStartedHosters]
: undefined,
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
attempt: 0,
maxAttempts: job.maxAttempts || 0,
link: '',
progress: 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({ historyPersisted = true } = {}) {
const persistableJobs = historyPersisted
? queueJobs.filter(job => !['done', 'skipped'].includes(job.status) || job.historyPending === true)
: queueJobs;
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 (historyPersisted && selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status) && job.historyPending !== true)) {
return null;
}
// After a restart no upload manager is running, so any in-flight state
// (queued / getting-server / uploading / retrying / aborted) is
// meaningless. Collapse them all to 'preview' so the queue shows a
// consistent "Bereit" for everything that didn't actually terminate.
// Only true terminal states (done / error / skipped) survive as-is.
const TERMINAL = new Set(['done', 'error', 'skipped']);
const completedKeys = [];
for (const k of _completedUploadKeys) {
const sep = k.lastIndexOf('|');
if (sep > 0 && selectedFileMap.has(k.slice(0, sep))) completedKeys.push(k);
}
const suppressedKeys = [];
for (const k of _suppressedPreviewKeys) {
const sep = k.lastIndexOf('|');
if (sep > 0 && selectedFileMap.has(k.slice(0, sep))) suppressedKeys.push(k);
}
return {
savedAt: Date.now(),
selectedUploadHosters: getSelectedHosters(),
selectedFiles: Array.from(selectedFileMap.values()),
completedKeys,
suppressedKeys,
queueJobs: persistableJobs.map(job => {
const isTerminal = TERMINAL.has(job.status);
return {
id: job.id,
file: job.file,
fileName: job.fileName,
hoster: job.hoster,
status: isTerminal ? job.status : 'preview',
bytesTotal: job.bytesTotal || 0,
error: isTerminal ? (job.error || null) : null,
failureDetails: isTerminal ? (job.failureDetails || null) : null,
remoteCommitUncertain: job.remoteCommitUncertain === true,
historyPending: job.historyPending === true,
result: isTerminal ? (job.result || null) : null,
sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null,
sourceCleanupToken: job.sourceCleanupToken || null,
sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
? [...job.sourceCleanupConfirmedHosters]
: [],
sourceCleanupStartedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupStartedHosters)
? [...job.sourceCleanupStartedHosters]
: undefined,
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
maxAttempts: job.maxAttempts || 0
};
})
};
}
async function persistQueueStateNow() {
const pendingQueue = buildPersistedQueueState();
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue
};
config.globalSettings = globalSettings;
await savePendingQueueTracked(pendingQueue);
return pendingQueue;
}
function persistQueueStateSoon(immediate) {
if (closePreparationState !== 'open') return;
if (immediate) {
queuePersistThrottle.cancel();
persistQueueStateNow().catch(() => {});
return;
}
const maxWait = uploading ? 20000 : undefined;
queuePersistThrottle.request(() => {
persistQueueStateNow().catch(() => {});
}, 500, maxWait);
}
function clearPersistedQueueStateSoon() {
if (closePreparationState !== 'open') return;
queuePersistThrottle.request(() => {
config.globalSettings = { ...(config.globalSettings || {}), pendingQueue: null };
savePendingQueueTracked(null).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());
dropZone.addEventListener('keydown', (event) => {
if (!['Enter', ' '].includes(event.key)) return;
event.preventDefault();
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 _pendingImportInspection = null;
let _importCoordination = Promise.resolve();
let _addingDropped = false;
function admitFilenameFilter(files) {
return window.FilenameFilter.applyFilenameFilter(files, config?.globalSettings?.filenameFilter);
}
function formatFilenameFilterResult(result) {
const accepted = Array.isArray(result.accepted) ? result.accepted.length : Number(result.accepted) || 0;
const excluded = Array.isArray(result.excluded) ? result.excluded.length : Number(result.excluded) || 0;
return localizeUiText(`${accepted} von ${result.total} Dateien werden hinzugefügt. ${excluded} durch den Dateinamenfilter ausgeschlossen.`);
}
function showFilenameFilterResult(result) {
showCopyToast(formatFilenameFilterResult(result), 6500);
}
function mergePendingImportInspection(result) {
const current = _pendingImportInspection || {
candidateCount: 0,
duplicateCount: 0,
filteredCount: 0,
unavailableCount: 0,
accepted: []
};
_pendingImportInspection = {
candidateCount: current.candidateCount + result.candidateCount,
duplicateCount: current.duplicateCount + result.duplicateCount,
filteredCount: current.filteredCount + result.filteredCount,
unavailableCount: current.unavailableCount + result.unavailableCount,
accepted: current.accepted.concat(result.accepted)
};
}
function getImportPlanHosters() {
const inputs = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'));
return inputs.length > 0 || document.getElementById('hosterModal')?.style.display === 'flex'
? inputs.map(input => input.dataset.hosterModal)
: getSelectedHosters();
}
function renderImportPlanSummary() {
if (!_pendingImportInspection || !window.ImportPreflight) return;
const summary = window.ImportPreflight.summarizeImportPlan({
inspection: _pendingImportInspection,
selectedHosters: getImportPlanHosters(),
hosterSettings
});
const values = {
importPlanCandidates: summary.candidateCount,
importPlanDuplicates: summary.duplicateCount,
importPlanFiltered: summary.filteredCount,
importPlanUnavailable: summary.unavailableCount,
importPlanAccepted: summary.acceptedCount,
importPlanTargets: summary.targetCount,
importPlanJobs: summary.jobCount,
importPlanSizeLimited: summary.sizeLimitedJobCount
};
for (const [id, value] of Object.entries(values)) {
const element = document.getElementById(id);
if (element) element.textContent = String(value);
}
}
function existingImportPaths() {
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
}
function coordinateImportEntries(entries) {
const run = async () => {
const candidates = Array.isArray(entries) ? entries : [];
if (candidates.length === 0) return null;
let inspection;
try {
inspection = await window.api.inspectImportFiles(candidates, existingImportPaths());
} catch {
showCopyToast('Vorabprüfung fehlgeschlagen.', 6500);
return null;
}
mergePendingImportInspection(inspection);
if (inspection.accepted.length > 0) {
if (document.getElementById('hosterModal')?.style.display === 'flex') {
selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'))
.map(input => input.dataset.hosterModal);
}
const acceptedPaths = new Set(inspection.accepted.map(file => file.path));
clearDedupKeysForPaths(acceptedPaths);
_pendingFiles.push(...inspection.accepted);
if (document.getElementById('hosterModal')?.style.display === 'flex') {
syncSelectedUploadHosters();
renderHosterModal();
} else {
openHosterModal();
}
} else if (_pendingFiles.length > 0) {
renderImportPlanSummary();
} else if (inspection.candidateCount > 0 && inspection.duplicateCount === inspection.candidateCount) {
showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
_pendingImportInspection = null;
} else {
showCopyToast('Keine Dateien wurden akzeptiert.', 6500);
_pendingImportInspection = null;
}
return inspection;
};
const pending = _importCoordination.then(run, run);
_importCoordination = pending.catch(() => {});
return pending;
}
async function addDropTargetEntries(entries) {
const files = [];
for (const entry of Array.isArray(entries) ? entries : []) {
const filePath = typeof entry === 'string' ? entry : entry?.path;
if (!filePath) continue;
if (entry && typeof entry === 'object' && entry.isDirectory) {
try {
const folderFiles = await window.api.resolveFolderFiles(filePath);
if (Array.isArray(folderFiles)) files.push(...folderFiles);
} catch {}
continue;
}
files.push(entry);
}
await addPathsToQueue(files);
}
async function addDroppedFiles(fileList) {
if (_addingDropped) return;
_addingDropped = true;
try {
const files = Array.from(fileList);
const entries = [];
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) {
const p = typeof fp === 'string' ? fp : (fp && fp.path);
if (!p) continue;
const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop());
const size = typeof fp === 'string' ? null : (fp.size || 0);
entries.push({ path: p, name, size });
}
continue;
}
} catch {}
}
// Regular file
const fileName = file.name || '';
entries.push({ path: filePath, name: fileName, size: file.size });
}
await addPathsToQueue(entries);
} finally {
_addingDropped = false;
}
}
async function pickFiles() {
const paths = await window.api.selectFiles();
if (!paths) return;
await addPathsToQueue(paths);
}
async function pickFolder() {
const richFiles = window.api.selectFolderWithSizes ? await window.api.selectFolderWithSizes() : null;
if (richFiles && Array.isArray(richFiles)) { await addPathsToQueue(richFiles); return; }
const paths = await window.api.selectFolder();
if (!paths) return;
await addPathsToQueue(paths);
}
async function addPathsToQueue(paths) {
return coordinateImportEntries(paths);
}
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 || sourceCleanupFinalizationPending || !(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();
_normalizeQueueSelectionToVisible();
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 || sourceCleanupFinalizationPending || !hasStartableSelection;
if (reuploadBtn) reuploadBtn.disabled = sourceCleanupFinalizationPending || !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;
syncDataActionState();
}
function syncDataActionState() {
const completedLinks = queueJobs.some(job => job.status === 'done' && (job.result?.download_url || job.result?.embed_url));
const hasRecent = sessionFilesData.length > 0;
const hasHistory = historyRowsData.length > 0;
const copyAll = document.getElementById('copyAllLinksBtn');
const clearRecent = document.getElementById('clearRecentFilesBtn');
const exportRecent = document.getElementById('exportRecentFilesBtn');
const exportHistoryButton = document.getElementById('exportHistoryBtn');
if (copyAll) copyAll.disabled = !completedLinks;
if (clearRecent) clearRecent.disabled = !hasRecent;
if (exportRecent) exportRecent.disabled = !hasRecent;
if (exportHistoryButton) exportHistoryButton.disabled = !hasHistory;
}
function clearDedupKeysForPaths(pathSet) {
if (!pathSet || pathSet.size === 0) return;
for (const set of [_completedUploadKeys, _suppressedPreviewKeys]) {
for (const key of [...set]) {
const sep = key.lastIndexOf('|');
if (sep > 0 && pathSet.has(key.slice(0, sep))) set.delete(key);
}
}
}
function suppressPreviewKeysStillSelected(keys) {
if (!keys || keys.length === 0) return;
const stillSelected = new Set(selectedFiles.map(f => f.path));
for (const key of keys) {
const sep = key.lastIndexOf('|');
if (sep > 0 && stillSelected.has(key.slice(0, sep))) _suppressedPreviewKeys.add(key);
}
}
// Build preview jobs from selected files x selected hosters (before upload starts)
function buildQueuePreview() {
const hosters = getSelectedHosters();
queueJobs = queueJobs.filter(j => j.status !== 'preview');
if (hosters.length > 0) {
const existingKeys = new Set();
for (const j of queueJobs) {
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) && !_suppressedPreviewKeys.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();
_queueStatsCache = null;
renderQueueTable();
updateStatusBar();
updateStatsPanel();
persistQueueStateSoon();
}
function hideStartupResumeBanner() {
const banner = document.getElementById('startupResumeBanner');
if (banner) banner.hidden = true;
}
function cancelStartupQueueAutoStart() {
_startupAutoResumeCanceled = true;
_startupAutoResumeController?.cancel();
hideStartupResumeBanner();
}
async function startRestoredQueueAfterChecks(jobIds) {
const message = document.getElementById('startupResumeMessage');
async function waitForAccountCheck() {
if (!healthCheckRunning || _startupAutoResumeCanceled) return;
if (message) message.textContent = localizeUiText('Warte auf Account-Prüfung…');
await new Promise(resolve => setTimeout(resolve, 250));
return waitForAccountCheck();
}
await waitForAccountCheck();
hideStartupResumeBanner();
if (_startupAutoResumeCanceled || uploading || config?.globalSettings?.autoStartRestoredQueue !== true) return;
const jobs = window.AutoResume.getAutoResumeJobs(queueJobs, jobIds);
if (!jobs.length) return;
await startSelectedUpload(jobs);
}
function scheduleRestoredQueueAutoStart() {
if (config?.globalSettings?.autoStartRestoredQueue !== true || !window.AutoResume) return;
const jobs = window.AutoResume.getAutoResumeJobs(queueJobs);
if (!jobs.length) return;
_startupAutoResumeCanceled = false;
const banner = document.getElementById('startupResumeBanner');
const message = document.getElementById('startupResumeMessage');
const cancelButton = document.getElementById('cancelStartupResumeBtn');
_startupAutoResumeController = window.AutoResume.createAutoResumeController({
delaySeconds: 8,
onTick: (seconds, count) => {
if (banner) banner.hidden = false;
if (message) message.textContent = localizeUiText(`Wiederhergestellte Warteschlange startet in ${seconds} s (${count} Jobs).`);
},
onStart: jobIds => { startRestoredQueueAfterChecks(jobIds); },
onCancel: hideStartupResumeBanner
});
if (cancelButton) cancelButton.onclick = cancelStartupQueueAutoStart;
_startupAutoResumeController.schedule(jobs.map(job => job.id));
}
// --- 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, keepCompletedKey) {
_jobIndexById.delete(job.id);
if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId);
if (selectionAnchorJobId === job.id) selectionAnchorJobId = null;
// 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 (!keepCompletedKey && 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(true); });
}
// Toggle the .selected class on existing rows without rebuilding the table.
// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort).
// Uses getElementsByClassName for the live HTMLCollection (DOM-cached after the
// first call, auto-tracks insertions/removals on tbody) instead of running a
// fresh querySelectorAll on every click. At 200 visible rows that's the
// difference between paying for a tree walk per click vs reading a memoized
// list that the engine already maintains.
function applyQueueSelectionClasses() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
const rows = tbody.getElementsByClassName('queue-row');
for (let i = 0; i < rows.length; i++) {
const tr = rows[i];
const selected = selectedJobIds.has(tr.dataset.jobId);
tr.classList.toggle('selected', selected);
tr.setAttribute('aria-selected', String(selected));
}
}
function applyRecentSelectionClasses() {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
const rows = tbody.getElementsByClassName('recent-file-row');
for (let i = 0; i < rows.length; i++) {
const tr = rows[i];
const order = parseInt(tr.dataset.order, 10);
tr.classList.toggle('selected', selectedRecentIds.has(order));
}
}
function scheduleThrottledUIUpdate() {
if (_uiUpdateTimer) return;
_uiUpdateTimer = setTimeout(() => {
_uiUpdateTimer = null;
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
}, UI_UPDATE_INTERVAL);
}
// Coalesces status-change updates (done/error/retrying/queued/…) into one
// frame. Without this, a batch of 500 jobs flipping queued→getting-server
// →uploading synchronously fires 1500+ updateStatusBar/Buttons/Stats calls
// and janks the renderer. rAF caps it to ~60 Hz.
let _statusChangeUpdateQueued = false;
function scheduleStatusChangeUpdate() {
if (_statusChangeUpdateQueued) return;
_statusChangeUpdateQueued = true;
requestAnimationFrame(() => {
_statusChangeUpdateQueued = false;
renderQueueTable();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
});
}
function _hydrateMissingJobSizes(jobsLike) {
if (!window.api || !window.api.getFileSizes) return;
const paths = [];
const seen = new Set();
const source = Array.isArray(jobsLike) ? jobsLike : queueJobs;
for (const j of source) {
if (!j || !j.file) continue;
if (j.bytesTotal && j.bytesTotal > 0) continue;
if (seen.has(j.file)) continue;
seen.add(j.file);
paths.push(j.file);
}
if (paths.length === 0) return;
window.api.getFileSizes(paths).then((sizeMap) => {
if (!sizeMap || typeof sizeMap !== 'object') return;
let changed = false;
for (const j of queueJobs) {
if (sizeMap[j.file] && (!j.bytesTotal || j.bytesTotal === 0)) {
j.bytesTotal = sizeMap[j.file];
changed = true;
}
}
for (const f of selectedFiles) {
if (sizeMap[f.path] && (!f.size || f.size === 0)) f.size = sizeMap[f.path];
}
if (changed) {
_queueStatsCache = null;
if (typeof renderQueueTable === 'function') renderQueueTable();
if (typeof updateStatusBar === 'function') updateStatusBar();
}
}).catch(() => {});
}
function _formatUploadedSize(job) {
const bt = job.bytesTotal || 0;
const bu = job.bytesUploaded || 0;
const s = job.status;
if (s === 'preview') return bt > 0 ? formatSize(bt) : '...';
if (s === 'queued' || s === 'getting-server' || s === 'retrying') {
return bt > 0 ? `${formatSize(bu)} / ${formatSize(bt)}` : '...';
}
return `${formatSize(bu)} / ${formatSize(bt)}`;
}
function buildRowHtml(job) {
const statusClass = `status-${job.status}`;
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
const uploadedSize = _formatUploadedSize(job);
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
const progress = Math.min(1, Math.max(0, Number(job.progress) || 0));
const pct = Math.round(progress * 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 = _formatUploadedSize(job);
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
const progress = Math.min(1, Math.max(0, Number(job.progress) || 0));
const pct = Math.round(progress * 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;
tr.setAttribute('aria-selected', String(selectedJobIds.has(job.id)));
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;
const label = badge.querySelector('.status-badge-label') || badge;
if (label.textContent !== statusText) label.textContent = statusText;
}
if (cells[3].title !== statusText) cells[3].title = 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 progressTransform = `scaleX(${progress})`;
if (fill.style.transform !== progressTransform) fill.style.transform = progressTransform;
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 _matchesUploadSidebarFilter(job, filter = uploadSidebarFilter) {
if (filter === 'active') return job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying';
if (filter === 'waiting') return job.status === 'preview' || job.status === 'queued';
if (filter === 'done') return job.status === 'done';
if (filter === 'error') return job.status === 'error';
return true;
}
function _matchesQueueDetailFilter(job) {
if (queueSearchQuery && !String(job.fileName || '').toLocaleLowerCase(getUiLocale()).includes(queueSearchQuery)) return false;
if (queueHosterFilter && job.hoster !== queueHosterFilter) return false;
if (queueStatusFilter === 'active') return ['uploading', 'getting-server', 'retrying'].includes(job.status);
if (queueStatusFilter === 'waiting') return ['preview', 'queued'].includes(job.status);
if (queueStatusFilter === 'done') return job.status === 'done';
return !queueStatusFilter || job.status === queueStatusFilter;
}
function _getVisibleQueueJobs() {
const filterKey = `${uploadSidebarFilter}\u0001${queueSearchQuery}\u0001${queueHosterFilter}\u0001${queueStatusFilter}`;
if (filterKey === 'all\u0001\u0001\u0001') return queueJobs;
const filtered = queueJobs.filter(job => _matchesUploadSidebarFilter(job) && _matchesQueueDetailFilter(job));
if (_queueFilterCache.filter === filterKey && _queueFilterCache.source === queueJobs && _queueFilterCache.result.length === filtered.length) {
let unchanged = true;
for (let index = 0; index < filtered.length; index++) {
if (_queueFilterCache.result[index] !== filtered[index]) {
unchanged = false;
break;
}
}
if (unchanged) return _queueFilterCache.result;
}
_queueFilterCache = { filter: filterKey, source: queueJobs, result: filtered };
return filtered;
}
function _normalizeQueueSelectionToVisible(visibleJobs = _getVisibleQueueJobs()) {
if (selectedJobIds.size === 0) {
selectionAnchorJobId = null;
return false;
}
const visibleIds = new Set(visibleJobs.map(job => job.id));
let changed = false;
for (const id of selectedJobIds) {
if (!visibleIds.has(id)) {
selectedJobIds.delete(id);
changed = true;
}
}
if (selectionAnchorJobId && !visibleIds.has(selectionAnchorJobId)) {
selectionAnchorJobId = selectedJobIds.values().next().value || null;
}
return changed;
}
function _getVisibleSelectedQueueJobs(predicate) {
const visibleJobs = _getVisibleQueueJobs();
_normalizeQueueSelectionToVisible(visibleJobs);
return visibleJobs.filter(job => selectedJobIds.has(job.id) && (!predicate || predicate(job)));
}
function renderQueueTable() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
syncQueueHosterFilterOptions();
const visibleJobs = _getVisibleQueueJobs();
const selectionChanged = _normalizeQueueSelectionToVisible(visibleJobs);
_sortedJobsCache = sortQueueJobs(visibleJobs);
if (selectionChanged) updateQueueActionButtons();
const totalRows = _sortedJobsCache.length;
const queueContainer = document.getElementById('queueContainer');
const filteredEmpty = totalRows === 0 && queueJobs.length > 0 && (uploadSidebarFilter !== 'all' || queueSearchQuery || queueHosterFilter || queueStatusFilter);
queueContainer?.classList.toggle('filter-empty', filteredEmpty);
if (queueContainer) queueContainer.dataset.emptyLabel = filteredEmpty ? localizeUiText('Keine Uploads entsprechen diesem Filter.') : '';
if (totalRows < 200) {
// Try in-place update if row count matches (fast path)
const existingRows = tbody.querySelectorAll('.queue-row');
const hasVirtualSpacers = Boolean(tbody.querySelector('.virtual-spacer'));
if (!hasVirtualSpacers && 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);
});
tbody.addEventListener('keydown', (e) => {
const row = e.target.closest('.queue-row');
if (!row) return;
if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {
e.preventDefault();
const rect = row.getBoundingClientRect();
handleRowContextMenu({ preventDefault() {}, clientX: rect.left + 24, clientY: rect.top + 20 }, row);
return;
}
if (['Enter', ' '].includes(e.key)) {
e.preventDefault();
handleRowClick(e, row);
}
});
}
// Update retry button visibility
const hasFailedJobs = queueJobs.some(j => j.status === 'error');
document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none';
syncQueueSortHeaders();
updateQueueActionButtons();
}
function syncQueueSortHeaders() {
document.querySelectorAll('#queueTable th.sortable').forEach(header => {
const active = header.dataset.sort === queueSortState.key;
header.setAttribute('aria-sort', active ? (queueSortState.direction === 'asc' ? 'ascending' : 'descending') : 'none');
});
}
function _renderVirtualRows(tbody) {
const scrollContainer = document.getElementById('queueContainer');
if (!scrollContainer) return;
const totalRows = _sortedJobsCache.length;
const scrollTop = scrollContainer.scrollTop;
// Use a minimum viewport height to avoid rendering nothing when container is being laid out
const viewportHeight = Math.max(scrollContainer.clientHeight, 200);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
// Same range — try in-place update to avoid hover flicker
if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) {
const rows = tbody.querySelectorAll('.queue-row');
if (rows.length === endIdx - startIdx) {
let allMatch = true;
for (let i = 0; i < rows.length; i++) {
const job = _sortedJobsCache[startIdx + i];
if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; }
_updateRowInPlace(rows[i], job);
}
if (allMatch) return; // all rows updated in-place, no rebuild needed
}
}
_lastVisibleRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT);
let html = '';
if (topPad > 0) html += ` `;
for (let i = startIdx; i < endIdx; i++) {
html += buildRowHtml(_sortedJobsCache[i]);
}
if (bottomPad > 0) html += ` `;
tbody.innerHTML = html;
}
// Coalesce rapid scroll events (a fast trackpad fling fires dozens) into one
// render per frame. rAF keeps the scroll thread cheap.
let _queueScrollQueued = false;
function _onQueueScroll() {
if (_queueScrollQueued) return;
if (_sortedJobsCache.length < 200) return;
_queueScrollQueued = true;
requestAnimationFrame(() => {
_queueScrollQueued = false;
const tbody = document.getElementById('queueBody');
if (tbody) _renderVirtualRows(tbody);
});
}
const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
const _collatorSimple = new Intl.Collator('de');
// Queue sort memoization. Keys that don't change after a job enters the queue
// (filename, host) reuse the cached result across progress-driven re-renders.
// Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when
// previews resolve / upload starts) are recomputed each call — otherwise a
// queue sorted by size during previews would be stuck in all-zeros order.
//
// CRITICAL: the cache also tracks jobsRef (identity of the queueJobs array) so
// that a full replacement (e.g. backup import, queue restore) invalidates the
// cache. Length alone can match across a replace and would otherwise pin the
// renderer to stale job references — the UI freezes showing old statuses even
// though queueJobs itself has fresh objects. Observed as "upload runs in
// status bar but all rows stay 'Bereit'" after importing a backup.
let _queueSortCache = { sig: '', result: [], jobsRef: null };
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
// Dynamic-key sort throttle: status/speed/progress/size change on every
// progress tick, so a strict per-call sort is O(N log N) per render. Within
// one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's
// slightly out of order — the user can't perceive sub-200ms reorder lag, and
// at 5000 queued jobs this is the difference between smooth and stuttering.
// Uses lib/throttled-cache.js (see tests/throttled-cache.test.js).
const DYNAMIC_SORT_REFRESH_MS = 200;
const _dynamicSortCache = window.ThrottledCache
? window.ThrottledCache.makeThrottledCache(DYNAMIC_SORT_REFRESH_MS)
: { get: () => undefined, set: (s, i, v) => v, clear: () => {} };
function sortQueueJobs(jobs) {
const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1;
const canCache = _STATIC_SORT_KEYS.has(key);
const sig = canCache ? `${key}|${direction}|${jobs.length}` : '';
if (sig && _queueSortCache.sig === sig && _queueSortCache.jobsRef === jobs) {
return _queueSortCache.result;
}
// Dynamic-key throttle: same key+direction+array, sorted within the last
// 200ms → reuse. The cache is keyed by `key|direction` and uses the jobs
// array identity as the input ref, so a fresh queueJobs (e.g. after
// backup import) misses correctly.
if (!canCache) {
const dynSig = `${key}|${direction}`;
const cached = _dynamicSortCache.get(dynSig, jobs);
if (cached) return cached;
}
const sorted = jobs.slice().sort((a, b) => {
let cmp = 0;
if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName);
else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0);
else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, b.hoster);
else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status);
else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0);
else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0);
return cmp * factor;
});
if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs };
else _dynamicSortCache.set(`${key}|${direction}`, jobs, sorted);
return sorted;
}
function getStatusOrder(status) {
const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 };
return order[status] ?? 4;
}
// "Primär" / "Fallback #1" / "Fallback #2"… derived from the job's current
// accountId position in the configured hoster account list. Returns '' if we
// can't resolve it (e.g. account was removed mid-session).
function getAccountLabel(job) {
if (!job || !job.accountId || !job.hoster) return '';
const accounts = config && config.hosters && config.hosters[job.hoster];
if (!Array.isArray(accounts)) return '';
const idx = accounts.findIndex(a => a && a.id === job.accountId);
if (idx < 0) return '';
return idx === 0 ? localizeUiText('Primär') : `Fallback #${idx}`;
}
function getStatusText(job) {
const rawError = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : '';
const shortErr = rawError ? getLocalizedErrorDetail(rawError) : '';
const acc = getAccountLabel(job);
const accSuffix = acc ? ` · ${acc}` : '';
let text;
switch (job.status) {
case 'preview': text = job.interrupted ? 'Unterbrochen · bereit zum Fortsetzen' : 'Bereit'; break;
case 'queued': text = 'Wartet'; break;
case 'getting-server': text = `Server…${accSuffix}`; break;
case 'uploading': text = `Upload${accSuffix}`; break;
case 'retrying': {
const base = `Retry ${job.attempt}/${job.maxAttempts}${accSuffix}`;
text = shortErr ? `${base}: ${shortErr}` : base;
break;
}
case 'done': text = 'Fertig'; break;
case 'aborted': text = shortErr || 'Abgebrochen'; break;
case 'error': text = shortErr ? (/^(?:Fehlgeschlagen|Failed)(?::|$)/.test(shortErr) ? shortErr : `Fehlgeschlagen: ${shortErr}`) : 'Fehlgeschlagen'; break;
case 'skipped': text = shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen'; break;
default: text = job.status;
}
return localizeUiText(text);
}
// --- 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);
selectionAnchorJobId = jobId;
} else if (e.shiftKey && (selectionAnchorJobId || selectedJobIds.size > 0)) {
// Use sorted jobs cache for correct shift-click with virtual scrolling
const sortedIds = _sortedJobsCache.map(j => j.id);
const fallbackAnchor = selectedJobIds.values().next().value || null;
const anchorId = sortedIds.includes(selectionAnchorJobId) ? selectionAnchorJobId : fallbackAnchor;
const lastIdx = sortedIds.indexOf(anchorId);
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);
selectionAnchorJobId = jobId;
// Single click on done job -> copy link
const job = _jobIndexById.get(jobId);
if (job && job.status === 'done' && job.result) {
const link = job.result.download_url || job.result.embed_url || '';
if (link) {
window.api.copyToClipboard(link);
showCopyToast('Link kopiert');
}
}
}
// Selection changes don't change sort order / row content — just toggle classes.
applyQueueSelectionClasses();
updateQueueActionButtons();
}
// --- Context menu ---
let alwaysOnTopState = false;
// Cache hoster-counts for the context menu. Invalidated on structural changes
// to queueJobs (the length-based signature is good enough — a job's hoster
// never changes after it's created).
let _hosterCountsCache = { sig: '', result: new Map() };
let contextMenuReturnFocus = null;
function _getHosterCounts() {
const sig = `${queueJobs.length}`;
if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result;
const m = new Map();
for (let i = 0; i < queueJobs.length; i++) {
const h = queueJobs[i].hoster;
m.set(h, (m.get(h) || 0) + 1);
}
_hosterCountsCache = { sig, result: m };
return m;
}
function handleRowContextMenu(e, row) {
e.preventDefault();
const jobId = row.dataset.jobId;
if (!selectedJobIds.has(jobId)) {
selectedJobIds.clear();
selectedJobIds.add(jobId);
selectionAnchorJobId = 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 copyableLinkCount = getSelectedJobLinks().length;
const copyItem = menu.querySelector('[data-action="copy-links"]');
if (copyItem) {
copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren';
copyItem.style.display = copyableLinkCount > 0 ? '' : 'none';
}
menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => {
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
});
const startItem = menu.querySelector('[data-action="start-selected"]');
if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten';
// Dynamic "delete by hoster" submenu — cached count keyed by queue length
// so a right-click on a 5000-job queue doesn't rescan everything.
const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu');
const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items');
const hosterCounts = _getHosterCounts();
deleteHosterContainer.innerHTML = '';
if (hosterCounts.size > 0) {
deleteHosterSubmenu.style.display = '';
hosterCounts.forEach((count, hoster) => {
const item = document.createElement('div');
item.className = 'ctx-item ctx-item-danger';
item.dataset.action = `delete-hoster:${hoster}`;
item.setAttribute('role', 'menuitem');
item.tabIndex = -1;
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';
contextMenuReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
getVisibleContextMenuItems(menu)[0]?.focus();
// 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 getVisibleContextMenuItems(menu) {
return [...menu.querySelectorAll('.ctx-item[data-action]')].filter(item => window.getComputedStyle(item).display !== 'none');
}
function showRecentContextMenu(row, x, y) {
const id = parseInt(row.dataset.order, 10);
if (!selectedRecentIds.has(id)) {
selectedRecentIds.clear();
selectedRecentIds.add(id);
applyRecentSelectionClasses();
}
const menu = document.getElementById('recentContextMenu');
const copyableLinkCount = getSelectedRecentLinks().length;
const copyItem = menu.querySelector('[data-action="recent-copy-links"]');
if (copyItem) {
copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren';
copyItem.style.display = copyableLinkCount > 0 ? '' : 'none';
}
menu.style.display = 'block';
menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px';
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
contextMenuReturnFocus = row;
getVisibleContextMenuItems(menu)[0]?.focus();
}
function hideContextMenu() {
document.getElementById('contextMenu').style.display = 'none';
document.getElementById('recentContextMenu').style.display = 'none';
const returnFocus = contextMenuReturnFocus;
contextMenuReturnFocus = null;
if (returnFocus?.isConnected) returnFocus.focus();
}
async function deleteSelectedRecentFiles() {
if (selectedRecentIds.size === 0) return;
const count = selectedRecentIds.size;
if (!await showAppConfirm({ title: 'Ausgewählte Einträge entfernen?', message: count === 1 ? 'Ein ausgewählter Eintrag wird aus diesem Panel entfernt.' : `${count} ausgewählte Einträge werden aus diesem Panel entfernt.`, confirmText: 'Entfernen', danger: true })) 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);
_recentDataVersion++;
selectedRecentIds.clear();
renderRecentUploadsPanel();
}
async function clearAllRecentFiles() {
if (sessionFilesData.length === 0) return;
if (!await showAppConfirm({ title: 'Alle Links entfernen?', message: `Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`, confirmText: 'Alle entfernen', danger: true })) return;
sessionFilesData = [];
_sessionFileKeys.clear();
_sessionDoneCount = 0;
_sessionErrorCount = 0;
_recentDataVersion++;
selectedRecentIds.clear();
renderRecentUploadsPanel();
}
async function exportAllRecentFiles() {
if (sessionFilesData.length === 0) {
await showAppAlert('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) {
await showAppAlert(formatLocalizedError('Export fehlgeschlagen', err), 'Export fehlgeschlagen');
}
}
function getSelectedRecentLinks() {
return sessionFilesData
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
.map(r => r.link)
.filter(Boolean);
}
function copySelectedRecentLinks() {
const links = getSelectedRecentLinks();
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(getCopiedLinksMessage(links.length)); }
}
// --- Backup export / import ---
async function doBackupExport() {
try {
const protectInput = document.getElementById('protectLocalBackupInput');
let password;
if (protectInput?.checked) {
const passwordInput = document.getElementById('localBackupPasswordInput');
const confirmInput = document.getElementById('localBackupPasswordConfirmInput');
password = passwordInput?.value || '';
if (password.length < 8) {
await showAppAlert('Das Backup-Passwort muss mindestens 8 Zeichen lang sein.', 'Passwort prüfen');
passwordInput?.focus();
return;
}
if (!secretsMatch(password, confirmInput?.value || '')) {
await showAppAlert('Die beiden Backup-Passwörter stimmen nicht überein.', 'Passwort prüfen');
confirmInput?.focus();
return;
}
}
await flushPendingSettingsSaves();
const result = await window.api.exportBackup(password === undefined ? {} : { password });
if (result && result.ok) {
const passwordInput = document.getElementById('localBackupPasswordInput');
const confirmInput = document.getElementById('localBackupPasswordConfirmInput');
if (passwordInput) passwordInput.value = '';
if (confirmInput) confirmInput.value = '';
showCopyToast('Backup exportiert');
}
} catch (err) {
await showAppAlert(formatLocalizedError('Export fehlgeschlagen', err), 'Export fehlgeschlagen');
}
}
function secretsMatch(left, right) {
const a = String(left || '');
const b = String(right || '');
const length = Math.max(a.length, b.length);
let difference = a.length ^ b.length;
for (let index = 0; index < length; index++) {
difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
}
return difference === 0;
}
function applyImportedConfig(importedConfig, message) {
const pendingQueue = buildPersistedQueueState();
config = {
...importedConfig,
globalSettings: {
...(importedConfig.globalSettings || {}),
pendingQueue
}
};
hosterSettings = config.hosterSettings || {};
accountStatusGenerations.clear();
accountStatuses = {};
ensureAccountStatusEntries();
syncSelectedUploadHosters();
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
renderSettings();
renderAccounts();
renderHosterSummary();
renderHosterModal();
loadHistory();
showCopyToast(message);
}
async function persistImportedQueueState() {
let persistenceError = null;
try {
await persistQueueStateNow();
} catch (error) {
persistenceError = error;
}
try {
await flushConfigWrites();
} catch (error) {
if (!persistenceError) persistenceError = error;
}
return persistenceError;
}
function showImportQueuePersistenceError(error) {
showCopyToast(formatLocalizedError('Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', error), 8000);
}
function setOnlineBackupStatus(message, state = '') {
const status = document.getElementById('onlineBackupStatus');
if (!status) return;
status.textContent = message;
status.dataset.state = state;
}
function setOnlineBackupBusy(busy) {
const createButton = document.getElementById('createOnlineBackupBtn');
const restoreButton = document.getElementById('restoreOnlineBackupBtn');
if (createButton) createButton.disabled = busy;
if (restoreButton) restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
}
async function doOnlineBackupCreate() {
if (doOnlineBackupCreate.busy) return;
doOnlineBackupCreate.busy = true;
try {
await flushPendingSettingsSaves();
openOnlineBackupView();
} catch (error) {
openOnlineBackupView();
setOnlineBackupStatus(error.message || String(error), 'error');
doOnlineBackupCreate.busy = false;
return;
}
setOnlineBackupBusy(true);
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy');
try {
const result = await window.api.createOnlineBackup();
if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht erstellt werden');
const output = document.getElementById('onlineBackupKeyOutput');
const copyButton = document.getElementById('copyOnlineBackupKeyBtn');
if (output) output.value = result.key;
if (copyButton) copyButton.disabled = false;
setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success');
showCopyToast('Online-Schlüssel erstellt');
} catch (error) {
setOnlineBackupStatus(error.message || String(error), 'error');
} finally {
setOnlineBackupBusy(false);
doOnlineBackupCreate.busy = false;
}
}
function openOnlineBackupView(focusRestore = false) {
document.querySelector('.tab[data-view="settings"]')?.click();
const searchInput = document.getElementById('settingsSearchInput');
if (searchInput && searchInput.value) {
searchInput.value = '';
searchInput.dispatchEvent(new window.Event('input', { bubbles: true }));
}
document.querySelector('[data-settings-page="backup"]')?.click();
if (focusRestore) document.getElementById('onlineBackupKeyInput')?.focus();
}
function openOnlineBackupRestore() {
openOnlineBackupView(true);
}
async function doOnlineBackupRestore() {
const input = document.getElementById('onlineBackupKeyInput');
const key = input?.value.trim() || '';
if (!/^MHU2-[A-Za-z0-9_-]{70}$/.test(key)) {
setOnlineBackupStatus('Der Schlüssel muss mit MHU2- beginnen und exakt 75 Zeichen lang sein.', 'error');
input?.focus();
return;
}
setOnlineBackupBusy(true);
setOnlineBackupStatus('Speichere aktuelle Einstellungen…', 'busy');
try {
await flushPendingSettingsSaves();
setOnlineBackupStatus('Lade und entschlüssele Einstellungen…', 'busy');
beginConfigImport();
let result;
let queuePersistenceError = null;
try {
result = await window.api.restoreOnlineBackup(key);
if (result && result.ok) applyImportedConfig(result.config, 'Online-Backup importiert');
} finally {
queuePersistenceError = await persistImportedQueueState();
endConfigImport();
}
if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht importiert werden');
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning');
else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success');
if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError);
} catch (error) {
setOnlineBackupStatus(error.message || String(error), 'error');
} finally {
setOnlineBackupBusy(false);
}
}
function askLegacyBackupPassword(hint) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
const card = document.createElement('div');
card.className = 'modal-card';
card.style.width = 'min(380px,100%)';
const header = document.createElement('div');
header.className = 'modal-header';
const h3 = document.createElement('h3');
h3.textContent = 'Backup nicht entschlüsselbar';
header.appendChild(h3);
const body = document.createElement('div');
body.className = 'modal-body';
const p = document.createElement('p');
p.style.margin = '0 0 10px';
p.style.fontSize = '13px';
p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.';
if (hint) {
const p2 = document.createElement('p');
p2.style.margin = '0 0 10px';
p2.style.fontSize = '12px';
p2.style.color = 'var(--text-dim)';
p2.textContent = hint;
body.appendChild(p2);
}
const input = document.createElement('input');
input.type = 'password';
input.className = 'key-input';
input.placeholder = 'Passwort';
input.autocomplete = 'off';
input.style.width = '100%';
body.appendChild(p);
body.appendChild(input);
const footer = document.createElement('div');
footer.className = 'modal-footer';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-secondary';
cancelBtn.textContent = 'Abbrechen';
const okBtn = document.createElement('button');
okBtn.className = 'btn btn-primary';
okBtn.textContent = 'Importieren';
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
card.appendChild(header);
card.appendChild(body);
card.appendChild(footer);
overlay.appendChild(card);
document.body.appendChild(overlay);
const done = (val) => { overlay.remove(); resolve(val); };
okBtn.onclick = () => done(input.value || null);
cancelBtn.onclick = () => done(null);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') done(input.value || null);
if (e.key === 'Escape') done(null);
});
input.focus();
});
}
async function doBackupImport(legacyPassword) {
const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined;
try {
await flushPendingSettingsSaves();
beginConfigImport();
let result;
let queuePersistenceError = null;
try {
result = await window.api.importBackup(pw);
if (result && result.ok) applyImportedConfig(result.config, 'Backup importiert');
} finally {
queuePersistenceError = await persistImportedQueueState();
endConfigImport();
}
if (!result || result.canceled) return;
if (result.needsPassword) {
const entered = await askLegacyBackupPassword(result.hint);
if (entered) doBackupImport(entered);
return;
}
if (result.ok) {
if (Array.isArray(result.warnings) && result.warnings.length) {
await showAppAlert(`Backup importiert. Bitte prüfen: ${result.warnings.join(', ')}.`, 'Backup importiert');
}
if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError);
} else if (result.error) {
await showAppAlert(formatLocalizedError('Import fehlgeschlagen', result.error), 'Import fehlgeschlagen');
}
} catch (err) {
await showAppAlert(formatLocalizedError('Import fehlgeschlagen', err), 'Import fehlgeschlagen');
}
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) hideContextMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideContextMenu();
}
if (e.target instanceof window.Element && 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 {
const visibleJobs = _getVisibleQueueJobs();
selectedJobIds.clear();
visibleJobs.forEach(j => selectedJobIds.add(j.id));
selectionAnchorJobId = visibleJobs[0]?.id || null;
renderQueueTable();
}
}
}
// Delete
if (e.key === 'Delete') {
if (activeView && activeView.id === 'upload-view') {
e.preventDefault();
_normalizeQueueSelectionToVisible();
if (selectedRecentIds.size > 0) {
deleteSelectedRecentFiles();
} else if (selectedJobIds.size > 0) {
handleContextAction('delete-selected');
}
}
}
});
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);
});
function removeSourceCleanupRequirements(jobs) {
if (!window.SourceCleanupPolicy || !Array.isArray(jobs)) return;
for (const job of jobs) window.SourceCleanupPolicy.removeRequirement(queueJobs, job, 'win32');
}
async function handleContextAction(action) {
_normalizeQueueSelectionToVisible();
if (action === 'start-selected') {
startSelectedUpload();
} else if (action === 'copy-links') {
const links = getSelectedJobLinks();
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(getCopiedLinksMessage(links.length)); }
} else if (action === 'retry-selected') {
retrySelectedJobs();
} else if (action === 'show-log') {
showJobLogModal();
} else if (action === 'delete-selected') {
const count = selectedJobIds.size;
if (!count || !await showAppConfirm({ title: 'Uploads entfernen?', message: count === 1 ? 'Ein ausgewählter Upload wird aus der Liste entfernt.' : `${count} ausgewählte Uploads werden aus der Liste entfernt.`, confirmText: 'Entfernen', danger: true })) return;
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) await window.api.cancelSelectedJobs(activeIds);
const removedJobs = queueJobs.filter(j => selectedJobIds.has(j.id));
removeSourceCleanupRequirements(removedJobs);
const _deletedKeys = [];
queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) {
if (j.file && j.hoster && j.status !== 'done') _deletedKeys.push(`${j.file}|${j.hoster}`);
removeJobFromIndex(j);
return false;
}
return true;
});
selectedJobIds.clear();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue();
suppressPreviewKeysStillSelected(_deletedKeys);
renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar();
persistQueueStateSoon(true);
} else if (action === 'copy-all-links') {
copyAllLinks();
} else if (action === 'delete-all') {
if (!queueJobs.length || !await showAppConfirm({ title: 'Alle Uploads entfernen?', message: `${queueJobs.length} ${queueJobs.length === 1 ? 'Upload wird' : 'Uploads werden'} aus der Liste entfernt.`, confirmText: 'Alle entfernen', danger: true })) return;
const hasActiveJobs = uploading || queueJobs.some(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
if (hasActiveJobs) {
_cancelAutoRetry(true);
await window.api.cancelUpload();
uploading = false;
}
removeSourceCleanupRequirements(queueJobs);
queueJobs.forEach(j => removeJobFromIndex(j));
queueJobs = [];
selectedJobIds.clear();
selectionAnchorJobId = null;
selectedFiles = [];
syncSelectedFilesFromQueue();
renderQueueTable();
updateUploadView();
updateStatusBar();
persistQueueStateSoon(true);
} else if (action === 'always-on-top') {
alwaysOnTopState = !alwaysOnTopState;
await setAlwaysOnTopTracked(alwaysOnTopState);
config.globalSettings = { ...(config.globalSettings || {}), alwaysOnTop: alwaysOnTopState };
} else if (action.startsWith('delete-hoster:')) {
const hoster = action.replace('delete-hoster:', '');
const hosterCount = queueJobs.filter(job => job.hoster === hoster).length;
if (!hosterCount || !await showAppConfirm({ title: 'Hoster entfernen?', message: `${hosterCount} ${hosterCount === 1 ? 'Upload wird' : 'Uploads werden'} für ${getHosterLabel(hoster)} entfernt.`, confirmText: 'Hoster entfernen', danger: true })) return;
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);
removeSourceCleanupRequirements(queueJobs.filter(j => j.hoster === hoster));
// Remove ALL jobs for this hoster
queueJobs = queueJobs.filter(j => {
if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
return true;
});
selectedJobIds.clear();
selectionAnchorJobId = null;
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 _getVisibleSelectedQueueJobs(j => j.status === 'done' && j.result)
.map(j => j.result.download_url || j.result.embed_url || '')
.filter(Boolean);
}
// --- Upload ---
function prepareSourceCleanup(jobs) {
if (!window.SourceCleanupPolicy) return { groups: [] };
const enabled = config.globalSettings?.deleteSourceAfterSuccessfulUpload === true;
const hasMetadata = Array.isArray(jobs) && jobs.some((job) => job?.sourceCleanupToken);
if (!enabled && !hasMetadata) return { groups: [] };
const preparation = window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32');
return enabled ? preparation : { ...preparation, groups: [] };
}
async function persistSourceCleanupRevocations(preparation) {
if (Array.isArray(preparation?.revokedHosters) && preparation.revokedHosters.length > 0) {
sourceCleanupRevocationPending = true;
}
if (!sourceCleanupRevocationPending) return false;
await _persistQueueSnapshotBeforeUploadStart();
return true;
}
async function persistQueueBeforeUploadStart(preparation) {
if (await persistSourceCleanupRevocations(preparation)) return;
await _persistQueueSnapshotBeforeUploadStart();
}
async function _persistQueueSnapshotBeforeUploadStart() {
try {
queuePersistThrottle.cancel();
await persistQueueStateNow();
await flushConfigWrites();
sourceCleanupRevocationPending = false;
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
error.queuePersistenceFailure = true;
throw error;
}
}
async function completeSourceCleanupFinalization(data) {
if (!data?.finalizationId || !window.api.completeUploadFinalization) return false;
queuePersistThrottle.cancel();
let writesReady = true;
try {
await flushConfigWrites();
} catch {
writesReady = false;
}
const persist = async () => {
if (!writesReady) {
try {
await window.api.completeUploadFinalization({
finalizationId: data.finalizationId,
deliveryId: data.deliveryId,
historyPersisted: data.historyPersisted === true,
ready: false
});
} catch {}
return false;
}
return window.api.completeUploadFinalization({
finalizationId: data.finalizationId,
deliveryId: data.deliveryId,
historyPersisted: data.historyPersisted === true,
pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status) || job.historyPending === true)
? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true })
: null
});
};
if (!window.SourceCleanupPolicy?.persistRoundCompletions) return Boolean(await persist());
return window.SourceCleanupPolicy.persistRoundCompletions(queueJobs, {
historyPersisted: data.historyPersisted === true,
persist
});
}
function serializeUploadJob(job) {
return {
id: job.id,
file: job.file,
fileName: job.fileName,
hoster: job.hoster,
sourceCleanupMetadataVersion: job.sourceCleanupToken ? 2 : null,
sourceCleanupToken: job.sourceCleanupToken || null,
sourceCleanupRequiredHosters: job.sourceCleanupRequiredHosters || [],
sourceCleanupConfirmedHosters: job.sourceCleanupConfirmedHosters || [],
sourceCleanupStartedHosters: Array.isArray(job.sourceCleanupStartedHosters)
? [...job.sourceCleanupStartedHosters]
: undefined,
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null
};
}
async function startUpload(opts) {
if (uploading || sourceCleanupFinalizationPending) return;
if (!(opts && opts._restoredAutoStart)) cancelStartupQueueAutoStart();
if (!(opts && opts._autoRetry)) _cancelAutoRetry(true);
else _cancelAutoRetry(false);
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
_hydrateMissingJobSizes();
const hosters = getSelectedHosters();
if (queueJobs.length === 0 && selectedFiles.length > 0) {
if (hosters.length === 0) {
await showAppAlert('Bitte mindestens einen Hoster auswählen.');
uploading = false;
updateQueueActionButtons();
return;
}
buildQueuePreview();
}
const scopedJobs = Array.isArray(opts?.jobs) ? opts.jobs : queueJobs;
const automaticStart = opts?._autoRetry === true || opts?._restoredAutoStart === true;
const startableJobs = scopedJobs.filter((job) => isStartableQueueStatus(job.status));
const jobsToStart = automaticStart
? startableJobs.filter((job) => job.remoteCommitUncertain !== true)
: startableJobs;
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
if (!automaticStart && !await confirmRemoteCommitRetry(jobsToStart)) {
uploading = false;
updateQueueActionButtons();
return;
}
try {
const cleanupPreparation = prepareSourceCleanup(jobsToStart);
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();
await persistQueueBeforeUploadStart(cleanupPreparation);
const uploadPayload = {
hosters,
isAutoRetry: !!(opts && opts._autoRetry),
jobs: jobsToStart.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
};
const result = await window.api.startUpload(uploadPayload);
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
}
_markSkippedJobs(result);
if (result?.finalized !== true) persistQueueStateSoon();
if (result && result.error) {
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
uploading = false;
updateQueueActionButtons();
updateStatusBar();
}
} catch (err) {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen';
await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen');
}
}
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 = 'skipped';
job.error = skipped.reason || 'Kein gültiger Account';
}
}
renderQueueTable();
}
async function startSelectedUpload(explicitJobs, options = {}) {
if (sourceCleanupFinalizationPending) return;
const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs();
if (uploading) {
_hydrateMissingJobSizes();
const addable = scopedJobs.filter(j => isStartableQueueStatus(j.status));
if (addable.length === 0) {
if (selectedJobIds.size > 0) showCopyToast('Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).');
return false;
}
if (options.remoteRetryConfirmed !== true && !await confirmRemoteCommitRetry(addable)) return false;
{
const cleanupPreparation = prepareSourceCleanup(addable);
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 {
await persistQueueBeforeUploadStart(cleanupPreparation);
result = await window.api.addJobsToBatch({
jobs: addable.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
});
} catch (err) {
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Jobs konnten nicht hinzugefügt werden';
showCopyToast(formatLocalizedError(prefix, err));
return false;
}
// 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();
return startSelectedUpload(addable, { remoteRetryConfirmed: true });
}
_markSkippedJobs(result);
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
}
persistQueueStateSoon();
const added = Number(result && result.added) || 0;
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(localizeUiText(`${added} hinzugefügt`));
if (alreadyInBatch > 0) toastParts.push(localizeUiText(`${alreadyInBatch} bereits im Batch`));
if (skipped > 0) toastParts.push(localizeUiText(`${skipped} ohne gültigen Account`));
if (result && result.error) {
showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', result.error));
} else if (toastParts.length > 0) {
showCopyToast(`Jobs: ${toastParts.join(', ')}`);
} else {
showCopyToast('Keine Jobs hinzugefügt');
}
return !(result && result.error);
}
}
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
const hosters = getSelectedHosters();
const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status));
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return false; }
if (options.remoteRetryConfirmed !== true && !await confirmRemoteCommitRetry(jobsToStart)) {
uploading = false;
updateQueueActionButtons();
return false;
}
try {
const cleanupPreparation = prepareSourceCleanup(jobsToStart);
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();
await persistQueueBeforeUploadStart(cleanupPreparation);
const uploadPayload = {
hosters,
jobs: jobsToStart.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
};
const result = await window.api.startUpload(uploadPayload);
if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
}
_markSkippedJobs(result);
if (result?.finalized !== true) persistQueueStateSoon();
if (result && result.error) {
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
uploading = false;
updateQueueActionButtons();
updateStatusBar();
return false;
}
return true;
} catch (err) {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
const prefix = err?.queuePersistenceFailure ? 'Warteschlange konnte vor dem Upload-Start nicht gespeichert werden' : 'Upload-Start fehlgeschlagen';
await showAppAlert(formatLocalizedError(prefix, err), 'Upload-Start fehlgeschlagen');
return false;
}
}
async function cancelUpload() {
_cancelAutoRetry(true);
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) {
try {
if (!data || typeof data !== 'object') return;
_handleProgressImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleProgress error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleProgressImpl(data) {
const hasJobId = data.jobId !== undefined && data.jobId !== null && data.jobId !== '';
let job = hasJobId ? _jobIndexById.get(data.jobId) : null;
if (hasJobId && !job) return;
if (!hasJobId && data.uploadId) {
job = _jobIndexByUploadId.get(data.uploadId);
if (!job && _deletedJobIds.has(data.uploadId)) return;
}
if (!job) {
const candidates = queueJobs.filter(candidate =>
candidate.fileName === data.fileName &&
candidate.hoster === data.hoster &&
candidate.status !== 'done' &&
candidate.status !== 'skipped'
);
if (candidates.length > 1) return;
job = candidates.length === 1 ? candidates[0] : null;
if (job && data.uploadId) {
job.uploadId = data.uploadId;
_jobIndexByUploadId.set(data.uploadId, job);
}
}
if (!job) {
if (['done', 'error', 'aborted', 'skipped'].includes(data.status)) return;
job = {
id: 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 (['done', 'error', 'aborted', 'skipped'].includes(job.status)) return;
// Update job state
job.status = data.status;
if (data.status !== 'preview') job.interrupted = false;
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.failureDetails = data.failureDetails || job.failureDetails || null;
job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, data);
job.result = data.result || job.result;
job.attempt = data.attempt || 0;
job.maxAttempts = data.maxAttempts || 0;
job.progress = data.progress || 0;
// Track which account the backend is currently using so the status cell
// can display "Primär" vs "Fallback #N" during rotation.
if (data.accountId) job.accountId = data.accountId;
if (data.accountId && data.hoster) {
const status = accountStatuses[data.accountId] || { status: 'unchecked', message: '' };
if (data.status === 'done') accountStatuses[data.accountId] = { ...status, status: 'ok', message: 'Letzter Upload erfolgreich', lastSuccessAt: new Date().toISOString() };
else if (data.status === 'error') accountStatuses[data.accountId] = { ...status, status: 'error', message: data.error || 'Letzter Upload fehlgeschlagen', lastFailureAt: new Date().toISOString() };
}
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') {
if (window.SourceCleanupPolicy) window.SourceCleanupPolicy.markCompleted(queueJobs, job, 'win32');
_completedUploadKeys.add(`${job.file}|${job.hoster}`);
}
// Status changes (done/error/etc) get one coalesced update per frame so a
// burst of 500 parallel jobs flipping state doesn't fire 2000 sync DOM
// updates. Ongoing uploading progress is throttled at 200ms.
if (data.status === 'uploading') {
scheduleThrottledUIUpdate();
} else {
scheduleStatusChangeUpdate();
}
persistQueueStateSoon();
}
function handleBatchDone(summary, options = {}) {
uploading = false;
applySummaryResults(summary, options.historyPersisted !== false);
_deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes
// Prune session-stats sets to current queue contents. Without this, IDs
// of jobs that were removed from queueJobs (via removeFromQueueOnDone
// or the cap-prune below) live forever in these sets — small leak per
// entry, real over weeks of use. _completedUploadKeys is intentionally
// kept (it's the dedup against re-queueing the same file).
if (_sessionTrackedJobs.size > 0 || _sessionDoneJobs.size > 0) {
const aliveIds = new Set();
for (const j of queueJobs) aliveIds.add(j.id);
for (const id of _sessionTrackedJobs) if (!aliveIds.has(id)) _sessionTrackedJobs.delete(id);
for (const id of _sessionDoneJobs) if (!aliveIds.has(id)) _sessionDoneJobs.delete(id);
}
// Reset aborted jobs back to queued so they can be restarted
for (const job of queueJobs) {
if (job.status === 'aborted') {
job.status = 'queued';
job.progress = 0;
job.bytesUploaded = 0;
job.speedKbs = 0;
job.elapsed = 0;
job.remaining = 0;
job.error = null;
}
}
syncSelectedFilesFromQueue();
updateQueueActionButtons();
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 (options.historyPersisted !== false && 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' && job.historyPending !== true) {
removeJobFromIndex(job, true);
selectedJobIds.delete(job.id);
} else {
nextJobs.push(job);
}
}
queueJobs = nextJobs;
} else if (options.historyPersisted !== false) {
// Auto-prune for the default (removeOnDone=false) too: cap terminal
// jobs (done/skipped/error/aborted) at the most recent N so the queue
// can't grow unbounded across long sessions. The algorithm lives in
// lib/queue-prune.js (same impl Node-tested, see tests/queue-prune.test.js)
// and the result tells us which jobs to drop so we can clean up the
// index + selection in one pass.
const TERMINAL_KEEP_LIMIT = 500;
// Optional-chain so the renderer still works if the prune script fails
// to load (e.g. file:// path issues during dev) — falls back to no-prune
// rather than crashing on every batch-done.
const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs.filter(job => job.historyPending !== true), TERMINAL_KEEP_LIMIT);
if (result) {
const droppedIds = new Set(result.dropped.map(job => job.id));
for (const j of result.dropped) {
removeJobFromIndex(j, true);
selectedJobIds.delete(j.id);
}
queueJobs = queueJobs.filter(job => !droppedIds.has(job.id));
}
}
renderQueueTable();
if (!options.deferPersistence) {
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status) || job.historyPending === true)) persistQueueStateSoon(true);
else clearPersistedQueueStateSoon();
}
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
updateStatusBar();
_refreshSessionFailedSnapshot();
_scheduleAutoRetryIfNeeded();
}
let _sessionFailedKeys = new Set();
const _autoRetryState = { round: 0, timer: null };
function _cancelAutoRetry(resetRound) {
if (_autoRetryState.timer) { clearTimeout(_autoRetryState.timer); _autoRetryState.timer = null; }
if (resetRound) _autoRetryState.round = 0;
}
function _collectAutoRetryableJobs() {
if (!window.Stats) return [];
return queueJobs.filter(j => j.status === 'error'
&& j.remoteCommitUncertain !== true
&& window.Stats.isRetryableCategory(window.Stats.classifyErrorCategory(j.error)));
}
function _scheduleAutoRetryIfNeeded() {
const rounds = Math.max(0, Math.min(5, Number(config.globalSettings?.autoRetryRounds) || 0));
if (rounds <= 0) return;
if (_autoRetryState.round >= rounds) { _autoRetryState.round = 0; return; }
const retryable = _collectAutoRetryableJobs();
if (retryable.length === 0) { _autoRetryState.round = 0; return; }
const delayMin = Math.max(1, Math.min(120, Number(config.globalSettings?.autoRetryDelayMin) || 5));
const nextRound = _autoRetryState.round + 1;
const waitMin = delayMin * nextRound;
_autoRetryState.round = nextRound;
showCopyToast(`Auto-Retry Runde ${nextRound}/${rounds}: ${retryable.length} transiente Fehler werden in ${waitMin} min neu versucht.`, 10000);
_autoRetryState.timer = setTimeout(() => {
_autoRetryState.timer = null;
const jobs = _collectAutoRetryableJobs();
if (jobs.length === 0) { _autoRetryState.round = 0; return; }
for (const j of jobs) {
j.status = 'queued'; j.error = null; j.result = null;
j.remoteCommitUncertain = false;
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
}
renderQueueTable();
startUpload({ _autoRetry: true, jobs });
}, waitMin * 60_000);
}
async function _refreshSessionFailedSnapshot() {
if (!window.api || !window.api.getSessionFailedAccounts) return;
try {
const keys = await window.api.getSessionFailedAccounts();
_sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []);
renderAccounts();
} catch { /* ignore */ }
}
function _maybeShowBatchSummary(summary) {
if (!window.Stats || !summary) return;
const buckets = window.Stats.summarizeBatchErrors(summary);
const total = Object.values(buckets).reduce((n, arr) => n + arr.length, 0);
if (total === 0) return;
const modal = document.getElementById('batchSummaryModal');
if (!modal) return;
const list = modal.querySelector('#batchSummaryList');
const retryAllBtn = modal.querySelector('#batchSummaryRetryAll');
const retryTransientBtn = modal.querySelector('#batchSummaryRetryTransient');
const closeBtn = modal.querySelector('#batchSummaryClose');
const order = ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error', 'aborted'];
list.innerHTML = order
.filter(cat => buckets[cat].length > 0)
.map(cat => {
const items = buckets[cat];
const sample = items.slice(0, 3).map(i => `${escapeHtml(i.fileName)} → ${escapeHtml(i.hoster)}: ${escapeHtml(i.error)} `).join('');
const more = items.length > 3 ? `… +${items.length - 3} weitere ` : '';
const retryable = window.Stats.isRetryableCategory(cat);
const tag = retryable ? 'erneut versuchbar ' : 'manuell ';
return `
${escapeHtml(window.Stats.CATEGORY_LABELS[cat] || cat)} ${items.length} ${tag}
`;
}).join('');
const transientCount = ['hoster-transient', 'network', 'unknown'].reduce((n, c) => n + buckets[c].length, 0);
retryTransientBtn.textContent = transientCount > 0 ? `Transiente erneut hochladen (${transientCount})` : 'Keine transienten Fehler';
retryTransientBtn.disabled = transientCount === 0;
const allRetryable = total - buckets['aborted'].length;
retryAllBtn.textContent = `Alle Fehler erneut versuchen (${allRetryable})`;
retryAllBtn.disabled = allRetryable === 0;
const close = () => { modal.style.display = 'none'; };
closeBtn.onclick = close;
retryAllBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, false)) close(); };
retryTransientBtn.onclick = async () => { if (await _retryFailedFromBuckets(buckets, true)) close(); };
modal.style.display = 'flex';
}
async function _retryFailedFromBuckets(buckets, transientOnly) {
if (sourceCleanupFinalizationPending) return;
const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error'];
const toRetry = [];
for (const cat of cats) {
for (const item of (buckets[cat] || [])) toRetry.push(item);
}
if (toRetry.length === 0) return false;
const jobsToRetry = [];
for (const item of toRetry) {
const job = item.jobId
? queueJobs.find(candidate => candidate.id === item.jobId && candidate.status === 'error')
: (() => {
const candidates = queueJobs.filter(candidate => candidate.fileName === item.fileName && candidate.hoster === item.hoster && candidate.status === 'error');
return candidates.length === 1 ? candidates[0] : null;
})();
if (job && !jobsToRetry.includes(job)) jobsToRetry.push(job);
}
if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return false; }
if (!await confirmRemoteCommitRetry(jobsToRetry)) return false;
const started = await startSelectedUpload(jobsToRetry, { remoteRetryConfirmed: true });
if (!started) return false;
showCopyToast(jobsToRetry.length === 1 ? '1 Job zum erneuten Upload zurückgesetzt' : `${jobsToRetry.length} Jobs zum erneuten Upload zurückgesetzt`);
return true;
}
function handleStats(data) {
try {
if (!data || typeof data !== 'object') return;
_handleStatsImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleStats error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleStatsImpl(data) {
lastUploadStats = {
state: data.state || 'idle',
globalSpeedKbs: data.globalSpeedKbs || 0,
totalBytes: data.totalBytes || 0,
elapsed: data.elapsed || 0,
activeJobs: data.activeJobs || 0
};
updateStatusBar();
updateStatsPanel();
if (data.state === 'uploading' && (data.activeJobs || 0) > 0) {
_maybeLogRendererPerf(data.activeJobs);
} else {
_resetRendererPerf();
}
// 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') {
if (statsRunTimer) clearInterval(statsRunTimer);
statsRunTimer = null;
statsStartTime = 0;
}
}
// --- Per-job log modal ---
let _jobLogGeneration = 0;
let _jobLogJobId = null;
async function showJobLogModal() {
const selectedJobs = _getVisibleSelectedQueueJobs();
if (selectedJobs.length === 0) return;
// Use the first selected job — log view is per-file, multi-select doesn't
// make sense here.
const job = selectedJobs[0];
const jobId = job.id;
const generation = ++_jobLogGeneration;
_jobLogJobId = jobId;
const modal = document.getElementById('jobLogModal');
const titleEl = document.getElementById('jobLogTitle');
const bodyEl = document.getElementById('jobLogBody');
if (!modal || !titleEl || !bodyEl) return;
titleEl.textContent = job && job.fileName ? `Log · ${job.fileName}` : 'Upload-Log';
bodyEl.textContent = localizeUiText('Lade…');
modalController.open(modal, {
initialFocus: '#closeJobLogBtn',
fallbackFocus: '#addFilesBtn',
onEscape: hideJobLogModal,
onBackdrop: hideJobLogModal
});
let entries = [];
try { entries = await window.api.getJobLog(jobId); } catch {}
if (generation !== _jobLogGeneration || _jobLogJobId !== jobId || !modalController.isOpen(modal) || _jobIndexById.get(jobId) !== job) return;
if (!Array.isArray(entries) || entries.length === 0) {
bodyEl.textContent = localizeUiText('Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).');
return;
}
const fmt = (e) => {
const t = new Date(e.ts || Date.now()).toLocaleTimeString(getUiLocale(), { hour12: false }) + '.' +
String((e.ts || 0) % 1000).padStart(3, '0');
if (e.kind === 'progress') {
const attempt = e.attempt ? ` (${e.attempt}/${e.maxAttempts || '?'})` : '';
const acc = e.accountId ? ` acc=${e.accountId.slice(0, 32)}` : '';
const err = e.error ? `\n → ${e.error}` : '';
return `[${t}] status=${e.status}${attempt}${acc}${err}`;
}
// rot-log
const rest = Object.entries(e)
.filter(([k]) => !['ts', 'kind', 'event', 'jobId'].includes(k))
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ');
return `[${t}] [${e.event}] ${rest}`;
};
const details = job.failureDetails && typeof job.failureDetails === 'object'
? Object.entries(job.failureDetails).map(([key, value]) => `${key}: ${value}`).join('\n')
: '';
const summary = [
`${localizeUiText('Hoster')}: ${job.hoster || '–'}`,
`${localizeUiText('Account')}: ${getAccountLabel(job) || job.accountId || '–'}`,
`${localizeUiText('Versuch')}: ${job.attempt || '–'} / ${job.maxAttempts || '–'}`,
job.error ? `${localizeUiText('Fehler')}: ${job.error}` : '',
details ? `${localizeUiText('Diagnose')}:\n${details}` : ''
].filter(Boolean).join('\n');
bodyEl.textContent = `${summary}\n\n${entries.map(fmt).join('\n')}`;
}
function hideJobLogModal() {
_jobLogGeneration++;
_jobLogJobId = null;
modalController.close('jobLogModal', { fallbackFocus: '#addFilesBtn' });
}
async function copyJobLogToClipboard() {
const body = document.getElementById('jobLogBody');
if (!body || !body.textContent) return;
try { await window.api.copyToClipboard(body.textContent); showCopyToast('Log in Zwischenablage'); } catch {}
}
// --- Retry ---
async function confirmRemoteCommitRetry(jobs) {
const count = jobs.filter(job => job.remoteCommitUncertain === true).length;
if (count === 0) return true;
return showAppConfirm({
title: 'Uploadstatus konnte nicht bestätigt werden',
message: count === 1
? 'Der Upload könnte beim Hoster bereits abgeschlossen sein. Ein erneuter Upload kann ein Duplikat erzeugen.'
: `${count} Uploads könnten bei den Hostern bereits abgeschlossen sein. Erneute Uploads können Duplikate erzeugen.`,
confirmText: 'Trotzdem erneut hochladen',
danger: true
});
}
async function retrySelectedJobs() {
if (sourceCleanupFinalizationPending) return;
_normalizeQueueSelectionToVisible();
const retryJobs = queueJobs.filter(j => selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status));
if (retryJobs.length === 0 || !await confirmRemoteCommitRetry(retryJobs)) return;
// Build a Set for O(1) selectedFiles dedup below.
const existingFilePaths = new Set();
for (const f of selectedFiles) existingFilePaths.add(f.path);
retryJobs.forEach(j => {
// 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;
if (!existingFilePaths.has(j.file)) {
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
existingFilePaths.add(j.file);
}
});
for (const j of retryJobs) {
if (j.file && j.hoster) {
_completedUploadKeys.delete(`${j.file}|${j.hoster}`);
_suppressedPreviewKeys.delete(`${j.file}|${j.hoster}`);
}
}
// Select the retry jobs and start them immediately.
// No renderQueueTable / updateQueueActionButtons / updateStatusBar here:
// startSelectedUpload() runs the exact same trio right after, and at 500+
// jobs the double render freezes the UI for multiple seconds.
selectedJobIds.clear();
retryJobs.forEach(j => selectedJobIds.add(j.id));
selectionAnchorJobId = retryJobs[0]?.id || null;
persistQueueStateSoon();
await startSelectedUpload(retryJobs, { remoteRetryConfirmed: true });
}
async function abortSelectedJobs() {
_normalizeQueueSelectionToVisible();
const activeJobIds = [];
queueJobs.forEach((job) => {
if (!selectedJobIds.has(job.id)) return;
if (job.status === 'preview') {
job.status = 'aborted';
job.error = 'Abgebrochen';
job.progress = 0;
job.uploadId = null;
} else if (['queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)) {
activeJobIds.push(job.id);
job.status = 'aborted';
job.error = 'Abgebrochen';
job.progress = 0;
}
});
if (activeJobIds.length > 0) {
await window.api.cancelSelectedJobs(activeJobIds);
}
selectedJobIds.clear();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue();
renderQueueTable();
updateQueueActionButtons();
updateStatusBar();
persistQueueStateSoon(true);
}
async function finishUploadsInProgress() {
if (!uploading) return;
await window.api.finishAfterActive();
lastUploadStats.state = 'stopping';
updateStatusBar();
}
async function abortAllUploads() {
if (!uploading) return;
if (!await showAppConfirm({ title: 'Alle Uploads abbrechen?', message: 'Alle laufenden Uploads werden abgebrochen und in die Warteschlange zurückgesetzt.', confirmText: 'Alle abbrechen', danger: true })) return;
await cancelUpload();
}
function moveSelectedJobs(direction) {
_normalizeQueueSelectionToVisible();
if (uploading || selectedJobIds.size === 0) return;
const jobs = queueJobs.slice();
if (direction === 'top') {
queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id)));
} else if (direction === 'bottom') {
queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id)));
} else if (direction === 'up') {
for (let i = 1; i < jobs.length; i++) {
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) {
[jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]];
}
}
queueJobs = jobs;
} else if (direction === 'down') {
for (let i = jobs.length - 2; i >= 0; i--) {
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) {
[jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]];
}
}
queueJobs = jobs;
}
rebuildJobIndex();
renderQueueTable();
updateStatusBar();
persistQueueStateSoon(true);
}
function syncSelectedFilesFromQueue() {
const fileMap = new Map();
queueJobs
.filter((job) => !['done', 'skipped', 'aborted'].includes(job.status))
.forEach((job) => {
if (!job.file || fileMap.has(job.file)) return;
fileMap.set(job.file, {
path: job.file,
name: job.fileName,
size: job.bytesTotal || 0
});
});
selectedFiles = Array.from(fileMap.values());
}
// Cap recent-files panel growth so a multi-thousand-job session doesn't
// turn every renderRecentUploadsPanel call into a multi-MB innerHTML write.
const SESSION_FILES_CAP = 2000;
function maybeAddSessionFile(job) {
if (!job) return;
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);
const dt = formatDateTime(new Date());
sessionFilesData.push({
date: dt.text,
dateTs: dt.ts,
filename: job.fileName || '',
host: job.hoster || '',
link,
isError: false,
order: _recentSeqCounter++
});
_recentDataVersion++;
_sessionDoneCount++;
_recentPendingAppends++;
// Drop oldest entries past the cap to keep render cost bounded.
// Without this, sessionFilesData grows unbounded across the session
// and every renderRecentUploadsPanel call becomes a megabyte-sized
// innerHTML write — visible as scroll/click lag in the lower panel.
if (sessionFilesData.length > SESSION_FILES_CAP) {
const drop = sessionFilesData.length - SESSION_FILES_CAP;
for (let i = 0; i < drop; i++) {
const r = sessionFilesData[i];
_sessionFileKeys.delete(`${r.link}${r.filename}${r.host}`);
}
sessionFilesData = sessionFilesData.slice(drop);
}
// Coalesce rapid successive adds into one render per frame.
scheduleRecentRender();
}
}
}
function applySummaryResults(summary, historyPersisted = true) {
const files = Array.isArray(summary?.files) ? summary.files : [];
const jobById = new Map();
const jobsByLegacyKey = new Map();
for (const j of queueJobs) {
jobById.set(j.id, j);
const key = `${j.fileName}\u0001${j.hoster}`;
const candidates = jobsByLegacyKey.get(key);
if (candidates) candidates.push(j);
else jobsByLegacyKey.set(key, [j]);
}
for (const file of files) {
for (const result of file.results || []) {
const hasJobId = result.jobId !== undefined && result.jobId !== null && result.jobId !== '';
const candidates = hasJobId ? null : jobsByLegacyKey.get(`${file.name}\u0001${result.hoster}`);
const job = hasJobId ? jobById.get(result.jobId) : (candidates?.length === 1 ? candidates[0] : null);
if (!job) continue;
job.remoteCommitUncertain = window.UploadRecovery.resolveRemoteCommitUncertainty(job.remoteCommitUncertain, result);
job.historyPending = historyPersisted !== true;
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;
job.failureDetails = null;
} 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';
job.failureDetails = result.failureDetails || job.failureDetails || null;
} else if (result.status === 'skipped') {
job.status = 'skipped';
job.error = result.error || 'Übersprungen';
}
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;
_queueStatsCache = window.QueueStats.calculateQueueStats(queueJobs);
(typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn))(() => { _queueStatsCache = null; });
return _queueStatsCache;
}
function _setSidebarCount(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = Number(value || 0).toLocaleString(getUiLocale());
}
function _syncSidebarIndicator(button, immediate = false) {
const navigation = button?.closest('.view-sidebar-navigation, .settings-navigation');
const indicator = navigation?.querySelector(':scope > .view-sidebar-indicator, :scope > .settings-nav-indicator');
if (!indicator || !button) return;
const navigationRect = navigation.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
if (buttonRect.width === 0 || buttonRect.height === 0) return;
const firstPosition = indicator.dataset.ready !== 'true';
if (immediate || firstPosition) indicator.style.transition = 'none';
indicator.style.width = `${buttonRect.width}px`;
indicator.style.height = `${buttonRect.height}px`;
indicator.style.transform = `translate(${buttonRect.left - navigationRect.left}px, ${buttonRect.top - navigationRect.top}px)`;
if (immediate || firstPosition) {
indicator.getBoundingClientRect();
requestAnimationFrame(() => {
indicator.style.transition = '';
indicator.dataset.ready = 'true';
});
}
}
function _syncSidebarFilterButtons(selector, datasetKey, value) {
let activeButton = null;
let selectionChanged = false;
document.querySelectorAll(selector).forEach(button => {
const active = button.dataset[datasetKey] === value;
if (active && !button.classList.contains('active')) selectionChanged = true;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
if (active) activeButton = button;
});
_syncSidebarIndicator(activeButton, !selectionChanged);
}
function setUploadSidebarFilter(value) {
if (!['all', 'active', 'waiting', 'done', 'error'].includes(value)) return;
uploadSidebarFilter = value;
_syncSidebarFilterButtons('[data-upload-sidebar-target]', 'uploadSidebarTarget', value);
_queueFilterCache = { filter: '', source: null, result: [] };
_normalizeQueueSelectionToVisible();
_lastVisibleRange = { start: -1, end: -1 };
const container = document.getElementById('queueContainer');
if (container) container.scrollTop = 0;
renderQueueTable();
}
function applyQueueDetailFilters() {
queueSearchQuery = String(document.getElementById('queueSearchInput')?.value || '').trim().toLocaleLowerCase(getUiLocale());
queueHosterFilter = String(document.getElementById('queueHosterFilter')?.value || '');
queueStatusFilter = String(document.getElementById('queueStatusFilter')?.value || '');
_queueFilterCache = { filter: '', source: null, result: [] };
_normalizeQueueSelectionToVisible();
_lastVisibleRange = { start: -1, end: -1 };
renderQueueTable();
}
function syncQueueHosterFilterOptions() {
const select = document.getElementById('queueHosterFilter');
if (!select) return;
const names = [...new Set(queueJobs.map(job => job.hoster).filter(Boolean))].sort(_collatorSimple.compare);
const current = select.value;
select.innerHTML = `Alle Hoster ${names.map(name => `${escapeHtml(getHosterLabel(name))} `).join('')}`;
select.value = names.includes(current) ? current : '';
queueHosterFilter = select.value;
}
function updateUploadSidebarSummary(stats = _computeQueueStats()) {
let waiting = 0;
let active = 0;
for (const job of queueJobs) {
if (job.status === 'preview' || job.status === 'queued') waiting++;
else if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') active++;
}
const accountCount = getAccountsWithCredsFlat().filter(({ account }) => account.enabled !== false).length;
_setSidebarCount('uploadSidebarAllCount', stats.total);
_setSidebarCount('uploadSidebarActiveCount', active);
_setSidebarCount('uploadSidebarWaitingCount', waiting);
_setSidebarCount('uploadSidebarDoneCount', stats.done);
_setSidebarCount('uploadSidebarErrorCount', stats.errors);
_setSidebarCount('uploadSidebarAccountsCount', accountCount);
}
function updateAccountSidebarSummary(allAccounts = getAllAccountsFlat()) {
let ready = 0;
let warning = 0;
let error = 0;
let availableAccounts = 0;
const hosterCounts = new Map();
for (const { name, account } of allAccounts) {
hosterCounts.set(name, (hosterCounts.get(name) || 0) + 1);
if (account.enabled !== false && accountHasCreds(name, account)) availableAccounts++;
const category = _getAccountSidebarCategory(name, account);
if (category === 'ready') ready++;
else if (category === 'error') error++;
else warning++;
}
_setSidebarCount('accountsSidebarAllCount', allAccounts.length);
_setSidebarCount('accountsSidebarReadyCount', ready);
_setSidebarCount('accountsSidebarWarningCount', warning);
_setSidebarCount('accountsSidebarErrorCount', error);
_setSidebarCount('uploadSidebarAccountsCount', availableAccounts);
const container = document.getElementById('accountsSidebarHosters');
if (container) {
container.replaceChildren(...HOSTERS.filter(name => hosterCounts.has(name)).map(name => {
const row = document.createElement('div');
row.className = 'view-sidebar-hoster';
const dot = document.createElement('span');
dot.className = 'view-sidebar-hoster-dot';
const label = document.createElement('span');
label.className = 'view-sidebar-copy';
label.textContent = getHosterLabel(name);
const count = document.createElement('span');
count.className = 'view-sidebar-badge';
count.textContent = hosterCounts.get(name).toLocaleString(getUiLocale());
row.append(dot, label, count);
return row;
}));
}
}
function _getAccountSidebarCategory(name, account) {
if (account.enabled === false || !accountHasCreds(name, account)) return 'warning';
const status = (accountStatuses[account.id] && accountStatuses[account.id].status) || 'unchecked';
if (status === 'ok') return 'ready';
if (status === 'error') return 'error';
return 'warning';
}
function _applyAccountSidebarFilter() {
const entries = new Map(getAllAccountsFlat().map(entry => [entry.account.id, entry]));
const list = document.getElementById('accountsList');
let visibleAccounts = 0;
document.querySelectorAll('#accountsList .account-hoster-group').forEach(group => {
let matches = 0;
group.querySelectorAll('.account-card').forEach(card => {
const entry = entries.get(card.dataset.accountId);
const visible = !!entry && (accountSidebarFilter === 'all' || _getAccountSidebarCategory(entry.name, entry.account) === accountSidebarFilter);
card.hidden = !visible;
if (visible) { matches++; visibleAccounts++; }
});
group.hidden = matches === 0;
});
let empty = list?.querySelector('.account-filter-empty');
if (visibleAccounts === 0 && entries.size > 0) {
if (!empty) {
empty = document.createElement('div');
empty.className = 'empty-state account-filter-empty';
list.appendChild(empty);
}
empty.textContent = localizeUiText('Keine Accounts entsprechen diesem Filter.');
empty.hidden = false;
} else if (empty) {
empty.hidden = true;
}
}
function setAccountSidebarFilter(value) {
if (!['all', 'ready', 'warning', 'error'].includes(value)) return;
accountSidebarFilter = value;
_syncSidebarFilterButtons('[data-accounts-sidebar-filter]', 'accountsSidebarFilter', value);
_applyAccountSidebarFilter();
}
function setHistorySidebarFilter(value) {
if (!['all', 'success', 'error', 'skipped'].includes(value)) return;
historySidebarFilter = value;
_syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', value);
const container = document.getElementById('historyContainer');
if (container) {
container.scrollTop = 0;
renderHistoryTable(container);
}
}
function updateHistorySidebarSummary() {
_setSidebarCount('historySidebarAllCount', historySidebarCounts.total);
_setSidebarCount('historySidebarSuccessCount', historySidebarCounts.success);
_setSidebarCount('historySidebarErrorCount', historySidebarCounts.error);
_setSidebarCount('historySidebarSkippedCount', historySidebarCounts.skipped);
const retention = document.getElementById('historySidebarRetention');
const select = document.getElementById('historyRetentionSelect');
const labels = {
all: 'Alles behalten',
'7d': 'Letzte 7 Tage',
'30d': 'Letzte 30 Tage',
'90d': 'Letzte 90 Tage',
'1000': 'Letzte 1000 Uploads',
'100': 'Letzte 100 Uploads'
};
if (retention && select) retention.textContent = labels[select.value] || labels.all;
}
function _setUploadTelemetryText(id, value) {
const element = document.getElementById(id);
if (!element) return;
const text = String(value);
element.textContent = text;
element.setAttribute('aria-label', text);
}
function _setRollingUploadMetric(id, value) {
const element = document.getElementById(id);
if (!element) return;
const numericValue = Number(value) || 0;
const nextText = numericValue.toLocaleString(getUiLocale());
const previousValue = Number(element.dataset.numericValue) || 0;
if (previousValue === numericValue) {
const previousText = element.getAttribute('aria-label');
element.setAttribute('aria-label', nextText);
if (previousText !== null && previousText !== nextText) {
element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
const settled = document.createElement('span');
settled.textContent = nextText;
element.replaceChildren(settled);
element.dataset.direction = 'none';
}
return;
}
const direction = numericValue > previousValue ? 'up' : 'down';
const previousText = element.getAttribute('aria-label') || previousValue.toLocaleString(getUiLocale());
element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
element.dataset.numericValue = String(numericValue);
element.setAttribute('aria-label', nextText);
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
const settled = document.createElement('span');
settled.textContent = nextText;
element.replaceChildren(settled);
element.dataset.direction = 'none';
return;
}
const outgoing = document.createElement('span');
const incoming = document.createElement('span');
outgoing.textContent = previousText;
incoming.textContent = nextText;
outgoing.className = 'upload-rolling-outgoing';
incoming.className = 'upload-rolling-incoming';
element.dataset.direction = direction;
element.replaceChildren(outgoing, incoming);
const distance = direction === 'up' ? -1 : 1;
const options = { duration: 320, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' };
const outgoingAnimation = outgoing.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: `translateY(${distance * 100}%)`, opacity: 0 }
], options);
const incomingAnimation = incoming.animate([
{ transform: `translateY(${-distance * 100}%)`, opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
], options);
Promise.allSettled([outgoingAnimation.finished, incomingAnimation.finished]).then(() => {
if (!incoming.isConnected || incoming.parentElement !== element) return;
const settled = document.createElement('span');
settled.textContent = nextText;
element.replaceChildren(settled);
element.dataset.direction = 'none';
});
}
function getUploadSpeedText(kbs = lastUploadStats.globalSpeedKbs) {
return !kbs || kbs <= 0 ? '0 B/s' : formatSpeed(kbs);
}
function updateUploadSpeedDisplays() {
const text = getUploadSpeedText();
_setUploadTelemetryText('uploadTelemetrySpeed', text);
_setUploadTelemetryText('uploadSpeedValue', text);
}
function drawUploadSpeedSparkline() {
const canvas = document.getElementById('uploadSpeedCanvas');
if (!canvas) return;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (width <= 0 || height <= 0) return;
const scale = Math.max(1, window.devicePixelRatio || 1);
const pixelWidth = Math.round(width * scale);
const pixelHeight = Math.round(height * scale);
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const context = canvas.getContext('2d');
context.setTransform(scale, 0, 0, scale, 0, 0);
context.clearRect(0, 0, width, height);
const values = uploadSpeedState.history;
if (values.length < 2) return;
const maximum = Math.max(1, ...values);
const step = width / Math.max(1, values.length - 1);
const y = value => height - 2 - (value / maximum) * (height - 4);
context.beginPath();
values.forEach((value, index) => {
const x = index * step;
if (index === 0) context.moveTo(x, y(value));
else context.lineTo(x, y(value));
});
context.lineTo(width, height);
context.lineTo(0, height);
context.closePath();
const fill = context.createLinearGradient(0, 0, 0, height);
fill.addColorStop(0, 'rgba(117, 211, 155, .22)');
fill.addColorStop(1, 'rgba(117, 211, 155, 0)');
context.fillStyle = fill;
context.fill();
context.beginPath();
values.forEach((value, index) => {
const x = index * step;
if (index === 0) context.moveTo(x, y(value));
else context.lineTo(x, y(value));
});
context.strokeStyle = window.getComputedStyle(document.documentElement).getPropertyValue('--success').trim() || '#75d39b';
context.lineWidth = 1.5;
context.lineJoin = 'round';
context.lineCap = 'round';
context.stroke();
}
function updateUploadSpeedSparkline() {
const speedKbs = Math.max(0, Number(lastUploadStats.globalSpeedKbs) || 0);
window.SpeedHistory.updateSpeedHistory(uploadSpeedState, speedKbs * 1024);
updateUploadSpeedDisplays();
drawUploadSpeedSparkline();
}
function initUploadSpeedSparkline() {
if (uploadSpeedTimer !== null) return;
updateUploadSpeedSparkline();
uploadSpeedTimer = window.setInterval(updateUploadSpeedSparkline, 250);
window.addEventListener('resize', drawUploadSpeedSparkline);
window.addEventListener('beforeunload', () => window.clearInterval(uploadSpeedTimer), { once: true });
}
function updateStatusBar() {
const stats = _computeQueueStats();
const etaSeconds = lastUploadStats.globalSpeedKbs > 0
? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
: 0;
_setRollingUploadMetric('uploadTelemetryTotal', stats.total);
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
_setRollingUploadMetric('uploadTelemetryCompleted', stats.done);
_setRollingUploadMetric('uploadTelemetryFailed', stats.errors);
updateUploadSpeedDisplays();
_setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--');
updateUploadSidebarSummary(stats);
}
// --- Health Check ---
function renderHealthCheckResults(_results) {
const container = document.getElementById('healthCheckResults');
if (container) container.innerHTML = '';
}
let _appAlertResolve = null;
function closeAppAlert(result = false) {
const modal = document.getElementById('appAlertModal');
if (!modal) return;
const resolve = _appAlertResolve;
_appAlertResolve = null;
modalController.close(modal);
if (resolve) resolve(result);
}
function showAppDialog({ message, title = 'Hinweis', confirmText = 'OK', cancelText = 'Abbrechen', alternateText = '', showCancel = false, danger = false } = {}) {
const modal = document.getElementById('appAlertModal');
const titleEl = document.getElementById('appAlertTitle');
const messageEl = document.getElementById('appAlertMessage');
const confirm = document.getElementById('appAlertConfirmBtn');
const cancel = document.getElementById('appAlertCancelBtn');
const alternate = document.getElementById('appAlertAlternateBtn');
if (!modal || !titleEl || !messageEl || !confirm || !cancel || !alternate) return Promise.resolve(false);
if (_appAlertResolve) closeAppAlert(false);
titleEl.textContent = localizeUiText(title);
messageEl.textContent = localizeUiText(String(message || ''));
confirm.textContent = localizeUiText(confirmText);
confirm.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
cancel.textContent = localizeUiText(cancelText);
cancel.hidden = !showCancel;
alternate.textContent = localizeUiText(alternateText);
alternate.hidden = !alternateText;
modalController.open(modal, {
initialFocus: () => showCancel ? cancel : confirm,
onEscape: () => closeAppAlert(false),
onBackdrop: () => closeAppAlert(false)
});
return new Promise(resolve => { _appAlertResolve = resolve; });
}
function showAppAlert(message, title = 'Hinweis') {
return showAppDialog({ message, title });
}
function showAppConfirm({ message, title = 'Bestätigen', confirmText = 'Bestätigen', cancelText = 'Abbrechen', danger = false } = {}) {
return showAppDialog({ message, title, confirmText, cancelText, showCancel: true, danger });
}
function showAppChoice({ message, title, confirmText, alternateText, cancelText = 'Abbrechen' } = {}) {
return showAppDialog({ message, title, confirmText, alternateText, cancelText, showCancel: true });
}
let _appAlertListenersReady = false;
function setupAppAlertListeners() {
if (_appAlertListenersReady) return;
const confirm = document.getElementById('appAlertConfirmBtn');
const alternate = document.getElementById('appAlertAlternateBtn');
const cancel = document.getElementById('appAlertCancelBtn');
const close = document.getElementById('appAlertCloseBtn');
const modal = document.getElementById('appAlertModal');
if (!confirm || !alternate || !cancel || !close || !modal) return;
_appAlertListenersReady = true;
confirm.addEventListener('click', () => closeAppAlert(true));
alternate.addEventListener('click', () => closeAppAlert('alternate'));
cancel.addEventListener('click', () => closeAppAlert(false));
close.addEventListener('click', () => closeAppAlert(false));
}
async function executeHealthCheck(hosters, _mode, generations) {
renderHealthCheckResults([]);
const result = await window.api.runHealthCheck({ hosters });
const rows = result && Array.isArray(result.results) ? result.results : [];
const currentRows = rows.filter((row) => {
if (!row) return false;
const key = row.accountId || row.hoster;
const generation = generations?.get(key);
return generation === undefined || _isCurrentAccountStatusGeneration(key, generation);
});
currentRows.forEach((row) => {
const key = row.accountId || row.hoster;
if (key) {
accountStatuses[key] = {
status: row.status || 'unchecked',
message: row.message || '',
checkedAt: result.checkedAt || new Date().toISOString()
};
}
});
renderHealthCheckResults(currentRows);
renderAccounts();
renderHosterModal();
return currentRows;
}
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
if (healthCheckRunning) {
if (mode === 'manual') showCopyToast('Account-Check läuft bereits.');
return [];
}
let hosters;
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
hosters = requestedHosters;
} else {
hosters = getAccountsWithCredsFlat()
.filter(({ account }) => account.enabled !== false)
.map(({ name, account }) => ({ hoster: name, accountId: account.id }));
}
if (hosters.length === 0) {
if (mode === 'manual') await showAppAlert('Keine Hoster mit Zugangsdaten für einen Check.');
return [];
}
healthCheckRunning = true;
const generations = new Map();
// Mark all accounts as checking
for (const h of hosters) {
const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
generations.set(key, _nextAccountStatusGeneration(key));
accountStatuses[key] = { status: 'checking', message: '', checkedAt: null };
}
renderAccounts();
try {
return await executeHealthCheck(hosters, mode, generations);
} catch (err) {
renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]);
return [];
} finally {
for (const [key, generation] of generations) _finishAccountStatusGeneration(key, generation);
healthCheckRunning = false;
renderAccounts();
}
}
// --- Settings ---
async function _renderLogPathsList(el) {
if (!el || !window.api || !window.api.getLogPaths) return;
try {
const paths = await window.api.getLogPaths();
if (!paths || typeof paths !== 'object') { el.innerHTML = 'Pfade nicht verfügbar. '; return; }
const entries = [
['fileuploader', 'fileuploader.log'],
['uploadAudit', 'upload-audit.log'],
['debug', 'debug.log'],
['accountRotation', 'account-rotation.log'],
['doodstreamDebug', 'doodstream-debug.log']
];
el.innerHTML = entries.map(([key, label]) => {
const p = paths[key] || '';
return `
${escapeHtml(label)}
${escapeHtml(p) || ''}
Zeigen
`;
}).join('');
el.querySelectorAll('[data-reveal-log]').forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.getAttribute('data-reveal-log');
if (window.api && window.api.revealLogFile) window.api.revealLogFile(target).catch(() => {});
});
});
} catch (err) {
el.innerHTML = `${escapeHtml(formatLocalizedError('Fehler', err))} `;
}
}
function filenameFilterConditionRowHtml(condition = {}) {
const operator = condition.operator === 'notContains' ? 'notContains' : 'contains';
return `
`;
}
function readFilenameFilterSettings() {
const conditions = Array.from(document.querySelectorAll('[data-filename-filter-condition]')).map(row => ({
operator: row.querySelector('[data-filename-filter-operator]')?.value || 'contains',
value: row.querySelector('[data-filename-filter-value]')?.value || ''
}));
return window.FilenameFilter.normalizeFilenameFilter({
enabled: document.getElementById('filenameFilterEnabledInput')?.checked === true,
action: document.getElementById('filenameFilterActionInput')?.value,
matchMode: document.getElementById('filenameFilterMatchModeInput')?.value,
conditions
});
}
function syncFilenameFilterControls() {
const enabled = document.getElementById('filenameFilterEnabledInput')?.checked === true;
const panel = document.getElementById('filenameFilterBuilder');
if (panel) panel.classList.toggle('disabled', !enabled);
const rows = Array.from(document.querySelectorAll('[data-filename-filter-condition]'));
document.querySelectorAll('#filenameFilterBuilder select, #filenameFilterBuilder input, #addFilenameFilterConditionBtn').forEach(control => {
control.disabled = !enabled;
});
rows.forEach(row => {
const remove = row.querySelector('[data-filename-filter-remove]');
if (remove) remove.disabled = !enabled || rows.length === 1;
});
}
function wireFilenameFilterConditionRow(row) {
if (!row || row.dataset.wired === 'true') return;
row.dataset.wired = 'true';
row.querySelectorAll('.settings-autosave').forEach(control => {
const eventName = control.tagName === 'SELECT' ? 'change' : 'input';
control.addEventListener(eventName, markSettingsDirty);
});
row.querySelector('[data-filename-filter-remove]')?.addEventListener('click', () => {
row.remove();
syncFilenameFilterControls();
markSettingsDirty();
});
}
function appendFilenameFilterCondition(condition = {}) {
const list = document.getElementById('filenameFilterConditions');
if (!list) return;
const template = document.createElement('template');
template.innerHTML = filenameFilterConditionRowHtml(condition).trim();
const row = template.content.firstElementChild;
list.appendChild(row);
wireFilenameFilterConditionRow(row);
syncFilenameFilterControls();
row.querySelector('[data-filename-filter-value]')?.focus();
markSettingsDirty();
}
function renderSettings() {
const container = document.getElementById('settingsHosters');
container.innerHTML = '';
const globalSettings = config.globalSettings || {};
const configuredAccounts = getAvailableHosters();
const fm = globalSettings.folderMonitor || {};
const remoteSettings = globalSettings.remote || {};
const filenameFilter = window.FilenameFilter.normalizeFilenameFilter(globalSettings.filenameFilter);
const filenameFilterConditions = filenameFilter.conditions.length > 0
? filenameFilter.conditions
: [{ operator: 'contains', value: '' }];
const pageDefinitions = [
{ id: 'allgemein', label: 'Allgemein', search: 'fenster window vordergrund foreground always on top drop target oberfläche interface updates update aktualisierung version language sprache' },
{ id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster dateiname filename filter enthält contains ausschließen exclude' },
{ id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', search: 'benachrichtigungen notifications webhook discord meldung message ping erwähnung mention batch fertig completed' },
{ id: 'logs', label: 'Logs & Support', search: 'log logs protokoll logging debug verbose diagnose diagnostics support paket package datei file ordner folder' },
{ id: 'remote', label: 'Fernsteuerung', search: 'remote control fernsteuerung server input port api token verbindung connection client' },
{ id: 'diagnose', label: 'Diagnose-Zugriff', search: 'diagnose diagnostics access zugriff lesen read allowlist netzwerk network lokal local verbindung connection code support' },
{ id: 'backup', label: 'Backup & Übertragen', search: 'backup transfer sichern export import online schlüssel key einstellungen settings accounts übertragen' }
];
const pageHeader = (title, description) => `
`;
const layout = document.createElement('div');
layout.className = 'settings-layout';
layout.innerHTML = `
`;
container.appendChild(layout);
const navigation = layout.querySelector('.settings-navigation');
const content = layout.querySelector('.settings-content');
const pages = {};
pageDefinitions.forEach(({ id }) => {
const page = document.createElement('div');
page.className = id === 'allgemein' ? 'settings-subpage active' : 'settings-subpage';
page.dataset.subpage = id;
pages[id] = page;
content.appendChild(page);
});
pages.allgemein.innerHTML = `
${pageHeader('Allgemein', 'Fensterverhalten, Drop-Target und Programmupdates.')}
Oberfläche
Sprache
Englisch
Deutsch
Englisch
Deutsch
Programmupdate
Nach neuer Version suchen
Verfügbare Updates werden zusammen mit dem Changelog angezeigt.
Nach Updates suchen
`;
pages.uploads.innerHTML = `
${pageHeader('Upload-Verhalten', 'Globale Leistung, Warteschlange und Verhalten nach einem erfolgreichen Upload.')}
Leistung
Globale parallele Uploads
0 = nur die Einstellung des jeweiligen Hosters verwenden
Globales Speed-Limit
MB/s · 0 = unbegrenzt
Warteschlange
Dateinamenfilter
${filenameFilterConditions.map(filenameFilterConditionRowHtml).join('')}
Bedingungen
alle müssen passen
mindestens eine muss passen
Wenn passend
Dateien hinzufügen
Dateien nicht hinzufügen
Quelldateien
Einstellungen einzelner Hoster wie Wiederholungen, Geschwindigkeit, Parallelität, Dateigröße und Logging findest du im Accounts -Tab direkt beim jeweiligen Hoster.
`;
pages.automatik.innerHTML = `
${pageHeader('Automatik', 'Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.')}
Unbeaufsichtigter Betrieb
Ordnerüberwachung ${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}
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.
'}
`;
pages.benachrichtigungen.innerHTML = `
${pageHeader('Benachrichtigungen', 'Meldungen nach einem abgeschlossenen Upload-Batch versenden.')}
Webhook
Webhook-Adresse
Test senden
Nach Batch-Ende wird eine Zusammenfassung versendet. Discord wird automatisch erkannt, andere Ziele erhalten JSON.
Discord-Erwähnung
Optional · leer lassen, wenn die Nachricht ohne Ping gesendet werden soll
`;
pages.logs.innerHTML = `
${pageHeader('Logs & Support', 'Protokollierung verwalten, Log-Dateien öffnen und ein bereinigtes Support-Paket erstellen.')}
Log
FileUploader Log
Ordner wählen
Öffnen
Log-Datei-Modus
Eine Datei
Pro Tag
Pro Session
Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.
Diagnose
Log-Dateien
Wird geladen…
Support-Paket
Diagnose-Paket exportieren
Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.
`;
pages.remote.innerHTML = `
${pageHeader('Fernsteuerung', 'Upload-Funktionen über einen verbundenen Client steuern.')}
Server ${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}
Port
API-Token
Kopieren
Neu
Status
Prüfe...
`;
pages.diagnose.innerHTML = `
${pageHeader('Diagnose-Zugriff', 'Zeitlich kontrollierter Lesezugriff für Fehleranalyse und Support.')}
Diagnose-Zugriff (nur lesen) …
Erlaubt nur lesenden Zugriff auf Logs, Queue-Status und bereinigte Einstellungen. Passwörter, API-Keys und Tokens werden maskiert. Bildschirm und Eingabesteuerung bleiben gesperrt. Der Verbindungs-Code ist ein Zugangsschlüssel — nur mit vertrauenswürdigen Stellen teilen; bei Verdacht „Neu" klicken. Standard-Bindung ist 127.0.0.1 und damit nur über einen SSH- oder VPN-Tunnel erreichbar.
Port
Sichtbarkeit
127.0.0.1
Bindet nur an 127.0.0.1. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).
Verbindungs-Code
Kopieren
Neu
Status
Prüfe…
`;
pages.backup.innerHTML = `
${pageHeader('Backup & Übertragen', 'Accounts und Einstellungen sichern oder auf ein anderes Gerät übernehmen.')}
Der Upload-Verlauf wird nicht übertragen und bleibt auf diesem Gerät.
Verschlüsseltes Online-Backup
Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.
Neuen Schlüssel erzeugen
Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.
Dein neuer Schlüssel
Kopieren
Vorhandenen Schlüssel importieren
Online importieren
Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.
Lokales Datei-Backup
Datei exportieren
Datei importieren
`;
const activateSettingsPage = (target, focus = false) => {
const activeButton = navigation.querySelector(`[data-settings-page="${target}"]`);
if (!activeButton || activeButton.hidden) return;
const indicator = navigation.querySelector('.settings-nav-indicator');
if (indicator) indicator.hidden = false;
navigation.querySelectorAll('.settings-nav-button').forEach((button) => {
const active = button === activeButton;
button.classList.toggle('active', active);
button.setAttribute('aria-current', active ? 'page' : 'false');
});
Object.values(pages).forEach((page) => {
page.classList.toggle('active', page.dataset.subpage === target);
});
_syncSidebarIndicator(activeButton);
if (focus) activeButton.focus();
content.scrollTop = 0;
};
navigation.addEventListener('click', (event) => {
const button = event.target.closest('[data-settings-page]');
if (button) activateSettingsPage(button.dataset.settingsPage);
});
navigation.addEventListener('keydown', (event) => {
const button = event.target.closest('[data-settings-page]');
if (!button || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const visibleButtons = [...navigation.querySelectorAll('.settings-nav-button')].filter((item) => !item.hidden);
const currentIndex = visibleButtons.indexOf(button);
let nextIndex = currentIndex;
if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % visibleButtons.length;
if (event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + visibleButtons.length) % visibleButtons.length;
if (event.key === 'Home') nextIndex = 0;
if (event.key === 'End') nextIndex = visibleButtons.length - 1;
event.preventDefault();
activateSettingsPage(visibleButtons[nextIndex].dataset.settingsPage, true);
});
const searchInput = layout.querySelector('#settingsSearchInput');
const searchEmpty = layout.querySelector('#settingsSearchEmpty');
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim().toLocaleLowerCase(getUiLocale());
const visibleButtons = [];
navigation.querySelectorAll('.settings-nav-button').forEach((button) => {
const visible = !query || button.dataset.search.includes(query);
button.hidden = !visible;
if (visible) visibleButtons.push(button);
});
searchEmpty.hidden = visibleButtons.length > 0;
const activeButton = navigation.querySelector('.settings-nav-button.active');
if (visibleButtons.length === 0) {
Object.values(pages).forEach((page) => page.classList.remove('active'));
const indicator = navigation.querySelector('.settings-nav-indicator');
if (indicator) indicator.hidden = true;
return;
}
if (!activeButton || activeButton.hidden || !content.querySelector('.settings-subpage.active')) {
activateSettingsPage((activeButton && !activeButton.hidden ? activeButton : visibleButtons[0]).dataset.settingsPage);
} else {
_syncSidebarIndicator(activeButton);
}
});
_renderLogPathsList(document.getElementById('logPathsList'));
const testWebhookBtn = document.getElementById('testWebhookBtn');
if (testWebhookBtn) {
testWebhookBtn.addEventListener('click', async () => {
const url = (document.getElementById('webhookUrlInput')?.value || '').trim();
const mention = (document.getElementById('webhookMentionInput')?.value || '').trim();
const hint = document.getElementById('webhookHint');
if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; }
testWebhookBtn.disabled = true;
const prev = testWebhookBtn.textContent;
testWebhookBtn.textContent = 'Sende…';
try {
const res = await window.api.testWebhook({ url, mention });
if (hint) hint.textContent = res && res.ok
? `Test erfolgreich gesendet (HTTP ${res.status}).`
: `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`;
} catch (err) {
if (hint) hint.textContent = formatLocalizedError('Test fehlgeschlagen', err);
} finally {
testWebhookBtn.disabled = false;
testWebhookBtn.textContent = prev;
}
});
}
const verboseInput = document.getElementById('logVerboseInput');
if (verboseInput) {
verboseInput.addEventListener('change', () => {
if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {});
});
}
const sbBtn = document.getElementById('createSupportBundleBtn');
if (sbBtn) {
sbBtn.addEventListener('click', async () => {
const hint = document.getElementById('supportBundleHint');
sbBtn.disabled = true;
const prevText = sbBtn.textContent;
sbBtn.textContent = 'Exportiere…';
try {
const res = await window.api.createSupportBundle();
if (res && res.ok) {
if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`;
} else if (res && res.canceled) {
if (hint) hint.textContent = 'Abgebrochen.';
} else {
if (hint) hint.textContent = formatLocalizedError('Fehler', res?.error);
}
} catch (err) {
if (hint) hint.textContent = formatLocalizedError('Fehler', err);
} finally {
sbBtn.disabled = false;
sbBtn.textContent = prevText;
}
});
}
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();
}
});
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);
}
});
document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => {
const confirmed = await showAppConfirm({ title: 'API-Token neu erzeugen?', message: 'Der bisherige Token wird sofort ungültig. Verbundene Clients müssen den neuen Token verwenden.', confirmText: 'Neu erzeugen', danger: true });
if (!confirmed) return;
const newToken = await window.api.remoteGenerateToken();
document.getElementById('remoteTokenInput').value = newToken;
scheduleSettingsSave();
});
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} ${status.clientCount === 1 ? 'Client' : 'Clients'} verbunden`;
el.style.color = '#10b981';
} else {
el.textContent = 'Nicht aktiv';
el.style.color = '#94a3b8';
}
}).catch(() => {});
(function wireDiagnostics() {
const enabledEl = document.getElementById('diagEnabledInput');
const portEl = document.getElementById('diagPortInput');
const codeEl = document.getElementById('diagCodeInput');
const issuedEl = document.getElementById('diagCodeIssued');
const badgeEl = document.getElementById('diagStatusBadge');
if (!enabledEl) return;
const diagnosticsEditedFields = new Set();
const fmtIssued = (ts) => {
if (!ts) return '';
try { return 'Code erstellt: ' + new Date(ts).toLocaleString(getUiLocale()); } catch { return ''; }
};
const applySettings = (s) => {
if (!s) return;
if (!diagnosticsEditedFields.has(enabledEl.id)) enabledEl.checked = !!s.enabled;
if (!diagnosticsEditedFields.has(portEl.id)) portEl.value = s.port || 9110;
codeEl.value = s.code || '';
issuedEl.textContent = fmtIssued(s.codeIssuedAt);
if (badgeEl) {
badgeEl.textContent = enabledEl.checked ? 'Aktiv' : 'Inaktiv';
badgeEl.className = 'panel-status' + (enabledEl.checked ? ' active' : '');
}
};
const refreshStatus = () => {
window.api.diagnosticsStatus().then((st) => {
const el = document.getElementById('diagConnectionStatus');
if (!el || !st) return;
if (st.running) {
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString(getUiLocale()) : '—';
el.textContent = `Aktiv auf 127.0.0.1:${st.port} (nur lokal) — ${st.clientCount} ${st.clientCount === 1 ? 'Client' : 'Clients'} — Letzter Zugriff: ${last}`;
el.style.color = '#10b981';
} else {
el.textContent = 'Nicht aktiv';
el.style.color = '#94a3b8';
}
}).catch(() => {});
};
[enabledEl, portEl].forEach(element => {
const markEdited = () => diagnosticsEditedFields.add(element.id);
element.addEventListener('input', markEdited);
element.addEventListener('change', markEdited);
});
window.api.diagnosticsGetSettings().then(applySettings).catch(() => {});
refreshStatus();
enabledEl.addEventListener('change', markSettingsDirty);
portEl.addEventListener('change', markSettingsDirty);
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
if (!codeEl.value) return;
await window.api.copyToClipboard(codeEl.value);
const b = document.getElementById('diagCopyCodeBtn');
b.textContent = 'Kopiert!';
setTimeout(() => { b.textContent = 'Kopieren'; }, 1500);
});
document.getElementById('diagRegenerateBtn').addEventListener('click', async () => {
const confirmed = await showAppConfirm({ title: 'Verbindungs-Code neu erzeugen?', message: 'Der bisherige Diagnose-Code wird sofort ungültig.', confirmText: 'Neu erzeugen', danger: true });
if (!confirmed) return;
const r = await runConfigWrite(() => window.api.diagnosticsRegenerate());
if (r && r.code) { codeEl.value = r.code; issuedEl.textContent = fmtIssued(r.codeIssuedAt); }
refreshStatus();
});
})();
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
document.getElementById('protectLocalBackupInput').addEventListener('change', (event) => {
const fields = document.getElementById('localBackupPasswordFields');
if (!fields) return;
fields.hidden = !event.target.checked;
if (event.target.checked) document.getElementById('localBackupPasswordInput')?.focus();
});
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => {
const key = document.getElementById('onlineBackupKeyOutput').value;
if (!key) return;
await window.api.copyToClipboard(key);
showCopyToast('Online-Schlüssel kopiert');
});
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
document.getElementById('restoreOnlineBackupBtn').disabled = !valid;
if (event.target.value && !valid) setOnlineBackupStatus('Der Schlüssel muss exakt 75 Zeichen lang sein.', '');
else setOnlineBackupStatus('', '');
});
document.getElementById('restoreOnlineBackupBtn').addEventListener('click', () => doOnlineBackupRestore());
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck);
container.querySelectorAll('[data-filename-filter-condition]').forEach(wireFilenameFilterConditionRow);
document.getElementById('addFilenameFilterConditionBtn')?.addEventListener('click', () => appendFilenameFilterCondition());
document.getElementById('filenameFilterEnabledInput')?.addEventListener('change', syncFilenameFilterControls);
syncFilenameFilterControls();
_syncHeaderUpdateState();
container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
input.addEventListener(eventName, async () => {
if (input.id === 'languageInput') {
setUiLanguage(input.value);
syncLanguagePicker(input.value);
}
if (input.id === 'deleteSourceAfterSuccessfulUploadInput' && input.checked) {
const confirmed = await showAppConfirm({
title: 'Quelldateien dauerhaft löschen?',
message: 'Nach einem vollständigen Upload zu allen ausgewählten Hostern wird die Originaldatei ohne Papierkorb endgültig von diesem PC gelöscht.',
confirmText: 'Dauerhaftes Löschen aktivieren',
danger: true
});
if (!confirmed) input.checked = false;
}
markSettingsDirty();
});
});
container.querySelectorAll('.language-option').forEach(button => {
button.addEventListener('click', () => {
const input = document.getElementById('languageInput');
if (!input || input.value === button.dataset.language) return;
input.value = button.dataset.language;
input.dispatchEvent(new window.Event('change', { bubbles: true }));
});
});
syncLanguagePicker(globalSettings.language);
establishSettingsBaseline();
window.requestAnimationFrame(() => {
if (layout.isConnected) _syncSidebarIndicator(navigation.querySelector('.settings-nav-button.active'), true);
});
}
function syncLanguagePicker(value) {
const language = window.I18n.normalizeLanguage(value);
const picker = document.getElementById('languagePicker');
if (!picker) return;
picker.dataset.language = language;
picker.querySelectorAll('.language-option').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.language === language));
});
}
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`;
markSettingsDirty();
}
function captureSettingsState() {
const controls = Array.from(document.querySelectorAll('#settings-view .settings-autosave'));
return JSON.stringify(controls.map(control => [
control.id || control.name || '',
control.type === 'checkbox' ? control.checked : control.value
]));
}
function syncSettingsSaveState(message) {
const button = document.getElementById('saveSettingsBtn');
const feedback = document.getElementById('saveFeedback');
if (button) {
button.disabled = settingsSaving || !settingsDirty;
button.classList.toggle('btn-success', settingsDirty && !settingsSaving);
button.classList.toggle('btn-secondary', !settingsDirty || settingsSaving);
}
if (feedback && message) feedback.textContent = message;
}
function establishSettingsBaseline(message = 'Keine ungespeicherten Änderungen') {
settingsBaseline = captureSettingsState();
settingsDirty = false;
syncSettingsSaveState(message);
}
function markSettingsDirty() {
settingsDirty = captureSettingsState() !== settingsBaseline;
syncSettingsSaveState(settingsDirty ? 'Ungespeicherte Änderungen' : 'Keine ungespeicherten Änderungen');
}
function scheduleSettingsSave() {
markSettingsDirty();
}
function saveSettings(options = {}) {
const requestedState = captureSettingsState();
settingsSaving = true;
syncSettingsSaveState('Speichert…');
return settingsSaveCoordinator.run(options).then(result => {
const savedMessage = options.feedbackText || 'Gespeichert!';
settingsBaseline = requestedState;
settingsDirty = captureSettingsState() !== settingsBaseline;
syncSettingsSaveState(settingsDirty ? 'Ungespeicherte Änderungen' : savedMessage);
setTimeout(() => {
const feedback = document.getElementById('saveFeedback');
if (!settingsDirty && feedback?.textContent === savedMessage) syncSettingsSaveState('Keine ungespeicherten Änderungen');
}, 1800);
return result;
}, error => {
settingsSaving = false;
settingsDirty = captureSettingsState() !== settingsBaseline;
syncSettingsSaveState(`Speichern fehlgeschlagen: ${error.message}`);
throw error;
}).finally(() => {
settingsSaving = false;
syncSettingsSaveState();
});
}
async function performSaveSettings(options = {}) {
const { feedbackText = 'Gespeichert!' } = options;
const newHosterSettings = { ...(config.hosterSettings || {}) };
const cur = config.globalSettings || {};
const curFm = cur.folderMonitor || {};
const curRemote = cur.remote || {};
const elTxt = (id, fb) => { const el = document.getElementById(id); return el ? el.value : fb; };
const elChk = (id, fb) => { const el = document.getElementById(id); return el ? !!el.checked : fb; };
const elInt = (id, curVal, dflt, lo, hi) => {
const el = document.getElementById(id);
if (!el) return curVal;
const n = parseInt(el.value || String(dflt), 10) || dflt;
return Math.max(lo, Math.min(hi, n));
};
const globalSettings = {
...cur,
language: (() => {
const el = document.getElementById('languageInput');
return window.I18n.normalizeLanguage(el ? el.value : cur.language);
})(),
logFilePath: elTxt('logFilePathInput', cur.logFilePath || '').trim(),
logMode: (() => {
const el = document.getElementById('logModeInput');
if (!el) return cur.logMode || 'single';
const v = el.value;
return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single';
})(),
resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false),
autoStartRestoredQueue: elChk('autoStartRestoredQueueInput', !!cur.autoStartRestoredQueue),
parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100),
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
deleteSourceAfterSuccessfulUpload: elChk('deleteSourceAfterSuccessfulUploadInput', !!cur.deleteSourceAfterSuccessfulUpload),
filenameFilter: readFilenameFilterSettings(),
showDropTarget: elChk('showDropTargetInput', !!cur.showDropTarget),
globalMaxSpeedKbs: (() => {
const el = document.getElementById('globalMaxSpeedMbsInput');
if (!el) return cur.globalMaxSpeedKbs ?? 0;
return Math.max(0, Math.round((parseFloat(el.value || '0') || 0) * 1024));
})(),
logVerbose: elChk('logVerboseInput', !!cur.logVerbose),
webhookUrl: elTxt('webhookUrlInput', cur.webhookUrl || '').trim(),
webhookMention: elTxt('webhookMentionInput', cur.webhookMention || '').trim(),
autoRetryRounds: elInt('autoRetryRoundsInput', cur.autoRetryRounds ?? 0, 0, 0, 5),
autoRetryDelayMin: elInt('autoRetryDelayMinInput', cur.autoRetryDelayMin ?? 5, 5, 1, 120),
folderMonitor: {
...curFm,
enabled: elChk('fmEnabledInput', !!curFm.enabled),
folderPath: elTxt('fmFolderPathInput', curFm.folderPath || '').trim(),
recursive: elChk('fmRecursiveInput', !!curFm.recursive),
includeExisting: elChk('fmIncludeExistingInput', !!curFm.includeExisting),
filterMode: (() => { const el = document.getElementById('fmFilterModeInput'); return el ? (el.value || 'include') : (curFm.filterMode || 'include'); })(),
extensions: elTxt('fmExtensionsInput', curFm.extensions || '').trim(),
skipDuplicates: elChk('fmSkipDuplicatesInput', curFm.skipDuplicates !== false),
delaySec: elInt('fmDelaySecInput', curFm.delaySec ?? 3, 3, 1, 300),
autoStart: elChk('fmAutoStartInput', curFm.autoStart !== false),
hosters: document.querySelector('.fm-hoster-checkbox')
? Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
: (curFm.hosters || [])
},
remote: {
...curRemote,
enabled: elChk('remoteEnabledInput', !!curRemote.enabled),
port: elInt('remotePortInput', curRemote.port || 9100, 9100, 1024, 65535),
token: elTxt('remoteTokenInput', curRemote.token || '').trim(),
allowInput: elChk('remoteAllowInputInput', curRemote.allowInput !== false)
}
};
// Always on top setting
const aotCheckbox = document.getElementById('alwaysOnTopInput');
if (aotCheckbox) {
const newAot = !!aotCheckbox.checked;
if (newAot !== alwaysOnTopState) {
alwaysOnTopState = newAot;
await setAlwaysOnTopTracked(newAot);
}
}
// Drop target window
const dtCheckbox = document.getElementById('showDropTargetInput');
if (dtCheckbox) {
if (dtCheckbox.checked) await window.api.showDropTarget();
else await window.api.hideDropTarget();
}
for (const name of HOSTERS) {
const hs = { ...(hosterSettings[name] || {}) };
document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => {
const field = input.dataset.hs;
if (input.type === 'checkbox') hs[field] = input.checked;
else if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024));
else hs[field] = parseInt(input.value, 10) || 0;
});
newHosterSettings[name] = hs;
}
const saves = [
saveHosterSettingsTracked(newHosterSettings),
saveGlobalSettingsTracked(globalSettings)
];
const diagnosticsEnabled = document.getElementById('diagEnabledInput');
if (diagnosticsEnabled) {
saves.push(saveDiagnosticsSettingsTracked({
enabled: diagnosticsEnabled.checked,
port: Math.min(65535, Math.max(1024, parseInt(document.getElementById('diagPortInput')?.value, 10) || 9110)),
bindMode: 'local',
publicHost: '127.0.0.1',
allowlist: []
}));
}
await Promise.all(saves);
config.hosterSettings = newHosterSettings;
config.globalSettings = globalSettings;
hosterSettings = newHosterSettings;
const startupUrl = new URL(window.location.href);
startupUrl.searchParams.set('language', globalSettings.language);
window.history.replaceState(null, '', startupUrl.href);
clearTimeout(settingsSaveTimer);
settingsSaveTimer = null;
// Start/stop folder monitor based on settings
const fmSettings = globalSettings.folderMonitor;
const badge = document.getElementById('folderMonitorStatusBadge');
if (fmSettings && fmSettings.enabled && fmSettings.folderPath) {
try {
const folderStart = await window.api.folderMonitorStart(fmSettings);
if (folderStart?.includesExisting) {
fmSettings.includeExisting = false;
globalSettings.folderMonitor.includeExisting = false;
config.globalSettings.folderMonitor.includeExisting = false;
const includeExistingInput = document.getElementById('fmIncludeExistingInput');
if (includeExistingInput) includeExistingInput.checked = false;
}
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 {
const remoteSaveResult = await saveRemoteSettingsTracked(remoteSettings);
if (remoteSaveResult && remoteSaveResult.settings) {
globalSettings.remote = { ...remoteSaveResult.settings };
config.globalSettings = { ...(config.globalSettings || {}), remote: { ...remoteSaveResult.settings } };
const tokenInput = document.getElementById('remoteTokenInput');
if (tokenInput) tokenInput.value = remoteSaveResult.settings.token || '';
}
if (remoteSaveResult && remoteSaveResult.runtimeError) throw new Error(remoteSaveResult.runtimeError);
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 = formatRemoteClientStatus(status.port, status.clientCount);
statusEl.style.color = '#10b981';
} else {
statusEl.textContent = 'Nicht aktiv';
statusEl.style.color = '#94a3b8';
}
}
} catch {
if (remoteBadge) {
remoteBadge.textContent = 'Fehler';
remoteBadge.className = 'panel-status';
}
}
}
const feedback = document.getElementById('saveFeedback');
if (feedback) feedback.textContent = feedbackText;
}
// --- 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 _buildAccountCardHtml(name, account, idx) {
const isDisabled = account.enabled === false;
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
const statusPresentation = window.AccountStatus.getAccountStatusPresentation(isDisabled ? 'disabled' : st.status);
const statusLabel = statusPresentation.label;
const statusClass = statusPresentation.statusClass;
const credLabel = getCredentialLabel(name, account);
const userLabel = account.label && String(account.label).trim();
// Subtitle: "Label: XYZ • API: ABC… • " — the user-set label is the
// disambiguator for accounts that otherwise look identical (e.g. two byse
// API-key accounts where you can't tell what's what from the masked key).
const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel;
const checkedLabel = uiLocalizer.getLanguage() === 'de' ? 'geprüft' : 'checked';
const checkedText = st.checkedAt ? ` • ${checkedLabel} ${new Date(st.checkedAt).toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit' })}` : '';
const statusMessage = st.message && !isDisabled
? (['error', 'warn', 'otp_required'].includes(st.status) ? getLocalizedErrorDetail(st.message) : localizeUiText(st.message))
: '';
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
const isSessionPaused = _sessionFailedKeys.has(`${name}:${account.id}`);
const sessionPausedBadge = isSessionPaused
? `Pausiert (Session) ↻ `
: '';
const otpAction = !isDisabled && statusPresentation.requiresOtp
? `
Prüfen und speichern
`
: '';
return `
☰
${escapeHtml(getAccountDisplayName(name, account))} ${priorityLabel} ${sessionPausedBadge}
${escapeHtml(subtitleText)}${statusMessage ? ` • ${escapeHtml(statusMessage)}` : ''}${checkedText}
${otpAction}
${statusLabel}
${toggleLabel}
Prüfen
Bearbeiten
Löschen
`;
}
// Replace only the one card for `accountId` instead of re-rendering the whole
// container. Runs on enable/disable, single health check, priority-badge bumps
// after a reorder — anywhere we only change one card's state.
function updateAccountCard(accountId) {
const container = document.getElementById('accountsList');
if (!container) return;
const found = findAccountById(accountId);
if (!found) return;
const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`);
if (!card) return;
const accounts = config.hosters[found.name] || [];
const idx = accounts.findIndex(a => a.id === accountId);
if (idx < 0) return;
const tmp = document.createElement('div');
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
card.replaceWith(tmp.firstElementChild);
_refreshHosterGroupHeader(found.name);
updateAccountSidebarSummary();
_applyAccountSidebarFilter();
}
function _refreshHosterGroupHeader(name) {
const container = document.getElementById('accountsList');
if (!container) return;
const group = container.querySelector(`.account-hoster-group[data-hoster-group="${name}"]`);
if (!group) return;
const accounts = config.hosters[name] || [];
const summary = _summarizeHosterGroup(accounts);
const dot = window.AccountStatus.getAccountGroupStatus(summary);
const dotEl = group.querySelector('.account-hoster-group-header .account-status-dot');
if (dotEl) dotEl.className = `account-status-dot status-${dot}`;
const countEl = group.querySelector('.account-hoster-group-count');
if (countEl) countEl.textContent = `${summary.ok}/${summary.total}`;
group.querySelectorAll('.account-hoster-group-meta').forEach(el => el.remove());
const header = group.querySelector('.account-hoster-group-header');
if (header) {
if (summary.disabled) {
const meta = document.createElement('span');
meta.className = 'account-hoster-group-meta';
meta.textContent = `${summary.disabled} deaktiviert`;
header.appendChild(meta);
}
if (summary.error) {
const meta = document.createElement('span');
meta.className = 'account-hoster-group-meta error';
meta.textContent = `${summary.error} Fehler`;
header.appendChild(meta);
}
}
}
let _accountListenersBound = false;
function renderAccounts() {
const container = document.getElementById('accountsList');
if (!container) return;
ensureAccountStatusEntries();
const allAccounts = getAllAccountsFlat();
updateAccountSidebarSummary(allAccounts);
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
const footer = document.getElementById('accountsListFooter');
if (allAccounts.length === 0) {
container.innerHTML = `
+
Noch keine Accounts
Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.
`;
if (footer) footer.style.display = 'none';
if (!_accountListenersBound) bindAccountListeners(container);
_applyAccountSidebarFilter();
return;
}
const byHoster = {};
for (const { name, account } of allAccounts) {
if (!byHoster[name]) byHoster[name] = [];
byHoster[name].push(account);
}
let html = '';
for (const name of HOSTERS) {
const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue;
html += _buildAccountHosterGroupHtml(name, accounts);
}
container.innerHTML = html;
if (footer) footer.style.display = '';
_updateToggleAllAccountsBtn();
if (!_accountListenersBound) bindAccountListeners(container);
_applyAccountSidebarFilter();
}
function _summarizeHosterGroup(accounts) {
let ok = 0, warn = 0, error = 0, checking = 0, unchecked = 0, disabled = 0;
for (const a of accounts) {
if (a.enabled === false) { disabled++; continue; }
const s = (accountStatuses[a.id] && accountStatuses[a.id].status) || 'unchecked';
if (s === 'ok') ok++;
else if (s === 'warn' || s === 'otp_required') warn++;
else if (s === 'error') error++;
else if (s === 'checking') checking++;
else unchecked++;
}
return { ok, warn, error, checking, unchecked, disabled, total: accounts.length };
}
function _hosterGroupOpenState(name, summary) {
const prev = _hosterGroupOpenMemory.get(name);
if (prev && typeof prev === 'object') {
if (summary.error > (prev.errorsAtClose || 0)) {
_hosterGroupOpenMemory.delete(name);
return true;
}
return prev.state === 'open';
}
return summary.error > 0;
}
const _hosterGroupOpenMemory = new Map();
let _hosterSettingsSaveTimer = null;
const hosterSettingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performHosterSettingsSave);
function scheduleHosterSettingsSave() {
if (closePreparationState !== 'open') return;
clearTimeout(_hosterSettingsSaveTimer);
_hosterSettingsSaveTimer = setTimeout(() => {
_hosterSettingsSaveTimer = null;
saveHosterSettingsFromDom().catch(() => {});
}, 350);
}
function saveHosterSettingsFromDom() {
return hosterSettingsSaveCoordinator.run();
}
async function performHosterSettingsSave() {
const newHosterSettings = { ...(config.hosterSettings || {}) };
for (const name of HOSTERS) {
const inputs = document.querySelectorAll(`.account-hoster-settings-body .hs-input[data-hoster="${name}"]`);
if (!inputs.length) continue;
const hs = { ...(newHosterSettings[name] || {}) };
inputs.forEach(input => {
const field = input.dataset.hs;
if (input.type === 'checkbox') hs[field] = input.checked;
else if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024));
else hs[field] = parseInt(input.value, 10) || 0;
});
newHosterSettings[name] = hs;
}
await saveHosterSettingsTracked(newHosterSettings);
config.hosterSettings = newHosterSettings;
hosterSettings = newHosterSettings;
}
async function recoverSerializedSave(coordinator, retry) {
try {
await coordinator.flush();
return false;
} catch {
await flushConfigWrites();
await retry();
return true;
}
}
async function flushPendingSettingsSaves() {
const flushSettings = settingsSaveTimer !== null;
const flushHosters = _hosterSettingsSaveTimer !== null;
clearTimeout(settingsSaveTimer);
clearTimeout(_hosterSettingsSaveTimer);
settingsSaveTimer = null;
_hosterSettingsSaveTimer = null;
queuePersistThrottle.flushSync();
const settingsRecovered = await recoverSerializedSave(
settingsSaveCoordinator,
() => saveSettings({ feedbackText: 'Automatisch gespeichert' })
);
const hostersRecovered = await recoverSerializedSave(
hosterSettingsSaveCoordinator,
saveHosterSettingsFromDom
);
if (flushSettings && !settingsRecovered) await saveSettings({ feedbackText: 'Automatisch gespeichert' });
if (flushHosters && !hostersRecovered) await saveHosterSettingsFromDom();
await Promise.all([settingsSaveCoordinator.flush(), hosterSettingsSaveCoordinator.flush()]);
await flushConfigWrites();
const persistedConfig = await window.api.getConfig();
config = persistedConfig;
hosterSettings = config.hosterSettings || {};
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
}
function _buildHosterSettingsHtml(name) {
const hs = (config.hosterSettings && config.hosterSettings[name]) || {};
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? String(+(hs.maxSpeedKbs / 1024).toFixed(2)) : '0';
const fieldPrefix = `hoster-${name.replace(/[^a-z0-9]/gi, '-')}`;
return ``;
}
function _buildAccountHosterGroupHtml(name, accounts) {
const summary = _summarizeHosterGroup(accounts);
const isOpen = _hosterGroupOpenState(name, summary);
const dot = window.AccountStatus.getAccountGroupStatus(summary);
const countLabel = `${summary.ok}/${summary.total}`;
let cardsHtml = '';
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
const lifeStat = _hosterLifetimeStat(name);
const lifeMeta = lifeStat && lifeStat.total > 0
? `${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total}) `
: ` `;
return `
${_buildHosterSettingsHtml(name)}
`;
}
let _hosterLifetimeCache = null;
function _hosterLifetimeStat(name) {
if (!_hosterLifetimeCache && window.Stats && Array.isArray(window._historyForStats)) {
_hosterLifetimeCache = window.Stats.summarizePerHoster(window._historyForStats, { lastNBatches: 50 });
}
return _hosterLifetimeCache ? _hosterLifetimeCache[name] : null;
}
function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
function _refreshAccountHosterLifetimeStats() {
document.querySelectorAll('[data-hoster-lifetime]').forEach(meta => {
const name = meta.dataset.hosterLifetime;
const lifeStat = _hosterLifetimeStat(name);
if (!lifeStat || lifeStat.total <= 0) {
meta.hidden = true;
meta.textContent = '';
meta.removeAttribute('title');
return;
}
meta.hidden = false;
meta.textContent = `${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})`;
meta.title = localizeUiText(`Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters`);
});
}
function _allAccountGroupsOpen() {
const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body');
if (!bodies.length) return false;
for (const body of bodies) if (!body.classList.contains('is-open')) return false;
return true;
}
function _setAccountCollapseOpen(trigger, body, open) {
if (!body) return;
body.classList.toggle('is-open', open);
body.setAttribute('aria-hidden', String(!open));
body.toggleAttribute('inert', !open);
if (trigger) trigger.setAttribute('aria-expanded', String(open));
}
function _setAllAccountGroupsOpen(open) {
const groups = document.querySelectorAll('#accountsList .account-hoster-group');
groups.forEach(group => {
const name = group.dataset.hosterGroup;
const body = group.querySelector('.account-hoster-group-body');
const header = group.querySelector('[data-hoster-toggle]');
_setAccountCollapseOpen(header, body, open);
if (name) {
const summary = _summarizeHosterGroup(config.hosters[name] || []);
_hosterGroupOpenMemory.set(name, { state: open ? 'open' : 'closed', errorsAtClose: summary.error });
}
});
_updateToggleAllAccountsBtn();
}
function _updateToggleAllAccountsBtn() {
const btn = document.getElementById('toggleAllAccountsBtn');
if (!btn) return;
btn.textContent = _allAccountGroupsOpen() ? 'Alle einklappen' : 'Alle ausklappen';
}
function moveAccountPriority(hosterName, accountId, direction) {
const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return false;
const fromIndex = accounts.findIndex(account => account.id === accountId);
const toIndex = Math.max(0, Math.min(accounts.length - 1, fromIndex + direction));
if (fromIndex < 0 || fromIndex === toIndex) return false;
const [account] = accounts.splice(fromIndex, 1);
accounts.splice(toIndex, 0, account);
renderAccounts();
requestAnimationFrame(() => {
document.querySelector(`.account-card[data-account-id="${window.CSS.escape(accountId)}"] .account-card-drag-handle`)?.focus();
});
saveConfigTracked({ hosters: config.hosters }).catch(() => {});
return true;
}
// Single set of delegated listeners on the accounts container. Bound once on
// the first render and reused for every subsequent in-place update / card
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
// per render — with 20 accounts that's 180 listener create/destroy cycles on
// every enable/disable click.
function bindAccountListeners(container) {
_accountListenersBound = true;
container.addEventListener('keydown', (e) => {
const priorityHandle = e.target.closest('.account-card-drag-handle');
if (priorityHandle && e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
const card = priorityHandle.closest('.account-card');
if (!card) return;
e.preventDefault();
moveAccountPriority(card.dataset.accountHoster, card.dataset.accountId, e.key === 'ArrowUp' ? -1 : 1);
return;
}
if (!['Enter', ' '].includes(e.key)) return;
const header = e.target.closest('[data-hoster-toggle]');
const settingsHeader = e.target.closest('[data-hoster-settings-toggle]');
if (!header && !settingsHeader) return;
e.preventDefault();
e.target.click();
});
container.addEventListener('click', (e) => {
const header = e.target.closest('[data-hoster-toggle]');
if (header && !e.target.closest('button')) {
const name = header.dataset.hosterToggle;
const group = header.closest('.account-hoster-group');
const body = group && group.querySelector('.account-hoster-group-body');
if (body) {
const willOpen = !body.classList.contains('is-open');
_setAccountCollapseOpen(header, body, willOpen);
const summary = _summarizeHosterGroup(config.hosters[name] || []);
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
}
return;
}
const settingsHeader = e.target.closest('[data-hoster-settings-toggle]');
if (settingsHeader) {
const body = settingsHeader.nextElementSibling;
if (body) {
const willOpen = !body.classList.contains('is-open');
_setAccountCollapseOpen(settingsHeader, body, willOpen);
}
return;
}
const btn = e.target.closest('button');
if (!btn) return;
if (btn.hasAttribute('data-account-empty-add')) return openAccountModal(null);
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
if (btn.dataset.accountOtpSubmit) return submitAccountOtp(btn.dataset.accountOtpSubmit);
if (btn.dataset.accountReactivate) {
const accountId = btn.dataset.accountReactivate;
const hoster = btn.dataset.accountReactivateHoster;
if (!hoster || !accountId) return;
e.stopPropagation();
window.api.resetSessionFailedAccount({ hoster, accountId }).then(() => {
_sessionFailedKeys.delete(`${hoster}:${accountId}`);
renderAccounts();
showCopyToast(`${getHosterLabel(hoster)} Account wieder aktiv — nächste Batch verwendet ihn`);
}).catch(() => {});
return;
}
});
const onHosterSettingInput = (e) => {
if (e.target && e.target.classList && e.target.classList.contains('hs-input')) scheduleHosterSettingsSave();
};
container.addEventListener('input', onHosterSettingInput);
container.addEventListener('change', onHosterSettingInput);
let draggedCard = null;
container.addEventListener('dragstart', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card) return;
draggedCard = card;
card.classList.add('dragging');
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', () => {
if (draggedCard) draggedCard.classList.remove('dragging');
draggedCard = null;
container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
});
container.addEventListener('dragover', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card || !draggedCard || draggedCard === card) return;
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
card.classList.toggle('drag-over-above', e.clientY < midY);
card.classList.toggle('drag-over-below', e.clientY >= midY);
});
container.addEventListener('dragleave', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (card) card.classList.remove('drag-over-above', 'drag-over-below');
});
container.addEventListener('drop', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card || !draggedCard || draggedCard === card) return;
e.preventDefault();
card.classList.remove('drag-over-above', 'drag-over-below');
const hosterName = card.dataset.accountHoster;
if (draggedCard.dataset.accountHoster !== hosterName) return;
const draggedId = draggedCard.dataset.accountId;
const targetId = card.dataset.accountId;
const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return;
const fromIdx = accounts.findIndex(a => a.id === draggedId);
if (fromIdx < 0) return;
const [moved] = accounts.splice(fromIdx, 1);
const rect = card.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2;
const newToIdx = accounts.findIndex(a => a.id === targetId);
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
// Move the DOM node in place — no full re-render.
if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
// The Primär / Fallback #N badges just changed for the whole group.
for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id);
// Persist in the background. saveConfig is idempotent; we don't need to
// await here or re-fetch — our in-memory config is already the truth.
saveConfigTracked({ hosters: config.hosters }).catch(() => {});
});
}
async function toggleAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
found.account.enabled = !found.account.enabled;
syncSelectedUploadHosters();
// In-place: swap only the one affected card. No full re-render, no IPC
// refetch, no flicker. Rapid click-toggles now feel instant even with 50
// accounts in the list.
updateAccountCard(accountId);
renderHosterSummary();
saveConfigTracked({ hosters: config.hosters }).catch(() => {});
}
async function checkSingleAccount(accountId) {
if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId);
if (!found) return;
const generation = _nextAccountStatusGeneration(accountId);
healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: '' };
updateAccountCard(accountId);
let nextStatus = null;
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) nextStatus = { status: row.status || 'error', message: row.message || '', checkedAt: row.checkedAt || result.checkedAt || new Date().toISOString() };
} catch (err) {
nextStatus = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen', checkedAt: new Date().toISOString() };
} finally {
healthCheckRunning = false;
}
if (!_isCurrentAccountStatusGeneration(accountId, generation) || !findAccountById(accountId)) return;
if (nextStatus) accountStatuses[accountId] = nextStatus;
updateAccountCard(accountId);
_finishAccountStatusGeneration(accountId, generation);
}
async function submitAccountOtp(accountId) {
if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId);
if (!found || found.account.enabled === false) return;
const card = Array.from(document.querySelectorAll('.account-card')).find(el => el.dataset.accountId === accountId);
const otpInput = card?.querySelector('[data-account-otp-input]');
const otp = otpInput?.value.trim() || '';
if (otpInput) otpInput.setCustomValidity('');
if (!otp) {
if (otpInput) {
otpInput.setCustomValidity('Bitte den OTP-Code eingeben.');
otpInput.reportValidity();
}
return;
}
const submitButton = card?.querySelector('[data-account-otp-submit]');
const generation = _nextAccountStatusGeneration(accountId);
healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: 'OTP wird geprüft…' };
if (otpInput) otpInput.disabled = true;
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Prüfe…';
}
let nextStatus;
try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, otp }] });
const row = result && Array.isArray(result.results)
? result.results.find(item => item.accountId === accountId)
: null;
nextStatus = row
? { status: row.status || 'error', message: row.message || '', checkedAt: row.checkedAt || result.checkedAt || new Date().toISOString() }
: { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt: result?.checkedAt || new Date().toISOString() };
} catch (err) {
nextStatus = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen', checkedAt: new Date().toISOString() };
} finally {
healthCheckRunning = false;
}
if (!_isCurrentAccountStatusGeneration(accountId, generation) || !findAccountById(accountId)) return;
accountStatuses[accountId] = nextStatus;
updateAccountCard(accountId);
renderHosterModal();
_finishAccountStatusGeneration(accountId, generation);
}
// Per-hoster overrides for the login form. VOE only accepts emails — the
// generic "Username / E-Mail" label sent users down a confusing rabbit hole
// (login fails → upload fetches login redirect → "CSRF token nicht gefunden").
// Other hosters that genuinely accept either keep the generic wording.
const LOGIN_FIELD_LABELS = {
'voe.sx': { label: 'E-Mail', placeholder: 'E-Mail-Adresse', inputType: 'email' }
};
function getCredsFieldsHtml(authType, account, hoster) {
account = account || {};
if (authType === 'login') {
const fld = (hoster && LOGIN_FIELD_LABELS[hoster]) || {
label: 'Username / E-Mail', placeholder: 'Username oder E-Mail', inputType: 'text'
};
return `
${escapeHtml(fld.label)}
Passwort
👁️
`;
}
// API key
return `
API-Key
👁️
`;
}
function wireCredentialVisibilityButtons(container) {
container.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
const visible = input.type === 'password';
const fieldName = input.id === 'accField_apiKey' ? 'API-Key' : 'Passwort';
input.type = visible ? 'text' : 'password';
btn.setAttribute('aria-pressed', String(visible));
btn.setAttribute('aria-label', `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`);
btn.title = `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`;
});
});
}
function openAccountModal(editAccountId) {
editingAccountId = editAccountId || null;
_resetAccountModalState();
const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle');
const subtitle = document.getElementById('accountModalSubtitle');
const hosterRow = document.getElementById('accountHosterRow');
const hosterSelect = document.getElementById('accountHosterSelect');
const credsContainer = document.getElementById('accountCredsFields');
const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn');
const labelInput = document.getElementById('accField_label');
statusEl.textContent = '';
statusEl.className = 'account-modal-status';
if (editingAccountId) {
// Edit mode
const found = findAccountById(editingAccountId);
if (!found) return;
title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`;
hosterRow.style.display = 'none';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true });
if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else {
// Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.';
hosterRow.style.display = 'flex';
saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false });
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`${escapeHtml(opt.label)} `
).join('');
const firstOpt = HOSTER_ADD_OPTIONS[0];
if (labelInput) labelInput.value = '';
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
}
wireCredentialVisibilityButtons(credsContainer);
_wireCredFieldInvalidation();
modalController.open(modal, {
initialFocus: () => editingAccountId ? document.getElementById('accField_label') : hosterSelect,
fallbackFocus: '#addAccountBtn',
onEscape: closeAccountModal,
onBackdrop: closeAccountModal
});
_openAccountModalMotion(modal);
}
let _accountModalMotionToken = 0;
function _openAccountModalMotion(modal) {
const card = modal.querySelector('.modal-card');
const token = ++_accountModalMotionToken;
modal.classList.remove('account-modal-opening', 'account-modal-closing');
if (!card) return;
void card.offsetHeight;
modal.classList.add('account-modal-opening');
const finish = event => {
if (event && event.target !== card) return;
card.removeEventListener('animationend', finish);
if (Object.is(token, _accountModalMotionToken)) modal.classList.remove('account-modal-opening');
};
card.addEventListener('animationend', finish);
window.setTimeout(() => finish(), 440);
}
function closeAccountModal() {
const modal = document.getElementById('accountModal');
if (!modalController.isOpen(modal) || modal.classList.contains('account-modal-closing')) return;
const card = modal.querySelector('.modal-card');
const token = ++_accountModalMotionToken;
modal.classList.remove('account-modal-opening', 'account-modal-closing');
if (card) void card.offsetHeight;
modalController.close(modal, { fallbackFocus: '#addAccountBtn' });
modal.style.display = 'flex';
modal.inert = true;
modal.classList.add('account-modal-closing');
_hideOtpField();
editingAccountId = null;
_resetAccountModalState();
const finish = event => {
if (event && event.target !== card) return;
if (card) card.removeEventListener('animationend', finish);
if (!Object.is(token, _accountModalMotionToken)) return;
modal.classList.remove('account-modal-closing');
modal.style.display = 'none';
};
if (card) card.addEventListener('animationend', finish);
window.setTimeout(() => finish(), 380);
}
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;
modalController.open(modal, {
initialFocus: '#cancelDeleteBtn',
fallbackFocus: '#addAccountBtn',
onEscape: closeDeleteModal,
onBackdrop: closeDeleteModal
});
}
function closeDeleteModal() {
modalController.close('deleteAccountModal', { fallbackFocus: '#addAccountBtn' });
}
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);
}
_invalidateAccountStatusGeneration(accountId);
delete accountStatuses[accountId];
// saveConfig is async — close the modal immediately so the UI feels
// responsive instead of waiting for the atomic write + safeStorage encrypt.
// The in-memory config already reflects the delete; the IPC just persists it.
ensureAccountStatusEntries();
syncSelectedUploadHosters();
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
renderAccounts();
renderHosterSummary();
closeDeleteModal();
// Fire-and-forget the persist. The earlier `await getConfig()` round-trip
// was redundant (we already have the truth in memory) and was the main
// source of perceived lag on add/delete.
saveConfigTracked({ hosters: config.hosters }).catch((err) => {
if (window.api && window.api.debugLog) window.api.debugLog(`deleteAccount saveConfig failed: ${err && err.message ? err.message : err}`);
showCopyToast('Account-Löschung konnte nicht persistiert werden — bitte erneut versuchen.');
});
}
function readAccountCredsFromModal(authType) {
const label = (document.getElementById('accField_label')?.value || '').trim();
if (authType === 'login') {
const username = (document.getElementById('accField_username')?.value || '').trim();
const password = (document.getElementById('accField_password')?.value || '').trim();
return { enabled: !!(username && password), authType: 'login', username, password, label };
}
// API
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
return { enabled: !!apiKey, authType: 'api', apiKey, label };
}
const _accountSubmitter = window.AccountSubmit.createAccountSubmitter();
let _accountModalCommitLocked = false;
let _autoCloseTimer = null;
let _accountModalSession = 0;
function _resetAccountModalState() {
_accountModalSession++;
_accountModalCommitLocked = false;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_syncAccountSubmitButton();
}
function _credsSnapshotKey(authType, creds) {
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function _defaultAccountSubmitButtonText(ctx) {
return window.AccountSubmit.getAccountSubmitLabel({ isEdit: !!(ctx && ctx.isEdit) });
}
function _syncAccountSubmitButton() {
const saveBtn = document.getElementById('saveAccountBtn');
if (!saveBtn) return;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
saveBtn.disabled = _accountSubmitter.isBusy() || _accountModalCommitLocked;
}
function _invalidateAccountSubmit() {
_accountModalSession++;
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) {
statusEl.textContent = '';
statusEl.className = 'account-modal-status';
}
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn && !_accountSubmitter.isBusy() && !_accountModalCommitLocked) {
saveBtn.disabled = false;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
}
}
function _wireCredFieldInvalidation() {
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) {
const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue;
el.addEventListener('input', _invalidateAccountSubmit);
el.dataset.invalidateBound = '1';
}
}
function _determineHosterContext() {
if (editingAccountId) {
const found = findAccountById(editingAccountId);
if (!found) return null;
return { hosterName: found.name, authType: found.account.authType || 'login', accountId: editingAccountId, isEdit: true };
}
const selectValue = document.getElementById('accountHosterSelect')?.value;
if (!selectValue) return null;
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
if (!opt) return null;
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
}
function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
if (session !== _accountModalSession) return false;
const currentCtx = _determineHosterContext();
if (!currentCtx) return false;
if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
const currentCreds = readAccountCredsFromModal(currentCtx.authType);
return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
}
async function saveAccount() {
if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return;
const ctx = _determineHosterContext();
if (!ctx) return;
const creds = readAccountCredsFromModal(ctx.authType);
const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn');
if (!creds.enabled) {
statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
statusEl.className = 'account-modal-status error';
return;
}
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
const mySession = _accountModalSession;
const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : '';
const payload = {
hoster: ctx.hosterName,
authType: ctx.authType,
username: creds.username || '',
password: creds.password || '',
apiKey: creds.apiKey || '',
otp
};
const submission = _accountSubmitter.submit({
validate: () => window.api.validateCredentials(payload),
commit: () => _persistAccount(ctx, creds),
afterCommit: (persisted, validation) => _applyCommittedAccount(persisted, validation),
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
});
if (!submission) return;
saveBtn.disabled = true;
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
statusEl.textContent = 'Prüfe Zugangsdaten…';
statusEl.className = 'account-modal-status checking';
let result;
try {
result = await submission;
} catch (error) {
result = { status: 'error', error };
}
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
if (result.status === 'committed' && current) {
_accountModalCommitLocked = true;
const validation = result.validation || {};
statusEl.textContent = validation.status === 'warn'
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
statusEl.className = 'account-modal-status ok';
_hideOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
saveBtn.disabled = true;
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => {
_autoCloseTimer = null;
closeAccountModal();
}, 600);
return;
}
_syncAccountSubmitButton();
if (!current) return;
if (result.status === 'otp_required') {
const validation = result.validation || {};
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error';
_showOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
return;
}
const validation = result.validation || {};
const msg = result.status === 'error'
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen'
: validation.message || 'Login fehlgeschlagen';
statusEl.textContent = msg;
statusEl.className = 'account-modal-status error';
}
function _copyHosterTree(hosters) {
const candidate = {};
for (const [name, accounts] of Object.entries(hosters || {})) {
candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
}
return candidate;
}
async function _persistAccount(ctx, creds) {
const candidateHosters = _copyHosterTree(config.hosters);
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
let accountId = ctx.accountId;
if (ctx.isEdit) {
const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
} else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
}
await saveConfigTracked({ hosters: candidateHosters });
return { accountId, candidateHosters, isEdit: ctx.isEdit };
}
function _applyCommittedAccount(persisted, validation) {
const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters;
_invalidateAccountStatusGeneration(accountId);
accountStatuses[accountId] = { status: validation.status, message: validation.message || '', checkedAt: validation.checkedAt || new Date().toISOString() };
ensureAccountStatusEntries();
syncSelectedUploadHosters();
if (isEdit) {
updateAccountCard(accountId);
} else {
renderAccounts();
}
renderHosterSummary();
}
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 ---
let historyRetentionMenuToken = 0;
function syncHistoryRetentionPicker() {
const select = document.getElementById('historyRetentionSelect');
const value = document.getElementById('historyRetentionValue');
const menu = document.getElementById('historyRetentionMenu');
if (!select || !value || !menu) return;
const labels = {
all: 'Alles behalten',
'7d': 'Letzte 7 Tage',
'30d': 'Letzte 30 Tage',
'90d': 'Letzte 90 Tage',
'1000': 'Letzte 1000 Uploads',
'100': 'Letzte 100 Uploads'
};
value.textContent = labels[select.value] || labels.all;
menu.querySelectorAll('[data-history-retention]').forEach(option => {
const selected = option.dataset.historyRetention === select.value;
option.setAttribute('aria-selected', String(selected));
option.tabIndex = selected ? 0 : -1;
});
}
function openHistoryRetentionMenu(focusOption = false) {
const trigger = document.getElementById('historyRetentionTrigger');
const menu = document.getElementById('historyRetentionMenu');
if (!trigger || !menu) return;
historyRetentionMenuToken++;
menu.classList.remove('menu-closing', 'menu-opening');
menu.style.display = 'block';
void menu.offsetHeight;
menu.classList.add('menu-opening');
trigger.setAttribute('aria-expanded', 'true');
if (focusOption) {
window.requestAnimationFrame(() => menu.querySelector('[aria-selected="true"]')?.focus());
}
}
function closeHistoryRetentionMenu(returnFocus = false) {
const trigger = document.getElementById('historyRetentionTrigger');
const menu = document.getElementById('historyRetentionMenu');
if (!trigger || !menu || window.getComputedStyle(menu).display === 'none') return;
const token = ++historyRetentionMenuToken;
menu.classList.remove('menu-opening', 'menu-closing');
void menu.offsetHeight;
menu.classList.add('menu-closing');
trigger.setAttribute('aria-expanded', 'false');
const finish = () => {
if (!Object.is(historyRetentionMenuToken, token)) return;
menu.style.display = 'none';
menu.classList.remove('menu-closing');
if (returnFocus) trigger.focus();
};
menu.addEventListener('animationend', finish, { once: true });
window.setTimeout(finish, 220);
}
function selectHistoryRetentionOption(value) {
const select = document.getElementById('historyRetentionSelect');
if (!select || !select.querySelector(`option[value="${value}"]`)) return;
select.value = value;
syncHistoryRetentionPicker();
closeHistoryRetentionMenu(true);
select.dispatchEvent(new window.Event('change', { bubbles: true }));
}
function syncHistoryClearAction() {
const button = document.getElementById('clearHistoryBtn');
if (button) button.disabled = historyRowsData.length === 0;
syncDataActionState();
}
function closeHistoryClearModal() {
modalController.close('historyClearModal', { fallbackFocus: '#clearHistoryBtn' });
}
function openHistoryClearModal() {
const button = document.getElementById('clearHistoryBtn');
const modal = document.getElementById('historyClearModal');
if (!modal || !button || button.disabled) return;
modalController.open(modal, {
initialFocus: '#cancelHistoryClearBtn',
returnFocus: button,
fallbackFocus: '#clearHistoryBtn',
onEscape: closeHistoryClearModal,
onBackdrop: closeHistoryClearModal
});
}
async function confirmHistoryClear() {
const confirmButton = document.getElementById('confirmHistoryClearBtn');
const cancelButton = document.getElementById('cancelHistoryClearBtn');
if (!confirmButton || confirmButton.disabled) return;
confirmButton.disabled = true;
cancelButton.disabled = true;
try {
await runConfigWrite(() => window.api.clearHistory());
await loadHistory();
closeHistoryClearModal();
} catch (error) {
showCopyToast(getLocalizedErrorDetail(error));
} finally {
confirmButton.disabled = false;
cancelButton.disabled = false;
}
}
let _historyLoadGeneration = 0;
async function loadHistory() {
const generation = ++_historyLoadGeneration;
const container = document.getElementById('historyContainer');
if (container) container.innerHTML = `${localizeUiText('Wird geladen…')}
`;
let history;
try {
history = await window.api.getHistory();
} catch (error) {
if (generation !== _historyLoadGeneration) return;
historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
updateHistorySidebarSummary();
syncHistoryClearAction();
if (container) container.innerHTML = `${escapeHtml(localizeUiText('Verlauf konnte nicht geladen werden.'))} ${escapeHtml(getLocalizedErrorDetail(error))} ${escapeHtml(localizeUiText('Erneut versuchen'))}
`;
container?.querySelector('[data-retry-history]')?.addEventListener('click', loadHistory);
return;
}
if (generation !== _historyLoadGeneration) return;
window._historyForStats = history || [];
_historyEverLoaded = true;
_historyDirty = false;
_invalidateHosterLifetimeCache();
_refreshAccountHosterLifetimeStats();
const retSel = document.getElementById('historyRetentionSelect');
if (retSel) {
retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
syncHistoryRetentionPicker();
}
if (!history || history.length === 0) {
historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
updateHistorySidebarSummary();
syncHistoryClearAction();
container.innerHTML = 'Noch keine Uploads.
';
return;
}
historySortState = { key: 'date', direction: 'desc' };
historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
let order = 0;
for (const batch of history) {
const dt = formatDateTime(batch.timestamp);
for (const file of (batch.files || [])) {
for (const result of (file.results || [])) {
historySidebarCounts.total++;
const category = window.HistoryStatus.classifyHistoryStatus(result.status);
historySidebarCounts[category]++;
const detail = window.HistoryStatus.historyDetail(result);
historyRowsData.push({
date: dt.text, dateTs: dt.ts, rawTimestamp: batch.timestamp,
filename: file.name || '', host: result.hoster || '',
link: detail,
status: result.status || '', category,
isError: category === 'error', order: order++
});
}
}
}
updateHistorySidebarSummary();
syncHistoryClearAction();
renderHistoryTable(container);
}
async function exportHistory() {
const history = await window.api.getHistory();
if (!history || history.length === 0) {
await showAppAlert('Kein Verlauf zum Exportieren vorhanden.');
return;
}
const choice = await showAppChoice({ title: 'Verlauf exportieren', message: 'Wähle das Dateiformat für den Export.', confirmText: 'CSV exportieren', alternateText: 'JSON exportieren' });
if (!choice) return;
const format = choice === true ? 'csv' : 'json';
const result = await window.api.exportHistory(format);
if (!result || result.canceled) return;
if (!result.ok) {
await showAppAlert(getLocalizedErrorDetail(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}|${_recentDataVersion}`;
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);
th.setAttribute('aria-sort', active ? (recentSortState.direction === 'asc' ? 'ascending' : 'descending') : 'none');
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;
let _recentPendingAppends = 0;
let _recentWorking = [];
let _recentLastRange = { start: -1, end: -1 };
let _recentScrollQueued = false;
function _onRecentScroll() {
if (_recentScrollQueued) return;
_recentScrollQueued = true;
requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
}
function _renderRecentVirtualRows() {
const wrap = document.querySelector('.recent-files-table-wrap');
const tbody = document.getElementById('recentFilesBody');
if (!wrap || !tbody) return;
const total = _recentWorking.length;
if (!total) return;
const scrollTop = wrap.scrollTop;
const viewportHeight = Math.max(wrap.clientHeight, 400);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return;
_recentLastRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
const parts = [];
if (topPad > 0) parts.push(` `);
for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
if (bottomPad > 0) parts.push(` `);
tbody.innerHTML = parts.join('');
}
function renderRecentUploadsPanel(_appendOnly = false) {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
const pendingAppends = _recentPendingAppends;
_recentPendingAppends = 0;
const wrap = tbody.closest('.recent-files-table-wrap');
if (!sessionFilesData.length) {
tbody.innerHTML = 'Noch keine Uploads in dieser Session. ';
_recentWorking = [];
_recentLastRange = { start: -1, end: -1 };
} else {
const prevLen = _recentWorking.length;
_recentWorking = sortRecentFiles(sessionFilesData);
_recentLastRange = { start: -1, end: -1 };
const sig = `${recentSortState.key}|${recentSortState.direction}`;
if (wrap) {
const added = Math.max(_recentWorking.length - prevLen, pendingAppends);
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
}
_renderRecentVirtualRows();
}
// Event delegation – bind once, not per-row
if (!_recentListenersBound) {
_recentListenersBound = true;
if (wrap) {
wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
}
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr) return;
const copyButton = e.target.closest('[data-copy-recent-link]');
if (copyButton) {
const link = tr.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
e.stopPropagation();
return;
}
// Clear queue selection when clicking in recent panel — class-toggle only.
if (selectedJobIds.size > 0) { selectedJobIds.clear(); selectionAnchorJobId = null; applyQueueSelectionClasses(); updateQueueActionButtons(); }
const id = parseInt(tr.dataset.order, 10);
if (e.ctrlKey || e.metaKey) {
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
else selectedRecentIds.add(id);
} else if (e.shiftKey && selectedRecentIds.size > 0) {
// Reuse the already-sorted array from the sort cache instead of
// querying every .recent-file-row in the DOM (O(visible) vs O(N)
// on large panels).
const sortedOrders = (_recentSortCache.result || sortRecentFiles(sessionFilesData))
.map(r => r.order);
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
const curIdx = sortedOrders.indexOf(id);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]);
}
} else {
selectedRecentIds.clear();
selectedRecentIds.add(id);
}
// Selection change — toggle classes, no tbody rebuild.
applyRecentSelectionClasses();
});
tbody.addEventListener('keydown', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr || e.target.closest('button')) return;
if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {
e.preventDefault();
const rect = tr.getBoundingClientRect();
showRecentContextMenu(tr, rect.left + 24, rect.top + 20);
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tr.click();
}
});
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'); }
});
}
updateRecentSortHeaders();
syncDataActionState();
}
function activateRecentSortHeader(th) {
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';
}
_recentLastRenderedSig = '';
renderRecentUploadsPanel();
}
const HISTORY_RENDER_CAP = 2000;
let _historyWorking = [];
let _historyLastRange = { start: -1, end: -1 };
let _historyListenersBound = false;
let _historyScrollQueued = false;
function _onHistoryScroll() {
if (_historyScrollQueued) return;
_historyScrollQueued = true;
requestAnimationFrame(() => { _historyScrollQueued = false; _renderHistoryVirtualRows(); });
}
function _renderHistoryVirtualRows() {
const container = document.getElementById('historyContainer');
const tbody = document.getElementById('historyBody');
if (!container || !tbody) return;
const total = _historyWorking.length;
const scrollTop = container.scrollTop;
const viewportHeight = Math.max(container.clientHeight, 600);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
if (startIdx === _historyLastRange.start && endIdx === _historyLastRange.end) return;
_historyLastRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
const parts = [];
if (topPad > 0) parts.push(` `);
for (let i = startIdx; i < endIdx; i++) {
const row = _historyWorking[i];
const link = row.link || '';
parts.push('`);
parts.push(escapeHtml(row.date));
parts.push(' ');
parts.push(escapeHtml(row.filename));
parts.push(' ');
parts.push(escapeHtml(row.host));
parts.push(' ');
parts.push(escapeHtml(link));
parts.push(' ');
if (row.category === 'success' && link) parts.push('⧉ ');
parts.push('
');
}
if (bottomPad > 0) parts.push(` `);
tbody.innerHTML = parts.join('');
}
function _getVisibleHistoryRows() {
if (historySidebarFilter !== 'all') return historyRowsData.filter(row => row.category === historySidebarFilter);
return historyRowsData;
}
function renderHistoryTable(container) {
const visibleRows = _getVisibleHistoryRows();
if (!container || !visibleRows.length) {
if (container) container.innerHTML = historyRowsData.length > 0
? `${escapeHtml(localizeUiText('Keine Verlaufseinträge entsprechen diesem Filter.'))}
`
: `${escapeHtml(localizeUiText('Noch keine Uploads.'))}
`;
const emptyNotice = document.getElementById('historyCapNotice');
if (emptyNotice) emptyNotice.style.display = 'none';
_historyWorking = [];
return;
}
const total = visibleRows.length;
const working = total > HISTORY_RENDER_CAP ? visibleRows.slice(-HISTORY_RENDER_CAP) : visibleRows;
const notice = document.getElementById('historyCapNotice');
if (notice) {
if (total > HISTORY_RENDER_CAP) {
notice.style.display = '';
notice.textContent = `Zeige neueste ${HISTORY_RENDER_CAP.toLocaleString(getUiLocale())} von ${total.toLocaleString(getUiLocale())} Einträgen. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar.`;
} else {
notice.style.display = 'none';
}
}
_historyWorking = sortHistoryRows(working);
_historyLastRange = { start: -1, end: -1 };
const headerCell = (key, label) => {
const active = historySortState.key === key;
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
const ariaSort = active ? (historySortState.direction === 'asc' ? 'ascending' : 'descending') : 'none';
return `${label}${dir} `;
};
container.innerHTML = `
${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Ergebnis')}
`;
if (!_historyListenersBound) {
_historyListenersBound = true;
container.addEventListener('scroll', _onHistoryScroll, { passive: true });
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onHistoryScroll).observe(container);
container.addEventListener('click', (e) => {
const th = e.target.closest('th.sortable');
if (th && container.contains(th)) {
const key = th.dataset.historySort;
const defaultDir = key === 'date' ? 'desc' : 'asc';
if (!_historySortClicked || historySortState.key !== key) {
_historySortClicked = true;
historySortState.key = key;
historySortState.direction = defaultDir;
} else {
historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
}
container.scrollTop = 0;
renderHistoryTable(container);
return;
}
const copyButton = e.target.closest('.history-copy-link');
if (copyButton && container.contains(copyButton)) {
const link = copyButton.closest('.history-row')?.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
e.stopPropagation();
return;
}
const row = e.target.closest('.history-row');
if (row && row.classList.contains('success')) {
const link = row.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
}
});
}
_renderHistoryVirtualRows();
}
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;
});
}
let closePreparationPromise = null;
let closePreparationOverlayState = null;
let closePreparationGeneration = 0;
let activeClosePreparationAttempt = null;
const CLOSE_PREPARATION_TIMEOUT_MS = 1200;
function waitForClosePreparationStep(promise) {
return Promise.race([
Promise.resolve(promise),
new Promise((_, reject) => setTimeout(() => reject(new Error('Speichern vor dem Beenden hat zu lange gedauert')), CLOSE_PREPARATION_TIMEOUT_MS))
]);
}
function setClosePreparationUi(active) {
const overlay = document.getElementById('shutdownOverlay');
const message = document.getElementById('shutdownMessage');
const cancelButton = document.getElementById('cancelShutdownBtn');
if (!overlay || !message || !cancelButton) return;
if (active) {
if (closePreparationOverlayState) return;
closePreparationOverlayState = {
wasOpen: modalController.isOpen(overlay),
message: message.innerHTML,
cancelDisplay: cancelButton.style.display
};
message.textContent = 'Einstellungen werden gespeichert…';
cancelButton.style.display = 'none';
modalController.open(overlay, {
initialFocus: () => overlay.querySelector('[role="dialog"]'),
onEscape: () => {}
});
return;
}
const previousState = closePreparationOverlayState;
message.innerHTML = previousState ? previousState.message : '';
cancelButton.style.display = previousState ? previousState.cancelDisplay : '';
closePreparationOverlayState = null;
if (previousState?.wasOpen) {
modalController.open(overlay, {
initialFocus: '#cancelShutdownBtn',
onEscape: cancelShutdownCountdown
});
requestAnimationFrame(() => cancelButton.focus());
} else {
modalController.close(overlay);
}
}
function isCurrentClosePreparation(generation, attempt) {
return generation === closePreparationGeneration && attempt === activeClosePreparationAttempt;
}
async function recoverWindowClose(generation, attempt, originalError) {
if (!isCurrentClosePreparation(generation, attempt)) return;
closePreparationState = 'recovering';
let restored = false;
try {
restored = await waitForClosePreparationStep(window.api.finishClosePreparation({ ready: false, attempt }));
} catch (error) {
if (isCurrentClosePreparation(generation, attempt)) showCopyToast(getLocalizedErrorDetail(error), 8000);
return;
}
if (!isCurrentClosePreparation(generation, attempt)) return;
if (restored !== true) {
showCopyToast('Die Anwendung konnte nach dem fehlgeschlagenen Speichern nicht entsperrt werden', 8000);
return;
}
try {
await waitForClosePreparationStep(withCloseWriteAccess(async () => {
await flushConfigWrites();
if (!isCurrentClosePreparation(generation, attempt)) return;
await persistQueueStateNow();
await flushConfigWrites();
if (failedConfigWriteOperations.length !== 0) throw new Error('Nicht alle Einstellungen konnten gespeichert werden');
}));
} catch (error) {
if (isCurrentClosePreparation(generation, attempt)) showCopyToast(getLocalizedErrorDetail(error), 8000);
return;
}
if (!isCurrentClosePreparation(generation, attempt)) return;
closePreparationPromise = null;
activeClosePreparationAttempt = null;
closePreparationState = 'open';
setClosePreparationUi(false);
showCopyToast(getLocalizedErrorDetail(originalError), 8000);
}
function prepareForWindowClose(attempt) {
if (!Number.isInteger(attempt)) return Promise.resolve(false);
if (closePreparationPromise && activeClosePreparationAttempt === attempt) return closePreparationPromise;
const generation = ++closePreparationGeneration;
activeClosePreparationAttempt = attempt;
closePreparationState = 'preparing';
setClosePreparationUi(true);
closePreparationPromise = (async () => {
await waitForClosePreparationStep(withCloseWriteAccess(flushPendingSettingsSaves));
if (!isCurrentClosePreparation(generation, attempt)) return;
const pendingQueue = buildPersistedQueueState();
closePreparationState = 'sealed';
await waitForClosePreparationStep(flushConfigWrites());
if (!isCurrentClosePreparation(generation, attempt)) return;
const accepted = await waitForClosePreparationStep(window.api.finishClosePreparation({ ready: true, attempt, pendingQueue }));
if (!accepted) throw new Error('Einstellungen konnten vor dem Beenden nicht gespeichert werden');
})();
closePreparationPromise.catch(error => recoverWindowClose(generation, attempt, error));
return closePreparationPromise;
}
// --- Setup Listeners ---
function setupListeners() {
try { initMenuBar(); } catch (err) { console.error('menu bar init failed', err); }
document.querySelectorAll('[data-upload-sidebar-target]').forEach(button => {
button.addEventListener('click', () => setUploadSidebarFilter(button.dataset.uploadSidebarTarget));
});
document.querySelectorAll('[data-accounts-sidebar-filter]').forEach(button => {
button.addEventListener('click', () => setAccountSidebarFilter(button.dataset.accountsSidebarFilter));
});
document.querySelectorAll('[data-history-filter]').forEach(button => {
button.addEventListener('click', () => setHistorySidebarFilter(button.dataset.historyFilter));
});
_syncSidebarFilterButtons('[data-upload-sidebar-target]', 'uploadSidebarTarget', uploadSidebarFilter);
_syncSidebarFilterButtons('[data-accounts-sidebar-filter]', 'accountsSidebarFilter', accountSidebarFilter);
_syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', historySidebarFilter);
let sidebarResizeFrame = 0;
window.addEventListener('resize', () => {
window.cancelAnimationFrame(sidebarResizeFrame);
sidebarResizeFrame = window.requestAnimationFrame(() => {
document.querySelectorAll('.view.active .view-sidebar-navigation > .view-sidebar-item.active, .view.active .settings-navigation > .settings-nav-button.active').forEach(button => {
_syncSidebarIndicator(button, true);
});
});
});
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;
activateRecentSortHeader(th);
});
document.getElementById('recentFilesHead').addEventListener('keydown', (e) => {
const th = e.target.closest('th[data-recent-sort]');
if (!th || !['Enter', ' '].includes(e.key)) return;
e.preventDefault();
activateRecentSortHeader(th);
});
document.getElementById('historyContainer').addEventListener('keydown', (e) => {
const th = e.target.closest('th[data-history-sort]');
if (!th || !['Enter', ' '].includes(e.key)) return;
e.preventDefault();
th.click();
});
// 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();
showRecentContextMenu(tr, e.clientX, e.clientY);
});
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.querySelectorAll('.context-menu').forEach(menu => {
menu.addEventListener('keydown', (e) => {
const items = getVisibleContextMenuItems(menu);
if (!items.length) return;
const current = Math.max(0, items.indexOf(document.activeElement));
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const offset = e.key === 'ArrowDown' ? 1 : -1;
items[(current + offset + items.length) % items.length].focus();
} else if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
items[e.key === 'Home' ? 0 : items.length - 1].focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
document.activeElement?.click();
} else if (e.key === 'Escape') {
e.preventDefault();
hideContextMenu();
}
});
});
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('toggleAllAccountsBtn').addEventListener('click', () => _setAllAccountGroupsOpen(!_allAccountGroupsOpen()));
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');
});
renderImportPlanSummary();
});
document.getElementById('clearHostersBtn').addEventListener('click', () => {
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
input.checked = false;
input.closest('.hoster-option')?.classList.remove('selected');
});
renderImportPlanSummary();
});
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
document.getElementById('clearHistoryBtn').addEventListener('click', openHistoryClearModal);
document.getElementById('confirmHistoryClearBtn').addEventListener('click', confirmHistoryClear);
document.getElementById('cancelHistoryClearBtn').addEventListener('click', closeHistoryClearModal);
document.getElementById('closeHistoryClearModalBtn').addEventListener('click', closeHistoryClearModal);
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
document.getElementById('exportSessionReportBtn').addEventListener('click', async () => {
const format = await showAppChoice({ title: 'Sitzungsbericht exportieren', message: 'Welches Format möchtest du speichern?', confirmText: 'CSV', alternateText: 'JSON' });
if (format === false) return;
const result = await window.api.exportSessionReport(format === 'alternate' ? 'json' : 'csv');
if (result?.ok) showCopyToast(result.totalRows === 1 ? 'Sitzungsbericht mit 1 Upload exportiert' : `Sitzungsbericht mit ${result.totalRows} Uploads exportiert`);
else if (!result?.canceled) await showAppAlert(result?.error || 'Sitzungsbericht konnte nicht exportiert werden.');
});
document.getElementById('queueSearchInput').addEventListener('input', applyQueueDetailFilters);
document.getElementById('queueHosterFilter').addEventListener('change', applyQueueDetailFilters);
document.getElementById('queueStatusFilter').addEventListener('change', applyQueueDetailFilters);
const historyRetentionPicker = document.getElementById('historyRetentionPicker');
const historyRetentionTrigger = document.getElementById('historyRetentionTrigger');
const historyRetentionMenu = document.getElementById('historyRetentionMenu');
historyRetentionTrigger.addEventListener('click', event => {
event.stopPropagation();
if (window.getComputedStyle(historyRetentionMenu).display === 'none' || historyRetentionMenu.classList.contains('menu-closing')) {
openHistoryRetentionMenu();
} else {
closeHistoryRetentionMenu();
}
});
historyRetentionTrigger.addEventListener('keydown', event => {
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
openHistoryRetentionMenu(true);
});
historyRetentionMenu.addEventListener('click', event => {
const option = event.target.closest('[data-history-retention]');
if (option) selectHistoryRetentionOption(option.dataset.historyRetention);
});
historyRetentionMenu.addEventListener('keydown', event => {
const options = [...historyRetentionMenu.querySelectorAll('[data-history-retention]')];
const current = options.indexOf(document.activeElement);
if (event.key === 'Escape') {
event.preventDefault();
closeHistoryRetentionMenu(true);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
const option = event.target.closest('[data-history-retention]');
if (!option) return;
event.preventDefault();
selectHistoryRetentionOption(option.dataset.historyRetention);
return;
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: (Math.max(0, current) + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length;
options[next]?.focus();
});
historyRetentionPicker.addEventListener('focusout', () => {
window.setTimeout(() => {
if (!historyRetentionPicker.contains(document.activeElement)) closeHistoryRetentionMenu();
}, 0);
});
document.addEventListener('mousedown', event => {
if (!historyRetentionPicker.contains(event.target)) closeHistoryRetentionMenu();
});
const historyRetentionSelect = document.getElementById('historyRetentionSelect');
if (historyRetentionSelect) {
historyRetentionSelect.addEventListener('change', async () => {
const value = historyRetentionSelect.value;
const prev = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
if (value !== 'all') {
const preview = await window.api.pruneHistory(value, { dryRun: true });
if (preview && preview.removedRows > 0) {
const count = preview.removedRows.toLocaleString(getUiLocale());
const noun = preview.removedRows === 1 ? 'Verlaufseintrag wird' : 'Verlaufseinträge werden';
const ok = await showAppConfirm({ title: 'Aufbewahrung ändern?', message: `${count} ${noun} dauerhaft entfernt.`, confirmText: 'Entfernen', danger: true });
if (!ok) {
historyRetentionSelect.value = prev;
syncHistoryRetentionPicker();
return;
}
}
}
try {
const res = await runConfigWrite(() => window.api.pruneHistory(value));
config.globalSettings = { ...(config.globalSettings || {}), historyRetention: value };
if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString(getUiLocale())} entfernt`);
loadHistory();
} catch (error) {
historyRetentionSelect.value = prev;
syncHistoryRetentionPicker();
showCopyToast(getLocalizedErrorDetail(error));
}
});
}
// 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 });
if (queueContainer && typeof window.ResizeObserver !== 'undefined') {
new window.ResizeObserver(_onQueueScroll).observe(queueContainer);
}
// Queue table sorting
document.querySelectorAll('#queueTable th.sortable').forEach(th => {
const activateSort = (e) => {
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 };
renderQueueTable();
};
th.addEventListener('click', activateSort);
th.addEventListener('keydown', (e) => {
if (!['Enter', ' '].includes(e.key)) return;
e.preventDefault();
activateSort(e);
});
});
// Queue table column resizing (JDownloader-style)
setupColumnResizing();
// Shutdown cancel
document.getElementById('cancelShutdownBtn').addEventListener('click', cancelShutdownCountdown);
// Click on empty area in queue → deselect all
document.getElementById('upload-view').addEventListener('click', (e) => {
if (e.target.closest('.view-main') && !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();
selectionAnchorJobId = null;
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);
});
// 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);
// Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
_invalidateAccountSubmit();
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value);
wireCredentialVisibilityButtons(credsContainer);
_wireCredFieldInvalidation();
});
// Delete account modal
document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal);
document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal);
document.getElementById('confirmDeleteBtn').addEventListener('click', () => {
const modal = document.getElementById('deleteAccountModal');
const accountId = modal.dataset.accountId;
if (accountId) deleteAccount(accountId);
});
// Job log modal
document.getElementById('closeJobLogBtn')?.addEventListener('click', hideJobLogModal);
document.getElementById('closeJobLogBtn2')?.addEventListener('click', hideJobLogModal);
document.getElementById('copyJobLogBtn')?.addEventListener('click', copyJobLogToClipboard);
document.getElementById('headerUpdateBtn')?.addEventListener('click', requestUpdateCheck);
document.getElementById('installUpdateBtn')?.addEventListener('click', installKnownUpdate);
document.getElementById('dismissUpdateBtn')?.addEventListener('click', closeUpdateDialog);
document.getElementById('updateCloseBtn')?.addEventListener('click', closeUpdateDialog);
_syncHeaderUpdateState();
}
// --- Update UI ---
function _formatUpdateReleaseNotes(value) {
const output = [];
let pendingBlank = false;
for (const rawLine of String(value || '').replace(/\r\n?/g, '\n').split('\n')) {
let line = rawLine.trim();
if (!line) {
if (output.length > 0) pendingBlank = true;
continue;
}
line = line
.replace(/^#{1,6}\s+/, '')
.replace(/^[-*+]\s+/, '• ')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
if (pendingBlank && output.at(-1) !== '') output.push('');
output.push(line);
pendingBlank = false;
}
return output.join('\n');
}
function _selectUpdateReleaseNotes(info) {
const language = uiLocalizer.getLanguage();
const candidates = [info?.releaseNotesLocalized, info?.releaseNotesByLanguage, info?.releaseNotes];
const localized = candidates.find(value => value && typeof value === 'object' && !Array.isArray(value));
if (localized) {
if (typeof localized[language] === 'string') return { value: localized[language], language };
if (typeof localized.en === 'string') return { value: localized.en, language: 'en' };
const first = Object.entries(localized).find(([, value]) => typeof value === 'string');
if (first) return { value: first[1], language: first[0] };
}
return { value: typeof info?.releaseNotes === 'string' ? info.releaseNotes : '', language: 'en' };
}
function showUpdateBanner(info) {
if (!info) return;
_knownUpdateInfo = { ...info, available: true };
if (_updateInstallBusy) {
_syncHeaderUpdateState();
_setUpdateDialogVisible(true);
return;
}
_updateInstallBusy = false;
const version = String(info.remoteVersion || '').replace(/^v/i, '') || 'unbekannt';
const title = document.getElementById('updateDialogTitle');
const message = document.getElementById('updateMessage');
const notes = document.getElementById('updateReleaseNotes');
const notesBody = document.getElementById('updateReleaseNotesBody');
const installButton = document.getElementById('installUpdateBtn');
if (title) title.textContent = 'Eine neue Version ist verfügbar';
if (message) {
message.textContent = `Update v${version} verfügbar`;
message.hidden = false;
}
if (notes && notesBody) {
const selectedNotes = _selectUpdateReleaseNotes(info);
const releaseNotes = _formatUpdateReleaseNotes(selectedNotes.value);
notesBody.textContent = releaseNotes.length > 2400 ? `${releaseNotes.slice(0, 2399)}…` : releaseNotes;
notesBody.lang = selectedNotes.language;
notes.hidden = !releaseNotes;
}
if (installButton) {
installButton.disabled = false;
installButton.textContent = 'Jetzt installieren';
}
_setUpdateProgress(0, 'Bereit zum Download');
_setUpdateDialogBusy(false);
_syncHeaderUpdateState();
_setUpdateDialogVisible(true);
}
function handleUpdateProgress(data) {
const progress = data || {};
const message = document.getElementById('updateMessage');
const button = document.getElementById('installUpdateBtn');
if (progress.stage === 'starting') {
_updateInstallBusy = true;
_setUpdateDialogBusy(true);
_setUpdateProgress(0, 'Download 0%');
if (message) message.hidden = true;
} else if (progress.stage === 'downloading') {
const percent = Math.max(0, Math.min(100, Math.round(Number(progress.percent) || 0)));
_updateInstallBusy = true;
_setUpdateDialogBusy(true);
_setUpdateProgress(percent, `Download ${percent}%`);
if (message) message.hidden = true;
} else if (progress.stage === 'verifying') {
_updateInstallBusy = true;
_setUpdateDialogBusy(true);
_setUpdateProgress(100, 'Prüfen…');
if (message) message.hidden = true;
} else if (progress.stage === 'prepared') {
_updateInstallBusy = true;
_setUpdateDialogBusy(true);
_setUpdateProgress(100, 'Neustart…');
if (message) message.hidden = true;
} else if (progress.stage === 'launching' || progress.stage === 'done') {
_updateInstallBusy = true;
_setUpdateDialogBusy(true);
_setUpdateProgress(100, 'Neustart…');
if (message) message.hidden = true;
} else if (progress.stage === 'error') {
_updateInstallBusy = false;
_setUpdateDialogBusy(false);
_setUpdateProgress(0, 'Update fehlgeschlagen');
_setUpdateProgressVisible(false);
if (message) {
message.hidden = false;
message.textContent = formatLocalizedError('Update fehlgeschlagen', progress.error);
}
if (button) {
button.disabled = false;
button.textContent = 'Wiederholen';
}
_setUpdateDialogVisible(true);
}
}
function _isUpdateDialogVisible() {
return modalController.isOpen('updateBanner');
}
function _setUpdateDialogVisible(visible) {
const overlay = document.getElementById('updateBanner');
if (!overlay) return;
if (visible) {
modalController.open(overlay, {
initialFocus: '#updateCloseBtn',
fallbackFocus: '#headerUpdateBtn',
onEscape: closeUpdateDialog,
onBackdrop: closeUpdateDialog
});
} else {
modalController.close(overlay, { fallbackFocus: '#headerUpdateBtn' });
}
}
function closeUpdateDialog() {
if (_updateInstallBusy) return false;
_setUpdateDialogVisible(false);
return true;
}
function _setUpdateDialogBusy(busy) {
const dialog = document.querySelector('#updateBanner .update-dialog');
if (dialog) dialog.setAttribute('aria-busy', busy ? 'true' : 'false');
['installUpdateBtn', 'dismissUpdateBtn', 'updateCloseBtn'].forEach(id => {
const button = document.getElementById(id);
if (button) button.disabled = busy;
});
if (busy && dialog && (!dialog.contains(document.activeElement) || document.activeElement.matches?.(':disabled'))) dialog.focus();
}
function _setUpdateProgress(percent, text) {
_setUpdateProgressVisible(true);
const value = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
const progressText = document.getElementById('updateProgressText');
const progressBar = document.getElementById('updateProgressBar');
if (progressText) progressText.textContent = text;
if (progressBar) {
progressBar.setAttribute('aria-valuenow', String(value));
progressBar.setAttribute('aria-valuetext', String(text || `${value}%`));
if ('value' in progressBar) progressBar.value = value;
progressBar.style.width = `${value}%`;
}
}
function _setUpdateProgressVisible(visible) {
const progress = document.querySelector('#updateBanner .update-progress');
const text = document.getElementById('updateProgressText');
if (progress) progress.hidden = !visible;
if (text) text.hidden = !visible;
}
async function installKnownUpdate() {
if (_updateInstallBusy) return;
if (!_knownUpdateInfo || !_knownUpdateInfo.available) {
await requestUpdateCheck();
return;
}
_updateInstallBusy = true;
_setUpdateDialogBusy(true);
_setUpdateProgress(0, 'Download 0%');
const message = document.getElementById('updateMessage');
if (message) message.hidden = true;
try {
await persistQueueStateNow();
const result = await window.api.installUpdate();
if (result && result.started === false) throw new Error(result.error || 'Update konnte nicht gestartet werden');
} catch (error) {
handleUpdateProgress({ stage: 'error', error: error && error.message ? error.message : String(error) });
}
}
// --- Shutdown ---
let shutdownCountdownInterval = null;
async function cancelShutdownCountdown() {
await window.api.cancelShutdown();
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
shutdownCountdownInterval = null;
modalController.close('shutdownOverlay');
}
function handleShutdownCountdown(data) {
const overlay = document.getElementById('shutdownOverlay');
const msgEl = document.getElementById('shutdownMessage');
if (!overlay || !msgEl) return;
const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' };
let remaining = data.seconds || 60;
const render = () => {
msgEl.textContent = localizeUiText(`${labels[data.mode] || data.mode} in ${remaining}s...`);
};
render();
modalController.open(overlay, {
initialFocus: '#cancelShutdownBtn',
onEscape: cancelShutdownCountdown
});
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
shutdownCountdownInterval = setInterval(() => {
remaining--;
render();
if (remaining <= 0) {
clearInterval(shutdownCountdownInterval);
shutdownCountdownInterval = null;
}
}, 1000);
}
// --- Auto-deduplicate restored queue against own upload log on startup ---
async function _autoDeduplicateFromLog() {
if (queueJobs.length === 0 && selectedFiles.length === 0) return;
try {
const entries = await window.api.readOwnUploadLog();
if (!entries || entries.length === 0) return;
// Drops 'done' jobs present in the log (declutter) AND any job that the log
// shows completed at/after the snapshot's savedAt (a stale 'preview' ghost).
// Pending jobs matching only OLDER log lines survive — intentional re-uploads.
// Decision lives in lib/queue-dedup.js (Node-tested, see tests/queue-dedup.test.js)
// so it can't silently regress to nuking the whole restored queue on restart.
const { kept, removed } = window.QueueDedup.partitionRestoredJobsByLog(queueJobs, entries, _restoredSnapshotSavedAt);
if (removed.length > 0) {
queueJobs = kept;
for (const job of removed) {
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
}
rebuildJobIndex();
syncSelectedFilesFromQueue();
window.api.debugLog(`auto-dedup: removed ${removed.length} already-uploaded (done) jobs from restored queue (${entries.length} log entries)`);
}
const seedKeys = window.QueueDedup.completedSelectionKeys(selectedFiles, getSelectedHosters(), entries, _restoredSnapshotSavedAt);
if (seedKeys.length > 0) {
for (const k of seedKeys) _completedUploadKeys.add(k);
window.api.debugLog(`auto-dedup: seeded ${seedKeys.length} completed file|hoster keys from log so buildQueuePreview won't re-create ghosts`);
}
} 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();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue();
rebuildJobIndex();
renderQueueTable();
updateUploadView();
updateStatusBar();
persistQueueStateSoon(true);
}
showCopyToast(`${removed} bereits hochgeladene Jobs aus Queue entfernt (${entries.length} Log-Einträge gelesen)`);
}
// --- Link operations ---
function copyAllLinks() {
const rows = queueJobs
.filter(j => j.status === 'done' && j.result)
.map(j => ({
fileName: j.fileName || '',
hoster: j.hoster || '',
url: j.result.download_url || j.result.embed_url || ''
}))
.filter(r => r.url);
if (rows.length === 0) return;
const formatEl = document.getElementById('linkExportFormat');
const fmt = (formatEl && formatEl.value) || 'plain';
const text = window.Stats ? window.Stats.formatLinks(rows, fmt) : rows.map(r => r.url).join('\n');
window.api.copyToClipboard(text);
showCopyToast(`${rows.length} Link${rows.length === 1 ? '' : 's'} als ${fmt.toUpperCase()} kopiert`);
}
// --- Utilities ---
function formatSize(bytes) {
if (!bytes || bytes <= 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatSpeed(kbs) {
if (!kbs || kbs <= 0) return '0 kB/s';
if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s';
return Math.round(kbs) + ' kB/s';
}
function formatTime(seconds) {
if (!seconds || seconds <= 0) return '00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
return `${pad(m)}:${pad(s)}`;
}
function pad(n) { return String(Math.floor(n)).padStart(2, '0'); }
function formatDateTime(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return { ts: 0, text: localizeUiText('Unbekannt') };
return {
ts: date.getTime(),
text: date.toLocaleDateString(getUiLocale(), { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + date.toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' })
};
}
function loadAutoCheckPreference() {
try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; }
catch { return true; }
}
// --- Queue table column resizing (JDownloader-style) ---
// Two-tier widths: _idealColumnWidths is what the user set (persisted); the
// displayed widths are scaled proportionally if the window is too narrow to fit
// all ideals (fullscreen → windowed). We never overwrite ideals just because
// the window shrunk — only an explicit drag updates the ideal for that column.
const _idealColumnWidths = {};
function restoreQueueColumnWidths() {
try {
const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY);
if (raw) {
const widths = JSON.parse(raw);
if (widths && typeof widths === 'object') {
for (const [col, px] of Object.entries(widths)) {
if (typeof px === 'number' && px > 20) _idealColumnWidths[col] = px;
}
}
}
_applyFittedColumnWidths();
} catch {}
}
function saveDraggedColumnWidth(col, width) {
// Called from the resizer onUp: the dragged column's new width becomes its
// new ideal. Other columns keep their saved ideals untouched (so a drag
// while the window is small doesn't bake the scaled values in).
if (!col || typeof width !== 'number' || width < 40) return;
_idealColumnWidths[col] = width;
try { localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(_idealColumnWidths)); } catch {}
_applyFittedColumnWidths();
}
function _applyFittedColumnWidths() {
const container = document.getElementById('queueContainer');
if (!container) return;
const ths = document.querySelectorAll('#queueTable th[data-col]');
if (!ths.length) return;
const entries = [];
let total = 0;
ths.forEach(th => {
// Fall back to the column's currently-measured width if no ideal exists
// yet (first render before the user ever dragged).
const ideal = _idealColumnWidths[th.dataset.col] || th.getBoundingClientRect().width || 0;
entries.push({ th, ideal });
total += ideal;
});
if (total <= 0) return;
const available = container.clientWidth;
if (available <= 0) return;
const MIN = 40;
if (total <= available) {
entries.forEach(({ th, ideal }) => { th.style.width = ideal + 'px'; });
return;
}
// Scale all columns proportionally so they exactly fit the available width.
const scale = available / total;
entries.forEach(({ th, ideal }) => {
th.style.width = Math.max(MIN, Math.round(ideal * scale)) + 'px';
});
}
// Debounced window-resize refit. Fires on every window size change — fullscreen
// → windowed, dragging the window edge, monitor unplug — and reshapes columns
// to the new viewport so the user never has to drag the window wider just to
// see a hidden column.
let _columnRefitTimer = null;
window.addEventListener('resize', () => {
clearTimeout(_columnRefitTimer);
_columnRefitTimer = setTimeout(_applyFittedColumnWidths, 60);
});
function setupColumnResizing() {
const headers = document.querySelectorAll('#queueTable th[data-col]');
headers.forEach(th => {
const resizer = th.querySelector('.col-resizer');
if (!resizer) return;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = th.getBoundingClientRect().width;
resizer.classList.add('dragging');
document.body.classList.add('col-resizing');
const onMove = (ev) => {
const delta = ev.clientX - startX;
const newWidth = Math.max(40, startWidth + delta);
th.style.width = newWidth + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
resizer.classList.remove('dragging');
document.body.classList.remove('col-resizing');
// Only the dragged column's new width becomes its new ideal; other
// columns keep their saved ideals (so dragging while the window is
// narrow doesn't permanently shrink everything else).
saveDraggedColumnWidth(th.dataset.col, th.getBoundingClientRect().width);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
}
// Single-pass escape instead of 4 chained .replace(/x/g, ...) calls.
// Hot path on large table rebuilds — every text cell runs through one of these.
const _HTML_ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' };
const _HTML_ESC_RE = /[&<>"]/g;
const _ATTR_ESC_MAP = { '&': '&', '"': '"', "'": ''' };
const _ATTR_ESC_RE = /[&"']/g;
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(_HTML_ESC_RE, (c) => _HTML_ESC_MAP[c]);
}
function escapeAttr(str) {
if (!str) return '';
return String(str).replace(_ATTR_ESC_RE, (c) => _ATTR_ESC_MAP[c]);
}
function showCopyToast(msg, durationMs) {
const toast = document.getElementById('copyToast');
toast.textContent = localizeUiText(msg);
toast.classList.add('show');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove('show'), durationMs || 1500);
}
// --- Resize handle for recent-files panel ---
{
const resizer = document.getElementById('recentFilesResizer');
const panel = document.getElementById('recentFilesPanel');
if (resizer && panel) {
let startY = 0;
let startH = 0;
let resizeFrame = 0;
const maxPanelHeight = () => {
if (!document.getElementById('upload-view')?.classList.contains('active')) return null;
const shell = panel.closest('.queue-shell');
const queue = document.getElementById('queueContainer');
if (!shell || !queue) return window.innerHeight * 0.7;
const shellRect = shell.getBoundingClientRect();
const queueRect = queue.getBoundingClientRect();
if (shellRect.width <= 0 || shellRect.height <= 0 || queueRect.width <= 0) return null;
let betweenHeight = 0;
let afterQueue = false;
for (const element of shell.children) {
if (element === queue) {
afterQueue = true;
continue;
}
if (element === panel) break;
if (afterQueue) betweenHeight += element.getBoundingClientRect().height;
}
const shellBottom = Math.min(shellRect.bottom, window.innerHeight);
const availableHeight = Math.max(0, shellBottom - queueRect.top - betweenHeight);
return Math.max(60, Math.min(window.innerHeight * 0.7, availableHeight - 120));
};
clampRecentPanelHeight = () => {
const requestedHeight = Number.parseFloat(panel.style.flexBasis);
if (!Number.isFinite(requestedHeight)) return;
const maximumHeight = maxPanelHeight();
if (!Number.isFinite(maximumHeight)) return;
const nextHeight = Math.max(60, Math.min(requestedHeight, maximumHeight));
if (Math.abs(nextHeight - requestedHeight) > 0.5) panel.style.flex = `0 0 ${nextHeight}px`;
};
window.addEventListener('resize', () => {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(clampRecentPanelHeight);
});
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 maximumHeight = maxPanelHeight();
if (!Number.isFinite(maximumHeight)) return;
const newH = Math.max(60, Math.min(maximumHeight, 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.remaining;
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 ---
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
window.api.onPrepareClose(prepareForWindowClose);
setupAppAlertListeners();
init().then(() => {
window.api.signalCloseHandshakeReady();
}).catch((err) => {
const message = err && err.message !== null && err.message !== undefined ? String(err.message) : String(err || 'Unknown error');
const stack = err && err.stack !== null && err.stack !== undefined ? String(err.stack) : '';
try {
window.api.signalRendererInitializationFailed({ message, stack });
} catch {}
try {
if (window.api && window.api.debugLog) window.api.debugLog(`init failed: ${err && err.stack ? err.stack : err}`);
const root = document.getElementById('app') || document.body;
if (root) {
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#5a1e1e;color:#fff;padding:8px;z-index:99999;font-family:sans-serif;font-size:13px';
banner.textContent = `${formatLocalizedError('Initialisierung fehlgeschlagen', err)} ${localizeUiText('— bitte Diagnose-Paket exportieren oder Programm neu starten.')}`;
root.appendChild(banner);
}
} catch {}
});